From 1cd00ab3a710a808a43210b7744e700564816483 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:26:14 -0400 Subject: [PATCH 001/250] Give Scotland and Wales working-age council tax reduction its own rules Working-age families in Scotland and Wales went through the pension-age means test built on Housing Benefit parameters. The whole Universal Credit award counted as income against an HB personal allowance with no children, so UC claimants almost never got CTR. Scotland's working-age scheme (SSI 2021/249) and the Welsh rules for persons who are not pensioners (WSI 2013/3029) now have their own route: - Wales, UC: applicable amount is the UC maximum amount and income is the Secretary of State's UC income plus the award (Sch 6 paras 3, 9). - Scotland, UC: own applicable amount; only the child and childcare elements of UC count as income (regs 35, 42, 57(1)(p), (2)(d)). - IS, income-based JSA and income-related ESA passport to the maximum reduction (SSI 2021/249 reg 13(11); WSI 2013/3029 Sch 9/10 para 8). - Applicable amounts: each country's personal allowances, child amounts, family premium and disabled child premiums, 2013-14 to 2026-27. - Income: Child Benefit disregarded, earnings disregards, childcare charges, closed unearned-income lists and tariff income from capital. Pension-age CTR is unchanged. Carries a byte-identical copy of #1944's council_tax_reduction_pensioner. Fixes #1962 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1962.fixed.md | 1 + .../child_enhanced_disability_premium.yaml | 79 ++++ .../working_age/child_premium.yaml | 69 +++ .../working_age/childcare.yaml | 119 +++++ .../working_age/disabled_child_premium.yaml | 79 ++++ .../working_age/earnings_disregard.yaml | 91 ++++ .../working_age/family_premium.yaml | 21 + .../working_age/personal_allowance.yaml | 269 +++++++++++ .../working_age/tariff_income.yaml | 36 ++ .../working_age/child_amount.yaml | 55 +++ .../child_enhanced_disability_premium.yaml | 82 ++++ .../working_age/childcare.yaml | 25 ++ .../working_age/disabled_child_premium.yaml | 80 ++++ .../working_age/earnings_disregard.yaml | 91 ++++ .../working_age/family_premium.yaml | 51 +++ .../working_age/personal_allowance.yaml | 338 ++++++++++++++ .../working_age/tariff_income.yaml | 36 ++ policyengine_uk/programs.yaml | 2 +- ...il_tax_reduction_devolved_working_age.yaml | 419 ++++++++++++++++++ ...duction_devolved_working_age_properties.py | 158 +++++++ .../test_means_test_income_properties.py | 4 +- .../council_tax_reduction_pensioner.py | 56 +++ ...simulated_council_tax_reduction_benunit.py | 24 +- ...ncil_tax_reduction_devolved_working_age.py | 28 ++ ...tion_relevant_universal_credit_payments.py | 41 ++ ...reduction_working_age_applicable_amount.py | 51 +++ ...reduction_working_age_applicable_income.py | 72 +++ ...uncil_tax_reduction_working_age_capital.py | 42 ++ ...tax_reduction_working_age_child_amounts.py | 70 +++ ...duction_working_age_childcare_deduction.py | 93 ++++ ...tax_reduction_working_age_earned_income.py | 58 +++ ...eduction_working_age_earnings_disregard.py | 88 ++++ ...uction_working_age_has_universal_credit.py | 20 + ...il_tax_reduction_working_age_passported.py | 30 ++ ...eduction_working_age_personal_allowance.py | 70 +++ ...tax_reduction_working_age_tariff_income.py | 42 ++ ...x_reduction_working_age_unearned_income.py | 57 +++ ...tion_working_age_universal_credit_award.py | 24 + 38 files changed, 2966 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1962.fixed.md create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/childcare.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/disabled_child_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/family_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/personal_allowance.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_amount.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/disabled_child_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/family_premium.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/personal_allowance.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_devolved_working_age.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_relevant_universal_credit_payments.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_child_amounts.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_has_universal_credit.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_universal_credit_award.py diff --git a/changelog.d/1962.fixed.md b/changelog.d/1962.fixed.md new file mode 100644 index 0000000000..1f2cbe1405 --- /dev/null +++ b/changelog.d/1962.fixed.md @@ -0,0 +1 @@ +- Give Scotland's working-age council tax reduction scheme and the Welsh rules for persons who are not pensioners their own route: Universal Credit claimants are means-tested under each country's Universal Credit rules (Wales against the Universal Credit maximum amount; Scotland counting only the child and childcare elements), Income Support, income-based JSA and income-related ESA passport to the maximum reduction, applicable amounts include each country's personal allowances, child amounts, family premium and disabled child premiums (2013-14 to 2026-27), and income follows the law (Child Benefit disregarded, earnings disregards, childcare charges and tariff income from capital). diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml new file mode 100644 index 0000000000..85c798a191 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml @@ -0,0 +1,79 @@ +description: Weekly enhanced disability premium for each child or young person in a working-age applicant's + family who gets the highest rate of the care component of disability living allowance or the enhanced + daily living component of personal independence payment, under the Scottish council tax reduction scheme. +values: + 2013-04-01: + value: 23.45 + reference: + - title: SSI 2013/48 reg 14(f)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 24.08 + reference: + - title: SSI 2014/35 reg 4(d)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 24.43 + reference: + - title: SSI 2015/46 reg 9(c)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 24.78 + reference: + - title: SSI 2017/41 reg 10(a)(vii) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 25.48 + reference: + - title: SSI 2018/69 reg 9(a)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 26.04 + reference: + - title: SSI 2019/29 reg 5(a)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 26.6 + reference: + - title: SSI 2020/25 reg 8(c)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 26.67 + reference: + - title: SSI 2021/51 reg 7(c)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 27.44 + reference: + - title: SSI 2022/52 reg 25(c)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 30.17 + reference: + - title: SSI 2023/38 reg 20(i)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 32.2 + reference: + - title: SSI 2024/35 reg 17(i)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 32.75 + reference: + - title: SSI 2025/24 reg 16(i)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 33.99 + reference: + - title: SSI 2026/56 reg 14(i)(v)(aa) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 +metadata: + label: Scotland working-age CTR child enhanced disability premium + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 3(b), 4(b) and 17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(i)(v) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_premium.yaml new file mode 100644 index 0000000000..25c64fb4ff --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/child_premium.yaml @@ -0,0 +1,69 @@ +description: Weekly child premium for each child or young person in a working-age applicant's family under + the Scottish council tax reduction scheme. Scotland raised it 25% above the Housing Benefit child allowance + from 1 April 2017. +values: + 2013-04-01: + value: 65.62 + reference: + - title: SSI 2013/48 reg 14(c) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 66.33 + reference: + - title: SSI 2014/35 reg 4(b) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 66.9 + reference: + - title: SSI 2015/46 reg 9(b) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 83.63 + reference: + - title: SSI 2016/253 reg 8 + href: https://www.legislation.gov.uk/ssi/2016/253/regulation/8 + 2020-04-01: + value: 85.34 + reference: + - title: SSI 2020/25 reg 8(b) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 85.75 + reference: + - title: SSI 2021/51 reg 7(b) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 88.5 + reference: + - title: SSI 2022/52 reg 25(b) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 97.23 + reference: + - title: SSI 2023/38 reg 20(b) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 104.05 + reference: + - title: SSI 2024/35 reg 17(b) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 105.85 + reference: + - title: SSI 2025/24 reg 16(b) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 109.85 + reference: + - title: SSI 2026/56 reg 14(b) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 +metadata: + label: Scotland working-age CTR child premium + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 35(b) and Sch. 1 para. 2 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/2 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(b) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/childcare.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/childcare.yaml new file mode 100644 index 0000000000..cec753c693 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/childcare.yaml @@ -0,0 +1,119 @@ +description: Weekly caps on the relevant childcare charges deducted from a working-age applicant's income + under the Scottish council tax reduction scheme. +maximum_one_child: + description: Weekly cap on relevant childcare charges for an applicant without Universal Credit whose + family has one child with charges. + values: + 2013-04-01: 175 + metadata: + label: Scotland working-age CTR childcare cap, one child + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 38(4)(a) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/38 +maximum_two_or_more_children: + description: Weekly cap on relevant childcare charges for an applicant without Universal Credit whose + family has more than one child with charges. + values: + 2013-04-01: 300 + metadata: + label: Scotland working-age CTR childcare cap, two or more children + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 38(4)(b) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/38 +universal_credit_maximum_one_child: + description: Weekly cap on relevant childcare charges for an applicant with Universal Credit whose family + has one child with charges. + values: + 2022-04-01: + value: 175.0 + reference: + - title: SSI 2021/249 reg 42(4)(a) as made + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/42/made + 2023-06-28: + value: 258.0 + reference: + - title: SSI 2023/197 reg 2(2)(a) + href: https://www.legislation.gov.uk/ssi/2023/197/regulation/2 + 2024-04-01: + value: 275.0 + reference: + - title: SSI 2024/35 reg 14(a) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/14 + 2025-04-01: + value: 280.0 + reference: + - title: SSI 2025/24 reg 13(a) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/13 + 2026-04-01: + value: 290.0 + reference: + - title: SSI 2026/56 reg 11(a) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/11 + metadata: + label: Scotland working-age CTR UC childcare cap, one child + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 42(4)(a) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/42 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 11(a) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/11 + uprating: gov.benefit_uprating_cpi +universal_credit_maximum_two_or_more_children: + description: Weekly cap on relevant childcare charges for an applicant with Universal Credit whose family + has more than one child with charges. + values: + 2022-04-01: + value: 300.0 + reference: + - title: SSI 2021/249 reg 42(4)(b) as made + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/42/made + 2023-06-28: + value: 442.0 + reference: + - title: SSI 2023/197 reg 2(2)(b) + href: https://www.legislation.gov.uk/ssi/2023/197/regulation/2 + 2024-04-01: + value: 471.0 + reference: + - title: SSI 2024/35 reg 14(b) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/14 + 2025-04-01: + value: 479.0 + reference: + - title: SSI 2025/24 reg 13(b) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/13 + 2026-04-01: + value: 497.0 + reference: + - title: SSI 2026/56 reg 11(b) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/11 + metadata: + label: Scotland working-age CTR UC childcare cap, two or more children + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 42(4)(b) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/42 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 11(b) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/11 + uprating: gov.benefit_uprating_cpi +universal_credit_costs_covered: + description: Share of childcare costs that the Universal Credit childcare costs element covers. Relevant + childcare charges of an applicant with Universal Credit are the childcare costs element divided by + this share. + values: + 2013-04-01: 0.85 + metadata: + label: Scotland working-age CTR UC childcare costs element share + unit: /1 + period: year + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 78(3) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/78 +metadata: + label: Scotland working-age CTR childcare charges diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/disabled_child_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/disabled_child_premium.yaml new file mode 100644 index 0000000000..4fb826763e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/disabled_child_premium.yaml @@ -0,0 +1,79 @@ +description: Weekly disabled child premium for each disabled child or young person in a working-age applicant's + family under the Scottish council tax reduction scheme. +values: + 2013-04-01: + value: 57.89 + reference: + - title: SSI 2013/48 reg 14(f)(iii) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 59.5 + reference: + - title: SSI 2014/35 reg 4(d)(iii) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 60.06 + reference: + - title: SSI 2015/46 reg 9(c)(iii) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 60.9 + reference: + - title: SSI 2017/41 reg 10(a)(v) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 62.86 + reference: + - title: SSI 2018/69 reg 9(a)(iii) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 64.19 + reference: + - title: SSI 2019/29 reg 5(a)(iii) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 65.52 + reference: + - title: SSI 2020/25 reg 8(c)(iii) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 65.94 + reference: + - title: SSI 2021/51 reg 7(c)(iii) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 68.04 + reference: + - title: SSI 2022/52 reg 25(c)(i) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 74.69 + reference: + - title: SSI 2023/38 reg 20(i)(i) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 80.01 + reference: + - title: SSI 2024/35 reg 17(i)(i) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 81.37 + reference: + - title: SSI 2025/24 reg 16(i)(i) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 84.46 + reference: + - title: SSI 2026/56 reg 14(i)(i) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 +metadata: + label: Scotland working-age CTR disabled child premium + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 35(c) and Sch. 1 paras. 3, 4 and + 17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(c)-(g), (i) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml new file mode 100644 index 0000000000..f18633a526 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml @@ -0,0 +1,91 @@ +description: Weekly earnings disregards for working-age applicants under the Scottish council tax reduction scheme. +single: + description: Weekly earnings disregard for a single applicant to whom no other disregard applies. + values: + 2013-04-01: 5 + metadata: + label: Scotland working-age CTR earnings disregard, single + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 11 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +couple: + description: Weekly earnings disregard for a couple, a member of which is in employment, to whom no higher disregard applies. + values: + 2013-04-01: 10 + metadata: + label: Scotland working-age CTR earnings disregard, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 8 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +lone_parent: + description: Weekly earnings disregard for a lone parent. + values: + 2013-04-01: 25 + metadata: + label: Scotland working-age CTR earnings disregard, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 5 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +disability_or_carer: + description: Weekly earnings disregard where the applicable amount includes a disability premium, severe disability premium or carer premium. + values: + 2013-04-01: 20 + metadata: + label: Scotland working-age CTR earnings disregard, disability or carer + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 paras. 4 and 6 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +additional: + description: Additional weekly earnings disregard for applicants without Universal Credit who meet a work condition. + values: + 2013-04-01: 17.10 + metadata: + label: Scotland working-age CTR additional earnings disregard + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 15 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +additional_full_time_hours: + description: Weekly hours of remunerative work from which an applicant aged 25 or over qualifies for the additional earnings disregard. + values: + 2013-04-01: 30 + metadata: + label: Scotland working-age CTR additional earnings disregard full-time hours + unit: hour + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 15(2) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +additional_full_time_age: + description: Age from which an applicant working the full-time hours qualifies for the additional earnings disregard. + values: + 2013-04-01: 25 + metadata: + label: Scotland working-age CTR additional earnings disregard full-time age + unit: year + period: year + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 15(2) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +remunerative_work_hours: + description: Weekly hours from which a person is in remunerative work. + values: + 2013-04-01: 16 + metadata: + label: Scotland working-age CTR remunerative work hours + unit: hour + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 10(1) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/10 +metadata: + label: Scotland working-age CTR earnings disregards diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/family_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/family_premium.yaml new file mode 100644 index 0000000000..503cb981c7 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/family_premium.yaml @@ -0,0 +1,21 @@ +description: Weekly family premium for a working-age applicant whose family includes a child or young person under the Scottish council tax reduction scheme. It was removed for new claims from 1 May 2016; families with an unbroken claim since then keep it as a transitional family premium, which the model does not track, so the model gives it to no one after that date. +values: + 2013-04-01: + value: 17.40 + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 Sch. 1 para. 4(1)(b) + href: https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/4/made + 2014-04-01: + value: 17.45 + reference: + - title: SSI 2014/35 reg 4(c) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2016-05-01: + value: 0 + reference: + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2016 (SSI 2016/81) regs. 2 and 5 + href: https://www.legislation.gov.uk/ssi/2016/81/regulation/5 +metadata: + label: Scotland working-age CTR family premium + unit: currency-GBP + period: week diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/personal_allowance.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/personal_allowance.yaml new file mode 100644 index 0000000000..a0a314701d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/personal_allowance.yaml @@ -0,0 +1,269 @@ +description: Weekly personal allowances in the applicable amount of a working-age applicant under the + Scottish council tax reduction scheme. +single_under_25: + description: Weekly personal allowance for a single applicant aged under 25 with no child premium. + values: + 2013-04-01: + value: 56.8 + reference: + - title: SSI 2013/48 reg 14(a)(ii) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 57.35 + reference: + - title: SSI 2014/35 reg 4(a)(ii) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 57.9 + reference: + - title: SSI 2015/46 reg 9(a)(ii) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2020-04-01: + value: 58.9 + reference: + - title: SSI 2020/25 reg 8(a)(ii) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 59.2 + reference: + - title: SSI 2021/51 reg 7(a)(ii) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 61.05 + reference: + - title: SSI 2022/52 reg 25(a)(ii) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 67.2 + reference: + - title: SSI 2023/38 reg 20(a)(ii) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 71.7 + reference: + - title: SSI 2024/35 reg 17(a)(i) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 72.9 + reference: + - title: SSI 2025/24 reg 16(a)(i) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 75.65 + reference: + - title: SSI 2026/56 reg 14(a)(i) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR personal allowance, single under 25 + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 1(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/1 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(a)(i) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi +single_25_or_over: + description: Weekly personal allowance for a single applicant aged 25 or over, or a single applicant + of any age entitled to main phase employment and support allowance or with an award of Universal Credit + and limited capability for work. + values: + 2013-04-01: + value: 71.7 + reference: + - title: SSI 2013/48 reg 14(a)(i) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 72.4 + reference: + - title: SSI 2014/35 reg 4(a)(i) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 73.1 + reference: + - title: SSI 2015/46 reg 9(a)(i) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2020-04-01: + value: 74.35 + reference: + - title: SSI 2020/25 reg 8(a)(i) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 74.7 + reference: + - title: SSI 2021/51 reg 7(a)(i) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 77.0 + reference: + - title: SSI 2022/52 reg 25(a)(i) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 84.8 + reference: + - title: SSI 2023/38 reg 20(a)(i) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 90.5 + reference: + - title: SSI 2024/35 reg 17(a)(ii) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 92.05 + reference: + - title: SSI 2025/24 reg 16(a)(ii) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 95.55 + reference: + - title: SSI 2026/56 reg 14(a)(ii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR personal allowance, single 25 or over + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 1(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/1 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(a)(ii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi +lone_parent: + description: Weekly personal allowance for a lone parent of any age. + values: + 2013-04-01: + value: 71.7 + reference: + - title: SSI 2013/48 reg 14(a)(iii) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 72.4 + reference: + - title: SSI 2014/35 reg 4(a)(iii) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 73.1 + reference: + - title: SSI 2015/46 reg 9(a)(iii) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2020-04-01: + value: 74.35 + reference: + - title: SSI 2020/25 reg 8(a)(i) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 74.7 + reference: + - title: SSI 2021/51 reg 7(a)(i) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 77.0 + reference: + - title: SSI 2022/52 reg 25(a)(i) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 84.8 + reference: + - title: SSI 2023/38 reg 20(a)(i) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 90.5 + reference: + - title: SSI 2024/35 reg 17(a)(ii) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 92.05 + reference: + - title: SSI 2025/24 reg 16(a)(ii) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 95.55 + reference: + - title: SSI 2026/56 reg 14(a)(ii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR personal allowance, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 1(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/1 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(a)(ii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi +couple: + description: Weekly personal allowance for a couple, whatever their ages. + values: + 2013-04-01: + value: 112.55 + reference: + - title: SSI 2013/48 reg 14(a)(iv) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 113.7 + reference: + - title: SSI 2014/35 reg 4(a)(iv) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 114.85 + reference: + - title: SSI 2015/46 reg 9(a)(iv) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2020-04-01: + value: 116.8 + reference: + - title: SSI 2020/25 reg 8(a)(iii) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 117.4 + reference: + - title: SSI 2021/51 reg 7(a)(iii) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 121.05 + reference: + - title: SSI 2022/52 reg 25(a)(iii) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 133.3 + reference: + - title: SSI 2023/38 reg 20(a)(iii) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 142.25 + reference: + - title: SSI 2024/35 reg 17(a)(iii) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 144.65 + reference: + - title: SSI 2025/24 reg 16(a)(iii) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 150.15 + reference: + - title: SSI 2026/56 reg 14(a)(iii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR personal allowance, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 1(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/1 + - title: The Council Tax Reduction (Scotland) Amendment Regulations 2026 reg. 14(a)(iii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + uprating: gov.benefit_uprating_cpi +age_threshold: + description: Age from which a single applicant receives the higher personal allowance. + values: + 2013-04-01: 25 + metadata: + label: Scotland working-age CTR personal allowance age threshold + unit: year + period: year + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 1(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/1 +metadata: + label: Scotland working-age CTR personal allowances diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml new file mode 100644 index 0000000000..3f3c0b67cf --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml @@ -0,0 +1,36 @@ +description: Tariff income treated as arising from a working-age applicant's capital under the Scottish council tax reduction scheme. +threshold: + description: Capital above which a working-age applicant is treated as having tariff income. + values: + 2013-04-01: 6_000 + metadata: + label: Scotland working-age CTR tariff income threshold + unit: currency-GBP + period: year + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 63(1)(b) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/63 +step: + description: Size of each capital step, or part of one, above the threshold that yields the weekly tariff income amount. + values: + 2013-04-01: 250 + metadata: + label: Scotland working-age CTR tariff income capital step + unit: currency-GBP + period: year + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 63(1)(b) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/63 +amount: + description: Weekly tariff income for each capital step, or part of one, above the threshold. + values: + 2013-04-01: 1 + metadata: + label: Scotland working-age CTR weekly tariff income per step + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 63(1)(b) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/63 +metadata: + label: Scotland working-age CTR tariff income diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_amount.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_amount.yaml new file mode 100644 index 0000000000..dad491f329 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_amount.yaml @@ -0,0 +1,55 @@ +description: Weekly amount for each child or young person in the family of an applicant who is not a pensioner + under the Welsh council tax reduction schemes. +values: + 2013-04-01: + value: 65.62 + reference: + - title: W.S.I. 2013/112 reg 10(b) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 66.33 + reference: + - title: W.S.I. 2014/66 reg 10(b) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 66.9 + reference: + - title: W.S.I. 2015/44 reg 11(b) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2022-04-01: + value: 70.8 + reference: + - title: W.S.I. 2022/51 reg 8(b) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 77.78 + reference: + - title: W.S.I. 2023/47 reg 9(b) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 83.24 + reference: + - title: W.S.I. 2024/56 reg 8(b) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 84.66 + reference: + - title: W.S.I. 2025/59 reg 9(b) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 87.88 + reference: + - title: W.S.I. 2026/13 reg 11(b) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 +metadata: + label: Wales working-age CTR child or young person amount + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 1(1)(b) and Sch. 7 para. 3(1) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/3 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(b) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml new file mode 100644 index 0000000000..9887925a20 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/child_enhanced_disability_premium.yaml @@ -0,0 +1,82 @@ +description: Weekly enhanced disability premium for each child or young person in the family of an applicant + who is not a pensioner and who gets the highest rate of the care component of disability living allowance + or the enhanced daily living component of personal independence payment, under the Welsh council tax + reduction schemes. +values: + 2013-04-01: + value: 23.45 + reference: + - title: W.S.I. 2013/112 reg 10(c)(v) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 24.08 + reference: + - title: W.S.I. 2014/66 reg 10(d)(v) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 24.43 + reference: + - title: W.S.I. 2015/44 reg 11(d)(v) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 24.78 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(ee) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 25.48 + reference: + - title: W.S.I. 2018/14 reg 10(c)(v) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 26.04 + reference: + - title: W.S.I. 2019/11 reg 7(b)(v) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 26.6 + reference: + - title: W.S.I. 2020/16 reg 10(b)(v) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 26.67 + reference: + - title: W.S.I. 2021/34 reg 8(b)(v) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 27.44 + reference: + - title: W.S.I. 2022/51 reg 8(d)(v) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 30.17 + reference: + - title: W.S.I. 2023/47 reg 9(d)(v) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 32.2 + reference: + - title: W.S.I. 2024/56 reg 8(d)(v) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 32.75 + reference: + - title: W.S.I. 2025/59 reg 9(d)(v) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 33.99 + reference: + - title: W.S.I. 2026/13 reg 11(d)(v) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 +metadata: + label: Wales working-age CTR child enhanced disability premium + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 paras. 12 and 17(5) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(d) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml new file mode 100644 index 0000000000..b83d15f029 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml @@ -0,0 +1,25 @@ +description: Weekly caps on the relevant childcare charges deducted from the earnings of an applicant who is not a pensioner under the Welsh council tax reduction schemes. +maximum_one_child: + description: Weekly cap on relevant childcare charges where the family has one child with charges. + values: + 2014-04-01: 175 + metadata: + label: Wales working-age CTR childcare cap, one child + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 20(3)(a) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20 +maximum_two_or_more_children: + description: Weekly cap on relevant childcare charges where the family has more than one child with charges. + values: + 2014-04-01: 300 + metadata: + label: Wales working-age CTR childcare cap, two or more children + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 20(3)(b) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20 +metadata: + label: Wales working-age CTR childcare charges diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/disabled_child_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/disabled_child_premium.yaml new file mode 100644 index 0000000000..7bbad6081a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/disabled_child_premium.yaml @@ -0,0 +1,80 @@ +description: Weekly disabled child premium for each disabled child or young person in the family of an + applicant who is not a pensioner, under the Welsh council tax reduction schemes. +values: + 2013-04-01: + value: 57.89 + reference: + - title: W.S.I. 2013/112 reg 10(c)(iii) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 59.5 + reference: + - title: W.S.I. 2014/66 reg 10(d)(iii) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 60.06 + reference: + - title: W.S.I. 2015/44 reg 11(d)(iii) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 60.9 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(cc) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 62.86 + reference: + - title: W.S.I. 2018/14 reg 10(c)(iii) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 64.19 + reference: + - title: W.S.I. 2019/11 reg 7(b)(iii) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 65.52 + reference: + - title: W.S.I. 2020/16 reg 10(b)(iii) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 65.94 + reference: + - title: W.S.I. 2021/34 reg 8(b)(iii) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 68.04 + reference: + - title: W.S.I. 2022/51 reg 8(d)(iii) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 74.69 + reference: + - title: W.S.I. 2023/47 reg 9(d)(iii) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 80.01 + reference: + - title: W.S.I. 2024/56 reg 8(d)(iii) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 81.37 + reference: + - title: W.S.I. 2025/59 reg 9(d)(iii) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 84.46 + reference: + - title: W.S.I. 2026/13 reg 11(d)(iii) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 +metadata: + label: Wales working-age CTR disabled child premium + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 paras. 13 and 17(3) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(d) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml new file mode 100644 index 0000000000..c8797630c7 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml @@ -0,0 +1,91 @@ +description: Weekly earnings disregards for applicants who are not pensioners under the Welsh council tax reduction schemes. +single: + description: Weekly earnings disregard where no other disregard applies. + values: + 2014-04-01: 5 + metadata: + label: Wales working-age CTR earnings disregard, single + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 11 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +couple: + description: Weekly earnings disregard for a couple, a member of which is in employment, to whom no higher disregard applies. + values: + 2014-04-01: 10 + metadata: + label: Wales working-age CTR earnings disregard, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 8 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +lone_parent: + description: Weekly earnings disregard for a lone parent. + values: + 2014-04-01: 25 + metadata: + label: Wales working-age CTR earnings disregard, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 5 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +disability_or_carer: + description: Weekly earnings disregard where the applicable amount includes a disability premium, severe disability premium or carer premium. + values: + 2014-04-01: 20 + metadata: + label: Wales working-age CTR earnings disregard, disability or carer + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 paras. 4 and 6 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +additional: + description: Additional weekly earnings disregard for applicants who meet a work condition. + values: + 2014-04-01: 17.10 + metadata: + label: Wales working-age CTR additional earnings disregard + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 18 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +additional_full_time_hours: + description: Weekly hours of remunerative work from which an applicant aged 25 or over qualifies for the additional earnings disregard. + values: + 2014-04-01: 30 + metadata: + label: Wales working-age CTR additional earnings disregard full-time hours + unit: hour + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 18(2) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +additional_full_time_age: + description: Age from which an applicant working the full-time hours qualifies for the additional earnings disregard. + values: + 2014-04-01: 25 + metadata: + label: Wales working-age CTR additional earnings disregard full-time age + unit: year + period: year + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 18(2) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 +remunerative_work_hours: + description: Weekly hours from which a person is in remunerative work. + values: + 2014-04-01: 16 + metadata: + label: Wales working-age CTR remunerative work hours + unit: hour + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. 10(1) + href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/10 +metadata: + label: Wales working-age CTR earnings disregards diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/family_premium.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/family_premium.yaml new file mode 100644 index 0000000000..ba50cb1dee --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/family_premium.yaml @@ -0,0 +1,51 @@ +description: Weekly family premium for an applicant who is not a pensioner and whose family includes at + least one child or young person, under the Welsh council tax reduction schemes. The higher rate protected + for some lone parents since 1998 is not modelled. +values: + 2013-04-01: + value: 17.4 + reference: + - title: W.S.I. 2012/3144 Sch 7 para 4(1)(b) as made + href: https://www.legislation.gov.uk/wsi/2012/3144/schedule/7/paragraph/4/made + 2015-04-01: + value: 17.45 + reference: + - title: W.S.I. 2015/44 reg 11(c) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2022-04-01: + value: 17.85 + reference: + - title: W.S.I. 2022/51 reg 8(c) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 18.53 + reference: + - title: W.S.I. 2023/47 reg 9(c) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 19.15 + reference: + - title: W.S.I. 2024/56 reg 8(c) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 19.48 + reference: + - title: W.S.I. 2025/59 reg 9(c) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 20.22 + reference: + - title: W.S.I. 2026/13 reg 11(c) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 +metadata: + label: Wales working-age CTR family premium + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 1(1)(c) and Sch. 7 para. 4(1)(b) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/4 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(c) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/personal_allowance.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/personal_allowance.yaml new file mode 100644 index 0000000000..59db1b6272 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/personal_allowance.yaml @@ -0,0 +1,338 @@ +description: Weekly personal allowances in the applicable amount of an applicant who is not a pensioner + under the Welsh council tax reduction schemes. +single_under_25: + description: Weekly personal allowance for a single applicant aged 18 to 24 who is not entitled to main + phase employment and support allowance. + values: + 2013-04-01: + value: 56.8 + reference: + - title: W.S.I. 2013/112 reg 10(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 57.35 + reference: + - title: W.S.I. 2014/66 reg 10(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 57.9 + reference: + - title: W.S.I. 2015/44 reg 11(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 58.5 + reference: + - title: W.S.I. 2017/46 reg 6(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 60.25 + reference: + - title: W.S.I. 2018/14 reg 10(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 61.7 + reference: + - title: W.S.I. 2019/11 reg 7(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 62.75 + reference: + - title: W.S.I. 2020/16 reg 10(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 63.05 + reference: + - title: W.S.I. 2021/34 reg 8(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 65.0 + reference: + - title: W.S.I. 2022/51 reg 8(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 71.55 + reference: + - title: W.S.I. 2023/47 reg 9(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 76.35 + reference: + - title: W.S.I. 2024/56 reg 8(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 77.65 + reference: + - title: W.S.I. 2025/59 reg 9(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 80.6 + reference: + - title: W.S.I. 2026/13 reg 11(a)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR personal allowance, single under 25 + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 para. 1(1) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/1 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(a) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi +single_25_or_over: + description: Weekly personal allowance for a single applicant aged 25 or over, or of any age if entitled + to main phase employment and support allowance. + values: + 2013-04-01: + value: 71.7 + reference: + - title: W.S.I. 2013/112 reg 10(a)(i) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 72.4 + reference: + - title: W.S.I. 2014/66 reg 10(a)(i) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 73.1 + reference: + - title: W.S.I. 2015/44 reg 11(a)(i) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 73.85 + reference: + - title: W.S.I. 2017/46 reg 6(a)(i) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 76.1 + reference: + - title: W.S.I. 2018/14 reg 10(a)(i) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 77.9 + reference: + - title: W.S.I. 2019/11 reg 7(a)(i) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 79.2 + reference: + - title: W.S.I. 2020/16 reg 10(a)(i) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 79.6 + reference: + - title: W.S.I. 2021/34 reg 8(a)(i) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 82.1 + reference: + - title: W.S.I. 2022/51 reg 8(a)(i) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 90.4 + reference: + - title: W.S.I. 2023/47 reg 9(a)(i) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 96.45 + reference: + - title: W.S.I. 2024/56 reg 8(a)(i) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 98.1 + reference: + - title: W.S.I. 2025/59 reg 9(a)(i) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 101.85 + reference: + - title: W.S.I. 2026/13 reg 11(a)(i) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR personal allowance, single 25 or over + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 paras. 1(1) and 2 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/1 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(a) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi +lone_parent: + description: Weekly personal allowance for a lone parent. + values: + 2013-04-01: + value: 71.7 + reference: + - title: W.S.I. 2013/112 reg 10(a)(ii) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 72.4 + reference: + - title: W.S.I. 2014/66 reg 10(a)(ii) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 73.1 + reference: + - title: W.S.I. 2015/44 reg 11(a)(ii) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 73.85 + reference: + - title: W.S.I. 2017/46 reg 6(a)(ii) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 76.1 + reference: + - title: W.S.I. 2018/14 reg 10(a)(ii) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 77.9 + reference: + - title: W.S.I. 2019/11 reg 7(a)(ii) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 79.2 + reference: + - title: W.S.I. 2020/16 reg 10(a)(ii) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 79.6 + reference: + - title: W.S.I. 2021/34 reg 8(a)(ii) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 82.1 + reference: + - title: W.S.I. 2022/51 reg 8(a)(ii) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 90.4 + reference: + - title: W.S.I. 2023/47 reg 9(a)(ii) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 96.45 + reference: + - title: W.S.I. 2024/56 reg 8(a)(ii) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 98.1 + reference: + - title: W.S.I. 2025/59 reg 9(a)(ii) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 101.85 + reference: + - title: W.S.I. 2026/13 reg 11(a)(ii) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR personal allowance, lone parent + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 para. 1(2) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/1 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(a) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi +couple: + description: Weekly personal allowance for a couple. + values: + 2013-04-01: + value: 112.55 + reference: + - title: W.S.I. 2013/112 reg 10(a)(iii) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 113.7 + reference: + - title: W.S.I. 2014/66 reg 10(a)(iii) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 114.85 + reference: + - title: W.S.I. 2015/44 reg 11(a)(iii) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 116.0 + reference: + - title: W.S.I. 2017/46 reg 6(a)(iii) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 119.5 + reference: + - title: W.S.I. 2018/14 reg 10(a)(iii) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 122.35 + reference: + - title: W.S.I. 2019/11 reg 7(a)(iii) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 124.45 + reference: + - title: W.S.I. 2020/16 reg 10(a)(iii) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 125.05 + reference: + - title: W.S.I. 2021/34 reg 8(a)(iii) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 128.95 + reference: + - title: W.S.I. 2022/51 reg 8(a)(iii) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 141.95 + reference: + - title: W.S.I. 2023/47 reg 9(a)(iii) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 151.45 + reference: + - title: W.S.I. 2024/56 reg 8(a)(iii) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 154.0 + reference: + - title: W.S.I. 2025/59 reg 9(a)(iii) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 159.85 + reference: + - title: W.S.I. 2026/13 reg 11(a)(iii) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR personal allowance, couple + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 para. 1(3) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/1 + - title: The Council Tax Reduction Schemes (Prescribed Requirements and Default Scheme) (Miscellaneous + Amendments) (Wales) Regulations 2026 reg. 11(a) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + uprating: gov.benefit_uprating_cpi +age_threshold: + description: Age from which a single applicant receives the higher personal allowance. + values: + 2013-04-01: 25 + metadata: + label: Wales working-age CTR personal allowance age threshold + unit: year + period: year + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 para. 1(1) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/1 +metadata: + label: Wales working-age CTR personal allowances diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml new file mode 100644 index 0000000000..bb8d5c4451 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml @@ -0,0 +1,36 @@ +description: Tariff income treated as arising from the capital of an applicant who is not a pensioner under the Welsh council tax reduction schemes. +threshold: + description: Capital above which an applicant who is not a pensioner is treated as having tariff income. + values: + 2014-04-01: 6_000 + metadata: + label: Wales working-age CTR tariff income threshold + unit: currency-GBP + period: year + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 33 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 +step: + description: Size of each capital step, or part of one, above the threshold that yields the weekly tariff income amount. + values: + 2014-04-01: 250 + metadata: + label: Wales working-age CTR tariff income capital step + unit: currency-GBP + period: year + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 33 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 +amount: + description: Weekly tariff income for each capital step, or part of one, above the threshold. + values: + 2014-04-01: 1 + metadata: + label: Wales working-age CTR weekly tariff income per step + unit: currency-GBP + period: week + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 33 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 +metadata: + label: Wales working-age CTR tariff income diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index f83e4acdd9..c7a5b587ee 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -521,7 +521,7 @@ programs: variable: council_tax_reduction parameter_prefix: gov.local_authorities verified_start_year: 2013 - notes: First-pass national support for Scotland, Wales, and England pensioner schemes, with reported CTB/CTR retained for unsupported English working-age local schemes. + notes: National support for the Scotland and Wales schemes (working-age rules including the Universal Credit routes, the income-related benefit passport and devolved applicable amounts) and the England pensioner scheme, with reported CTB/CTR retained for unsupported English working-age local schemes. # --- Treasury programs --- - id: cost_of_living_support diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml new file mode 100644 index 0000000000..6c5815ae8e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -0,0 +1,419 @@ +# Scotland's working-age council tax reduction scheme (SSI 2021/249) and the +# Welsh rules for persons who are not pensioners (WSI 2013/3029). Expected +# values are worked from the 2026-27 weekly amounts times 52, the model's +# weeks in a year. Council tax is £1,800 and the taper is 20% of annual +# excess income. + +- name: Wales, Universal Credit, no earnings, gets the maximum reduction + # Sch 6 paras 3 and 9: the applicable amount is the UC maximum amount and + # income is the Secretary of State's income figure (nil) plus the award, + # which equals the maximum amount, so there is no excess income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + council_tax_reduction_devolved_working_age: true + council_tax_reduction_working_age_has_universal_credit: true + simulated_council_tax_reduction_benunit: 1_800 + council_tax_reduction: 1_800 + +- name: Wales, Universal Credit, earnings with no work allowance + # Income = earnings E + award, and award = maximum - 0.55 E, so excess + # income = 0.45 E = 0.45 x 12,000 = 5,400. No income tax or NI below + # £12,570. Award = 1,800 - 0.2 x 5,400 = 720. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + employment_income: 12_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + council_tax_reduction_working_age_earned_income: 12_000 + council_tax_reduction_working_age_earnings_disregard: 0 + simulated_council_tax_reduction_benunit: 720 + +- name: Wales, Universal Credit award does not depend on the housing costs element + # The same claimant with twice the rent: the housing costs element is in + # both the applicable amount and the award, so it cancels. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + employment_income: 12_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + rent: 12_000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 720 + +- name: Wales, couple with a child and no Universal Credit + # Sch 7: 159.85 couple + 87.88 child + 20.22 family premium = 267.95 a week, + # 13,933.40 a year. Earnings 20,000 less income tax 1,234 (1,486 less the + # 252 marriage allowance from the partner's unused personal allowance) and + # NI 594.40 = 18,171.60. Disregards: £10 (couple) + £17.10 (a 25+ member + # works 30 hours) = 27.10 a week, 1,409.20 a year. Child Benefit is + # disregarded (Sch 9 para 66). Income 16,762.40; excess 2,829; award + # 1,800 - 565.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 20_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + partner: + age: 30 + child: + age: 5 + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: false + households: + household: + members: [claimant, partner, child] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_applicable_amount: 13_933.40 + income_tax: [1_234, 0, 0] + council_tax_reduction_working_age_earned_income: 18_171.60 + council_tax_reduction_working_age_earnings_disregard: 1_409.20 + council_tax_reduction_working_age_applicable_income: 16_762.40 + simulated_council_tax_reduction_benunit: 1_234.20 + +- name: Scotland, Universal Credit lone parent counts only the child element + # Reg 35: 95.55 lone parent + 109.85 child premium = 205.40 a week, + # 10,680.80 a year. Reg 57(1)(p): only the UC child element is income, + # which is below the applicable amount, so the maximum reduction is due. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + council_tax_benefit_reported: 100 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + households: + household: + members: [parent, child] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + council_tax_reduction_working_age_applicable_amount: 10_680.80 + simulated_council_tax_reduction_benunit: 1_800 + +- name: Scotland, Universal Credit single earner without children + # No child element, so no UC counts (reg 57(2)(d)). Income = 12,000 less the + # £5 disregard (260 a year; no £17.10 with UC) = 11,740. Applicable amount + # 95.55 x 52 = 4,968.60. Excess 6,771.40; award 1,800 - 1,354.28 = 445.72. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + employment_income: 12_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + council_tax_reduction_relevant_universal_credit_payments: 0 + council_tax_reduction_working_age_earnings_disregard: 260 + council_tax_reduction_working_age_applicable_income: 11_740 + simulated_council_tax_reduction_benunit: 445.72 + +- name: Scotland, single aged 20 without Universal Credit gets the under-25 allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 20 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + # 75.65 x 52 + council_tax_reduction_working_age_applicable_amount: 3_933.80 + simulated_council_tax_reduction_benunit: 1_800 + +- name: Wales, income-related ESA passports to the maximum reduction whatever the capital + # Sch 9 para 8 and Sch 10 para 8: the whole of the applicant's income and + # capital is disregarded. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 3_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + esa_income: 5_000 + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + savings: 20_000 + output: + council_tax_reduction_working_age_passported: true + council_tax_reduction_working_age_applicable_income: 0 + council_tax_reduction_working_age_capital: 0 + simulated_council_tax_reduction_benunit: 1_800 + +- name: Scotland, income-related ESA passports to the maximum reduction + # Reg 13(11): a qualifying income-related benefit claimant gets the maximum + # reduction whatever their income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 3_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + esa_income: 5_000 + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_passported: true + simulated_council_tax_reduction_benunit: 1_800 + +- name: Scotland, a passported claimant still faces the capital limit + # Reg 13(1)(a) keeps the capital condition; reg 13(11) deems only the income + # conditions met. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + esa_income: 5_000 + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 20_000 + output: + simulated_council_tax_reduction_benunit: 0 + +- name: Scotland, tariff income of £1 a week for each £250 or part above £6,000 + # Savings 10,100: 4,100 above £6,000 is 16.4 steps, rounded up to 17, so + # £17 a week, 884 a year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 10_100 + output: + council_tax_reduction_working_age_tariff_income: 884 + +- name: Wales, a mixed-age couple on Universal Credit claims under the working-age rules + # WSI 2013/3029 reg 3(1)(b)(ii): a person over the qualifying age whose + # partner has an award of Universal Credit is not a pensioner. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + council_tax_benefit_reported: 100 + younger: + age: 50 + benunits: + benunit: + members: [older, younger] + would_claim_uc: true + households: + household: + members: [older, younger] + region: WALES + country: WALES + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + council_tax_reduction_devolved_working_age: true + simulated_council_tax_reduction_benunit: 1_800 + +- name: Scotland 2015-16, lone parent without Universal Credit, before the family premium closed + # SSI 2012/303 as amended by SSI 2015/46: lone parent 73.10 + child 66.90 + + # family premium 17.45 (SSI 2014/35) = 157.45 a week, 8,187.40 a year. + period: 2015 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + child: + age: 4 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_200 + output: + council_tax_reduction_working_age_applicable_amount: 8_187.40 + +- name: Scotland 2018-19, child premium 25% above Housing Benefit and no family premium + # Lone parent 73.10 + child premium 83.63 (SSI 2016/253 reg 8); the family + # premium closed to new claims on 1 May 2016 (SSI 2016/81). 156.73 x 52. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + child: + age: 4 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_200 + output: + council_tax_reduction_working_age_applicable_amount: 8_149.96 + +- name: Wales 2016-17, couple with a child without Universal Credit + # WSI 2013/3029 as amended by WSI 2015/44 (unchanged in 2016): couple 114.85 + # + child 66.90 + family premium 17.45 = 199.20 a week, 10,358.40 a year. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + partner: + age: 30 + child: + age: 4 + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: false + households: + household: + members: [claimant, partner, child] + region: WALES + country: WALES + council_tax: 1_200 + output: + council_tax_reduction_working_age_applicable_amount: 10_358.40 diff --git a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py new file mode 100644 index 0000000000..02e8d58779 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py @@ -0,0 +1,158 @@ +"""Properties of the Scottish and Welsh working-age council tax reduction. + +Invariants, for any working-age family in Scotland or Wales: + +1. The award is between 0 and the council tax liability. +2. Wales, Universal Credit: the award does not depend on the rent. The UC + housing costs element sits in both the applicable amount (the UC maximum + amount, WSI 2013/3029 Sch 6 para 3) and the income (the award, para 9), so + it cancels. Benefit-capped awards are excluded: the cap lowers the award + but not the maximum amount. +3. Universal Credit, both countries: the award never rises with the + claimant's earnings. In Wales excess income is the Secretary of State's + income plus the award less the maximum amount, which rises by at least 45p + for each £1 of net earnings. In Scotland only the child and childcare + elements of the award count (SSI 2021/249 reg 57(1)(p)), so income rises + by at least 45p for each £1 as well. +4. A claimant on income-related ESA and not on Universal Credit gets the + whole liability (SSI 2021/249 reg 13(11); WSI 2013/3029 Sch 9 para 8), + whatever their earnings. + +Each example puts every family and its variants in one simulation, so the +formulas also run on arrays with several benefit units and households. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +REGIONS = ["SCOTLAND", "WALES"] + + +@st.composite +def families(draw): + region = draw(st.sampled_from(REGIONS)) + couple = draw(st.booleans()) + children = draw(st.integers(0, 3)) + people = [ + { + "age": draw(st.integers(18, 60)), + "employment_income": draw(st.floats(0, 40_000)), + "weekly_hours": draw(st.sampled_from([0, 10, 20, 35])), + "council_tax_benefit_reported": 100, + } + ] + if couple: + people.append( + { + "age": draw(st.integers(18, 60)), + "employment_income": draw(st.floats(0, 20_000)), + } + ) + for _ in range(children): + people.append({"age": draw(st.integers(0, 15))}) + return { + "region": region, + "people": people, + "would_claim_uc": draw(st.booleans()), + "rent": draw(st.floats(0, 12_000)), + "council_tax": draw(st.floats(500, 3_500)), + "savings": draw(st.floats(0, 15_000)), + "esa_income": 0, + } + + +def situation(units): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit["people"]): + name = f"p{i}_{j}" + people[name] = {k: {YEAR: v} for k, v in inputs.items()} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {YEAR: unit["would_claim_uc"]}, + "esa_income": {YEAR: unit["esa_income"]}, + } + households[f"h{i}"] = { + "members": names, + "region": {YEAR: unit["region"]}, + "country": {YEAR: unit["region"]}, + "rent": {YEAR: unit["rent"]}, + "council_tax": {YEAR: unit["council_tax"]}, + "savings": {YEAR: unit["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def with_earnings_raised(unit, amount): + people = [dict(p) for p in unit["people"]] + people[0]["employment_income"] = people[0]["employment_income"] + amount + return {**unit, "people": people} + + +SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@SETTINGS +@given(st.lists(families(), min_size=1, max_size=5), st.floats(100, 5_000)) +def test_devolved_working_age_award_properties(units, raise_by): + n = len(units) + variants = ( + units + + [{**u, "rent": u["rent"] * 2 + 1_000} for u in units] + + [with_earnings_raised(u, raise_by) for u in units] + ) + sim = Simulation(situation=situation(variants)) + award = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + working_age = sim.calculate("council_tax_reduction_devolved_working_age", YEAR) + has_uc = sim.calculate( + "council_tax_reduction_working_age_has_universal_credit", YEAR + ) + capped = sim.calculate("benefit_cap_reduction", YEAR) > 0 + liability = sim.calculate( + "council_tax_reduction_maximum_eligible_liability", YEAR, map_to="benunit" + ) + for i in range(n): + assert working_age[i], units[i] + # 1. Bounds. + assert -0.01 <= award[i] <= liability[i] + 0.01, units[i] + # 2. Wales UC: the rent cancels. + rent_i = n + i + if ( + units[i]["region"] == "WALES" + and has_uc[i] + and has_uc[rent_i] + and not capped[i] + and not capped[rent_i] + ): + assert abs(award[i] - award[rent_i]) < 0.01, units[i] + # 3. UC: more earnings never raise the award. + earn_i = 2 * n + i + if has_uc[i] and has_uc[earn_i] and not capped[i] and not capped[earn_i]: + assert award[earn_i] <= award[i] + 0.01, units[i] + + +@SETTINGS +@given(st.lists(families(), min_size=1, max_size=5)) +def test_income_related_esa_passports_to_the_full_liability(units): + passported = [ + {**u, "would_claim_uc": False, "esa_income": 4_000, "savings": 0} for u in units + ] + sim = Simulation(situation=situation(passported)) + award = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + liability = sim.calculate( + "council_tax_reduction_maximum_eligible_liability", YEAR, map_to="benunit" + ) + flag = sim.calculate("council_tax_reduction_working_age_passported", YEAR) + assert flag.all() + assert np.allclose(award, liability, atol=0.01) diff --git a/policyengine_uk/tests/test_means_test_income_properties.py b/policyengine_uk/tests/test_means_test_income_properties.py index fcfb8f56a2..384059de3e 100644 --- a/policyengine_uk/tests/test_means_test_income_properties.py +++ b/policyengine_uk/tests/test_means_test_income_properties.py @@ -4,7 +4,8 @@ claimant, the partner, nor the programme's own child or young person (for example an 18- or 19-year-old presumed the claimant's child but not in education, or a third adult) never changes a means test's income. The UC, HB, -IS, tax credit, CTR and Pension Credit income totals are covered. +IS, tax credit, CTR (including the Scottish and Welsh working-age income) and +Pension Credit income totals are covered. Each example builds families twice in one simulation: once with the other member's income and once without, in separate households and benefit units. @@ -24,6 +25,7 @@ "income_support_applicable_income", "tax_credits_applicable_income", "council_tax_reduction_applicable_income", + "council_tax_reduction_working_age_applicable_income", "pension_credit_income", ] EDUCATIONS = ["NOT_IN_EDUCATION", "UPPER_SECONDARY", "TERTIARY"] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py new file mode 100644 index 0000000000..f2c9d2acbd --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -0,0 +1,56 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_pensioner(Variable): + value_type = bool + entity = BenUnit + label = "Pensioner for Council Tax Reduction" + documentation = ( + "Whether the family's claim falls under the pension-age Council Tax " + "Reduction rules: the claimant or partner has reached the qualifying " + "age for State Pension Credit, and neither of them is on " + "Income Support, income-based Jobseeker's Allowance or income-related " + "Employment and Support Allowance, or has an award of Universal Credit. " + "A mixed-age couple on Universal Credit is therefore not a pensioner in " + "England, Wales or Scotland. English schemes are routed on this test, " + "and Welsh and Scottish claims that fail it take the working-age rules." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/12", + ) + + def formula(benunit, period, parameters): + person = benunit.members + claimant_or_partner = person("is_uc_claimant", period) + # Either member of a couple can claim, so the older one can claim as a + # pensioner. is_SP_age stands in for the qualifying age for State + # Pension Credit, as elsewhere in the model. + over_qualifying_age = person("is_SP_age", period) + attained_qualifying_age = benunit.any(claimant_or_partner & over_qualifying_age) + income_related_benefit = benunit( + "council_tax_reduction_relevant_income_based_benefit", period + ) + # The award before the benefit cap: the cap reduces an award + # (UC Regs 2013 reg 81) rather than removing it. An award counts only + # where a claimant or partner is under the qualifying age, which + # Universal Credit needs (UC Regs 2013 reg 3(2)(a)); is_uc_eligible + # counts any working-age adult, including a qualifying young person. + # This also stands in for the rules that disregard an award held after + # both members reach the qualifying age (SI 2012/2885 reg 3(2); + # WSI 2013/3029 reg 3(2); SSI 2021/249 reg 3(2)). England and Wales + # also disregard a tax credit migrant's award (UC (TP) Regs 2014 reg + # 60A) and Scotland does not; the model has no such awards. The guard + # relies on is_uc_claimant, which needs is_parent to recognise a + # qualifying young person as a dependant. + working_age_claimant = benunit.any(claimant_or_partner & ~over_qualifying_age) + universal_credit_award = ( + benunit("is_uc_entitled", period) & working_age_claimant + ) + return ( + attained_qualifying_age & ~income_related_benefit & ~universal_credit_award + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index ce3eeb7ee9..749ff4379b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -43,8 +43,21 @@ def formula(benunit, period, parameters): liability = benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) - applicable_amount = benunit("council_tax_reduction_applicable_amount", period) - applicable_income = benunit("council_tax_reduction_applicable_income", period) + # Scotland's working-age scheme and the Welsh rules for persons who + # are not pensioners have their own applicable amounts and income. + devolved_working_age = benunit( + "council_tax_reduction_devolved_working_age", period + ) + applicable_amount = where( + devolved_working_age, + benunit("council_tax_reduction_working_age_applicable_amount", period), + benunit("council_tax_reduction_applicable_amount", period), + ) + applicable_income = where( + devolved_working_age, + benunit("council_tax_reduction_working_age_applicable_income", period), + benunit("council_tax_reduction_applicable_income", period), + ) non_dep_deductions = benunit("council_tax_reduction_non_dep_deductions", period) max_support = select( @@ -82,7 +95,12 @@ def formula(benunit, period, parameters): - excess_income * withdrawal_rate - non_dep_deductions, ) - capital_eligible = benunit.household("savings", period) <= capital_limit + capital = where( + devolved_working_age, + benunit("council_tax_reduction_working_age_capital", period), + benunit.household("savings", period), + ) + capital_eligible = capital <= capital_limit national_ctr = ( national_scheme * is_household_head_benunit diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_devolved_working_age.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_devolved_working_age.py new file mode 100644 index 0000000000..ea5f61a9d9 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_devolved_working_age.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, + is_wales_scheme, +) + + +class council_tax_reduction_devolved_working_age(Variable): + value_type = bool + entity = BenUnit + label = "Claims under the Scottish or Welsh working-age council tax reduction rules" + documentation = ( + "Whether the family lives in Scotland or Wales and is not a pensioner " + "for council tax reduction. Scotland's working-age scheme and the " + "Welsh rules for persons who are not pensioners have their own " + "applicable amounts, income rules and Universal Credit route." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + ) + + def formula(benunit, period, parameters): + country = benunit.household("country", period) + devolved = is_scotland_scheme(country) | is_wales_scheme(country) + pensioner = benunit("council_tax_reduction_pensioner", period) + return devolved & ~pensioner diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_relevant_universal_credit_payments.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_relevant_universal_credit_payments.py new file mode 100644 index 0000000000..f6dfaea86f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_relevant_universal_credit_payments.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_relevant_universal_credit_payments(Variable): + value_type = float + entity = BenUnit + label = "Relevant Universal Credit payments for Scottish council tax reduction" + documentation = ( + "The part of a Universal Credit award that Scotland's working-age " + "council tax reduction scheme counts as income: the child element, " + "including the disabled child additions, plus the childcare costs " + "element, or the whole award if lower. An award with no child element " + "counts nothing. The standard allowance and the housing, carer and " + "limited capability elements are never income." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ssi/2021/249/regulation/57" + + def formula(benunit, period, parameters): + child_element = benunit("uc_child_element", period) + disabled_child_additions = add( + benunit, + period, + [ + "uc_individual_disabled_child_element", + "uc_individual_severely_disabled_child_element", + ], + ) + childcare_element = benunit("uc_childcare_element", period) + award = benunit( + "council_tax_reduction_working_age_universal_credit_award", period + ) + has_child_element = child_element > 0 + counted = min_( + child_element + disabled_child_additions + childcare_element, award + ) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + return where(has_universal_credit & has_child_element, counted, 0) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py new file mode 100644 index 0000000000..4edc309476 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_wales_scheme, +) + + +class council_tax_reduction_working_age_applicable_amount(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction applicable amount" + documentation = ( + "Annual applicable amount of a working-age applicant in Scotland or " + "Wales. In Wales, an applicant with an award of Universal Credit uses " + "the Universal Credit maximum amount, including the housing costs " + "element (WSI 2013/3029 Sch 6 para 3; monthly amounts times 12/52 " + "give the same annual total). Otherwise, and for every Scottish " + "applicant including those on Universal Credit, it is the personal " + "allowance, the amounts for children, and the adult disability, " + "severe disability, enhanced disability and carer premiums. The adult " + "premiums use the model's legacy-benefit premium variables, whose " + "amounts equal the Scottish and Welsh amounts. The employment and " + "support allowance components are not modelled." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/35", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/1", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/3", + ) + + def formula(benunit, period, parameters): + wales = is_wales_scheme(benunit.household("country", period)) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + universal_credit_route = wales & has_universal_credit + own_amount = add( + benunit, + period, + [ + "council_tax_reduction_working_age_personal_allowance", + "council_tax_reduction_working_age_child_amounts", + "benefits_premiums", + ], + ) + return where( + universal_credit_route, + benunit("uc_maximum_amount", period), + own_amount, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py new file mode 100644 index 0000000000..7d462c0d2a --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py @@ -0,0 +1,72 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_wales_scheme, +) + + +class council_tax_reduction_working_age_applicable_income(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction income" + documentation = ( + "Annual income of a working-age applicant in Scotland or Wales, " + "compared with the applicable amount. An applicant on Income Support, " + "income-based Jobseeker's Allowance or income-related Employment and " + "Support Allowance, and not on Universal Credit, has no income for " + "this purpose and gets the maximum reduction. A Welsh applicant with " + "Universal Credit counts the Secretary of State's Universal Credit " + "income figure (earnings before the work allowance, plus Universal " + "Credit unearned income) plus the Universal Credit award. Everyone " + "else counts net earnings less disregards and childcare charges, " + "unearned income, tariff income from capital and, in Scotland, the " + "relevant Universal Credit payments." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/13", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/38", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/42", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20", + ) + + def formula(benunit, period, parameters): + wales = is_wales_scheme(benunit.household("country", period)) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + passported = benunit("council_tax_reduction_working_age_passported", period) + earnings = benunit("council_tax_reduction_working_age_earned_income", period) + award = benunit( + "council_tax_reduction_working_age_universal_credit_award", period + ) + wales_universal_credit_income = ( + earnings + benunit("uc_unearned_income", period) + award + ) + disregard = benunit( + "council_tax_reduction_working_age_earnings_disregard", period + ) + childcare = benunit( + "council_tax_reduction_working_age_childcare_deduction", period + ) + own_rules_income = ( + earnings + - disregard + + add( + benunit, + period, + [ + "council_tax_reduction_working_age_unearned_income", + "council_tax_reduction_working_age_tariff_income", + "council_tax_reduction_relevant_universal_credit_payments", + ], + ) + - childcare + ) + income = where( + wales & has_universal_credit, + wales_universal_credit_income, + own_rules_income, + ) + return where(passported, 0, max_(0, income)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py new file mode 100644 index 0000000000..d13ab89f6e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_wales_scheme, +) + + +class council_tax_reduction_working_age_capital(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction capital" + documentation = ( + "Capital tested against the council tax reduction capital limit for a " + "working-age claim in Scotland or Wales. A Welsh applicant with " + "Universal Credit uses the Secretary of State's Universal Credit " + "capital figure, and a Welsh applicant on Income Support, " + "income-based Jobseeker's Allowance or income-related Employment and " + "Support Allowance has all capital disregarded. Otherwise, as for " + "other council tax reduction claims in the model, household savings " + "stand in for the claimant's and partner's capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/66", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/30", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/10/paragraph/8", + ) + + def formula(benunit, period, parameters): + wales = is_wales_scheme(benunit.household("country", period)) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + passported = benunit("council_tax_reduction_working_age_passported", period) + savings = benunit.household("savings", period) + return select( + [wales & has_universal_credit, wales & passported], + [benunit("uc_assessable_capital", period), 0], + savings, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_child_amounts.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_child_amounts.py new file mode 100644 index 0000000000..4dca906ef6 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_child_amounts.py @@ -0,0 +1,70 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, +) + + +class council_tax_reduction_working_age_child_amounts(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction amounts for children" + documentation = ( + "Annual amounts in a working-age applicable amount in Scotland or " + "Wales for the children and young persons in the family: the child " + "premium (Scotland) or child or young person amount (Wales) for each " + "one, the family premium, and the disabled child premium and " + "child enhanced disability premium. There is no limit on the number " + "of children. A child or young person with a disability benefit or " + "who is blind gets the disabled child premium; one with the highest " + "rate of the care component of disability living allowance or the " + "enhanced daily living component of personal independence payment " + "also gets the enhanced disability premium, the same test the model " + "uses for Universal Credit's higher disabled child addition. " + "Scotland removed the family premium for new claims from 1 May " + "2016; it survives only as a transitional premium for families with " + "an unbroken claim since then, which the model does not track." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/35", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/4", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/1", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + scot = p.scotland.council_tax_reduction.working_age + wales = p.wales.council_tax_reduction.working_age + scotland = is_scotland_scheme(benunit.household("country", period)) + person = benunit.members + child = person("is_child_or_young_person_for_legacy_benefits", period) + disabled = person("is_disabled_for_benefits", period) | person( + "is_blind", period + ) + severely_disabled = person("is_severely_disabled_for_benefits", period) + children = benunit.sum(child) + disabled_children = benunit.sum(child & (disabled | severely_disabled)) + severely_disabled_children = benunit.sum(child & severely_disabled) + per_child = where(scotland, scot.child_premium, wales.child_amount) + family_premium = (children > 0) * where( + scotland, scot.family_premium, wales.family_premium + ) + disabled_child_premium = where( + scotland, scot.disabled_child_premium, wales.disabled_child_premium + ) + child_enhanced_disability_premium = where( + scotland, + scot.child_enhanced_disability_premium, + wales.child_enhanced_disability_premium, + ) + weekly = ( + children * per_child + + family_premium + + disabled_children * disabled_child_premium + + severely_disabled_children * child_enhanced_disability_premium + ) + return weekly * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py new file mode 100644 index 0000000000..bbb559d910 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py @@ -0,0 +1,93 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, +) + + +class council_tax_reduction_working_age_childcare_deduction(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction childcare charges deduction" + documentation = ( + "Annual relevant childcare charges deducted from a working-age " + "applicant's income in Scotland or Wales. Without Universal Credit, " + "childcare spending is deducted from earnings, up to a weekly cap for " + "one child or for two or more, where a lone parent, or both members " + "of a couple, are in remunerative work. A Scottish applicant with " + "Universal Credit deducts the Universal Credit childcare costs element " + "grossed up to the full cost, up to higher caps. A Welsh applicant " + "with Universal Credit uses the Secretary of State's income figure, " + "which has no childcare deduction. The number of children in the " + "family stands in for the number with childcare charges, and the " + "incapacity, hospital and prison cases for couples are not modelled." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/38", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/42", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/77", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/78", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/21", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + scot = p.scotland.council_tax_reduction.working_age + wales = p.wales.council_tax_reduction.working_age + scotland = is_scotland_scheme(benunit.household("country", period)) + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + children = benunit.sum( + person("is_child_or_young_person_for_legacy_benefits", period) + ) + two_or_more = children >= 2 + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + # Without Universal Credit: charges paid, capped, where the lone parent + # or both members of the couple are in remunerative work. + hours_threshold = where( + scotland, + scot.earnings_disregard.remunerative_work_hours, + wales.earnings_disregard.remunerative_work_hours, + ) + in_work = person("weekly_hours", period) >= benunit.project(hours_threshold) + all_claimants_in_work = benunit.sum(claimant_or_partner & ~in_work) == 0 + charges = benunit.sum(person("childcare_expenses", period)) + weekly_cap = where( + scotland, + where( + two_or_more, + scot.childcare.maximum_two_or_more_children, + scot.childcare.maximum_one_child, + ), + where( + two_or_more, + wales.childcare.maximum_two_or_more_children, + wales.childcare.maximum_one_child, + ), + ) + earnings = benunit("council_tax_reduction_working_age_earned_income", period) + legacy = where( + (children > 0) & all_claimants_in_work, + min_(min_(charges, weekly_cap * WEEKS_IN_YEAR), earnings), + 0, + ) + # Scotland, with Universal Credit: the childcare costs element grossed + # up to the full charge. + uc_charges = benunit("uc_childcare_element", period) / ( + scot.childcare.universal_credit_costs_covered + ) + uc_weekly_cap = where( + two_or_more, + scot.childcare.universal_credit_maximum_two_or_more_children, + scot.childcare.universal_credit_maximum_one_child, + ) + scotland_uc = min_(uc_charges, uc_weekly_cap * WEEKS_IN_YEAR) + return where( + has_universal_credit, + where(scotland, scotland_uc, 0), + legacy, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py new file mode 100644 index 0000000000..94687ce2db --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py @@ -0,0 +1,58 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_working_age_earned_income(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction net earned income" + documentation = ( + "Annual net earnings of the claimant and partner for a working-age " + "council tax reduction claim in Scotland or Wales, before earnings " + "disregards. With an award of Universal Credit, earnings follow the " + "Universal Credit measure: gross earnings (with any minimum income " + "floor) less income tax, National Insurance and all pension " + "contributions, with no work allowance. Without Universal Credit, " + "earnings are employment and self-employment income and statutory " + "sick and maternity pay, less income tax, National Insurance and half " + "of pension contributions. The earnings of children and young persons " + "do not count. As elsewhere in the model, all of a person's income " + "tax is deducted from earnings." + ) + 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/wsi/2013/3029/schedule/6/paragraph/9", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15", + ) + + def formula(benunit, period, parameters): + person = benunit.members + members = person("is_claimant_or_partner", period) + tax = add_for_members(benunit, period, ["tax"], members) + pension_contributions = add_for_members( + benunit, period, ["pension_contributions"], members + ) + universal_credit_earnings = add_for_members( + benunit, period, ["uc_mif_capped_earned_income"], members + ) + legacy_earnings = add_for_members( + benunit, + period, + [ + "employment_income", + "self_employment_income", + "statutory_sick_pay", + "statutory_maternity_pay", + ], + members, + ) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + return where( + has_universal_credit, + max_(0, universal_credit_earnings - tax - pension_contributions), + max_(0, legacy_earnings - tax - 0.5 * pension_contributions), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py new file mode 100644 index 0000000000..ed61f15e08 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py @@ -0,0 +1,88 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, +) + + +class council_tax_reduction_working_age_earnings_disregard(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction earnings disregard" + documentation = ( + "Annual earnings disregarded in a working-age council tax reduction " + "claim in Scotland or Wales. One weekly disregard applies: £25 for a " + "lone parent; otherwise £20 where the applicable amount includes a " + "disability, severe disability or carer premium; otherwise £10 for a " + "couple and £5 for a single applicant. Applicants without Universal " + "Credit who meet a work condition have £17.10 more disregarded if " + "their earnings cover the other disregards, their childcare charges " + "and the £17.10. Scotland applies the disregards to Universal Credit " + "earnings too, without the £17.10. A Welsh applicant with Universal " + "Credit uses the Secretary of State's income figure, which has no " + "disregards. The disregard is never more than the earnings. The £20 " + "disregards for special occupations and for permitted work are not " + "modelled." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/8", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + scotland = is_scotland_scheme(benunit.household("country", period)) + scot = p.scotland.council_tax_reduction.working_age.earnings_disregard + wales = p.wales.council_tax_reduction.working_age.earnings_disregard + + def rate(name): + return where(scotland, getattr(scot, name), getattr(wales, name)) + + lone_parent = benunit("is_lone_parent", period) + couple = benunit("is_couple", period) + disability = (benunit("disability_premium", period) > 0) | ( + benunit("severe_disability_premium", period) > 0 + ) + carer = benunit("carer_premium", period) > 0 + base = select( + [lone_parent, disability | carer, couple], + [rate("lone_parent"), rate("disability_or_carer"), rate("couple")], + rate("single"), + ) + + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + hours = person("weekly_hours", period) + age = person("age", period) + in_work = benunit.any( + claimant_or_partner + & (hours >= benunit.project(rate("remunerative_work_hours"))) + ) + full_time_25_or_over = benunit.any( + claimant_or_partner + & (hours >= benunit.project(rate("additional_full_time_hours"))) + & (age >= benunit.project(rate("additional_full_time_age"))) + ) + children = benunit.sum( + person("is_child_or_young_person_for_legacy_benefits", period) + ) + work_condition = ( + full_time_25_or_over + | (couple & (children > 0) & in_work) + | (lone_parent & in_work) + | ((benunit("disability_premium", period) > 0) & in_work) + ) + earnings = benunit("council_tax_reduction_working_age_earned_income", period) + childcare = benunit( + "council_tax_reduction_working_age_childcare_deduction", period + ) + additional = rate("additional") + covers_additional = earnings >= (base + additional) * WEEKS_IN_YEAR + childcare + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + additional_applies = work_condition & covers_additional & ~has_universal_credit + weekly = base + additional * additional_applies + weekly = where(has_universal_credit & ~scotland, 0, weekly) + return min_(earnings, weekly * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_has_universal_credit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_has_universal_credit.py new file mode 100644 index 0000000000..6f62c76a8a --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_has_universal_credit.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_working_age_has_universal_credit(Variable): + value_type = bool + entity = BenUnit + label = "Has a Universal Credit award for working-age council tax reduction" + documentation = ( + "Whether the claimant or partner has an award of Universal Credit, " + "which moves a working-age council tax reduction claim onto the " + "Universal Credit income rules in Scotland and Wales." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/42", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", + ) + + def formula(benunit, period, parameters): + return benunit("is_uc_entitled", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py new file mode 100644 index 0000000000..af6b286e3c --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_working_age_passported(Variable): + value_type = bool + entity = BenUnit + label = "Passported to the maximum working-age council tax reduction" + documentation = ( + "Whether the claimant or partner is on Income Support, income-based " + "Jobseeker's Allowance or income-related Employment and Support " + "Allowance and not on Universal Credit. In Scotland such a claimant " + "gets the maximum reduction (SSI 2021/249 reg 13(11)); in Wales the " + "whole of their income and capital is disregarded (WSI 2013/3029 " + "Sch 9 para 8, Sch 10 para 8)." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/10/paragraph/8", + ) + + def formula(benunit, period, parameters): + income_related_benefit = benunit( + "council_tax_reduction_relevant_income_based_benefit", period + ) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + return income_related_benefit & ~has_universal_credit diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py new file mode 100644 index 0000000000..a68f4b2cba --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py @@ -0,0 +1,70 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, +) + + +class council_tax_reduction_working_age_personal_allowance(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction personal allowance" + documentation = ( + "Annual personal allowance in the applicable amount of a working-age " + "applicant in Scotland or Wales. A couple and a lone parent get one " + "rate at any age. A single applicant under 25 gets the lower rate " + "unless entitled to main phase Employment and Support Allowance or, " + "in Scotland, on Universal Credit with limited capability for work. " + "Receipt of Employment and Support Allowance stands in for the main " + "phase, and the model's limited-capability-for-work-related-activity " + "flag for limited capability for work." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/1", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + country = benunit.household("country", period) + scotland = is_scotland_scheme(country) + scot = p.scotland.council_tax_reduction.working_age.personal_allowance + wales = p.wales.council_tax_reduction.working_age.personal_allowance + couple = benunit("is_couple", period) + lone_parent = benunit("is_lone_parent", period) + age = benunit("eldest_claimant_or_partner_age", period) + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + on_esa = ( + add_for_members( + benunit, period, ["esa_contrib", "esa_income"], claimant_or_partner + ) + > 0 + ) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + limited_capability = benunit.any( + claimant_or_partner & person("uc_limited_capability_for_WRA", period) + ) + scotland_higher_single = ( + (age >= scot.age_threshold) + | on_esa + | (has_universal_credit & limited_capability) + ) + wales_higher_single = (age >= wales.age_threshold) | on_esa + scotland_single = where( + scotland_higher_single, scot.single_25_or_over, scot.single_under_25 + ) + wales_single = where( + wales_higher_single, wales.single_25_or_over, wales.single_under_25 + ) + scotland_amount = select( + [couple, lone_parent], [scot.couple, scot.lone_parent], scotland_single + ) + wales_amount = select( + [couple, lone_parent], [wales.couple, wales.lone_parent], wales_single + ) + pa = where(scotland, scotland_amount, wales_amount) + return pa * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py new file mode 100644 index 0000000000..23b8bfc387 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, + is_wales_scheme, +) + + +class council_tax_reduction_working_age_tariff_income(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction tariff income" + documentation = ( + "Annual tariff income from capital in a working-age council tax " + "reduction claim in Scotland or Wales: £1 a week for each £250, or " + "part of £250, above £6,000. A Welsh applicant with Universal Credit " + "has none, because the Universal Credit income figure already " + "includes Universal Credit's own assumed yield from capital." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/63", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + country = benunit.household("country", period) + scotland = is_scotland_scheme(country) + wales = is_wales_scheme(country) + scot = p.scotland.council_tax_reduction.working_age.tariff_income + welsh = p.wales.council_tax_reduction.working_age.tariff_income + threshold = where(scotland, scot.threshold, welsh.threshold) + step = where(scotland, scot.step, welsh.step) + amount = where(scotland, scot.amount, welsh.amount) + capital = benunit("council_tax_reduction_working_age_capital", period) + steps = np.ceil(max_(0, capital - threshold) / step) + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + applies = ~(wales & has_universal_credit) + return applies * steps * amount * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py new file mode 100644 index 0000000000..a6d43be3fb --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py @@ -0,0 +1,57 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_working_age_unearned_income(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction unearned income" + documentation = ( + "Annual unearned income of the claimant and partner counted in a " + "working-age council tax reduction claim in Scotland or Wales, other " + "than Universal Credit and tariff income from capital. Retirement " + "pensions, contributory Jobseeker's Allowance and Employment and " + "Support Allowance, carer's allowance, carer support payment, " + "maternity allowance, industrial injuries benefit, incapacity benefit, " + "severe disablement allowance and working and child tax credits " + "count. Child Benefit, the income-related benefits, disability " + "benefits, Housing Benefit and child maintenance do not. Actual " + "savings interest, dividends and rent are treated as capital, which " + "yields tariff income instead." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/57", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/63", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/17", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9", + ) + + def formula(benunit, period, parameters): + person = benunit.members + members = person("is_claimant_or_partner", period) + income = add_for_members( + benunit, + period, + [ + "state_pension", + "private_pension_income", + "jsa_contrib", + "esa_contrib", + "carers_allowance", + "carer_support_payment", + "maternity_allowance", + "iidb", + "incapacity_benefit", + "sda", + ], + members, + ) + tax_credits = add(benunit, period, ["working_tax_credit", "child_tax_credit"]) + bi = parameters(period).gov.contrib.ubi_center.basic_income + basic_income = ( + add_for_members(benunit, period, ["basic_income"], members) + if bi.interactions.include_in_means_tests + else 0 + ) + return income + tax_credits + basic_income diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_universal_credit_award.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_universal_credit_award.py new file mode 100644 index 0000000000..3262781744 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_universal_credit_award.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_working_age_universal_credit_award(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit award for working-age council tax reduction" + documentation = ( + "The Universal Credit award after any benefit cap reduction and before " + "deductions from payment, such as advance or third-party deductions. " + "The benefit cap reduces the award (UC Regs 2013 reg 81), while " + "deductions from payment do not." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/57", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", + ) + + def formula(benunit, period, parameters): + award_before_cap = benunit("universal_credit_pre_benefit_cap", period) + cap_reduction = benunit("benefit_cap_reduction", period) + return max_(0, award_before_cap - cap_reduction) From ee5bd58353aabcf1ed6dff9496cf589a32c7d003 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:13:20 -0400 Subject: [PATCH 002/250] Use devolved CTR premiums and fix earnings, tax and carer rules after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adult premiums: Scotland's and Wales's own disability, severe disability, enhanced disability and carer premium amounts (2013-14 to 2026-27), with a carer premium for each qualifying claimant or partner (SSI 2021/249 Sch 1 paras 5, 17; WSI 2013/3029 Sch 7 paras 14, 17). - Carer earnings disregard: £20 of the carers' own earnings plus up to £10 of a non-carer partner's, within £20 (Sch 8 / Sch 3 paras 6-7). - £17.10: a couple's disabled member must be the one in remunerative work; in Scotland from April 2022 only employed earnings qualify (Sch 3 para 15). - Wales: tax on unearned income is disregarded (Sch 9 para 4). - Scotland: only the Carer Support Payment component counts, not the Carer Supplement (reg 4(1), reg 57(1)). - Statutory sick, maternity and paternity pay count as earnings on the UC route too; childcare comes off earnings after the disregards. Co-Authored-By: Claude Opus 5.5 --- .../working_age/adult_premiums.yaml | 544 +++++++++++++++++ .../working_age/earnings_disregard.yaml | 14 + .../working_age/adult_premiums.yaml | 551 ++++++++++++++++++ ...il_tax_reduction_devolved_working_age.yaml | 270 +++++++++ .../working_age/_earnings.py | 34 ++ ...ax_reduction_working_age_adult_premiums.py | 74 +++ ...reduction_working_age_applicable_amount.py | 9 +- ...reduction_working_age_applicable_income.py | 30 +- ...ouncil_tax_reduction_working_age_carers.py | 30 + ...duction_working_age_childcare_deduction.py | 6 +- ...tax_reduction_working_age_earned_income.py | 50 +- ...eduction_working_age_earnings_disregard.py | 98 +++- ...tion_working_age_employed_earned_income.py | 32 + ...uction_working_age_person_earned_income.py | 33 ++ ...ax_reduction_working_age_unabsorbed_tax.py | 29 + ...x_reduction_working_age_unearned_income.py | 12 +- 16 files changed, 1732 insertions(+), 84 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/adult_premiums.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/adult_premiums.yaml create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/adult_premiums.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/adult_premiums.yaml new file mode 100644 index 0000000000..0e54ef0fcd --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/adult_premiums.yaml @@ -0,0 +1,544 @@ +description: Weekly adult premiums in the applicable amount of a working-age applicant under the Scotland + council tax reduction rules. +disability_premium: + description: Scotland working-age CTR disability premium. + single: + description: Weekly disability premium for a single applicant or lone parent. + values: + 2013-04-01: + value: 31.0 + reference: + - title: SSI 2013/48 reg 14(f)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 31.85 + reference: + - title: SSI 2014/35 reg 4(d)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 32.25 + reference: + - title: SSI 2015/46 reg 9(c)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 32.55 + reference: + - title: SSI 2017/41 reg 10(a)(i) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 33.55 + reference: + - title: SSI 2018/69 reg 9(a)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 34.35 + reference: + - title: SSI 2019/29 reg 5(a)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 34.95 + reference: + - title: SSI 2020/25 reg 8(c)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 35.1 + reference: + - title: SSI 2021/51 reg 7(c)(i)(aa) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 36.2 + reference: + - title: SSI 2022/52 reg 25(c)(iii)(aa) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 39.85 + reference: + - title: SSI 2023/38 reg 20(i)(iii)(aa) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 42.5 + reference: + - title: SSI 2024/35 reg 17(i)(iii)(aa) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 43.2 + reference: + - title: SSI 2025/24 reg 16(i)(iii)(aa) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 44.85 + reference: + - title: SSI 2026/56 reg 14(i)(iii)(aa) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR disability premium, single + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 + couple: + description: Weekly disability premium for a couple. + values: + 2013-04-01: + value: 44.2 + reference: + - title: SSI 2013/48 reg 14(f)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 45.4 + reference: + - title: SSI 2014/35 reg 4(d)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 45.95 + reference: + - title: SSI 2015/46 reg 9(c)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 46.4 + reference: + - title: SSI 2017/41 reg 10(a)(ii) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 47.8 + reference: + - title: SSI 2018/69 reg 9(a)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 48.95 + reference: + - title: SSI 2019/29 reg 5(a)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 49.8 + reference: + - title: SSI 2020/25 reg 8(c)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 50.05 + reference: + - title: SSI 2021/51 reg 7(c)(i)(bb) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 51.6 + reference: + - title: SSI 2022/52 reg 25(c)(iii)(bb) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 56.8 + reference: + - title: SSI 2023/38 reg 20(i)(iii)(bb) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 60.6 + reference: + - title: SSI 2024/35 reg 17(i)(iii)(bb) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 61.65 + reference: + - title: SSI 2025/24 reg 16(i)(iii)(bb) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 64.0 + reference: + - title: SSI 2026/56 reg 14(i)(iii)(bb) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR disability premium, couple + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 +severe_disability_premium: + description: Scotland working-age CTR severe disability premium. + single: + description: Weekly severe disability premium where one person qualifies, or where someone is paid + a carer benefit for caring for a member of the couple. + values: + 2013-04-01: + value: 59.5 + reference: + - title: SSI 2013/48 reg 14(f)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 61.1 + reference: + - title: SSI 2014/35 reg 4(d)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 61.85 + reference: + - title: SSI 2015/46 reg 9(c)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 62.45 + reference: + - title: SSI 2017/41 reg 10(a)(iii) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 64.3 + reference: + - title: SSI 2018/69 reg 9(a)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 65.85 + reference: + - title: SSI 2019/29 reg 5(a)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 66.95 + reference: + - title: SSI 2020/25 reg 8(c)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 67.3 + reference: + - title: SSI 2021/51 reg 7(c)(ii)(aa) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 69.4 + reference: + - title: SSI 2022/52 reg 25(c)(iv)(aa) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 76.4 + reference: + - title: SSI 2023/38 reg 20(i)(iv)(aa) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 81.5 + reference: + - title: SSI 2024/35 reg 17(i)(iv)(aa) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 82.9 + reference: + - title: SSI 2025/24 reg 16(i)(iv)(aa) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 86.05 + reference: + - title: SSI 2026/56 reg 14(i)(iv)(aa) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR severe disability premium, single + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 + couple: + description: Weekly severe disability premium for a couple who both qualify and for whom no one is + paid a carer benefit. + values: + 2013-04-01: + value: 119.0 + reference: + - title: SSI 2013/48 reg 14(f)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 122.2 + reference: + - title: SSI 2014/35 reg 4(d)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 123.7 + reference: + - title: SSI 2015/46 reg 9(c)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 124.9 + reference: + - title: SSI 2017/41 reg 10(a)(iv) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 128.6 + reference: + - title: SSI 2018/69 reg 9(a)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 131.7 + reference: + - title: SSI 2019/29 reg 5(a)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 133.9 + reference: + - title: SSI 2020/25 reg 8(c)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 134.6 + reference: + - title: SSI 2021/51 reg 7(c)(ii)(bb) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 138.8 + reference: + - title: SSI 2022/52 reg 25(c)(iv)(bb) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 152.8 + reference: + - title: SSI 2023/38 reg 20(i)(iv)(bb) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 163.0 + reference: + - title: SSI 2024/35 reg 17(i)(iv)(bb) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 165.8 + reference: + - title: SSI 2025/24 reg 16(i)(iv)(bb) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 172.1 + reference: + - title: SSI 2026/56 reg 14(i)(iv)(bb) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR severe disability premium, couple + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 +enhanced_disability_premium: + description: Scotland working-age CTR enhanced disability premium. + single: + description: Weekly enhanced disability premium for a single applicant or lone parent. + values: + 2013-04-01: + value: 15.15 + reference: + - title: SSI 2013/48 reg 14(f)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 15.55 + reference: + - title: SSI 2014/35 reg 4(d)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 15.75 + reference: + - title: SSI 2015/46 reg 9(c)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 15.9 + reference: + - title: SSI 2017/41 reg 10(a)(viii) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 16.4 + reference: + - title: SSI 2018/69 reg 9(a)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 16.8 + reference: + - title: SSI 2019/29 reg 5(a)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 17.1 + reference: + - title: SSI 2020/25 reg 8(c)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 17.2 + reference: + - title: SSI 2021/51 reg 7(c)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 17.75 + reference: + - title: SSI 2022/52 reg 25(c)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 19.55 + reference: + - title: SSI 2023/38 reg 20(i)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 20.85 + reference: + - title: SSI 2024/35 reg 17(i)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 21.2 + reference: + - title: SSI 2025/24 reg 16(i)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 22.0 + reference: + - title: SSI 2026/56 reg 14(i)(v)(bb) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR enhanced disability premium, single + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 + couple: + description: Weekly enhanced disability premium for a couple. + values: + 2013-04-01: + value: 21.75 + reference: + - title: SSI 2013/48 reg 14(f)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 22.35 + reference: + - title: SSI 2014/35 reg 4(d)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 22.6 + reference: + - title: SSI 2015/46 reg 9(c)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 22.85 + reference: + - title: SSI 2017/41 reg 10(a)(ix) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 23.55 + reference: + - title: SSI 2018/69 reg 9(a)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 24.1 + reference: + - title: SSI 2019/29 reg 5(a)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 24.5 + reference: + - title: SSI 2020/25 reg 8(c)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 24.6 + reference: + - title: SSI 2021/51 reg 7(c)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 25.35 + reference: + - title: SSI 2022/52 reg 25(c)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 27.9 + reference: + - title: SSI 2023/38 reg 20(i)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 29.75 + reference: + - title: SSI 2024/35 reg 17(i)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 30.25 + reference: + - title: SSI 2025/24 reg 16(i)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 31.4 + reference: + - title: SSI 2026/56 reg 14(i)(v)(cc) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR enhanced disability premium, couple + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 +carer_premium: + description: Weekly carer premium for each claimant or partner who qualifies. + values: + 2013-04-01: + value: 33.3 + reference: + - title: SSI 2013/48 reg 14(f)(iv) + href: https://www.legislation.gov.uk/ssi/2013/48/regulation/14 + 2014-04-01: + value: 34.2 + reference: + - title: SSI 2014/35 reg 4(d)(iv) + href: https://www.legislation.gov.uk/ssi/2014/35/regulation/4 + 2015-04-01: + value: 34.6 + reference: + - title: SSI 2015/46 reg 9(c)(iv) + href: https://www.legislation.gov.uk/ssi/2015/46/regulation/9 + 2017-04-01: + value: 34.95 + reference: + - title: SSI 2017/41 reg 10(a)(vi) + href: https://www.legislation.gov.uk/ssi/2017/41/regulation/10 + 2018-04-01: + value: 36.0 + reference: + - title: SSI 2018/69 reg 9(a)(iv) + href: https://www.legislation.gov.uk/ssi/2018/69/regulation/9 + 2019-04-01: + value: 36.85 + reference: + - title: SSI 2019/29 reg 5(a)(iv) + href: https://www.legislation.gov.uk/ssi/2019/29/regulation/5 + 2020-04-01: + value: 37.5 + reference: + - title: SSI 2020/25 reg 8(c)(iv) + href: https://www.legislation.gov.uk/ssi/2020/25/regulation/8 + 2021-04-01: + value: 37.7 + reference: + - title: SSI 2021/51 reg 7(c)(iv) + href: https://www.legislation.gov.uk/ssi/2021/51/regulation/7 + 2022-04-01: + value: 38.85 + reference: + - title: SSI 2022/52 reg 25(c)(ii) + href: https://www.legislation.gov.uk/ssi/2022/52/regulation/25/made + 2023-04-01: + value: 42.75 + reference: + - title: SSI 2023/38 reg 20(i)(ii) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/20 + 2024-04-01: + value: 45.6 + reference: + - title: SSI 2024/35 reg 17(i)(ii) + href: https://www.legislation.gov.uk/ssi/2024/35/regulation/17 + 2025-04-01: + value: 46.4 + reference: + - title: SSI 2025/24 reg 16(i)(ii) + href: https://www.legislation.gov.uk/ssi/2025/24/regulation/16 + 2026-04-01: + value: 48.15 + reference: + - title: SSI 2026/56 reg 14(i)(ii) + href: https://www.legislation.gov.uk/ssi/2026/56/regulation/14 + metadata: + label: Scotland working-age CTR carer premium + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 paras. 5 and 11-17 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1 +metadata: + label: Scotland working-age CTR adult premiums diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml index f18633a526..ce5766be2c 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/earnings_disregard.yaml @@ -76,6 +76,20 @@ additional_full_time_age: reference: - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 15(2) href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 +additional_employed_earnings_only: + description: Whether the additional earnings disregard depends on employed earnings only (1) rather than all net earnings (0). From 1 April 2022 it applies where earnings are calculated under regulation 50 (employed earnings) and the employed earnings cover the other disregards, childcare charges and the additional amount. + values: + 2013-04-01: 0 + 2022-04-01: 1 + metadata: + label: Scotland working-age CTR additional disregard on employed earnings only + unit: bool + period: year + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 3 para. 15(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/3 + - title: The Council Tax Reduction (Scotland) Regulations 2012 Sch. 3 para. 18(1) (net earnings, before 1 April 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/schedule/3 remunerative_work_hours: description: Weekly hours from which a person is in remunerative work. values: diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/adult_premiums.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/adult_premiums.yaml new file mode 100644 index 0000000000..cee373b9c6 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/adult_premiums.yaml @@ -0,0 +1,551 @@ +description: Weekly adult premiums in the applicable amount of a working-age applicant under the Wales + council tax reduction rules. +disability_premium: + description: Wales working-age CTR disability premium. + single: + description: Weekly disability premium for a single applicant or lone parent. + values: + 2013-04-01: + value: 31.0 + reference: + - title: W.S.I. 2013/112 reg 10(c)(i) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 31.85 + reference: + - title: W.S.I. 2014/66 reg 10(d)(i) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 32.25 + reference: + - title: W.S.I. 2015/44 reg 11(d)(i) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 32.55 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(aa) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 33.55 + reference: + - title: W.S.I. 2018/14 reg 10(c)(i) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 34.35 + reference: + - title: W.S.I. 2019/11 reg 7(b)(i) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 34.95 + reference: + - title: W.S.I. 2020/16 reg 10(b)(i) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 35.1 + reference: + - title: W.S.I. 2021/34 reg 8(b)(i) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 36.2 + reference: + - title: W.S.I. 2022/51 reg 8(d)(i) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 39.85 + reference: + - title: W.S.I. 2023/47 reg 9(d)(i) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 42.5 + reference: + - title: W.S.I. 2024/56 reg 8(d)(i) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 43.2 + reference: + - title: W.S.I. 2025/59 reg 9(d)(i) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 44.85 + reference: + - title: W.S.I. 2026/13 reg 11(d)(i) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR disability premium, single + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 + Sch. 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 + couple: + description: Weekly disability premium for a couple. + values: + 2013-04-01: + value: 44.2 + reference: + - title: W.S.I. 2013/112 reg 10(c)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 45.4 + reference: + - title: W.S.I. 2014/66 reg 10(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 45.95 + reference: + - title: W.S.I. 2015/44 reg 11(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 46.4 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(aa) (figure 2) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 47.8 + reference: + - title: W.S.I. 2018/14 reg 10(c)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 48.95 + reference: + - title: W.S.I. 2019/11 reg 7(b)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 49.8 + reference: + - title: W.S.I. 2020/16 reg 10(b)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 50.05 + reference: + - title: W.S.I. 2021/34 reg 8(b)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 51.6 + reference: + - title: W.S.I. 2022/51 reg 8(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 56.8 + reference: + - title: W.S.I. 2023/47 reg 9(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 60.6 + reference: + - title: W.S.I. 2024/56 reg 8(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 61.65 + reference: + - title: W.S.I. 2025/59 reg 9(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 64.0 + reference: + - title: W.S.I. 2026/13 reg 11(d)(i) (figure 2) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR disability premium, couple + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 + Sch. 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 +severe_disability_premium: + description: Wales working-age CTR severe disability premium. + single: + description: Weekly severe disability premium where one person qualifies, or where someone is paid + a carer benefit for caring for a member of the couple. + values: + 2013-04-01: + value: 59.5 + reference: + - title: W.S.I. 2013/112 reg 10(c)(ii) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 61.1 + reference: + - title: W.S.I. 2014/66 reg 10(d)(ii) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 61.85 + reference: + - title: W.S.I. 2015/44 reg 11(d)(ii) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 62.45 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(bb) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 64.3 + reference: + - title: W.S.I. 2018/14 reg 10(c)(ii) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 65.85 + reference: + - title: W.S.I. 2019/11 reg 7(b)(ii) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 66.95 + reference: + - title: W.S.I. 2020/16 reg 10(b)(ii) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 67.3 + reference: + - title: W.S.I. 2021/34 reg 8(b)(ii) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 69.4 + reference: + - title: W.S.I. 2022/51 reg 8(d)(ii) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 76.4 + reference: + - title: W.S.I. 2023/47 reg 9(d)(ii) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 81.5 + reference: + - title: W.S.I. 2024/56 reg 8(d)(ii) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 82.9 + reference: + - title: W.S.I. 2025/59 reg 9(d)(ii) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 86.05 + reference: + - title: W.S.I. 2026/13 reg 11(d)(ii) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR severe disability premium, single + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 + Sch. 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 + couple: + description: Weekly severe disability premium for a couple who both qualify and for whom no one is + paid a carer benefit. + values: + 2013-04-01: + value: 119.0 + reference: + - title: W.S.I. 2013/112 reg 10(c)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 122.2 + reference: + - title: W.S.I. 2014/66 reg 10(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 123.7 + reference: + - title: W.S.I. 2015/44 reg 11(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 124.9 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(bb) (figure 2) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 128.6 + reference: + - title: W.S.I. 2018/14 reg 10(c)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 131.7 + reference: + - title: W.S.I. 2019/11 reg 7(b)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 133.9 + reference: + - title: W.S.I. 2020/16 reg 10(b)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 134.6 + reference: + - title: W.S.I. 2021/34 reg 8(b)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 138.8 + reference: + - title: W.S.I. 2022/51 reg 8(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 152.8 + reference: + - title: W.S.I. 2023/47 reg 9(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 163.0 + reference: + - title: W.S.I. 2024/56 reg 8(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 165.8 + reference: + - title: W.S.I. 2025/59 reg 9(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 172.1 + reference: + - title: W.S.I. 2026/13 reg 11(d)(ii) (figure 2) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR severe disability premium, couple + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 + Sch. 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 +enhanced_disability_premium: + description: Wales working-age CTR enhanced disability premium. + single: + description: Weekly enhanced disability premium for a single applicant or lone parent. + values: + 2013-04-01: + value: 15.15 + reference: + - title: W.S.I. 2013/112 reg 10(c)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 15.55 + reference: + - title: W.S.I. 2014/66 reg 10(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 15.75 + reference: + - title: W.S.I. 2015/44 reg 11(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 15.9 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(ee) (figure 2) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 16.4 + reference: + - title: W.S.I. 2018/14 reg 10(c)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 16.8 + reference: + - title: W.S.I. 2019/11 reg 7(b)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 17.1 + reference: + - title: W.S.I. 2020/16 reg 10(b)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 17.2 + reference: + - title: W.S.I. 2021/34 reg 8(b)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 17.75 + reference: + - title: W.S.I. 2022/51 reg 8(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 19.55 + reference: + - title: W.S.I. 2023/47 reg 9(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 20.85 + reference: + - title: W.S.I. 2024/56 reg 8(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 21.2 + reference: + - title: W.S.I. 2025/59 reg 9(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 22.0 + reference: + - title: W.S.I. 2026/13 reg 11(d)(v) (figure 2) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR enhanced disability premium, single + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 + Sch. 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 + couple: + description: Weekly enhanced disability premium for a couple. + values: + 2013-04-01: + value: 21.75 + reference: + - title: W.S.I. 2013/112 reg 10(c)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 22.35 + reference: + - title: W.S.I. 2014/66 reg 10(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 22.6 + reference: + - title: W.S.I. 2015/44 reg 11(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 22.85 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(ee) (figure 3) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 23.55 + reference: + - title: W.S.I. 2018/14 reg 10(c)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 24.1 + reference: + - title: W.S.I. 2019/11 reg 7(b)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 24.5 + reference: + - title: W.S.I. 2020/16 reg 10(b)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 24.6 + reference: + - title: W.S.I. 2021/34 reg 8(b)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 25.35 + reference: + - title: W.S.I. 2022/51 reg 8(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 27.9 + reference: + - title: W.S.I. 2023/47 reg 9(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 29.75 + reference: + - title: W.S.I. 2024/56 reg 8(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 30.25 + reference: + - title: W.S.I. 2025/59 reg 9(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 31.4 + reference: + - title: W.S.I. 2026/13 reg 11(d)(v) (figure 3) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR enhanced disability premium, couple + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 + Sch. 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 +carer_premium: + description: Weekly carer premium for each claimant or partner who qualifies. + values: + 2013-04-01: + value: 33.3 + reference: + - title: W.S.I. 2013/112 reg 10(c)(iv) + href: https://www.legislation.gov.uk/wsi/2013/112/regulation/10 + 2014-04-01: + value: 34.2 + reference: + - title: W.S.I. 2014/66 reg 10(d)(iv) + href: https://www.legislation.gov.uk/wsi/2014/66/regulation/10/made + 2015-04-01: + value: 34.6 + reference: + - title: W.S.I. 2015/44 reg 11(d)(iv) + href: https://www.legislation.gov.uk/wsi/2015/44/regulation/11 + 2017-04-01: + value: 34.95 + reference: + - title: W.S.I. 2017/46 reg 6(c)(ii)(dd) + href: https://www.legislation.gov.uk/wsi/2017/46/regulation/6 + 2018-04-01: + value: 36.0 + reference: + - title: W.S.I. 2018/14 reg 10(c)(iv) + href: https://www.legislation.gov.uk/wsi/2018/14/regulation/10 + 2019-04-01: + value: 36.85 + reference: + - title: W.S.I. 2019/11 reg 7(b)(iv) + href: https://www.legislation.gov.uk/wsi/2019/11/regulation/7 + 2020-04-01: + value: 37.5 + reference: + - title: W.S.I. 2020/16 reg 10(b)(iv) + href: https://www.legislation.gov.uk/wsi/2020/16/regulation/10 + 2021-04-01: + value: 37.7 + reference: + - title: W.S.I. 2021/34 reg 8(b)(iv) + href: https://www.legislation.gov.uk/wsi/2021/34/regulation/8 + 2022-04-01: + value: 38.85 + reference: + - title: W.S.I. 2022/51 reg 8(d)(iv) + href: https://www.legislation.gov.uk/wsi/2022/51/regulation/8 + 2023-04-01: + value: 42.75 + reference: + - title: W.S.I. 2023/47 reg 9(d)(iv) + href: https://www.legislation.gov.uk/wsi/2023/47/regulation/9 + 2024-04-01: + value: 45.6 + reference: + - title: W.S.I. 2024/56 reg 8(d)(iv) + href: https://www.legislation.gov.uk/wsi/2024/56/regulation/8 + 2025-04-01: + value: 46.4 + reference: + - title: W.S.I. 2025/59 reg 9(d)(iv) + href: https://www.legislation.gov.uk/wsi/2025/59/regulation/9 + 2026-04-01: + value: 48.15 + reference: + - title: W.S.I. 2026/13 reg 11(d)(iv) + href: https://www.legislation.gov.uk/wsi/2026/13/regulation/11 + metadata: + label: Wales working-age CTR carer premium + unit: currency-GBP + period: week + uprating: gov.benefit_uprating_cpi + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 7 paras. 9-17 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/7 +metadata: + label: Wales working-age CTR adult premiums diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index 6c5815ae8e..e3b7fab53c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -417,3 +417,273 @@ council_tax: 1_200 output: council_tax_reduction_working_age_applicable_amount: 10_358.40 + +- name: Wales, carer disregard is £20 of the carer's earnings plus up to £10 of the partner's + # Sch 8 paras 6-7: the carer (Carer's Allowance, no earnings) contributes + # nothing, so only £10 of the working partner's earnings is disregarded + # (520 a year). Partner: 14,000 less tax 34 (after the 252 marriage + # allowance) and NI 114.40 = 13,851.60. Applicable amount (159.85 couple + + # 48.15 carer premium) x 52 = 10,816. Income 13,851.60 - 520 + 4,495.40 + # Carer's Allowance = 17,827; excess 7,011; award 1,800 - 1,402.20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + council_tax_benefit_reported: 100 + partner: + age: 40 + employment_income: 14_000 + weekly_hours: 20 + benunits: + benunit: + members: [carer, partner] + would_claim_uc: false + households: + household: + members: [carer, partner] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_earnings_disregard: 520 + council_tax_reduction_working_age_applicable_income: 17_827 + simulated_council_tax_reduction_benunit: 397.80 + +- name: Wales, the £17.10 needs the disabled member of a couple to be the one in work + # Sch 8 para 18(2)(b)(v): the disabled claimant does not work, so only the + # £20 disability disregard applies (para 4), 1,040 a year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_disabled_for_benefits: true + council_tax_benefit_reported: 100 + partner: + age: 40 + employment_income: 14_000 + weekly_hours: 20 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_earnings_disregard: 1_040 + simulated_council_tax_reduction_benunit: 1_565.72 + +- name: Wales, tax on unearned income is disregarded + # Sch 9 para 4: income tax of 286 on a 14,000 private pension comes off + # unearned income. Income 13,714; applicable amount 101.85 x 52 = 5,296.20; + # award 2,000 - 0.2 x 8,417.80 = 316.44. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + private_pension_income: 14_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_working_age_unabsorbed_tax: 286 + council_tax_reduction_working_age_applicable_income: 13_714 + simulated_council_tax_reduction_benunit: 316.44 + +- name: Scotland, only the Carer Support Payment component counts, not the Carer Supplement + # SSI 2021/249 reg 4(1) (as amended by SSI 2025/340) and reg 57(1)(b)(iva): + # 86.45 x 52 = 4,495.40. Earnings 5,000 less the £20 carer disregard; + # applicable amount (95.55 + 48.15) x 52 = 7,472.40; income 8,455.40; + # award 1,800 - 196.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 35 + care_hours: 40 + would_claim_carers_allowance: true + employment_income: 5_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_unearned_income: 4_495.40 + simulated_council_tax_reduction_benunit: 1_603.40 + +- name: Scotland, a carer premium for each partner who is a carer + # Sch 1 para 5(3): 2 x 48.15 x 52 = 5,007.60. Applicable amount 150.15 x 52 + # + 5,007.60 = 12,815.40. Income 6,000 - 1,040 + 2 x 4,495.40 = 13,950.80; + # award 1,800 - 0.2 x 1,135.40 = 1,572.92. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer_one: + age: 40 + care_hours: 40 + would_claim_carers_allowance: true + employment_income: 6_000 + weekly_hours: 10 + council_tax_benefit_reported: 100 + carer_two: + age: 40 + care_hours: 40 + would_claim_carers_allowance: true + benunits: + benunit: + members: [carer_one, carer_two] + would_claim_uc: false + households: + household: + members: [carer_one, carer_two] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_adult_premiums: 5_007.60 + council_tax_reduction_working_age_applicable_amount: 12_815.40 + simulated_council_tax_reduction_benunit: 1_572.92 + +- name: Scotland, statutory maternity pay is earnings on the Universal Credit route + # Reg 49(4): SMP counts as employed earnings, which takes this family's + # income well above its applicable amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 15_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + partner: + age: 30 + statutory_maternity_pay: 9_000 + baby: + age: 0 + benunits: + benunit: + members: [claimant, partner, baby] + would_claim_uc: true + households: + household: + members: [claimant, partner, baby] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 0 + +- name: Scotland, the £17.10 needs employed earnings from April 2022 + # Sch 3 para 15(1): a self-employed lone parent gets only the £25 disregard + # (1,300 a year). Applicable amount (95.55 + 109.85) x 52 = 10,680.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + self_employment_income: 15_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_earnings_disregard: 1_300 + simulated_council_tax_reduction_benunit: 1_279.66 + +- name: Scotland, an employed lone parent working 30 hours gets the £17.10 + # (25 + 17.10) x 52 = 2,189.20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 15_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_earnings_disregard: 2_189.20 + +- name: Scotland 2025-26, the disability premium is the Scottish amount + # SSI 2025/24: £43.20 a week (2,246.40 a year), not the projected DWP rate. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_disabled_for_benefits: true + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_adult_premiums: 2_246.40 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py new file mode 100644 index 0000000000..c955749df7 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + +STATUTORY_PAY = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", +] + + +def working_age_earnings_components(person, period): + """Gross earnings, employed earnings, pension deduction and tax per person. + + With an award of Universal Credit, earnings follow the Universal Credit + measure (with any minimum income floor) and all pension contributions are + deducted; otherwise employment and self-employment income count and half + of pension contributions are deducted. Statutory sick, maternity and + paternity pay are employed earnings on both routes (SSI 2021/249 regs + 49(4), 50(2); WSI 2013/3029 Sch 6 para 14(1)). + """ + has_universal_credit = person.benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + statutory_pay = add(person, period, STATUTORY_PAY) + employment = person("employment_income", period) + universal_credit_gross = ( + person("uc_mif_capped_earned_income", period) + statutory_pay + ) + legacy_gross = employment + person("self_employment_income", period) + statutory_pay + gross = where(has_universal_credit, universal_credit_gross, legacy_gross) + employed_gross = min_(employment + statutory_pay, gross) + pension_share = where(has_universal_credit, 1, 0.5) + pension_deduction = person("pension_contributions", period) * pension_share + tax = person("tax", period) + return gross, employed_gross, pension_deduction, tax diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py new file mode 100644 index 0000000000..7df38e8f44 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py @@ -0,0 +1,74 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, +) + + +class council_tax_reduction_working_age_adult_premiums(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction adult premiums" + documentation = ( + "Annual disability, severe disability, enhanced disability and carer " + "premiums in a working-age applicable amount in Scotland or Wales, at " + "each country's own amounts. Who qualifies follows the model's " + "legacy-benefit premium variables (disability_premium, " + "enhanced_disability_premium, severe_disability_premium), including " + "whether the severe disability premium is at the single or couple " + "rate. The carer premium is paid for each qualifying claimant or " + "partner. The employment and support allowance components, and the " + "disability premium's exclusion for limited capability for work, are " + "not modelled." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/17", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.local_authorities + scotland = is_scotland_scheme(benunit.household("country", period)) + scot = p.scotland.council_tax_reduction.working_age.adult_premiums + wales = p.wales.council_tax_reduction.working_age.adult_premiums + dwp = parameters(period).gov.dwp.disability_premia + + def amount(node, field=None): + s = getattr(scot, node) + w = getattr(wales, node) + if field is not None: + s, w = getattr(s, field), getattr(w, field) + return where(scotland, s, w) + + couple = benunit("is_couple", period) + disability = benunit("disability_premium", period) > 0 + enhanced = benunit("enhanced_disability_premium", period) > 0 + severe_weekly = benunit("severe_disability_premium", period) / WEEKS_IN_YEAR + severe = severe_weekly > 0 + # The legacy variable pays either the single or the double rate; + # follow whichever it chose. + severe_double = severe_weekly > (dwp.severe_single + dwp.severe_couple) / 2 + weekly = ( + disability + * where( + couple, + amount("disability_premium", "couple"), + amount("disability_premium", "single"), + ) + + enhanced + * where( + couple, + amount("enhanced_disability_premium", "couple"), + amount("enhanced_disability_premium", "single"), + ) + + severe + * where( + severe_double, + amount("severe_disability_premium", "couple"), + amount("severe_disability_premium", "single"), + ) + + benunit("council_tax_reduction_working_age_carers", period) + * amount("carer_premium") + ) + return weekly * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py index 4edc309476..fa80ffc541 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_amount.py @@ -16,10 +16,9 @@ class council_tax_reduction_working_age_applicable_amount(Variable): "give the same annual total). Otherwise, and for every Scottish " "applicant including those on Universal Credit, it is the personal " "allowance, the amounts for children, and the adult disability, " - "severe disability, enhanced disability and carer premiums. The adult " - "premiums use the model's legacy-benefit premium variables, whose " - "amounts equal the Scottish and Welsh amounts. The employment and " - "support allowance components are not modelled." + "severe disability, enhanced disability and carer premiums at each " + "country's own amounts. The employment and support allowance " + "components are not modelled." ) definition_period = YEAR unit = GBP @@ -41,7 +40,7 @@ def formula(benunit, period, parameters): [ "council_tax_reduction_working_age_personal_allowance", "council_tax_reduction_working_age_child_amounts", - "benefits_premiums", + "council_tax_reduction_working_age_adult_premiums", ], ) return where( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py index 7d462c0d2a..85c57351bc 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py @@ -18,8 +18,8 @@ class council_tax_reduction_working_age_applicable_income(Variable): "income figure (earnings before the work allowance, plus Universal " "Credit unearned income) plus the Universal Credit award. Everyone " "else counts net earnings less disregards and childcare charges, " - "unearned income, tariff income from capital and, in Scotland, the " - "relevant Universal Credit payments." + "unearned income (in Wales, less tax on it), tariff income from " + "capital and, in Scotland, the relevant Universal Credit payments." ) definition_period = YEAR unit = GBP @@ -50,19 +50,37 @@ def formula(benunit, period, parameters): childcare = benunit( "council_tax_reduction_working_age_childcare_deduction", period ) + # Without Universal Credit, childcare charges come off earnings after + # the disregards (WSI 2013/3029 Sch 6 para 20(1)(c); SSI 2021/249 reg + # 38(2)(c)). A Scottish applicant with Universal Credit deducts them + # from income (reg 42(2)(c)). + earnings_after_deductions = max_( + 0, earnings - disregard - where(has_universal_credit, 0, childcare) + ) + unearned = benunit("council_tax_reduction_working_age_unearned_income", period) + # Wales disregards tax on unearned income (Sch 9 para 4); Scotland + # counts unearned income gross (reg 57). + unearned_tax = where( + wales & ~has_universal_credit, + min_( + benunit("council_tax_reduction_working_age_unabsorbed_tax", period), + unearned, + ), + 0, + ) own_rules_income = ( - earnings - - disregard + earnings_after_deductions + + unearned + - unearned_tax + add( benunit, period, [ - "council_tax_reduction_working_age_unearned_income", "council_tax_reduction_working_age_tariff_income", "council_tax_reduction_relevant_universal_credit_payments", ], ) - - childcare + - where(has_universal_credit, childcare, 0) ) income = where( wales & has_universal_credit, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py new file mode 100644 index 0000000000..ad0e8b91a6 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, +) + + +class council_tax_reduction_working_age_carers(Variable): + value_type = int + entity = BenUnit + label = "Claimants and partners qualifying for the working-age CTR carer premium" + documentation = ( + "The number of claimants and partners who receive Carer's Allowance " + "or, in Scotland, Carer Support Payment. In Scotland an award of " + "Universal Credit with the carer element also qualifies; when no one " + "in the family receives a carer benefit, it counts as one carer." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/14", + ) + + def formula(benunit, period, parameters): + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + receives = person("receives_carer_benefit", period) + carers = benunit.sum(claimant_or_partner & receives) + scotland = is_scotland_scheme(benunit.household("country", period)) + uc_carer_element = benunit("uc_carer_element", period) > 0 + return carers + (scotland & uc_carer_element & (carers == 0)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py index bbb559d910..b476cdea4b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py @@ -11,7 +11,8 @@ class council_tax_reduction_working_age_childcare_deduction(Variable): documentation = ( "Annual relevant childcare charges deducted from a working-age " "applicant's income in Scotland or Wales. Without Universal Credit, " - "childcare spending is deducted from earnings, up to a weekly cap for " + "childcare spending is deducted from earnings after the earnings " + "disregards (never below zero), up to a weekly cap for " "one child or for two or more, where a lone parent, or both members " "of a couple, are in remunerative work. A Scottish applicant with " "Universal Credit deducts the Universal Credit childcare costs element " @@ -69,10 +70,9 @@ def formula(benunit, period, parameters): wales.childcare.maximum_one_child, ), ) - earnings = benunit("council_tax_reduction_working_age_earned_income", period) legacy = where( (children > 0) & all_claimants_in_work, - min_(min_(charges, weekly_cap * WEEKS_IN_YEAR), earnings), + min_(charges, weekly_cap * WEEKS_IN_YEAR), 0, ) # Scotland, with Universal Credit: the childcare costs element grossed diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py index 94687ce2db..be2729f3cc 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py @@ -8,51 +8,11 @@ class council_tax_reduction_working_age_earned_income(Variable): documentation = ( "Annual net earnings of the claimant and partner for a working-age " "council tax reduction claim in Scotland or Wales, before earnings " - "disregards. With an award of Universal Credit, earnings follow the " - "Universal Credit measure: gross earnings (with any minimum income " - "floor) less income tax, National Insurance and all pension " - "contributions, with no work allowance. Without Universal Credit, " - "earnings are employment and self-employment income and statutory " - "sick and maternity pay, less income tax, National Insurance and half " - "of pension contributions. The earnings of children and young persons " - "do not count. As elsewhere in the model, all of a person's income " - "tax is deducted from earnings." + "disregards. Income tax and National Insurance are deducted from each " + "person's earnings up to those earnings; in Wales any tax the earnings " + "cannot absorb is disregarded from unearned income instead (see " + "council_tax_reduction_working_age_unabsorbed_tax)." ) 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/wsi/2013/3029/schedule/6/paragraph/9", - "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15", - ) - - def formula(benunit, period, parameters): - person = benunit.members - members = person("is_claimant_or_partner", period) - tax = add_for_members(benunit, period, ["tax"], members) - pension_contributions = add_for_members( - benunit, period, ["pension_contributions"], members - ) - universal_credit_earnings = add_for_members( - benunit, period, ["uc_mif_capped_earned_income"], members - ) - legacy_earnings = add_for_members( - benunit, - period, - [ - "employment_income", - "self_employment_income", - "statutory_sick_pay", - "statutory_maternity_pay", - ], - members, - ) - has_universal_credit = benunit( - "council_tax_reduction_working_age_has_universal_credit", period - ) - return where( - has_universal_credit, - max_(0, universal_credit_earnings - tax - pension_contributions), - max_(0, legacy_earnings - tax - 0.5 * pension_contributions), - ) + adds = ["council_tax_reduction_working_age_person_earned_income"] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py index ed61f15e08..4f689d53d4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py @@ -11,17 +11,21 @@ class council_tax_reduction_working_age_earnings_disregard(Variable): documentation = ( "Annual earnings disregarded in a working-age council tax reduction " "claim in Scotland or Wales. One weekly disregard applies: £25 for a " - "lone parent; otherwise £20 where the applicable amount includes a " - "disability, severe disability or carer premium; otherwise £10 for a " - "couple and £5 for a single applicant. Applicants without Universal " - "Credit who meet a work condition have £17.10 more disregarded if " - "their earnings cover the other disregards, their childcare charges " - "and the £17.10. Scotland applies the disregards to Universal Credit " - "earnings too, without the £17.10. A Welsh applicant with Universal " - "Credit uses the Secretary of State's income figure, which has no " - "disregards. The disregard is never more than the earnings. The £20 " - "disregards for special occupations and for permitted work are not " - "modelled." + "lone parent; otherwise £20 of the family's earnings where the " + "applicable amount includes a disability or severe disability " + "premium; otherwise, with a carer premium, £20 of the carers' own " + "earnings, plus up to £10 of a non-carer partner's earnings within the " + "same £20; otherwise £10 for a couple and £5 for a single applicant. " + "Applicants without Universal Credit who meet a work condition have " + "£17.10 more disregarded if their earnings cover the other disregard, " + "their childcare charges and the £17.10; for a couple's disability " + "condition, the disabled member must be the one in remunerative work, " + "and in Scotland from April 2022 only employed earnings count. " + "Scotland applies the disregards to Universal Credit earnings too, " + "without the £17.10. A Welsh applicant with Universal Credit uses the " + "Secretary of State's income figure, which has no disregards. The " + "disregard is never more than the earnings. The £20 disregards for " + "special occupations and permitted work are not modelled." ) definition_period = YEAR unit = GBP @@ -39,31 +43,71 @@ def formula(benunit, period, parameters): def rate(name): return where(scotland, getattr(scot, name), getattr(wales, name)) + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + weekly_person_earnings = ( + person("council_tax_reduction_working_age_person_earned_income", period) + / WEEKS_IN_YEAR + ) + earnings = benunit("council_tax_reduction_working_age_earned_income", period) + weekly_earnings = earnings / WEEKS_IN_YEAR + lone_parent = benunit("is_lone_parent", period) couple = benunit("is_couple", period) disability = (benunit("disability_premium", period) > 0) | ( benunit("severe_disability_premium", period) > 0 ) - carer = benunit("carer_premium", period) > 0 + carers = benunit("council_tax_reduction_working_age_carers", period) + # Carer disregard: £20 of the carers' own earnings, plus up to £10 of a + # non-carer partner's earnings within the £20 (Wales Sch 8 paras 6-7; + # Scotland Sch 3 paras 6-7). Carers are identified by carer benefit + # receipt; a Scottish UC carer element with no carer benefit in the + # family falls back to the family's earnings. + is_carer = claimant_or_partner & person("receives_carer_benefit", period) + carer_earnings = benunit.sum(is_carer * weekly_person_earnings) + other_earnings = benunit.sum( + (claimant_or_partner & ~is_carer) * weekly_person_earnings + ) + named_carers = benunit.sum(is_carer) + carer_cap = rate("disability_or_carer") + partner_cap = rate("couple") + carer_disregard = where( + named_carers == 0, + min_(carer_cap, weekly_earnings), + where( + couple & (named_carers == 1), + min_(carer_cap, carer_earnings + min_(partner_cap, other_earnings)), + min_(carer_cap, carer_earnings), + ), + ) base = select( - [lone_parent, disability | carer, couple], - [rate("lone_parent"), rate("disability_or_carer"), rate("couple")], - rate("single"), + [lone_parent, disability, carers > 0, couple], + [ + min_(rate("lone_parent"), weekly_earnings), + min_(rate("disability_or_carer"), weekly_earnings), + carer_disregard, + min_(rate("couple"), weekly_earnings), + ], + min_(rate("single"), weekly_earnings), ) - person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) hours = person("weekly_hours", period) age = person("age", period) - in_work = benunit.any( - claimant_or_partner - & (hours >= benunit.project(rate("remunerative_work_hours"))) - ) + remunerative = hours >= benunit.project(rate("remunerative_work_hours")) + in_work = benunit.any(claimant_or_partner & remunerative) full_time_25_or_over = benunit.any( claimant_or_partner & (hours >= benunit.project(rate("additional_full_time_hours"))) & (age >= benunit.project(rate("additional_full_time_age"))) ) + # For a couple, the member in remunerative work must be the one who + # meets the disability premium conditions (Wales Sch 8 para + # 18(2)(b)(v); Scotland Sch 3 para 15(2)(b)(iv)). + disabled_worker = benunit.any( + claimant_or_partner + & person("is_disabled_for_benefits", period) + & remunerative + ) children = benunit.sum( person("is_child_or_young_person_for_legacy_benefits", period) ) @@ -71,14 +115,20 @@ def rate(name): full_time_25_or_over | (couple & (children > 0) & in_work) | (lone_parent & in_work) - | ((benunit("disability_premium", period) > 0) & in_work) + | ((benunit("disability_premium", period) > 0) & disabled_worker) ) - earnings = benunit("council_tax_reduction_working_age_earned_income", period) childcare = benunit( "council_tax_reduction_working_age_childcare_deduction", period ) additional = rate("additional") - covers_additional = earnings >= (base + additional) * WEEKS_IN_YEAR + childcare + employed_only = scotland & (scot.additional_employed_earnings_only > 0) + employed = benunit( + "council_tax_reduction_working_age_employed_earned_income", period + ) + tested_earnings = where(employed_only, employed, earnings) + covers_additional = (tested_earnings > 0) & ( + tested_earnings >= (base + additional) * WEEKS_IN_YEAR + childcare + ) has_universal_credit = benunit( "council_tax_reduction_working_age_has_universal_credit", period ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py new file mode 100644 index 0000000000..da19a41a7d --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( + working_age_earnings_components, +) + + +class council_tax_reduction_working_age_employed_earned_income(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction net employed earnings" + documentation = ( + "The employed part of the claimant's and partner's net earnings: " + "employment income and statutory sick, maternity and paternity pay, " + "with each person's deductions shared pro rata with their " + "self-employed earnings. Scotland's additional £17.10 earnings " + "disregard turns on employed earnings from April 2022." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ssi/2021/249/schedule/3" + + def formula(benunit, period, parameters): + person = benunit.members + gross, employed_gross, _, _ = working_age_earnings_components(person, period) + net = person("council_tax_reduction_working_age_person_earned_income", period) + share = np.divide( + employed_gross, + gross, + out=np.zeros_like(gross, dtype=float), + where=gross > 0, + ) + return benunit.sum(net * share) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py new file mode 100644 index 0000000000..22014a4298 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( + working_age_earnings_components, +) + + +class council_tax_reduction_working_age_person_earned_income(Variable): + value_type = float + entity = Person + label = "Working-age council tax reduction net earned income (person)" + documentation = ( + "A claimant's or partner's annual net earnings for a working-age " + "council tax reduction claim in Scotland or Wales, before earnings " + "disregards: gross earnings less pension contributions (all of them " + "with Universal Credit, half otherwise) and the person's income tax " + "and National Insurance, not below zero. Children's and other " + "members' earnings do not count." + ) + 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/wsi/2013/3029/schedule/6/paragraph/9", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15", + ) + + def formula(person, period, parameters): + gross, _, pension_deduction, tax = working_age_earnings_components( + person, period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return claimant_or_partner * max_(0, gross - pension_deduction - tax) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py new file mode 100644 index 0000000000..1cb5767d8d --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( + working_age_earnings_components, +) + + +class council_tax_reduction_working_age_unabsorbed_tax(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction tax not absorbed by earnings" + documentation = ( + "Income tax and National Insurance of the claimant and partner beyond " + "what their earnings (less pension deductions) can absorb, which is " + "tax on unearned income. Wales disregards it from unearned income " + "(WSI 2013/3029 Sch 9 para 4); Scotland counts unearned income gross " + "(SSI 2021/249 reg 57)." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/4" + + def formula(benunit, period, parameters): + person = benunit.members + gross, _, pension_deduction, tax = working_age_earnings_components( + person, period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + absorbed = max_(0, gross - pension_deduction) + return benunit.sum(claimant_or_partner * max_(0, tax - absorbed)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py index a6d43be3fb..16d16bf3b5 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py @@ -10,7 +10,8 @@ class council_tax_reduction_working_age_unearned_income(Variable): "working-age council tax reduction claim in Scotland or Wales, other " "than Universal Credit and tariff income from capital. Retirement " "pensions, contributory Jobseeker's Allowance and Employment and " - "Support Allowance, carer's allowance, carer support payment, " + "Support Allowance, carer's allowance, the Carer Support Payment " + "component (not the Scottish Carer Supplement), " "maternity allowance, industrial injuries benefit, incapacity benefit, " "severe disablement allowance and working and child tax credits " "count. Child Benefit, the income-related benefits, disability " @@ -47,6 +48,15 @@ def formula(benunit, period, parameters): ], members, ) + # Only the Carer Support Payment component counts, not the Scottish + # Carer Supplement (SSI 2021/249 reg 4(1), as amended by SSI 2025/340, + # and the closed list in reg 57(1)). + csp = parameters(period).gov.social_security_scotland.carer_support_payment + csp_total = add_for_members(benunit, period, ["carer_support_payment"], members) + csp_rate = csp.rate + csp.supplement + supplement_share = csp.supplement / csp_rate if csp_rate > 0 else 0 + supplement = csp_total * supplement_share + income = income - supplement tax_credits = add(benunit, period, ["working_tax_credit", "child_tax_credit"]) bi = parameters(period).gov.contrib.ubi_center.basic_income basic_income = ( From 12b8157a7aa800df1dcac4f4e5f0e1775596eaf1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:31:39 -0400 Subject: [PATCH 003/250] Model rent from boarders, lodgers and sub-tenants for the householder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Family Resources Survey records the rent a boarder or lodger pays the householder only on the payer (CVPAY). New person inputs `rent_paid_as_boarder` and `rent_paid_as_lodger` carry it, and `rent_from_boarders_and_lodgers` gives it to the household head. - Income tax: rent-a-room relief (ITTOIA 2005 ss. 784-802). Receipts up to the limit (£3,250 from 1992-93, £4,250 from 1997-98, £7,500 from 2016-17; halved where someone else in the household also receives rent from the home) are not taxed; above it the excess is property income (the s. 800 election), with no property allowance (s. 783BB(2)). `sublet_income` counts as rent-a-room receipts too. - Legacy means tests: `legacy_benefits_home_letting_income` (#1995) now also counts lodger rent less £20 a week (per person at pension age, per lodger family at working age) and board and lodging less £20 and half the excess per boarder (IS Sch 9 paras 19-20; HB Sch 5 paras 22, 42; HB (SPC) Sch 5 paras 9-10; SPC Sch IV paras 8-9; CTR Sch 5 paras 9-10). Tax on counted letting income is deducted (`rent_a_room_income_tax`). - Tax credits count the taxable rent-a-room income (SI 2002/2006 reg 11(2)); Universal Credit counts none of it (reg 66(1)). - People paying the householder rent are not non-dependants for UC, HB or CTR (UC Sch 4 para 9(2)(d); HB reg 3(2)(e)(i); CTR reg 9(2)(e)). - Rent paid between household members is not household income; `sublet_income`, from outside the household, now is. Co-Authored-By: Claude Opus 5.5 --- changelog.d/boarder-lodger-rent.added.md | 1 + policyengine_uk/data/uprating_indices.yaml | 2 + .../board_and_lodging_disregard/amount.yaml | 33 ++ .../excess_rate.yaml | 22 ++ .../income_tax/rent_a_room/basic_amount.yaml | 26 ++ .../boarders_and_lodgers.yaml | 283 ++++++++++++++++++ .../boarders_and_lodgers_non_dependants.yaml | 58 ++++ .../rent_a_room/rent_a_room_relief.yaml | 211 +++++++++++++ .../test_boarder_lodger_rent_properties.py | 235 +++++++++++++++ ...t_individual_non_dep_deduction_eligible.py | 6 +- .../legacy_benefits_home_letting_income.py | 75 +++-- .../gov/dwp/legacy_means_test_income_tax.py | 15 +- .../gov/dwp/tax_credits_applicable_income.py | 2 + ...c_individual_non_dep_deduction_eligible.py | 6 +- .../bases/taxable_property_income.py | 22 +- .../rent_a_room/rent_a_room_income_tax.py | 26 ++ .../rent_a_room/rent_a_room_limit.py | 25 ++ .../rent_a_room/rent_a_room_receipts.py | 18 ++ .../rent_a_room/rent_a_room_relief.py | 27 ++ .../rent_a_room/taxable_rent_a_room_income.py | 23 ++ .../gov/hmrc/income_tax/total_income.py | 1 + ...n_individual_non_dep_deduction_eligible.py | 11 +- .../income/hbai_household_net_income.py | 1 + .../income/household_market_income.py | 1 + .../household/income/market_income.py | 1 + .../income/pays_rent_to_householder.py | 25 ++ .../income/rent_from_boarders_and_lodgers.py | 27 ++ .../variables/input/rent_paid_as_boarder.py | 17 ++ .../variables/input/rent_paid_as_lodger.py | 17 ++ .../variables/input/sublet_income.py | 9 +- 30 files changed, 1196 insertions(+), 30 deletions(-) create mode 100644 changelog.d/boarder-lodger-rent.added.md create mode 100644 policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/amount.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/excess_rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/rent_a_room/basic_amount.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.yaml create mode 100644 policyengine_uk/tests/test_boarder_lodger_rent_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_limit.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_receipts.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/taxable_rent_a_room_income.py create mode 100644 policyengine_uk/variables/household/income/pays_rent_to_householder.py create mode 100644 policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py create mode 100644 policyengine_uk/variables/input/rent_paid_as_boarder.py create mode 100644 policyengine_uk/variables/input/rent_paid_as_lodger.py diff --git a/changelog.d/boarder-lodger-rent.added.md b/changelog.d/boarder-lodger-rent.added.md new file mode 100644 index 0000000000..10b0575397 --- /dev/null +++ b/changelog.d/boarder-lodger-rent.added.md @@ -0,0 +1 @@ +Model rent from boarders, lodgers and sub-tenants for the householder: rent-a-room relief in income tax, the £20 and board-and-lodging disregards in the legacy means tests (not Universal Credit), and no non-dependant deductions for people who pay the householder rent. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index c7175e2fcc..8eebae691c 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -102,6 +102,8 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - private_pension_wealth - private_transfer_income - property_income +- rent_paid_as_boarder +- rent_paid_as_lodger - savings - savings_interest_income - sublet_income diff --git a/policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/amount.yaml b/policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/amount.yaml new file mode 100644 index 0000000000..4d56006381 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/amount.yaml @@ -0,0 +1,33 @@ +description: > + Weekly amount disregarded in full from the payments a claimant receives for + board and lodging accommodation provided in their home, for each person + provided with it. Payments above this amount are disregarded in part (see the + excess rate). Income Support, income-based Jobseeker's Allowance, + income-related Employment and Support Allowance, Housing Benefit, Pension + Credit and the council tax reduction schemes use it. +metadata: + label: Legacy benefits board and lodging disregard + period: week + unit: currency-GBP + reference: + - title: The Income Support (General) Regulations 1987 Schedule 9 para. 20 + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/20 + - title: The Income Support (General) Amendment Regulations 1990 regs. 1 and 22(c) + href: https://www.legislation.gov.uk/uksi/1990/547/made + - title: The Income-related Benefits Schemes (Miscellaneous Amendments) Regulations 1994 regs. 1(1)(b), 1(3) and 9(4) + href: https://www.legislation.gov.uk/uksi/1994/527/made + - title: The Housing Benefit Regulations 2006 Schedule 5 para. 42 + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/42 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 5 para. 9 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/9 + - title: The State Pension Credit Regulations 2002 Schedule IV para. 8 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/8 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 5 para. 9 + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/9 +values: + # Income Support Schedule 9 para. 20 has disregarded £20 and half the + # excess since 9 April 1990 (SI 1990/547), restated in its current words + # from 11 April 1994 (SI 1994/527). As made it disregarded £35 of the weekly + # charge where the claimant was employed in providing board and lodging; + # that rule is not modelled. + 1990-04-09: 20 diff --git a/policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/excess_rate.yaml b/policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/excess_rate.yaml new file mode 100644 index 0000000000..28765f4b56 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/legacy_means_tests/board_and_lodging_disregard/excess_rate.yaml @@ -0,0 +1,22 @@ +description: > + Share of the weekly board and lodging payments above the board and lodging + disregard amount that is also disregarded, for each person provided with + board and lodging accommodation in the claimant's home. +metadata: + label: Legacy benefits board and lodging disregard rate on the excess + unit: /1 + reference: + - title: The Income Support (General) Regulations 1987 Schedule 9 para. 20(b) + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/20 + - title: The Income Support (General) Amendment Regulations 1990 reg. 22(c) + href: https://www.legislation.gov.uk/uksi/1990/547/made + - title: The Housing Benefit Regulations 2006 Schedule 5 para. 42(1)(b) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/42 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 5 para. 9(b) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/9 + - title: The State Pension Credit Regulations 2002 Schedule IV para. 8(1)(b) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/8 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 5 para. 9(b) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/9 +values: + 1990-04-09: 0.5 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rent_a_room/basic_amount.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rent_a_room/basic_amount.yaml new file mode 100644 index 0000000000..887f419028 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rent_a_room/basic_amount.yaml @@ -0,0 +1,26 @@ +description: Basic amount of rent-a-room receipts an individual can receive in a tax year free of income tax. An individual who does not meet the exclusive receipts condition, because someone else also receives rent from the residence, has half this amount. +values: + 1992-04-06: + value: 3_250 + reference: + - title: Finance (No. 2) Act 1992 Sch. 10 paras. 6 and 13 + href: https://www.legislation.gov.uk/ukpga/1992/48/schedule/10/enacted + 1997-04-06: + value: 4_250 + reference: + - title: The Income Tax (Furnished Accommodation) (Basic Amount) Order 1996 art. 2 + href: https://www.legislation.gov.uk/uksi/1996/2953/made + - title: Income Tax (Trading and Other Income) Act 2005 s. 789(4) (as enacted) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/789/enacted + 2016-04-06: + value: 7_500 + reference: + - title: The Income Tax (Limit for Rent-a-Room Relief) Order 2015 arts. 1(2) and 2 + href: https://www.legislation.gov.uk/uksi/2015/1539/made +metadata: + label: Rent-a-room relief basic amount + period: year + unit: currency-GBP + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 789 + href: https://www.legislation.gov.uk/ukpga/2005/5/section/789 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml new file mode 100644 index 0000000000..9d26790caa --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml @@ -0,0 +1,283 @@ +# Rent from boarders and lodgers living in the household, in the legacy means +# tests (legacy_benefits_home_letting_income, counted by Income Support, +# Housing Benefit, Pension Credit and council tax reduction). +# +# Board and lodging, per boarder: the first £20 a week is disregarded and half +# the excess (IS Sch 9 para 20; HB Sch 5 para 42; HB (SPC) Sch 5 para 9; SPC Sch +# IV para 8; CTR (England) Sch 5 para 9). Lodging only: £20 a week is +# disregarded. Claimants over the qualifying age for State Pension Credit +# disregard it from "the amount paid by that person" (HB (SPC) Sch 5 para 10; +# SPC Sch IV para 9); working-age claimants from the aggregate paid by "that +# person or a member of his family" (HB Sch 5 para 22; IS Sch 9 para 19), £4 +# before 7 April 2008. Universal Credit does not count this rent (UC Regs 2013 +# reg 66(1); ADM H5112). + +- name: Pension-age householder, one lodger paying £100 a week + # (100 - 20) x 52 = 4,160. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 5_200 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + legacy_benefits_home_letting_income: [4_160, 0] + +- name: One boarder paying £100 a week + # 0.5 x (100 - 20) x 52 = 2,080. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + boarder: + age: 30 + is_household_head: false + rent_paid_as_boarder: 5_200 + benunits: + householder_unit: + members: [householder] + boarder_unit: + members: [boarder] + households: + household: + members: [householder, boarder] + output: + legacy_benefits_home_letting_income: [2_080, 0] + +- name: A boarder paying £15 a week is disregarded in full + # 780 / 52 = 15 < 20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + boarder: + age: 30 + is_household_head: false + rent_paid_as_boarder: 780 + benunits: + householder_unit: + members: [householder] + boarder_unit: + members: [boarder] + households: + household: + members: [householder, boarder] + output: + legacy_benefits_home_letting_income: [0, 0] + +- name: Pension-age householder, a lodger couple each paying £60 a week + # Per person: (60 - 20) + (60 - 20) = 80 a week; x 52 = 4,160. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + lodger_1: + age: 30 + is_household_head: false + rent_paid_as_lodger: 3_120 + lodger_2: + age: 32 + is_household_head: false + rent_paid_as_lodger: 3_120 + benunits: + householder_unit: + members: [householder] + lodger_couple: + members: [lodger_1, lodger_2] + households: + household: + members: [householder, lodger_1, lodger_2] + output: + legacy_benefits_home_letting_income: [4_160, 0] + +- name: Working-age householder, the same lodger couple + # Aggregate for the family: 120 - 20 = 100 a week; x 52 = 5,200. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + lodger_1: + age: 30 + is_household_head: false + rent_paid_as_lodger: 3_120 + lodger_2: + age: 32 + is_household_head: false + rent_paid_as_lodger: 3_120 + benunits: + householder_unit: + members: [householder] + lodger_couple: + members: [lodger_1, lodger_2] + households: + household: + members: [householder, lodger_1, lodger_2] + output: + legacy_benefits_home_letting_income: [5_200, 0] + +- name: A boarder and a lodger, each paying £100 a week + # Boarder 0.5 x 80 = 40; lodger 80; 120 a week; x 52 = 6,240. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + boarder: + age: 30 + is_household_head: false + rent_paid_as_boarder: 5_200 + lodger: + age: 40 + is_household_head: false + rent_paid_as_lodger: 5_200 + benunits: + householder_unit: + members: [householder] + boarder_unit: + members: [boarder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, boarder, lodger] + output: + legacy_benefits_home_letting_income: [6_240, 0, 0] + +- name: Working-age lodger disregard was £4 before April 2008 + # 2007-08: lodger (100 - 4) x 52 = 4,992; a boarder at £100 would give + # 0.5 x 80 x 52 = 2,080, here alongside: total 7,072. + period: 2007 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 5_200 + boarder: + age: 31 + is_household_head: false + rent_paid_as_boarder: 5_200 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + boarder_unit: + members: [boarder] + households: + household: + members: [householder, lodger, boarder] + output: + legacy_benefits_home_letting_income: [7_072, 0, 0] + +- name: Universal Credit does not count rent from a lodger + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 35 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 5_200 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + uc_unearned_income: [0, 0] + +- name: Rent between members of a household is not household income + # The lodger's earnings are the household's only market income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 35 + is_household_head: true + lodger: + age: 30 + is_household_head: false + employment_income: 20_000 + rent_paid_as_lodger: 5_200 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + household_market_income: 20_000 + +- name: Tax on counted rent from a lodger is deducted in the legacy means tests + # 2026-27. Private pension 15,000: (15,000 - 12,570) x 20% = 486. Lodger rent + # 10,400 is rent-a-room receipts; 10,400 - 7,500 = 2,900 taxable, x 20% = + # 580. Income tax 1,066, all of it on income the means tests count (IS Sch 9 + # para 1; HB Sch 5 para 1; SPC reg 17), so all of it is deducted. Counted + # rent: (200 - 20) x 52 = 9,360. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + private_pension_income: 15_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 10_400 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + income_tax: [1_066, 0] + property_income_tax: [580, 0] + rent_a_room_income_tax: [580, 0] + legacy_means_test_income_tax: [1_066, 0] + legacy_benefits_home_letting_income: [9_360, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml new file mode 100644 index 0000000000..59a7ed2215 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml @@ -0,0 +1,58 @@ +# A boarder or lodger is liable to pay the householder on a commercial basis +# for their occupation, so is not a non-dependant: UC Regs 2013 Sch 4 para +# 9(2)(d); HB Regs 2006 reg 3(2)(e)(i); CTR (England) prescribed requirements +# reg 9(2)(e). An unrelated adult in the household who pays nothing remains a +# non-dependant. + +- name: An adult lodger is not a non-dependant + period: 2026 + input: + people: + householder: + age: 45 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 4_000 + benunits: + householder_unit: + members: [householder] + benunit_rent: 9_000 + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + tenure_type: RENT_PRIVATELY + output: + pays_rent_to_householder: [false, true] + uc_individual_non_dep_deduction_eligible: [false, false] + housing_benefit_individual_non_dep_deduction_eligible: [false, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + +- name: The same adult paying nothing is a non-dependant + period: 2026 + input: + people: + householder: + age: 45 + is_household_head: true + adult: + age: 30 + is_household_head: false + benunits: + householder_unit: + members: [householder] + benunit_rent: 9_000 + adult_unit: + members: [adult] + households: + household: + members: [householder, adult] + tenure_type: RENT_PRIVATELY + output: + pays_rent_to_householder: [false, false] + uc_individual_non_dep_deduction_eligible: [false, true] + housing_benefit_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.yaml new file mode 100644 index 0000000000..6160dbe273 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.yaml @@ -0,0 +1,211 @@ +# Rent-a-room relief (ITTOIA 2005 Part 7 Chapter 1). Receipts up to the +# individual's limit are not charged (s. 793). Above it the model assumes the +# s. 800 election: the limit is deducted and no expenses are allowed (s. 797). +# The basic amount is £4,250 (s. 789(4) as enacted) and £7,500 from 2016-17 +# (SI 2015/1539 arts. 1(2), 2), halved where someone else also receives rent +# from the residence (ss. 789(3), 790). Rent-a-room receipts are not relievable +# receipts for the £1,000 property allowance (s. 783BB(2)). +# +# Tax figures, 2026-27: personal allowance £12,570; basic rate 20%. + +- name: Rent from a lodger within the limit is not taxed + # Receipts 6,000 <= 7,500, so nothing is charged. Earnings tax: + # (30,000 - 12,570) x 20% = 3,486. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 6_000 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + rent_from_boarders_and_lodgers: [6_000, 0] + rent_a_room_receipts: [6_000, 0] + rent_a_room_relief: [6_000, 0] + taxable_rent_a_room_income: [0, 0] + taxable_property_income: [0, 0] + income_tax: [3_486, 0] + +- name: Rent from a lodger above the limit is taxed on the excess + # Taxable: 10,000 - 7,500 = 2,500, taxed at 20% = 500 on top of 3,486. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 10_000 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + rent_a_room_relief: [7_500, 0] + taxable_rent_a_room_income: [2_500, 0] + taxable_property_income: [2_500, 0] + rent_a_room_income_tax: [500, 0] + income_tax: [3_986, 0] + +- name: The property allowance does not reduce taxable rent-a-room income + # Other property income 500 is covered by the property allowance + # (min(500, 1,000)); the rent-a-room excess of 2,500 is not. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + property_income: 500 + boarder: + age: 30 + is_household_head: false + rent_paid_as_boarder: 10_000 + benunits: + householder_unit: + members: [householder] + boarder_unit: + members: [boarder] + households: + household: + members: [householder, boarder] + output: + property_allowance_deduction: [500, 0] + taxable_rent_a_room_income: [2_500, 0] + taxable_property_income: [2_500, 0] + +- name: The limit was 4,250 before 2016-17 + # 6,000 - 4,250 = 1,750. + period: 2015 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 6_000 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + rent_a_room_limit: [4_250, 4_250] + rent_a_room_relief: [4_250, 0] + taxable_rent_a_room_income: [1_750, 0] + +- name: Two people receiving rent from the home each have half the limit + # The householder receives the lodger's 6,000; the partner sublets part of + # the home for 2,000. Each limit is 7,500 / 2 = 3,750. Householder: + # 6,000 - 3,750 = 2,250 taxable; partner: 2,000 < 3,750, nothing taxable. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + partner: + age: 44 + is_household_head: false + sublet_income: 2_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 6_000 + benunits: + couple: + members: [householder, partner] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, partner, lodger] + output: + rent_a_room_receipts: [6_000, 2_000, 0] + # The lodger receives nothing, so keeps the basic amount. + rent_a_room_limit: [3_750, 3_750, 7_500] + taxable_rent_a_room_income: [2_250, 0, 0] + +- name: Rent paid by someone in the householder's own benefit unit is not received + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + partner: + age: 44 + is_household_head: false + rent_paid_as_lodger: 6_000 + benunits: + couple: + members: [householder, partner] + households: + household: + members: [householder, partner] + output: + rent_from_boarders_and_lodgers: [0, 0] + pays_rent_to_householder: [false, false] + +- name: Tax credits count only the taxable rent-a-room income + # 2023-24. Taxable rent-a-room income: 10,000 - 7,500 = 2,500. Step 1 income + # (pensions, savings, dividends, property): 2,500 less the £300 disregard = + # 2,200. Step 2: earnings 10,000. Total 12,200. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 10_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 10_000 + benunits: + householder_unit: + members: [householder] + income_support: 0 + esa_income: 0 + jsa_income: 0 + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + taxable_rent_a_room_income: [2_500, 0] + tax_credits_applicable_income: [12_200, 0] diff --git a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py new file mode 100644 index 0000000000..1cdbc152e1 --- /dev/null +++ b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py @@ -0,0 +1,235 @@ +"""Property-based tests for rent from boarders, lodgers and sub-tenants. + +A household head may receive rent from boarders and lodgers who live in the +household in their own benefit units (rent_paid_as_boarder and +rent_paid_as_lodger, recorded on the payer), and from a sub-tenant outside it +(sublet_income). + +Invariants, for any generated household: + +1. Conservation: the rent the head receives equals the rent paid by members + outside the head's benefit unit, and nobody else receives any. +2. Rent-a-room bounds: 0 <= relief <= receipts, the limit is the basic amount + or half of it, and taxable rent-a-room income = max(0, receipts - limit). +3. Disregards: the counted home-letting income is between nil and the rent + received, and equals the statutory closed form (boarders: half the excess + over £20 a week each; lodgers: the excess over £20 a week, per person at + pension age and per lodger family at working age; sub-tenants: the excess + over £20 a week). +4. Monotone: more rent from a boarder or lodger who already pays never lowers + taxable rent-a-room income or counted home-letting income, and never raises + Income Support, Housing Benefit, Pension Credit or council tax reduction. +5. Boarders keep more: the same weekly amount counts no more when paid for + board and lodging than when paid for lodging only. +6. Universal Credit unearned income and household market income do not depend + on the rent paid between members of the household. +7. Tax: legacy_means_test_income_tax is between nil and income tax. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = [2025, 2026] +WEEKS = 52 +DISREGARD = 20 +BASIC_AMOUNT = 7_500 +AWARDS = ["income_support", "housing_benefit", "pension_credit", "council_tax_benefit"] +rent = st.one_of(st.just(0.0), st.floats(100, 20_000)) + + +@st.composite +def households(draw): + pension_age = draw(st.booleans()) + age = st.integers(67, 90) if pension_age else st.integers(25, 60) + head_unit = [ + dict(age=draw(age), employment_income=draw(st.floats(0, 25_000))) + for _ in range(draw(st.integers(1, 2))) + ] + payers = [] + for _ in range(draw(st.integers(0, 2))): + kind = draw(st.sampled_from(["rent_paid_as_boarder", "rent_paid_as_lodger"])) + payers.append( + [ + {"age": draw(st.integers(19, 60)), kind: draw(rent)} + for _ in range(draw(st.integers(1, 2))) + ] + ) + return dict( + head_unit=head_unit, + payers=payers, + sublet=draw(st.one_of(st.just(0.0), st.floats(0, 8_000))), + tenure=draw(st.sampled_from(["RENT_PRIVATELY", "OWNED_OUTRIGHT"])), + region=draw(st.sampled_from(["NORTH_WEST", "LONDON", "WALES", "SCOTLAND"])), + ) + + +def situation(h, year, scale=1.0, as_kind=None): + people, benunits = {}, {} + names = [] + for i, attrs in enumerate(h["head_unit"]): + name = f"head_{i}" + people[name] = { + "age": {year: attrs["age"]}, + "employment_income": {year: attrs["employment_income"]}, + "is_household_head": {year: i == 0}, + "sublet_income": {year: h["sublet"] if i == 0 else 0.0}, + } + names.append(name) + benunits["head_unit"] = {"members": list(names)} + for j, unit in enumerate(h["payers"]): + members = [] + for k, attrs in enumerate(unit): + name = f"payer_{j}_{k}" + person = {"age": {year: attrs["age"]}, "is_household_head": {year: False}} + for kind in ("rent_paid_as_boarder", "rent_paid_as_lodger"): + if kind in attrs: + target = as_kind or kind + person[target] = {year: attrs[kind] * scale} + people[name] = person + members.append(name) + benunits[f"payer_{j}"] = {"members": members} + names += members + return { + "people": people, + "benunits": benunits, + "households": { + "household": { + "members": names, + "tenure_type": {year: h["tenure"]}, + "region": {year: h["region"]}, + "rent": {year: 8_000.0 if h["tenure"] == "RENT_PRIVATELY" else 0.0}, + "council_tax": {year: 1_500.0}, + } + }, + } + + +def calculate(h, year, variables, **kwargs): + sim = Simulation(situation=situation(h, year, **kwargs)) + return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in variables} + + +def payments(h, kind=None): + return [ + [ + a.get(kind, 0.0) if kind else sum(v for k, v in a.items() if k != "age") + for a in unit + ] + for unit in h["payers"] + ] + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_rent_is_received_by_the_head_and_conserved(h, year): + v = calculate(h, year, ["rent_from_boarders_and_lodgers"]) + received = v["rent_from_boarders_and_lodgers"] + paid = sum(sum(unit) for unit in payments(h)) + np.testing.assert_allclose(received.sum(), paid, rtol=1e-6, atol=0.01) + assert np.all(received[1:] == 0) + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_rent_a_room_bounds(h, year): + v = calculate( + h, + year, + [ + "rent_a_room_receipts", + "rent_a_room_limit", + "rent_a_room_relief", + "taxable_rent_a_room_income", + ], + ) + receipts, limit = v["rent_a_room_receipts"], v["rent_a_room_limit"] + assert np.all(np.isin(limit, [BASIC_AMOUNT, BASIC_AMOUNT / 2])) + assert np.all(v["rent_a_room_relief"] >= 0) + assert np.all(v["rent_a_room_relief"] <= receipts + 0.01) + np.testing.assert_allclose( + v["taxable_rent_a_room_income"], np.maximum(0, receipts - limit), atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_home_letting_income_closed_form(h, year): + sim = Simulation(situation=situation(h, year)) + counted = np.asarray(sim.calculate("legacy_benefits_home_letting_income", year)) + pension_age = bool( + np.asarray(sim.calculate("is_SP_age", year))[: len(h["head_unit"])].any() + ) + weekly = lambda x: x / WEEKS + board = sum( + 0.5 * max(0, weekly(x) - DISREGARD) + for unit in payments(h, "rent_paid_as_boarder") + for x in unit + ) + lodgers = payments(h, "rent_paid_as_lodger") + if pension_age: + lodging = sum(max(0, weekly(x) - DISREGARD) for unit in lodgers for x in unit) + else: + lodging = sum(max(0, weekly(sum(unit)) - DISREGARD) for unit in lodgers) + sublet = max(0, weekly(h["sublet"]) - DISREGARD) + expected = (board + lodging + sublet) * WEEKS + np.testing.assert_allclose(counted[0], expected, rtol=1e-6, atol=0.01) + assert np.all(counted[1:] == 0) + received = sum(sum(unit) for unit in payments(h)) + h["sublet"] + assert 0 <= counted[0] <= received + 0.01 + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS), st.floats(1.1, 4)) +def test_more_rent_is_monotone(h, year, scale): + variables = [ + "taxable_rent_a_room_income", + "legacy_benefits_home_letting_income", + ] + AWARDS + base = calculate(h, year, variables) + more = calculate(h, year, variables, scale=scale) + for v in ["taxable_rent_a_room_income", "legacy_benefits_home_letting_income"]: + assert np.all(more[v] >= base[v] - 0.01), v + for v in AWARDS: + assert np.all(more[v] <= base[v] + 0.01), v + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_board_and_lodging_counts_no_more_than_lodging(h, year): + v = "legacy_benefits_home_letting_income" + as_boarders = calculate(h, year, [v], as_kind="rent_paid_as_boarder")[v] + as_lodgers = calculate(h, year, [v], as_kind="rent_paid_as_lodger")[v] + pension_age = calculate(h, year, ["is_SP_age"])["is_SP_age"][ + : len(h["head_unit"]) + ].any() + if pension_age: + # Per-person lodger disregard at pension age: boarders keep at least + # as much per person. + assert np.all(as_boarders <= as_lodgers + 0.01) + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_rent_within_the_household_changes_no_uc_income_or_market_income(h, year): + variables = ["uc_unearned_income", "household_market_income"] + with_rent = calculate(h, year, variables) + without = calculate(h, year, variables, scale=0.0) + for v in variables: + np.testing.assert_allclose(with_rent[v], without[v], atol=0.01, err_msg=v) + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_legacy_means_test_income_tax_bounds(h, year): + v = calculate(h, year, ["legacy_means_test_income_tax", "income_tax"]) + assert np.all(v["legacy_means_test_income_tax"] >= 0) + assert np.all(v["legacy_means_test_income_tax"] <= v["income_tax"] + 0.01) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py index 37ea75a311..00913bc2ac 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py @@ -6,9 +6,13 @@ class housing_benefit_individual_non_dep_deduction_eligible(Variable): entity = Person label = "eligible person for the Housing Benefit non-dependent deduction" definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/3" def formula(person, period, parameters): rent_liable = person.benunit("benunit_is_rent_liable", period) p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction age_eligible = person("age", period) >= p.age_threshold - return age_eligible & ~rent_liable + # A boarder or lodger is liable to pay the claimant on a commercial + # basis for their occupation, so is not a non-dependant (reg 3(2)(e)(i)). + commercial = person("pays_rent_to_householder", period) + return age_eligible & ~rent_liable & ~commercial diff --git a/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py b/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py index 5ebe2d5a59..ed794f5bfa 100644 --- a/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py +++ b/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py @@ -7,33 +7,74 @@ class legacy_benefits_home_letting_income(Variable): label = "Rent from letting part of the home counted in the legacy means tests" documentation = ( "Rent the benefit unit receives for letting part of the home it lives " - "in (sublet_income), after the weekly sub-tenant disregard. The legacy " - "means tests treat other income derived from capital, such as rent from " - "other property, interest and dividends, as capital, but the home is " - "disregarded capital, so rent for part of it stays income: Income " - "Support Schedule 9 paragraph 19, Housing Benefit Schedule 5 paragraph " - "22, and for claimants over the qualifying age for State Pension Credit " - "Pension Credit regulation 15(5)(i) with Schedule IV paragraph 9 and " - "pension-age Housing Benefit regulation 29(1)(v) with Schedule 5 " - "paragraph 10. The disregard applies per occupier; the data give one " - "annual amount and no count of occupiers, so the model assumes one. " - "Board and lodging, which has its own disregard (£20 and half the " - "excess), is not modelled." + "in, after the weekly disregards. The legacy means tests treat other " + "income derived from capital, such as rent from other property, " + "interest and dividends, as capital, but the home is disregarded " + "capital, so rent for part of it stays income. Three sources count. " + "(1) Rent from a sub-tenant outside the household (sublet_income), " + "less the sub-tenant disregard; the data give one annual amount and " + "no count of occupiers, so the model assumes one. (2) Rent from " + "lodgers in the household, paid to the household head, less the same " + "disregard: per person for claimants over the qualifying age for " + "State Pension Credit, whose rules disregard 'the amount paid by that " + "person', and per lodger family for working-age claimants, whose " + "rules aggregate payments by 'that person or a member of his family'. " + "(3) Payments for board and lodging, paid to the household head, less " + "the board and lodging disregard for each boarder: the first £20 a " + "week and half the excess." ) definition_period = YEAR unit = GBP reference = [ "https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/19", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/20", "https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/22", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/42", "https://www.legislation.gov.uk/uksi/2006/214/regulation/29", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/9", "https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/10", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/15", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/8", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/9", ] def formula(benunit, period, parameters): - p = parameters(period).gov.dwp.legacy_means_tests.sub_tenant_rent_disregard - weekly_rent = add(benunit, period, ["sublet_income"]) / WEEKS_IN_YEAR - pension_age = benunit.any(benunit.members("is_SP_age", period)) - disregard = where(pension_age, p.pension_age, p.working_age) - return max_(0, weekly_rent - disregard) * WEEKS_IN_YEAR + p = parameters(period).gov.dwp.legacy_means_tests + sub_tenant = p.sub_tenant_rent_disregard + board = p.board_and_lodging_disregard + person = benunit.members + pension_age = benunit.any(person("is_SP_age", period)) + + weekly_sublet = add(benunit, period, ["sublet_income"]) / WEEKS_IN_YEAR + sublet_disregard = where( + pension_age, sub_tenant.pension_age, sub_tenant.working_age + ) + counted_sublet = max_(0, weekly_sublet - sublet_disregard) + + # Boarders and lodgers are household members outside the head's + # benefit unit; what each pays is counted for the head's benefit unit. + head = person("is_household_head", period) + pays_head = ~person.benunit.any(head) + boarder = person("rent_paid_as_boarder", period) / WEEKS_IN_YEAR * pays_head + lodger = person("rent_paid_as_lodger", period) / WEEKS_IN_YEAR * pays_head + counted_board = (1 - board.excess_rate) * max_(0, boarder - board.amount) + counted_lodger_pension_age = max_(0, lodger - sub_tenant.pension_age) + family_lodger = person.benunit.sum(lodger) + family_share = np.divide( + lodger, + family_lodger, + out=np.zeros_like(lodger), + where=family_lodger > 0, + ) + counted_lodger_working_age = ( + max_(0, family_lodger - sub_tenant.working_age) * family_share + ) + household = person.household + from_occupiers = where( + pension_age, + benunit.max(household.sum(counted_board + counted_lodger_pension_age)), + benunit.max(household.sum(counted_board + counted_lodger_working_age)), + ) + # The head's benefit unit receives it; split by heads if more than one. + head_share = benunit.sum(head) / max_(benunit.max(household.sum(head)), 1) + return (counted_sublet + from_occupiers * head_share) * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/legacy_means_test_income_tax.py b/policyengine_uk/variables/gov/dwp/legacy_means_test_income_tax.py index 042addb578..aca8c34039 100644 --- a/policyengine_uk/variables/gov/dwp/legacy_means_test_income_tax.py +++ b/policyengine_uk/variables/gov/dwp/legacy_means_test_income_tax.py @@ -9,8 +9,11 @@ class legacy_means_test_income_tax(Variable): "Income Tax less the tax on savings interest, dividends and property " "income, floored at zero. The legacy means tests disregard tax only on " "income they take into account, and they treat income derived from " - "capital as capital, not income, so tax on it is not deducted. Tax " - "reductions are set against the remaining tax first." + "capital as capital, not income, so tax on it is not deducted. Rent " + "from boarders, lodgers and sub-tenants in the home is income they " + "take into account, so the tax on rent-a-room income above the limit " + "stays deducted. Tax reductions are set against the remaining tax " + "first." ) definition_period = YEAR unit = GBP @@ -28,4 +31,10 @@ def formula(person, period, parameters): period, ["savings_income_tax", "dividend_income_tax", "property_income_tax"], ) - return max_(0, person("income_tax", period) - tax_on_income_from_capital) + tax_on_counted_letting_income = person("rent_a_room_income_tax", period) + return max_( + 0, + person("income_tax", period) + - tax_on_income_from_capital + + tax_on_counted_letting_income, + ) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index d2d67ed018..eae033062c 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -18,6 +18,8 @@ def formula(benunit, period, parameters): "savings_interest_income", "dividend_income", "property_income", + # Rent-a-room receipts count only where income tax charges them. + "taxable_rent_a_room_income", ] income = add(benunit, period, STEP_1_COMPONENTS) income = max_(income - TC.means_test.non_earned_disregard, 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py index abd9869256..a42dbbe2fb 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py @@ -6,6 +6,7 @@ class uc_individual_non_dep_deduction_eligible(Variable): entity = Person label = "Eligible person for the Universal Credit non-dependent deduction" definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9" def formula(person, period, parameters): not_rent_liable = ~person.benunit("benunit_is_rent_liable", period) @@ -15,4 +16,7 @@ def formula(person, period, parameters): age = person("age", period) age_eligible = age >= p.age_threshold exempt = person("uc_non_dep_deduction_exempt", period) - return ~exempt & age_eligible & not_rent_liable + # A boarder or lodger is liable to pay on a commercial basis for their + # occupation, so is not a non-dependant (Sch 4 para 9(2)(d)). + commercial = person("pays_rent_to_householder", period) + return ~exempt & age_eligible & not_rent_liable & ~commercial diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py index d70b06548b..4f3876ba0a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py @@ -5,11 +5,23 @@ class taxable_property_income(Variable): value_type = float entity = Person label = "Amount of property income that is taxable" - definition_period = YEAR - reference = dict( - title="Income Tax (Trading and Other Income) Act 2005, s. 268", - href="https://www.legislation.gov.uk/ukpga/2005/5/section/268", + documentation = ( + "Property income less the property allowance, plus rent-a-room " + "receipts above the rent-a-room limit, which are profits of the same " + "UK property business but are not relievable receipts for the " + "property allowance." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 268", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/268", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BB(2)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BB", + ), + ] unit = GBP def formula(person, period, parameters): @@ -17,4 +29,4 @@ def formula(person, period, parameters): 0, person("property_income", period) - person("property_allowance_deduction", period), - ) + ) + person("taxable_rent_a_room_income", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax.py new file mode 100644 index 0000000000..d8c3b0d72f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class rent_a_room_income_tax(Variable): + value_type = float + entity = Person + label = "income tax on rent-a-room income" + documentation = ( + "The share of income tax on property income that falls on taxable " + "rent-a-room income, in proportion to its share of taxable property " + "income. The legacy means tests count rent-a-room receipts as income " + "and disregard the tax paid on income they count." + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + taxable_rent_a_room = person("taxable_rent_a_room_income", period) + taxable_property = person("taxable_property_income", period) + share = np.divide( + taxable_rent_a_room, + taxable_property, + out=np.zeros_like(taxable_property), + where=taxable_property > 0, + ) + return person("property_income_tax", period) * share diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_limit.py b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_limit.py new file mode 100644 index 0000000000..06f51f6965 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_limit.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class rent_a_room_limit(Variable): + value_type = float + entity = Person + label = "rent-a-room relief limit" + documentation = ( + "The basic amount, or half of it where the individual does not meet " + "the exclusive receipts condition. The model takes the condition to " + "fail where the individual and another member of the household both " + "have rent-a-room receipts from the home." + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ukpga/2005/5/section/789", + "https://www.legislation.gov.uk/ukpga/2005/5/section/790", + ] + + def formula(person, period, parameters): + basic_amount = parameters(period).gov.hmrc.income_tax.rent_a_room.basic_amount + receives = person("rent_a_room_receipts", period) > 0 + others_receive = person.household.sum(receives) - receives > 0 + return where(receives & others_receive, basic_amount / 2, basic_amount) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_receipts.py b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_receipts.py new file mode 100644 index 0000000000..d01c781e2f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_receipts.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class rent_a_room_receipts(Variable): + value_type = float + entity = Person + label = "rent-a-room receipts" + documentation = ( + "Receipts for the use of furnished accommodation in the individual's " + "only or main residence, including meals and other services supplied " + "with it: rent from boarders and lodgers who live in the household, " + "and rent from letting part of the home to someone outside it. The " + "model treats all of it as furnished accommodation." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2005/5/section/786" + adds = ["rent_from_boarders_and_lodgers", "sublet_income"] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.py new file mode 100644 index 0000000000..bb2a8c263c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_relief.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class rent_a_room_relief(Variable): + value_type = float + entity = Person + label = "rent-a-room relief" + documentation = ( + "Rent-a-room receipts not charged to income tax. Receipts up to the " + "limit are not brought into account (full relief). Above the limit " + "the model assumes the individual elects for the alternative method, " + "under which the limit is deducted from the receipts and no expenses " + "are allowed; the data hold no expenses for this income." + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ukpga/2005/5/section/793", + "https://www.legislation.gov.uk/ukpga/2005/5/section/797", + "https://www.legislation.gov.uk/ukpga/2005/5/section/800", + ] + + def formula(person, period, parameters): + return min_( + person("rent_a_room_receipts", period), + person("rent_a_room_limit", period), + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/taxable_rent_a_room_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/taxable_rent_a_room_income.py new file mode 100644 index 0000000000..b212e2e4d1 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/taxable_rent_a_room_income.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class taxable_rent_a_room_income(Variable): + value_type = float + entity = Person + label = "taxable rent-a-room income" + documentation = ( + "Rent-a-room receipts above the individual's limit, brought into " + "account as profits of a UK property business. They are not " + "relievable receipts for the property allowance." + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ukpga/2005/5/section/797", + "https://www.legislation.gov.uk/ukpga/2005/5/section/783BB", + ] + + def formula(person, period, parameters): + return person("rent_a_room_receipts", period) - person( + "rent_a_room_relief", period + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py index f13a71fb41..a7daa17e7a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py @@ -18,6 +18,7 @@ class total_income(Variable): "social_security_income", "self_employment_income", "property_income", + "taxable_rent_a_room_income", "savings_interest_income", "dividend_income", "miscellaneous_income", diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 36cb046b48..6323da1c43 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -6,8 +6,15 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): entity = Person label = "eligible person for CTR non-dependent deduction" definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9" def formula(person, period, parameters): - return (person("age", period) >= 18) & ~person.benunit( - "benunit_contains_household_head", period + # A boarder or lodger is liable to pay the applicant on a commercial + # basis for their occupation, so is not a non-dependant (SI 2012/2885 + # reg 9(2)(e)). + commercial = person("pays_rent_to_householder", period) + return ( + (person("age", period) >= 18) + & ~person.benunit("benunit_contains_household_head", period) + & ~commercial ) diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index f3272ebc5b..83531ae285 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -10,6 +10,7 @@ "dividend_income", "miscellaneous_income", "property_income", + "sublet_income", "private_pension_income", "private_transfer_income", "maintenance_income", diff --git a/policyengine_uk/variables/household/income/household_market_income.py b/policyengine_uk/variables/household/income/household_market_income.py index 3991db6b7c..e531eabd55 100644 --- a/policyengine_uk/variables/household/income/household_market_income.py +++ b/policyengine_uk/variables/household/income/household_market_income.py @@ -10,6 +10,7 @@ "dividend_income", "miscellaneous_income", "property_income", + "sublet_income", "private_pension_income", "private_transfer_income", "maintenance_income", diff --git a/policyengine_uk/variables/household/income/market_income.py b/policyengine_uk/variables/household/income/market_income.py index 9df8de09cf..0fbd44bb6a 100644 --- a/policyengine_uk/variables/household/income/market_income.py +++ b/policyengine_uk/variables/household/income/market_income.py @@ -19,6 +19,7 @@ def formula(person, period, parameters): "dividend_income", "miscellaneous_income", "property_income", + "sublet_income", "private_pension_income", "private_transfer_income", "maintenance_income", diff --git a/policyengine_uk/variables/household/income/pays_rent_to_householder.py b/policyengine_uk/variables/household/income/pays_rent_to_householder.py new file mode 100644 index 0000000000..2cc695e85e --- /dev/null +++ b/policyengine_uk/variables/household/income/pays_rent_to_householder.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class pays_rent_to_householder(Variable): + value_type = bool + entity = Person + label = "in a family that pays the householder rent as boarders or lodgers" + documentation = ( + "Whether this person's benefit unit, which does not contain the " + "household head, pays the household head rent for board and lodging " + "or for lodging. Such a person is liable to make payments on a " + "commercial basis for their occupation, so is not a non-dependant of " + "the householder." + ) + definition_period = YEAR + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + ] + + def formula(person, period, parameters): + paid = add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) + in_head_benunit = person.benunit.any(person("is_household_head", period)) + return person.benunit.any(paid > 0) & ~in_head_benunit diff --git a/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py b/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py new file mode 100644 index 0000000000..3c9d9a4ec8 --- /dev/null +++ b/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class rent_from_boarders_and_lodgers(Variable): + value_type = float + entity = Person + label = "rent received from boarders and lodgers" + documentation = ( + "Rent the household head receives from boarders and lodgers: the " + "rent paid as a boarder or lodger by household members outside the " + "head's benefit unit, shared equally where more than one person is " + "flagged as household head. The Family Resources Survey records this " + "rent only on the payer. It is a transfer within the household, so " + "household income measures do not include it; it enters income tax " + "as rent-a-room receipts and the legacy means tests through the " + "home-letting income." + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + paid = add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) + head = person("is_household_head", period) + in_head_benunit = person.benunit.any(head) + paid_to_head = person.household.sum(paid * ~in_head_benunit) + heads = person.household.sum(head) + return where(head, paid_to_head / max_(heads, 1), 0) diff --git a/policyengine_uk/variables/input/rent_paid_as_boarder.py b/policyengine_uk/variables/input/rent_paid_as_boarder.py new file mode 100644 index 0000000000..ba168a0ce0 --- /dev/null +++ b/policyengine_uk/variables/input/rent_paid_as_boarder.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class rent_paid_as_boarder(Variable): + value_type = float + entity = Person + label = "rent paid to the householder as a boarder" + documentation = ( + "Rent this person pays the householder for board and lodging (a room " + "and at least some meals) in the householder's home, where this " + "person lives as a member of the household outside the householder's " + "benefit unit. The Family Resources Survey records it on the payer " + "(CVPAY where CONVBL is 1)." + ) + definition_period = YEAR + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/rent_paid_as_lodger.py b/policyengine_uk/variables/input/rent_paid_as_lodger.py new file mode 100644 index 0000000000..f788d94414 --- /dev/null +++ b/policyengine_uk/variables/input/rent_paid_as_lodger.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class rent_paid_as_lodger(Variable): + value_type = float + entity = Person + label = "rent paid to the householder as a lodger" + documentation = ( + "Rent this person pays the householder for lodging without meals in " + "the householder's home, where this person lives as a member of the " + "household outside the householder's benefit unit. The Family " + "Resources Survey records it on the payer (CVPAY where CONVBL is not " + "1)." + ) + definition_period = YEAR + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/sublet_income.py b/policyengine_uk/variables/input/sublet_income.py index f1e600600a..daaad5ce35 100644 --- a/policyengine_uk/variables/input/sublet_income.py +++ b/policyengine_uk/variables/input/sublet_income.py @@ -5,7 +5,14 @@ class sublet_income(Variable): value_type = float entity = Person label = "sublet income" - documentation = "Income from subletting properties" + documentation = ( + "Rent this person receives for letting part of the home they live in " + "to someone who is not a member of the household. Income tax treats " + "it as rent-a-room receipts, and the legacy means tests count it less " + "a weekly disregard. Rent from other property is property_income; " + "rent from boarders and lodgers who live in the household is " + "rent_from_boarders_and_lodgers." + ) definition_period = YEAR unit = GBP uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" From c336e29a8812736e8895bf8a864c6e01284d02dc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:10:11 -0400 Subject: [PATCH 004/250] Test that counted rent-a-room tax shares tax reductions pro rata Co-Authored-By: Claude Opus 5.5 --- .../boarders_and_lodgers.yaml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml index 9d26790caa..7664ab2d43 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml @@ -281,3 +281,43 @@ rent_a_room_income_tax: [580, 0] legacy_means_test_income_tax: [1_066, 0] legacy_benefits_home_letting_income: [9_360, 0] + +- name: Tax on counted rent-a-room income shares a tax reduction pro rata + # 2026-27. Step 4 tax: private pension (36,000 - 12,570) x 20% = 4,686; + # rent-a-room income (10,400 - 7,500) x 20% = 580; savings interest + # (3,000 - 1,000 personal savings allowance) x 20% = 400, with no starting + # rate for savings because non-savings income exceeds the allowances by more + # than £5,000; total 5,666. The married couple's allowance reduction + # (11,000 x 10% = 1,100, set as an input) leaves income tax of 4,566. The + # tests count the pension and the rent but treat interest as capital: the + # capital tax of 400 keeps its share of the reduction, + # 1,100 x 400 / 5,666 = 77.66, so 322.34 is not deducted, and the tax + # deducted is 4,566 - 322.34 = 4,243.66. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + private_pension_income: 36_000 + savings_interest_income: 3_000 + married_couples_allowance: 11_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 10_400 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + output: + rent_a_room_income_tax: [580, 0] + savings_income_tax: [400, 0] + capped_mcad: [1_100, 0] + income_tax: [4_566, 0] + legacy_means_test_income_tax: [4_243.66, 0] From 6df59cdb47d9b35610f0af5bd2384bf6f275a72e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:54:57 -0400 Subject: [PATCH 005/250] Do not deduct tax on rent a programme does not count; tighten tests Review of #2002: - UC earned income stops deducting the tax on rent-a-room income: UC does not count the rent (reg 66(1)) and reg 55(5)(b) allows only tax in respect of the employment. New rent_a_room_income_tax_after_reductions is that tax less its pro-rata share of Step 6 reductions. - Council tax reduction does the same where it excludes the rent: the Scottish working-age scheme and Welsh applicants with a UC award, whose income is the UC assessment (WSI 2013/3029 reg 3, Sch 6 para 9); home-letting income is no longer added for the latter. - Property tests: the rent-a-room limit must be halved exactly when two people in the household receive rent; uncounted rent leaves UC earned income and the excluded CTR routes unchanged; partner sublet income in the closed form; no lambda (ruff E731). - YAML: UC earned income with a lodger above the limit; Welsh CTR for a UC recipient. - Documentation: commercial and non-relative assumption for the non-dependant exclusion, multiple heads, annual inputs over 52 weeks. Co-Authored-By: Claude Opus 5.5 --- .../boarders_and_lodgers.yaml | 61 ++++++++++++++++ .../test_boarder_lodger_rent_properties.py | 70 ++++++++++++++++--- .../income/uc_earned_income.py | 7 ++ ...rent_a_room_income_tax_after_reductions.py | 41 +++++++++++ ...council_tax_reduction_applicable_income.py | 24 ++++++- .../income/pays_rent_to_householder.py | 6 +- .../income/rent_from_boarders_and_lodgers.py | 5 +- .../variables/input/rent_paid_as_boarder.py | 3 +- .../variables/input/rent_paid_as_lodger.py | 2 +- 9 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax_after_reductions.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml index 7664ab2d43..3b917fd6be 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml @@ -321,3 +321,64 @@ capped_mcad: [1_100, 0] income_tax: [4_566, 0] legacy_means_test_income_tax: [4_243.66, 0] + +- name: UC does not deduct the tax on rent it does not count + # 2026-27, England. Earnings 20,000: tax (20,000 - 12,570) x 20% = 1,486, + # NI 7,430 x 8% = 594.40. Lodger rent 10,000 gives taxable rent-a-room income + # 2,500 and tax 500, so income tax is 1,986. UC counts neither the rent + # (reg 66(1)) nor its tax (reg 55(5)(b)): earned income + # 20,000 - 1,486 - 594.40 = 17,919.60, as without the lodger. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 20_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 10_000 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + region: NORTH_WEST + output: + income_tax: [1_986, 0] + rent_a_room_income_tax_after_reductions: [500, 0] + uc_earned_income: [17_919.60, 0] + +- name: Welsh CTR for a UC recipient does not add rent from a lodger + # WSI 2013/3029 Sch 6 para 9: with a UC award, income is the UC assessment, + # which excludes this rent. Here UC (5,000, set as an input) is the only + # income, so CTR applicable income is 5,000, not 5,000 + 4,160. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 5_200 + benunits: + householder_unit: + members: [householder] + universal_credit: 5_000 + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + region: WALES + output: + legacy_benefits_home_letting_income: [4_160, 0] + council_tax_reduction_applicable_income: [5_000, 0] diff --git a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py index 1cdbc152e1..e1b963bfa2 100644 --- a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py +++ b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py @@ -9,8 +9,10 @@ 1. Conservation: the rent the head receives equals the rent paid by members outside the head's benefit unit, and nobody else receives any. -2. Rent-a-room bounds: 0 <= relief <= receipts, the limit is the basic amount - or half of it, and taxable rent-a-room income = max(0, receipts - limit). +2. Rent-a-room bounds: 0 <= relief <= receipts; the limit is half the basic + amount exactly when the person and someone else in the household both + have receipts, and the basic amount otherwise; taxable rent-a-room income + = max(0, receipts - limit). 3. Disregards: the counted home-letting income is between nil and the rent received, and equals the statutory closed form (boarders: half the excess over £20 a week each; lodgers: the excess over £20 a week, per person at @@ -23,7 +25,11 @@ board and lodging than when paid for lodging only. 6. Universal Credit unearned income and household market income do not depend on the rent paid between members of the household. -7. Tax: legacy_means_test_income_tax is between nil and income tax. +7. Where a programme does not count this rent, it does not deduct the tax on + it either: UC earned income, and council tax reduction income under the + Scottish working-age scheme and the Welsh universal credit route, do not + change when the rent changes. +8. Tax: legacy_means_test_income_tax is between nil and income tax. """ import numpy as np @@ -67,6 +73,7 @@ def households(draw): head_unit=head_unit, payers=payers, sublet=draw(st.one_of(st.just(0.0), st.floats(0, 8_000))), + partner_sublet=draw(st.one_of(st.just(0.0), st.floats(100, 8_000))), tenure=draw(st.sampled_from(["RENT_PRIVATELY", "OWNED_OUTRIGHT"])), region=draw(st.sampled_from(["NORTH_WEST", "LONDON", "WALES", "SCOTLAND"])), ) @@ -81,7 +88,7 @@ def situation(h, year, scale=1.0, as_kind=None): "age": {year: attrs["age"]}, "employment_income": {year: attrs["employment_income"]}, "is_household_head": {year: i == 0}, - "sublet_income": {year: h["sublet"] if i == 0 else 0.0}, + "sublet_income": {year: h["sublet"] if i == 0 else h["partner_sublet"]}, } names.append(name) benunits["head_unit"] = {"members": list(names)} @@ -118,6 +125,10 @@ def calculate(h, year, variables, **kwargs): return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in variables} +def per_week(annual): + return annual / WEEKS + + def payments(h, kind=None): return [ [ @@ -152,7 +163,11 @@ def test_rent_a_room_bounds(h, year): ], ) receipts, limit = v["rent_a_room_receipts"], v["rent_a_room_limit"] - assert np.all(np.isin(limit, [BASIC_AMOUNT, BASIC_AMOUNT / 2])) + receives = receipts > 0 + shared = receives & (receives.sum() - receives > 0) + np.testing.assert_allclose( + limit, np.where(shared, BASIC_AMOUNT / 2, BASIC_AMOUNT), atol=0.01 + ) assert np.all(v["rent_a_room_relief"] >= 0) assert np.all(v["rent_a_room_relief"] <= receipts + 0.01) np.testing.assert_allclose( @@ -168,22 +183,26 @@ def test_home_letting_income_closed_form(h, year): pension_age = bool( np.asarray(sim.calculate("is_SP_age", year))[: len(h["head_unit"])].any() ) - weekly = lambda x: x / WEEKS board = sum( - 0.5 * max(0, weekly(x) - DISREGARD) + 0.5 * max(0, per_week(x) - DISREGARD) for unit in payments(h, "rent_paid_as_boarder") for x in unit ) lodgers = payments(h, "rent_paid_as_lodger") if pension_age: - lodging = sum(max(0, weekly(x) - DISREGARD) for unit in lodgers for x in unit) + lodging = sum(max(0, per_week(x) - DISREGARD) for unit in lodgers for x in unit) else: - lodging = sum(max(0, weekly(sum(unit)) - DISREGARD) for unit in lodgers) - sublet = max(0, weekly(h["sublet"]) - DISREGARD) + lodging = sum(max(0, per_week(sum(unit)) - DISREGARD) for unit in lodgers) + sublet_received = h["sublet"] + ( + h["partner_sublet"] if len(h["head_unit"]) > 1 else 0.0 + ) + # #1995 sums the claimant's and partner's sublet income before one + # disregard (one occupier assumed). + sublet = max(0, per_week(sublet_received) - DISREGARD) expected = (board + lodging + sublet) * WEEKS np.testing.assert_allclose(counted[0], expected, rtol=1e-6, atol=0.01) assert np.all(counted[1:] == 0) - received = sum(sum(unit) for unit in payments(h)) + h["sublet"] + received = sum(sum(unit) for unit in payments(h)) + sublet_received assert 0 <= counted[0] <= received + 0.01 @@ -233,3 +252,32 @@ def test_legacy_means_test_income_tax_bounds(h, year): v = calculate(h, year, ["legacy_means_test_income_tax", "income_tax"]) assert np.all(v["legacy_means_test_income_tax"] >= 0) assert np.all(v["legacy_means_test_income_tax"] <= v["income_tax"] + 0.01) + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS), st.floats(0.3, 0.9)) +def test_programmes_that_exclude_the_rent_ignore_its_tax(h, year, scale): + # Both runs keep every payment positive, so non-dependant status is fixed. + variables = [ + "uc_earned_income", + "council_tax_reduction_applicable_income", + "universal_credit", + "is_SP_age", + ] + full = calculate(h, year, variables) + part = calculate(h, year, variables, scale=scale) + np.testing.assert_allclose( + full["uc_earned_income"], part["uc_earned_income"], atol=0.01 + ) + head_unit = 0 + working_age = not full["is_SP_age"][: len(h["head_unit"])].any() + on_uc = full["universal_credit"][head_unit] > 0 + excluded = (h["region"] == "SCOTLAND" and (working_age or on_uc)) or ( + h["region"] == "WALES" and on_uc + ) + if excluded and (part["universal_credit"][head_unit] > 0) == on_uc: + np.testing.assert_allclose( + full["council_tax_reduction_applicable_income"][head_unit], + part["council_tax_reduction_applicable_income"][head_unit], + atol=0.01, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py index 6e496b8681..538f10fe0a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py @@ -28,4 +28,11 @@ def formula(benunit, period, parameters): ["uc_work_allowance", "tax", "pension_contributions"], members, ) + # UC does not count rent from boarders, lodgers or sub-tenants (reg + # 66(1)), so the tax on it is not tax in respect of any employment + # (reg 55(5)(b)) and is not deducted. The rest of `tax` is wider than + # reg 55(5)(b) allows; that is #1942. + disregards -= add_for_members( + benunit, period, ["rent_a_room_income_tax_after_reductions"], members + ) return max_(0, personal_gross_earned_income - disregards) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax_after_reductions.py b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax_after_reductions.py new file mode 100644 index 0000000000..85e6c7606e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/rent_a_room/rent_a_room_income_tax_after_reductions.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class rent_a_room_income_tax_after_reductions(Variable): + value_type = float + entity = Person + label = "income tax on rent-a-room income after tax reductions" + documentation = ( + "The tax on taxable rent-a-room income less its share of the Step 6 " + "tax reductions, shared in proportion to the Step 4 tax on each kind " + "of income as legacy_means_test_income_tax does. It is the part of " + "income tax paid on this rent, which programmes that do not count the " + "rent must not deduct from the income they do count." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/23" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax + rent_a_room_tax = person("rent_a_room_income_tax", period) + before_reductions = add( + person, + period, + [ + "earned_income_tax", + "savings_income_tax", + "dividend_income_tax", + "property_income_tax", + ], + ) + reductions = min_( + add(person, period, p.income_tax_subtractions), before_reductions + ) + share = np.divide( + rent_a_room_tax, + before_reductions, + out=np.zeros_like(rent_a_room_tax, dtype=float), + where=before_reductions > 0, + ) + return rent_a_room_tax - reductions * share 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 53bba409a8..dc402a4ac3 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 @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_scotland_scheme, + is_wales_scheme, ) @@ -113,9 +114,18 @@ def formula(benunit, period, parameters): p_scotland = parameters( period ).gov.local_authorities.scotland.council_tax_reduction.means_test - counts_home_letting = ~scottish_working_age_scheme | ( - p_scotland.working_age_counts_home_letting_income - ) + # Wales: for an applicant with an award of universal credit (who is + # never a pensioner, WSI 2013/3029 reg 3), income is the Secretary of + # State's universal credit assessment (Sch 6 para 9), which has no + # rent from part of the home. That route is otherwise not modelled + # here (#1966); this keeps such rent out of it. + wales_universal_credit_route = is_wales_scheme( + benunit.household("country", period) + ) & (add(benunit, period, ["universal_credit"]) > 0) + counts_home_letting = ( + ~scottish_working_age_scheme + | p_scotland.working_age_counts_home_letting_income + ) & ~wales_universal_credit_route increased_income += where( counts_home_letting, benunit("legacy_benefits_home_letting_income", period), @@ -136,6 +146,14 @@ def formula(benunit, period, parameters): ["legacy_means_test_income_tax", "national_insurance"], members, ) + # Where the rent is not counted, neither is the tax on it. + tax -= where( + counts_home_letting, + 0, + add_for_members( + benunit, period, ["rent_a_room_income_tax_after_reductions"], members + ), + ) income_under_general_rules = max_( 0, increased_income - tax - pension_contributions ) diff --git a/policyengine_uk/variables/household/income/pays_rent_to_householder.py b/policyengine_uk/variables/household/income/pays_rent_to_householder.py index 2cc695e85e..75b47f92c7 100644 --- a/policyengine_uk/variables/household/income/pays_rent_to_householder.py +++ b/policyengine_uk/variables/household/income/pays_rent_to_householder.py @@ -10,7 +10,11 @@ class pays_rent_to_householder(Variable): "household head, pays the household head rent for board and lodging " "or for lodging. Such a person is liable to make payments on a " "commercial basis for their occupation, so is not a non-dependant of " - "the householder." + "the householder. The model takes every such payment to be commercial " + "and the payer not to be a close relative living there, whom the " + "rules keep as a non-dependant (HB reg 3(3) with reg 9(1); CTR reg " + "9(3)). The Family Resources Survey asks about this rent only for " + "people not related to the household reference person." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py b/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py index 3c9d9a4ec8..e2aa086a3f 100644 --- a/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py +++ b/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py @@ -9,8 +9,9 @@ class rent_from_boarders_and_lodgers(Variable): "Rent the household head receives from boarders and lodgers: the " "rent paid as a boarder or lodger by household members outside the " "head's benefit unit, shared equally where more than one person is " - "flagged as household head. The Family Resources Survey records this " - "rent only on the payer. It is a transfer within the household, so " + "flagged as household head (the survey data have one; with more, the " + "means tests share the rent after the disregards, not before). The " + "Family Resources Survey records this rent only on the payer. It is a transfer within the household, so " "household income measures do not include it; it enters income tax " "as rent-a-room receipts and the legacy means tests through the " "home-letting income." diff --git a/policyengine_uk/variables/input/rent_paid_as_boarder.py b/policyengine_uk/variables/input/rent_paid_as_boarder.py index ba168a0ce0..92ffc11cf3 100644 --- a/policyengine_uk/variables/input/rent_paid_as_boarder.py +++ b/policyengine_uk/variables/input/rent_paid_as_boarder.py @@ -10,7 +10,8 @@ class rent_paid_as_boarder(Variable): "and at least some meals) in the householder's home, where this " "person lives as a member of the household outside the householder's " "benefit unit. The Family Resources Survey records it on the payer " - "(CVPAY where CONVBL is 1)." + "(CVPAY where CONVBL is 1). Annual amount; the weekly means-test " + "rules divide it by 52." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/rent_paid_as_lodger.py b/policyengine_uk/variables/input/rent_paid_as_lodger.py index f788d94414..0859f6b9fd 100644 --- a/policyengine_uk/variables/input/rent_paid_as_lodger.py +++ b/policyengine_uk/variables/input/rent_paid_as_lodger.py @@ -10,7 +10,7 @@ class rent_paid_as_lodger(Variable): "the householder's home, where this person lives as a member of the " "household outside the householder's benefit unit. The Family " "Resources Survey records it on the payer (CVPAY where CONVBL is not " - "1)." + "1). Annual amount; the weekly means-test rules divide it by 52." ) definition_period = YEAR unit = GBP From 8bf733e1443ab5bd1a9d1dc7cac8e15e6d9e2907 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 22:07:08 -0400 Subject: [PATCH 006/250] Update CTR tests for the Welsh working-age rules and main's Class 4 NI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1909's Welsh working-age earner now gets the Welsh allowance and the £5 earnings disregard (1,111.24, not the HB-based 993.72). - #1944's capped mixed-age UC couple in Wales gets the maximum reduction under WSI 2013/3029 Sch 6 paras 3 and 9; give it a UC maximum amount consistent with its award. - The Scottish self-employed £17.10 case now pays Class 4 NI on its profits. Co-Authored-By: Claude Opus 5.5 --- ...il_tax_reduction_devolved_working_age.yaml | 6 +++-- .../council_tax_reduction_pension_credit.yaml | 8 +++++-- .../council_tax_reduction_pensioner.yaml | 22 ++++++++++--------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index e3b7fab53c..84ef38293e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -611,7 +611,9 @@ - name: Scotland, the £17.10 needs employed earnings from April 2022 # Sch 3 para 15(1): a self-employed lone parent gets only the £25 disregard - # (1,300 a year). Applicable amount (95.55 + 109.85) x 52 = 10,680.80. + # (1,300 a year). Earnings 15,000 less income tax 461.70 and Class 4 NI + # 145.80 = 14,392.50; income 13,092.50; applicable amount (95.55 + 109.85) + # x 52 = 10,680.80; award 1,800 - 0.2 x 2,411.70 = 1,317.66. period: 2026 absolute_error_margin: 0.01 input: @@ -636,7 +638,7 @@ savings: 0 output: council_tax_reduction_working_age_earnings_disregard: 1_300 - simulated_council_tax_reduction_benunit: 1_279.66 + simulated_council_tax_reduction_benunit: 1_317.66 - name: Scotland, an employed lone parent working 30 hours gets the £17.10 # (25 + 17.10) x 52 = 2,189.20. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 6e52f66d3d..376fbdb565 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -357,10 +357,14 @@ savings: 0 output: # Working age, so not eligible for Pension Credit: no guarantee credit. + # The Welsh working-age rules apply (WSI 2013/3029 Sch 6-8): applicable + # amount 101.85 x 52 = 5,296.20; income 10,000 less the £5 earnings + # disregard (260) = 9,740; award 2,000 - 0.2 x 4,443.80 = 1,111.24. guarantee_credit: 0 in_receipt_of_guarantee_credit: false - council_tax_reduction_applicable_income: 10_000 - council_tax_reduction: 993.72 + council_tax_reduction_working_age_applicable_amount: 5_296.20 + council_tax_reduction_working_age_applicable_income: 9_740 + council_tax_reduction: 1_111.24 - name: Scotland guarantee credit recipient gets maximum CTR above the capital limit period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml index 444244a941..b1e3b7c2ef 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml @@ -304,14 +304,13 @@ council_tax_reduction_scheme_supported: true - name: In Wales the cap reaches Council Tax Reduction through Universal Credit income - # The model's Welsh formula does not route on pensioner status, but it - # counts Universal Credit as income. Capping a mixed-age couple's award - # (cap 22,020 outside London; 30,000 + 3,000 - 22,020 = 10,980) lowers that - # income and so raises the reduction. Without the cap Universal Credit of - # 30,000 would take income far above the applicable amount. 1,582.84 is the - # model's pension-age formula, not the law: the Welsh route for Universal - # Credit applicants (WSI 2013/3029 Sch 6 paras 3 and 9) would give this - # family the maximum reduction, which the model does not yet implement. + # A mixed-age couple on Universal Credit is not a CTR pensioner, so the + # Welsh route for Universal Credit applicants applies (WSI 2013/3029 Sch 6 + # paras 3 and 9): the applicable amount is the UC maximum amount (30,000) + # and income is the Secretary of State's income (nil) plus the award after + # the cap (cap 22,020 outside London; 30,000 + 3,000 - 22,020 = 10,980 off, + # leaving 19,020). Income is below the applicable amount, so the family gets + # the maximum reduction. period: 2026 absolute_error_margin: 0.01 input: @@ -325,6 +324,7 @@ benunits: benunit: members: [older, younger, child] + uc_maximum_amount: 30_000 universal_credit_pre_benefit_cap: 30_000 child_benefit: 3_000 uc_deductions: 0 @@ -339,5 +339,7 @@ output: benefit_cap_reduction: 10_980 universal_credit: 19_020 - council_tax_reduction_applicable_income: 22_020 - council_tax_benefit: 1_582.84 + council_tax_reduction_devolved_working_age: true + council_tax_reduction_working_age_applicable_amount: 30_000 + council_tax_reduction_working_age_applicable_income: 19_020 + council_tax_benefit: 2_000 From e7f7a25c49e21523173d947ef5231ea79ac65a3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:29:24 -0400 Subject: [PATCH 007/250] Apply tax and NI only to earnings, count carers on entitlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Take off earnings only the tax and NI in respect of the employment or trade (the UC split of income tax; Class 1, 2 and 4, not Class 3), and disregard tax on counted unearned income in Wales. - Count the carer premium on entitlement to a carer benefit; give the Scottish UC carer fallback only with an award of UC. - Scotland: the £20 disregard for a UC worker with LCW or LCWRA. - Wales UC: the UC model's own earned income, as the Secretary of State's figure. - Welsh fixed amounts from 1 April 2013 (W.S.I. 2012/3144). Co-Authored-By: Claude Opus 5.5 --- .../working_age/childcare.yaml | 25 +++-- .../working_age/earnings_disregard.yaml | 82 ++++++++++------ .../working_age/tariff_income.yaml | 33 ++++--- .../working_age/_earnings.py | 94 +++++++++++++++---- ...reduction_working_age_applicable_income.py | 4 +- ...ouncil_tax_reduction_working_age_carers.py | 21 +++-- ...tax_reduction_working_age_earned_income.py | 6 +- ...eduction_working_age_earnings_disregard.py | 42 +++++++-- ...tion_working_age_employed_earned_income.py | 22 ++--- ...uction_working_age_person_earned_income.py | 21 +++-- ...ax_reduction_working_age_unabsorbed_tax.py | 32 ------- ...duction_working_age_unearned_income_tax.py | 29 ++++++ 12 files changed, 272 insertions(+), 139 deletions(-) delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml index b83d15f029..c0732e9dbb 100644 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/childcare.yaml @@ -1,25 +1,34 @@ -description: Weekly caps on the relevant childcare charges deducted from the earnings of an applicant who is not a pensioner under the Welsh council tax reduction schemes. +description: Weekly caps on the relevant childcare charges deducted from the earnings of an applicant + who is not a pensioner under the Welsh council tax reduction schemes. maximum_one_child: description: Weekly cap on relevant childcare charges where the family has one child with charges. values: - 2014-04-01: 175 + 2013-04-01: 175 metadata: label: Wales working-age CTR childcare cap, one child unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 20(3)(a) - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 20(3)(a) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20 + - &id001 + title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 + schemes) + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made maximum_two_or_more_children: - description: Weekly cap on relevant childcare charges where the family has more than one child with charges. + description: Weekly cap on relevant childcare charges where the family has more than one child with + charges. values: - 2014-04-01: 300 + 2013-04-01: 300 metadata: label: Wales working-age CTR childcare cap, two or more children unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 20(3)(b) - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 20(3)(b) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/20 + - *id001 metadata: label: Wales working-age CTR childcare charges diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml index c8797630c7..b5dfcc158f 100644 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/earnings_disregard.yaml @@ -1,91 +1,115 @@ -description: Weekly earnings disregards for applicants who are not pensioners under the Welsh council tax reduction schemes. +description: Weekly earnings disregards for applicants who are not pensioners under the Welsh council + tax reduction schemes. single: description: Weekly earnings disregard where no other disregard applies. values: - 2014-04-01: 5 + 2013-04-01: 5 metadata: label: Wales working-age CTR earnings disregard, single unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 11 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 para. 11 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - &id001 + title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 + schemes) + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made couple: - description: Weekly earnings disregard for a couple, a member of which is in employment, to whom no higher disregard applies. + description: Weekly earnings disregard for a couple, a member of which is in employment, to whom no + higher disregard applies. values: - 2014-04-01: 10 + 2013-04-01: 10 metadata: label: Wales working-age CTR earnings disregard, couple unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 8 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 para. 8 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - *id001 lone_parent: description: Weekly earnings disregard for a lone parent. values: - 2014-04-01: 25 + 2013-04-01: 25 metadata: label: Wales working-age CTR earnings disregard, lone parent unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 5 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 para. 5 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - *id001 disability_or_carer: - description: Weekly earnings disregard where the applicable amount includes a disability premium, severe disability premium or carer premium. + description: Weekly earnings disregard where the applicable amount includes a disability premium, severe + disability premium or carer premium. values: - 2014-04-01: 20 + 2013-04-01: 20 metadata: label: Wales working-age CTR earnings disregard, disability or carer unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 paras. 4 and 6 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 paras. 4 and 6 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - *id001 additional: description: Additional weekly earnings disregard for applicants who meet a work condition. values: - 2014-04-01: 17.10 + 2013-04-01: 17.1 metadata: label: Wales working-age CTR additional earnings disregard unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 18 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 para. 18 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - *id001 additional_full_time_hours: - description: Weekly hours of remunerative work from which an applicant aged 25 or over qualifies for the additional earnings disregard. + description: Weekly hours of remunerative work from which an applicant aged 25 or over qualifies for + the additional earnings disregard. values: - 2014-04-01: 30 + 2013-04-01: 30 metadata: label: Wales working-age CTR additional earnings disregard full-time hours unit: hour period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 18(2) - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 para. 18(2) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - *id001 additional_full_time_age: - description: Age from which an applicant working the full-time hours qualifies for the additional earnings disregard. + description: Age from which an applicant working the full-time hours qualifies for the additional earnings + disregard. values: - 2014-04-01: 25 + 2013-04-01: 25 metadata: label: Wales working-age CTR additional earnings disregard full-time age unit: year period: year reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 8 para. 18(2) - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 8 para. 18(2) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/8 + - *id001 remunerative_work_hours: description: Weekly hours from which a person is in remunerative work. values: - 2014-04-01: 16 + 2013-04-01: 16 metadata: label: Wales working-age CTR remunerative work hours unit: hour period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. 10(1) - href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/10 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. + 10(1) + href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/10 + - *id001 metadata: label: Wales working-age CTR earnings disregards diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml index bb8d5c4451..3fdd064d72 100644 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml @@ -1,36 +1,47 @@ -description: Tariff income treated as arising from the capital of an applicant who is not a pensioner under the Welsh council tax reduction schemes. +description: Tariff income treated as arising from the capital of an applicant who is not a pensioner + under the Welsh council tax reduction schemes. threshold: description: Capital above which an applicant who is not a pensioner is treated as having tariff income. values: - 2014-04-01: 6_000 + 2013-04-01: 6000 metadata: label: Wales working-age CTR tariff income threshold unit: currency-GBP period: year reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 33 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 33 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 + - &id001 + title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 + schemes) + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made step: - description: Size of each capital step, or part of one, above the threshold that yields the weekly tariff income amount. + description: Size of each capital step, or part of one, above the threshold that yields the weekly tariff + income amount. values: - 2014-04-01: 250 + 2013-04-01: 250 metadata: label: Wales working-age CTR tariff income capital step unit: currency-GBP period: year reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 33 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 33 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 + - *id001 amount: description: Weekly tariff income for each capital step, or part of one, above the threshold. values: - 2014-04-01: 1 + 2013-04-01: 1 metadata: label: Wales working-age CTR weekly tariff income per step unit: currency-GBP period: week reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 33 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. + 6 para. 33 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 + - *id001 metadata: label: Wales working-age CTR tariff income diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py index c955749df7..433fdb8f70 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_wales_scheme, +) STATUTORY_PAY = [ "statutory_sick_pay", @@ -6,29 +9,86 @@ "statutory_paternity_pay", ] +# Taxable unearned income the working-age schemes count. Tax on it is the part +# of a person's non-savings income tax that is not tax on their earnings, +# shared pro rata with property income (which the schemes treat as capital). +COUNTED_TAXABLE_UNEARNED = [ + "state_pension", + "private_pension_income", + "carers_allowance", + "carer_support_payment", + "esa_contrib", + "jsa_contrib", + "incapacity_benefit", +] + -def working_age_earnings_components(person, period): - """Gross earnings, employed earnings, pension deduction and tax per person. +def working_age_earnings(person, period): + """Net earnings and the employed part of them, per person. - With an award of Universal Credit, earnings follow the Universal Credit - measure (with any minimum income floor) and all pension contributions are - deducted; otherwise employment and self-employment income count and half - of pension contributions are deducted. Statutory sick, maternity and - paternity pay are employed earnings on both routes (SSI 2021/249 regs - 49(4), 50(2); WSI 2013/3029 Sch 6 para 14(1)). + Tax and National Insurance come off earnings only as far as they are in + respect of the employment or trade: the model's Universal Credit split of + each person's income tax (earnings taken as the lowest slice of + non-savings income) and their Class 1, 2 and 4 contributions, not + voluntary Class 3 (SSI 2021/249 regs 49(6), 50(3); WSI 2013/3029 Sch 6 + paras 15(3), 16). With an award of Universal Credit: + - Wales uses the Secretary of State's figure for the award, which is the + model's Universal Credit earned income before the work allowance + (Sch 6 para 9); + - Scotland uses the Universal Credit measure plus statutory sick, + maternity and paternity pay, which reg 49(4) treats as earnings. + Without Universal Credit, employment and self-employment income and + statutory pay count, less half of pension contributions. + Returns (net earnings, net employed earnings). """ has_universal_credit = person.benunit( "council_tax_reduction_working_age_has_universal_credit", period ) + wales = is_wales_scheme(person.household("country", period)) statutory_pay = add(person, period, STATUTORY_PAY) employment = person("employment_income", period) - universal_credit_gross = ( - person("uc_mif_capped_earned_income", period) + statutory_pay + self_employment = person("self_employment_income", period) + tax_on_earnings = add( + person, + period, + ["uc_income_tax_on_earnings", "uc_national_insurance_on_earnings"], + ) + universal_credit_earnings = person("uc_individual_earned_income", period) + scotland_universal_credit = universal_credit_earnings + statutory_pay + legacy_gross = employment + self_employment + statutory_pay + legacy = max_( + 0, + legacy_gross - 0.5 * person("pension_contributions", period) - tax_on_earnings, + ) + net = where( + has_universal_credit, + where(wales, universal_credit_earnings, scotland_universal_credit), + legacy, + ) + gross = where(has_universal_credit, net, legacy_gross) + employed_gross = employment + statutory_pay + employed_share = np.divide( + min_(employed_gross, gross), + gross, + out=np.zeros_like(gross, dtype=float), + where=gross > 0, + ) + return net, net * employed_share + + +def working_age_unearned_income_tax(person, period): + """Income tax on the person's counted taxable unearned income.""" + counted = add(person, period, COUNTED_TAXABLE_UNEARNED) + property_income = max_(0, person("property_income", period)) + other_non_savings_tax = max_( + 0, + person("earned_income_tax", period) + - person("uc_income_tax_on_earnings", period), + ) + share = np.divide( + counted, + counted + property_income, + out=np.zeros_like(counted, dtype=float), + where=(counted + property_income) > 0, ) - legacy_gross = employment + person("self_employment_income", period) + statutory_pay - gross = where(has_universal_credit, universal_credit_gross, legacy_gross) - employed_gross = min_(employment + statutory_pay, gross) - pension_share = where(has_universal_credit, 1, 0.5) - pension_deduction = person("pension_contributions", period) * pension_share - tax = person("tax", period) - return gross, employed_gross, pension_deduction, tax + return other_non_savings_tax * share diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py index 85c57351bc..de1e520908 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py @@ -63,7 +63,9 @@ def formula(benunit, period, parameters): unearned_tax = where( wales & ~has_universal_credit, min_( - benunit("council_tax_reduction_working_age_unabsorbed_tax", period), + benunit( + "council_tax_reduction_working_age_unearned_income_tax", period + ), unearned, ), 0, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py index 0ba18b2d9d..c5e03680e0 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py @@ -12,10 +12,12 @@ class council_tax_reduction_working_age_carers(Variable): entity = BenUnit label = "Claimants and partners qualifying for the working-age CTR carer premium" documentation = ( - "The number of claimants and partners who receive Carer's Allowance " - "or, in Scotland, Carer Support Payment. In Scotland an award of " - "Universal Credit with the carer element also qualifies; when no one " - "in the family receives a carer benefit, it counts as one carer." + "The number of claimants and partners who qualify for the working-age " + "carer premium: entitled to Carer's Allowance or, in Scotland, Carer " + "Support Payment, whether or not an overlapping benefit reduces the " + "payment to nil. In Scotland an award of Universal Credit that " + "includes the carer element also qualifies; when no one in the family " + "is entitled to a carer benefit, it counts as one carer." ) definition_period = YEAR reference = ( @@ -26,8 +28,13 @@ class council_tax_reduction_working_age_carers(Variable): def formula(benunit, period, parameters): person = benunit.members claimant_or_partner = working_age_applicant_or_partner(person, period) - receives = person("receives_carer_benefit", period) - carers = benunit.sum(claimant_or_partner & receives) + entitled = person("is_entitled_to_carer_benefit", period) + carers = benunit.sum(claimant_or_partner & entitled) scotland = is_scotland_scheme(benunit.household("country", period)) - uc_carer_element = benunit("uc_carer_element", period) > 0 + # An award of Universal Credit that includes the carer element + # (SSI 2021/249 Sch 1 para 6(1)(c)); uc_carer_element alone is the + # potential element. + uc_carer_element = (benunit("uc_carer_element", period) > 0) & benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) return carers + (scotland & uc_carer_element & (carers == 0)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py index be2729f3cc..692942a246 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earned_income.py @@ -8,10 +8,8 @@ class council_tax_reduction_working_age_earned_income(Variable): documentation = ( "Annual net earnings of the claimant and partner for a working-age " "council tax reduction claim in Scotland or Wales, before earnings " - "disregards. Income tax and National Insurance are deducted from each " - "person's earnings up to those earnings; in Wales any tax the earnings " - "cannot absorb is disregarded from unearned income instead (see " - "council_tax_reduction_working_age_unabsorbed_tax)." + "disregards: the sum of each person's net earnings (see " + "council_tax_reduction_working_age_person_earned_income)." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py index 02944d9970..ea81a325d2 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py @@ -16,7 +16,9 @@ class council_tax_reduction_working_age_earnings_disregard(Variable): "claim in Scotland or Wales. One weekly disregard applies: £25 for a " "lone parent; otherwise £20 of the family's earnings where the " "applicable amount includes a disability or severe disability " - "premium; otherwise, with a carer premium, £20 of the carers' own " + "premium (in Scotland also for a Universal Credit applicant with " + "limited capability for work who is in employment); otherwise, with a " + "carer benefit in payment, £20 of the carers' own " "earnings, plus up to £10 of a non-carer partner's earnings within the " "same £20; otherwise £10 for a couple and £5 for a single applicant. " "Applicants without Universal Credit who meet a work condition have " @@ -57,9 +59,33 @@ def rate(name): lone_parent = benunit("is_lone_parent", period) couple = benunit("is_couple", period) - disability = (benunit("disability_premium", period) > 0) | ( - benunit("severe_disability_premium", period) > 0 + has_universal_credit = benunit( + "council_tax_reduction_working_age_has_universal_credit", period + ) + # Scotland also gives the £20 to a Universal Credit applicant or + # partner with limited capability for work (or for work-related + # activity) where one of them is under pensionable age and in + # employment (SSI 2021/249 Sch 3 para 4(3)). + scottish_uc_limited_capability = ( + scotland + & has_universal_credit + & benunit.any( + claimant_or_partner & person("uc_limited_capability_for_WRA", period) + ) + & benunit.any( + claimant_or_partner + & ~person("is_SP_age", period) + & (weekly_person_earnings > 0) + ) ) + disability = ( + (benunit("disability_premium", period) > 0) + | (benunit("severe_disability_premium", period) > 0) + | scottish_uc_limited_capability + ) + # The carer disregard needs a carer benefit in payment (Wales Sch 8 + # para 6: "in receipt of carer's allowance"); in Scotland an award of + # Universal Credit with the carer element also qualifies. carers = benunit("council_tax_reduction_working_age_carers", period) # Carer disregard: £20 of the carers' own earnings, plus up to £10 of a # non-carer partner's earnings within the £20 (Wales Sch 8 paras 6-7; @@ -84,7 +110,12 @@ def rate(name): ), ) base = select( - [lone_parent, disability, carers > 0, couple], + [ + lone_parent, + disability, + (named_carers > 0) | (scotland & (carers > 0)), + couple, + ], [ min_(rate("lone_parent"), weekly_earnings), min_(rate("disability_or_carer"), weekly_earnings), @@ -132,9 +163,6 @@ def rate(name): covers_additional = (tested_earnings > 0) & ( tested_earnings >= (base + additional) * WEEKS_IN_YEAR + childcare ) - has_universal_credit = benunit( - "council_tax_reduction_working_age_has_universal_credit", period - ) additional_applies = work_condition & covers_additional & ~has_universal_credit weekly = base + additional * additional_applies weekly = where(has_universal_credit & ~scotland, 0, weekly) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py index da19a41a7d..a0b9819ea9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_employed_earned_income.py @@ -1,6 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._applicant import ( + working_age_applicant_or_partner, +) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( - working_age_earnings_components, + working_age_earnings, ) @@ -11,9 +14,9 @@ class council_tax_reduction_working_age_employed_earned_income(Variable): documentation = ( "The employed part of the claimant's and partner's net earnings: " "employment income and statutory sick, maternity and paternity pay, " - "with each person's deductions shared pro rata with their " - "self-employed earnings. Scotland's additional £17.10 earnings " - "disregard turns on employed earnings from April 2022." + "sharing each person's deductions pro rata with their self-employed " + "earnings. Scotland's additional £17.10 earnings disregard turns on " + "employed earnings from April 2022." ) definition_period = YEAR unit = GBP @@ -21,12 +24,5 @@ class council_tax_reduction_working_age_employed_earned_income(Variable): def formula(benunit, period, parameters): person = benunit.members - gross, employed_gross, _, _ = working_age_earnings_components(person, period) - net = person("council_tax_reduction_working_age_person_earned_income", period) - share = np.divide( - employed_gross, - gross, - out=np.zeros_like(gross, dtype=float), - where=gross > 0, - ) - return benunit.sum(net * share) + _, employed = working_age_earnings(person, period) + return benunit.sum(working_age_applicant_or_partner(person, period) * employed) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py index 4a5181c578..28311eb1c6 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_person_earned_income.py @@ -3,7 +3,7 @@ working_age_applicant_or_partner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( - working_age_earnings_components, + working_age_earnings, ) @@ -14,10 +14,14 @@ class council_tax_reduction_working_age_person_earned_income(Variable): documentation = ( "A claimant's or partner's annual net earnings for a working-age " "council tax reduction claim in Scotland or Wales, before earnings " - "disregards: gross earnings less pension contributions (all of them " - "with Universal Credit, half otherwise) and the person's income tax " - "and National Insurance, not below zero. Children's and other " - "members' earnings do not count." + "disregards. Only income tax and National Insurance in respect of the " + "employment or trade are deducted (not tax on pensions, benefits, " + "property, savings or dividends, nor voluntary Class 3 " + "contributions). With Universal Credit, Wales uses the Universal " + "Credit earned income the award is based on, and Scotland adds " + "statutory sick, maternity and paternity pay to it; otherwise half of " + "pension contributions are deducted. Children's and other members' " + "earnings do not count." ) definition_period = YEAR unit = GBP @@ -29,8 +33,5 @@ class council_tax_reduction_working_age_person_earned_income(Variable): ) def formula(person, period, parameters): - gross, _, pension_deduction, tax = working_age_earnings_components( - person, period - ) - claimant_or_partner = working_age_applicant_or_partner(person, period) - return claimant_or_partner * max_(0, gross - pension_deduction - tax) + net, _ = working_age_earnings(person, period) + return working_age_applicant_or_partner(person, period) * net diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py deleted file mode 100644 index 566c6c2457..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unabsorbed_tax.py +++ /dev/null @@ -1,32 +0,0 @@ -from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._applicant import ( - working_age_applicant_or_partner, -) -from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( - working_age_earnings_components, -) - - -class council_tax_reduction_working_age_unabsorbed_tax(Variable): - value_type = float - entity = BenUnit - label = "Working-age council tax reduction tax not absorbed by earnings" - documentation = ( - "Income tax and National Insurance of the claimant and partner beyond " - "what their earnings (less pension deductions) can absorb, which is " - "tax on unearned income. Wales disregards it from unearned income " - "(WSI 2013/3029 Sch 9 para 4); Scotland counts unearned income gross " - "(SSI 2021/249 reg 57)." - ) - definition_period = YEAR - unit = GBP - reference = "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/4" - - def formula(benunit, period, parameters): - person = benunit.members - gross, _, pension_deduction, tax = working_age_earnings_components( - person, period - ) - claimant_or_partner = working_age_applicant_or_partner(person, period) - absorbed = max_(0, gross - pension_deduction) - return benunit.sum(claimant_or_partner * max_(0, tax - absorbed)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py new file mode 100644 index 0000000000..1aa79d1a47 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._applicant import ( + working_age_applicant_or_partner, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._earnings import ( + working_age_unearned_income_tax, +) + + +class council_tax_reduction_working_age_unearned_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Working-age council tax reduction tax on counted unearned income" + documentation = ( + "Income tax of the claimant and partner on the taxable unearned " + "income the working-age schemes count (pensions, State Pension and " + "taxable benefits): their non-savings income tax less the tax on their " + "earnings, shared pro rata with property income. Wales disregards it " + "from unearned income (WSI 2013/3029 Sch 9 para 4); Scotland counts " + "unearned income gross (SSI 2021/249 reg 57)." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/4" + + def formula(benunit, period, parameters): + person = benunit.members + tax = working_age_unearned_income_tax(person, period) + return benunit.sum(working_age_applicant_or_partner(person, period) * tax) From 1e12d2f32ec2e617545a7209925d8393130dd712 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:39:14 -0400 Subject: [PATCH 008/250] Fix round-2 review findings and add their regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Count Carer Support Payment before the overlap reduction, as the pension-age route does, in income and in tax on unearned income. - Update main's Carer Support Payment tests for the devolved amounts. - Regression tests: tax on pensions and dividends stays off wages (Wales and Scotland), carer premium on entitlement, no UC carer premium without an award, Scottish UC LCWRA £20 disregard, Class 3 NI, the Welsh UC income figure (differential against the UC variables), a partial Welsh UC award independent of rent, and the Welsh 2013 first dates. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_carer_support_payment.yaml | 34 ++- ...il_tax_reduction_devolved_working_age.yaml | 270 +++++++++++++++++- ...duction_devolved_working_age_properties.py | 106 +++++++ .../working_age/_earnings.py | 2 +- ...x_reduction_working_age_unearned_income.py | 7 +- 5 files changed, 404 insertions(+), 15 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 83dc25ff15..9ea9fd7d1b 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 @@ -20,8 +20,11 @@ # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, # carer premium 48.15, personal allowance 95.55 (single, 25 or over), 256.00 # (single, pension age) and 383.35 (couple, pension age). 2025: CSP 83.30, no -# supplement, and the model's carer premium 46.38 and personal allowance 92.04 -# (the statutory 2025-26 amounts were 46.40 and 92.05; see #2110). 2024: CA +# supplement, and Scotland's working-age carer premium 46.40 and personal +# allowance 92.05 (SSI 2025/24 reg 16; the Housing Benefit amounts the +# pension-age route reads were 46.38 and 92.04, see #2110). Wales's 2026 +# working-age personal allowance is its own, 101.85 (W.S.I. 2026/13 reg +# 11(a)). 2024: CA # 81.90, carer premium 45.60, personal allowance 90.50. Reduction is withdrawn # at 20% of income above the applicable amount. Council tax is 2,000 # throughout. @@ -85,10 +88,14 @@ savings: 0 output: carer_support_payment: 86.45 * 52 - council_tax_reduction_applicable_income: 7_000 + 86.45 * 52 + council_tax_reduction_working_age_applicable_income: 7_000 + 86.45 * 52 + council_tax_reduction_working_age_applicable_amount: (95.55 + 48.15) * 52 council_tax_reduction: 1_195.40 -- name: A Welsh carer on Carer's Allowance gets the same Council Tax Reduction as the Scottish carer +- name: A Welsh carer on Carer's Allowance has it counted in Council Tax Reduction + # Wales's working-age rules (W.S.I. 2026/13 reg 11): applicable amount + # (101.85 + 48.15 carer premium) x 52 = 7,800; income 7,000 pension + + # 4,495.40 Carer's Allowance; award 2,000 - 0.2 x 3,695.40 = 1,260.92. period: 2026 absolute_error_margin: 0.01 input: @@ -112,8 +119,9 @@ carers_allowance: 86.45 * 52 carer_support_payment: 0 scottish_carer_supplement: 0 - council_tax_reduction_applicable_income: 7_000 + 86.45 * 52 - council_tax_reduction: 1_195.40 + council_tax_reduction_working_age_applicable_income: 7_000 + 86.45 * 52 + council_tax_reduction_working_age_applicable_amount: (101.85 + 48.15) * 52 + council_tax_reduction: 1_260.92 - name: A Scottish carer who does not claim Carer Support Payment has none counted period: 2026 @@ -188,6 +196,10 @@ # applicable amount, and a full award of 2,000. council_tax_reduction_applicable_income: 5_000 + 86.45 * 52 council_tax_reduction_applicable_amount: (95.55 + 48.15) * 52 + # The working-age route counts the same amount. Scotland's 2026-27 + # amounts (SSI 2026/56 reg 14) equal these: 95.55 + 48.15 carer premium. + council_tax_reduction_working_age_applicable_income: 5_000 + 86.45 * 52 + council_tax_reduction_working_age_applicable_amount: (95.55 + 48.15) * 52 # 2,000 - 0.2 x (9,495.40 - 7,472.40) council_tax_reduction: 1_595.40 @@ -215,10 +227,12 @@ carers_allowance: 0 carer_support_payment: 83.30 * 52 scottish_carer_supplement: 0 - council_tax_reduction_applicable_income: 7_000 + 83.30 * 52 - council_tax_reduction_applicable_amount: (92.04 + 46.38) * 52 - # 2,000 - 0.2 x (11,331.60 - 7,197.84) = 1,173.25 - council_tax_reduction: 2_000 - 0.2 * (7_000 + 83.30 * 52 - (92.04 + 46.38) * 52) + council_tax_reduction_working_age_applicable_income: 7_000 + 83.30 * 52 + # Scotland's own 2025-26 working-age amounts (SSI 2025/24 reg 16): personal + # allowance 92.05 and carer premium 46.40 a week. + council_tax_reduction_working_age_applicable_amount: (92.05 + 46.40) * 52 + # 2,000 - 0.2 x (11,331.60 - 7,199.40) = 1,173.56 + council_tax_reduction: 2_000 - 0.2 * (7_000 + 83.30 * 52 - (92.05 + 46.40) * 52) - name: In 2024 the Scottish carer is still on Carer's Allowance, which was already counted period: 2024 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index 84ef38293e..2558661d18 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -508,7 +508,7 @@ council_tax: 2_000 savings: 0 output: - council_tax_reduction_working_age_unabsorbed_tax: 286 + council_tax_reduction_working_age_unearned_income_tax: 286 council_tax_reduction_working_age_applicable_income: 13_714 simulated_council_tax_reduction_benunit: 316.44 @@ -689,3 +689,271 @@ savings: 0 output: council_tax_reduction_working_age_adult_premiums: 2_246.40 + +- name: Wales, tax on a pension does not come off wages + # Only tax "deducted from those earnings" comes off earnings (Sch 6 para + # 15(3)(a)); tax on the pension is disregarded from the pension (Sch 9 para + # 4). Earnings are the lowest slice of income, so the £500 wages bear no tax + # and the 386 = 0.2 x (14,500 - 12,570) is all on the pension. Income: + # 500 - 260 (£5 disregard) + 14,000 - 386 = 13,854; applicable amount + # 101.85 x 52 = 5,296.20; award 1,800 - 0.2 x 8,557.80 = 88.44. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + employment_income: 500 + private_pension_income: 14_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + income_tax: 386 + council_tax_reduction_working_age_earned_income: 500 + council_tax_reduction_working_age_earnings_disregard: 260 + council_tax_reduction_working_age_unearned_income_tax: 386 + council_tax_reduction_working_age_applicable_income: 13_854 + simulated_council_tax_reduction_benunit: 88.44 + +- name: Wales, pension tax survives when childcare absorbs the wages + # Lone parent, child aged 3, £10,000 wages at 16 hours, £13,000 pension, + # £9,000 childcare (under the £175 a week cap). The wages bear no tax; the + # 2,086 = 0.2 x (23,000 - 12,570) is on the pension. Earnings 10,000 - + # 1,300 (£25 disregard) - 9,000 childcare = 0 (the £17.10 needs earnings to + # cover it). Pension 13,000 - 2,086 = 10,914, below the applicable amount + # (101.85 + 87.88 child + 20.22 family premium) x 52 = 10,917.40, so the + # award is the full 1,800. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 10_000 + weekly_hours: 16 + private_pension_income: 13_000 + childcare_expenses: 9_000 + council_tax_benefit_reported: 100 + child: + age: 3 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_earned_income: 10_000 + council_tax_reduction_working_age_earnings_disregard: 1_300 + council_tax_reduction_working_age_childcare_deduction: 9_000 + council_tax_reduction_working_age_unearned_income_tax: 2_086 + council_tax_reduction_working_age_applicable_amount: 10_917.40 + council_tax_reduction_working_age_applicable_income: 10_914 + simulated_council_tax_reduction_benunit: 1_800 + +- name: Scotland, a pension counts gross and its tax does not come off wages + # SSI 2021/249 reg 50(3)(a) deducts tax "deducted from" the earnings; reg 57 + # counts the pension gross. The £4,000 wages bear no tax (the 271.70 is on + # the pension). Income 4,000 - 260 + 10,000 = 13,740; applicable amount + # 95.55 x 52 = 4,968.60; award 1,800 - 0.2 x 8,771.40 = 45.72. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 4_000 + weekly_hours: 20 + private_pension_income: 10_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + income_tax: 271.70 + council_tax_reduction_working_age_earned_income: 4_000 + council_tax_reduction_working_age_applicable_income: 13_740 + simulated_council_tax_reduction_benunit: 45.72 + +- name: Wales, dividend tax does not come off wages + # £15,000 wages and £10,000 dividends. Only the tax and NI on the wages + # come off them: 0.2 x 2,430 = 486 income tax and 0.08 x 2,430 = 194.40 + # Class 1, leaving 14,319.60. The dividend tax (1,021.25) is not deducted + # from earnings, and the dividends are not on the closed income list. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 15_000 + weekly_hours: 37 + dividend_income: 10_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + income_tax: 1_507.25 + council_tax_reduction_working_age_earned_income: 14_319.60 + council_tax_reduction_working_age_unearned_income: 0 + council_tax_reduction_working_age_unearned_income_tax: 0 + +- name: Wales, a carer premium on entitlement when an overlapping benefit takes the payment to nil + # Sch 7 para 14(1) needs the claimant to be "entitled to a carer's + # allowance"; contributory ESA of £6,000 overlaps it to nil. Applicable + # amount (101.85 + 48.15) x 52 = 7,800; income 6,000 ESA + 4,000 pension, + # untaxed; award 1,800 - 0.2 x 2,200 = 1,360. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + esa_contrib_reported: 6_000 + private_pension_income: 4_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + carers_allowance: 0 + council_tax_reduction_working_age_carers: 1 + council_tax_reduction_working_age_applicable_amount: 7_800 + council_tax_reduction_working_age_applicable_income: 10_000 + simulated_council_tax_reduction_benunit: 1_360 + +- name: Scotland, caring hours without a carer benefit or a Universal Credit award give no carer premium + # Sch 1 para 6(1)(c) needs "an award of universal credit which includes the + # carer element"; a potential element is not one. The claimant does not + # claim Carer Support Payment. £5 disregard; income 12,000 - 260 = 11,740; + # award 1,800 - 0.2 x (11,740 - 4,968.60) = 445.72. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 12_000 + weekly_hours: 20 + care_hours: 40 + would_claim_carers_allowance: false + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_carers: 0 + council_tax_reduction_working_age_earnings_disregard: 260 + simulated_council_tax_reduction_benunit: 445.72 + +- name: Scotland, a Universal Credit worker with LCWRA gets the £20 disregard + # Sch 3 para 4(3): the £20 also applies to a UC applicant with limited + # capability for work-related activity who is in employment and under + # pensionable age, with no disability premium. Earnings 12,000 (no tax or + # NI) - 1,040; no children, so no UC counts. Award 1,800 - 0.2 x (10,960 - + # 4,968.60) = 601.72. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + employment_income: 12_000 + weekly_hours: 30 + uc_limited_capability_for_WRA: true + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + rent: 6_000 + savings: 0 + output: + council_tax_reduction_working_age_has_universal_credit: true + council_tax_reduction_working_age_earnings_disregard: 1_040 + council_tax_reduction_working_age_applicable_income: 10_960 + simulated_council_tax_reduction_benunit: 601.72 + +- name: Scotland, voluntary Class 3 contributions do not come off earnings + # Reg 50(3)(a) deducts "primary Class 1 contributions"; voluntary Class 3 + # is not deductible. Same award as without them: 445.72. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + employment_income: 12_000 + weekly_hours: 20 + ni_class_3: 500 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + national_insurance: 500 + council_tax_reduction_working_age_earned_income: 12_000 + simulated_council_tax_reduction_benunit: 445.72 diff --git a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py index 02e8d58779..2d88fac200 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py @@ -156,3 +156,109 @@ def test_income_related_esa_passports_to_the_full_liability(units): flag = sim.calculate("council_tax_reduction_working_age_passported", YEAR) assert flag.all() assert np.allclose(award, liability, atol=0.01) + + +def welsh_universal_credit_family(earnings, rent, statutory_maternity_pay=0): + return { + "region": "WALES", + "people": [ + { + "age": 30, + "employment_income": earnings, + "weekly_hours": 30, + "council_tax_benefit_reported": 100, + }, + {"age": 30, "statutory_maternity_pay": statutory_maternity_pay}, + {"age": 0}, + ], + "would_claim_uc": True, + "rent": rent, + "council_tax": 1_800, + "savings": 0, + "esa_income": 0, + } + + +def test_welsh_universal_credit_uses_the_universal_credit_income_figure(): + """Wales with Universal Credit (WSI 2013/3029 Sch 6 paras 3 and 9). + + The applicable amount is the UC maximum amount, and income is the + Secretary of State's income figure for the award plus the award. Earnings + are therefore whatever the UC model counts, statutory pay included or + not, so CTR and UC can never disagree on them. Differential check against + the UC variables, with and without statutory maternity pay. + """ + units = [ + welsh_universal_credit_family(6_000, 6_000), + welsh_universal_credit_family(6_000, 6_000, statutory_maternity_pay=5_000), + welsh_universal_credit_family(8_000, 6_000), + ] + sim = Simulation(situation=situation(units)) + earnings = sim.calculate("council_tax_reduction_working_age_earned_income", YEAR) + uc_earnings = sim.calculate("uc_individual_earned_income", YEAR, map_to="benunit") + award = sim.calculate( + "council_tax_reduction_working_age_universal_credit_award", YEAR + ) + income = sim.calculate("council_tax_reduction_working_age_applicable_income", YEAR) + applicable_amount = sim.calculate( + "council_tax_reduction_working_age_applicable_amount", YEAR + ) + assert sim.calculate( + "council_tax_reduction_working_age_has_universal_credit", YEAR + ).all() + assert np.allclose(earnings, uc_earnings, atol=0.01) + assert np.allclose( + income, + earnings + sim.calculate("uc_unearned_income", YEAR) + award, + atol=0.01, + ) + assert np.allclose( + applicable_amount, sim.calculate("uc_maximum_amount", YEAR), atol=0.01 + ) + + +def test_welsh_universal_credit_partial_award_does_not_depend_on_rent(): + """A targeted case for property 2, where the award is partial. + + Couple with a baby, £11,000 earnings (above the benefit cap's earnings + exemption) and rent of £6,000 or £13,000. The housing element raises both + the maximum amount and the award, so excess income, 0.45 x 11,000 + 0.55 + x 5,124 work allowance = 7,768.20, and the award, 1,800 - 0.2 x 7,768.20 + = 246.36, are the same at either rent. + """ + units = [ + welsh_universal_credit_family(11_000, 6_000), + welsh_universal_credit_family(11_000, 13_000), + ] + sim = Simulation(situation=situation(units)) + award = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + assert sim.calculate("benefit_cap_reduction", YEAR).max() == 0 + assert np.allclose(award, 246.36, atol=0.01) + + +def test_welsh_fixed_amounts_start_with_the_2013_scheme(): + """Wales's fixed working-age amounts apply from 1 April 2013, when the + Council Tax Reduction Schemes (Prescribed Requirements) (Wales) + Regulations 2012 (W.S.I. 2012/3144) set them. A parameter read before its + first date takes its earliest value, so check the first dates.""" + from policyengine_uk.system import system + + wales = system.parameters.gov.local_authorities.wales.council_tax_reduction + working_age = wales.working_age + first = { + "earnings_disregard.single": 5, + "earnings_disregard.couple": 10, + "earnings_disregard.lone_parent": 25, + "earnings_disregard.disability_or_carer": 20, + "earnings_disregard.additional": 17.10, + "tariff_income.threshold": 6_000, + "tariff_income.step": 250, + "tariff_income.amount": 1, + "childcare.maximum_one_child": 175, + "childcare.maximum_two_or_more_children": 300, + } + for name, value in first.items(): + parameter = working_age.get_child(name) + earliest = parameter.values_list[-1] + assert earliest.instant_str == "2013-04-01", name + assert earliest.value == value, name diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py index 433fdb8f70..83c3251545 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py @@ -16,7 +16,7 @@ "state_pension", "private_pension_income", "carers_allowance", - "carer_support_payment", + "carer_support_payment_pre_overlap", "esa_contrib", "jsa_contrib", "incapacity_benefit", diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py index d78e74b127..d6632bed91 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py @@ -14,8 +14,9 @@ class council_tax_reduction_working_age_unearned_income(Variable): "than Universal Credit and tariff income from capital. Retirement " "pensions, contributory Jobseeker's Allowance and Employment and " "Support Allowance, carer's allowance, carer support payment (the " - "Carer Support Payment component; the Scottish Carer Supplement is a " - "separate variable that does not count), " + "Carer Support Payment component before any overlapping-benefit " + "reduction, as the pension-age route counts it; the Scottish Carer " + "Supplement is a separate variable that does not count), " "maternity allowance, industrial injuries benefit, incapacity benefit, " "severe disablement allowance and working and child tax credits " "count. Child Benefit, the income-related benefits, disability " @@ -44,7 +45,7 @@ def formula(benunit, period, parameters): "jsa_contrib", "esa_contrib", "carers_allowance", - "carer_support_payment", + "carer_support_payment_pre_overlap", "maternity_allowance", "iidb", "incapacity_benefit", From 8916273ecdf24972f8f0e826af59e7d545abaa80 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:57:09 -0400 Subject: [PATCH 009/250] Document the Scottish carer disregard fallback and the Welsh UC statutory-pay inheritance Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction/working_age/_earnings.py | 3 ++- ..._tax_reduction_working_age_earnings_disregard.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py index 83c3251545..732c4e7f7a 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py @@ -34,7 +34,8 @@ def working_age_earnings(person, period): paras 15(3), 16). With an award of Universal Credit: - Wales uses the Secretary of State's figure for the award, which is the model's Universal Credit earned income before the work allowance - (Sch 6 para 9); + (Sch 6 para 9). It follows the Universal Credit model, which does not + yet count statutory pay as earnings (UC Regs 2013 reg 55(4)); - Scotland uses the Universal Credit measure plus statutory sick, maternity and paternity pay, which reg 49(4) treats as earnings. Without Universal Credit, employment and self-employment income and diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py index ea81a325d2..9f823b1738 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_earnings_disregard.py @@ -20,7 +20,13 @@ class council_tax_reduction_working_age_earnings_disregard(Variable): "limited capability for work who is in employment); otherwise, with a " "carer benefit in payment, £20 of the carers' own " "earnings, plus up to £10 of a non-carer partner's earnings within the " - "same £20; otherwise £10 for a couple and £5 for a single applicant. " + "same £20. In Scotland, where the carer premium is included but no " + "one in the family has a carer benefit in payment (an award of " + "Universal Credit with the carer element, or a carer benefit an " + "overlapping benefit reduces to nil), the £20 is approximated against " + "the family's earnings, which can exceed the £10 limit on a non-carer " + "partner's earnings. Otherwise £10 for a couple and £5 for a single " + "applicant. " "Applicants without Universal Credit who meet a work condition have " "£17.10 more disregarded if their earnings cover the other disregard, " "their childcare charges and the £17.10; for a couple's disability " @@ -90,8 +96,9 @@ def rate(name): # Carer disregard: £20 of the carers' own earnings, plus up to £10 of a # non-carer partner's earnings within the £20 (Wales Sch 8 paras 6-7; # Scotland Sch 3 paras 6-7). Carers are identified by carer benefit - # receipt; a Scottish UC carer element with no carer benefit in the - # family falls back to the family's earnings. + # receipt. A Scottish carer premium with no carer benefit in payment + # in the family (a UC carer element, or a benefit overlapped to nil) + # falls back to the family's earnings, an approximation. is_carer = claimant_or_partner & person("receives_carer_benefit", period) carer_earnings = benunit.sum(is_carer * weekly_person_earnings) other_earnings = benunit.sum( From 505ac7e28d238efc77bed16003ab1e6e449b82cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 19:57:23 -0400 Subject: [PATCH 010/250] 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 011/250] 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 012/250] 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 013/250] 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 014/250] 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 015/250] 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 016/250] 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 017/250] 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 018/250] 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 019/250] 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 020/250] 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 021/250] 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 022/250] 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 aed672468974d363f6730a1763cc2b713f569852 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:33:44 -0400 Subject: [PATCH 023/250] Read only the applicant's own ESA for the higher single allowance The personal allowance read the benefit unit's esa_income, which holds awards other members claim in their own right and, where the household head applies alone, the young couple's award. Read contributory ESA for the applicant and partner, and claimant_or_partner_esa_income only where the benefit unit's awards are the applicant's. Co-Authored-By: Claude Opus 5.5 --- ...il_tax_reduction_devolved_working_age.yaml | 41 +++++++++++++++++++ ...eduction_working_age_personal_allowance.py | 13 +++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index 926f3d5284..ec16bf9de2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -1113,3 +1113,44 @@ council_tax_reduction_working_age_applicable_amount: 95.55 * 52 council_tax_reduction_working_age_applicable_income: 100_000 simulated_council_tax_reduction_benunit: 0 + +- name: Scotland, the benefit unit's income-related ESA does not give a head who applies alone the higher single rate + # The household head, 22, applies alone; the young couple in her benefit + # unit are its claimant and partner, and their income-related ESA is + # theirs, not hers. She keeps the under-25 single allowance, 75.65 x 52 = + # 3,933.80 (SSI 2026/56 reg 14(a)(i)), and is not passported. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 22 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [head, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + esa_income: 4_000 + households: + home: + members: [head, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + council_tax_reduction_working_age_passported: false + council_tax_reduction_working_age_personal_allowance: 75.65 * 52 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py index e81c8ca4df..a7479b93e4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_personal_allowance.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.working_age._applicant import ( + applicant_family, working_age_applicant_or_partner, working_age_couple, working_age_lone_parent, @@ -41,12 +42,14 @@ def formula(benunit, period, parameters): person = benunit.members claimant_or_partner = working_age_applicant_or_partner(person, period) age = benunit.max(where(claimant_or_partner, person("age", period), -np.inf)) + # The applicant's or partner's own ESA: contributory ESA is personal; + # income-related ESA is the benefit unit's claimant's and partner's, + # not a household head's who applies alone. on_esa = ( - add_for_members( - benunit, period, ["esa_contrib", "esa_income"], claimant_or_partner - ) - > 0 - ) + add_for_members(benunit, period, ["esa_contrib"], claimant_or_partner) + + applicant_family(benunit, period) + * benunit("claimant_or_partner_esa_income", period) + ) > 0 has_universal_credit = benunit( "council_tax_reduction_working_age_has_universal_credit", period ) From 36674138c6d93c165a77b84e8e2def8d4ba1f21e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 19:24:30 -0400 Subject: [PATCH 024/250] State the Welsh UC rent property only where both variants have housing costs Hypothesis found a family with no rent: gaining a housing costs element lowers the UC work allowance (UC Regs 2013 reg 22), which changes the award by more than the element, so the reduction changes. That is the law, not a defect. The property now compares variants that both have a housing costs element, and a new test executes the boundary. Co-Authored-By: Claude Opus 5.5 --- ...duction_devolved_working_age_properties.py | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py index 2d88fac200..1b2186ed95 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py @@ -3,11 +3,14 @@ Invariants, for any working-age family in Scotland or Wales: 1. The award is between 0 and the council tax liability. -2. Wales, Universal Credit: the award does not depend on the rent. The UC - housing costs element sits in both the applicable amount (the UC maximum - amount, WSI 2013/3029 Sch 6 para 3) and the income (the award, para 9), so - it cancels. Benefit-capped awards are excluded: the cap lowers the award - but not the maximum amount. +2. Wales, Universal Credit: the award does not depend on the rent, given a + housing costs element. The element sits in both the applicable amount + (the UC maximum amount, WSI 2013/3029 Sch 6 para 3) and the income (the + award, para 9), so it cancels. Benefit-capped awards are excluded: the cap + lowers the award but not the maximum amount. A family without housing + costs is also excluded, intentionally: gaining a housing costs element + lowers the UC work allowance (UC Regs 2013 reg 22), which changes the + award by more than the element. 3. Universal Credit, both countries: the award never rises with the claimant's earnings. In Wales excess income is the Secretary of State's income plus the award less the maximum amount, which rises by at least 45p @@ -119,6 +122,7 @@ def test_devolved_working_age_award_properties(units, raise_by): "council_tax_reduction_working_age_has_universal_credit", YEAR ) capped = sim.calculate("benefit_cap_reduction", YEAR) > 0 + housing = sim.calculate("uc_housing_costs_element", YEAR) > 0 liability = sim.calculate( "council_tax_reduction_maximum_eligible_liability", YEAR, map_to="benunit" ) @@ -130,6 +134,8 @@ def test_devolved_working_age_award_properties(units, raise_by): rent_i = n + i if ( units[i]["region"] == "WALES" + and housing[i] + and housing[rent_i] and has_uc[i] and has_uc[rent_i] and not capped[i] @@ -262,3 +268,37 @@ def test_welsh_fixed_amounts_start_with_the_2013_scheme(): earliest = parameter.values_list[-1] assert earliest.instant_str == "2013-04-01", name assert earliest.value == value, name + + +def test_welsh_universal_credit_rent_cancels_only_given_housing_costs(): + """Property 2's boundary, executed: an 18-year-old lone parent with a baby + and £21,462 earnings on Universal Credit in Wales. At rents of £1,000 and + £6,000 the housing costs element cancels and the award is the same; with + no rent the work allowance is the higher one (UC Regs 2013 reg 22), the + award is larger and so is the income CTR counts, so the reduction is + lower. Intended, not a defect.""" + + def family(rent): + return { + "region": "WALES", + "people": [ + { + "age": 18, + "employment_income": 21_462, + "council_tax_benefit_reported": 100, + }, + {"age": 0}, + ], + "would_claim_uc": True, + "rent": rent, + "council_tax": 2_400, + "savings": 0, + "esa_income": 0, + } + + sim = Simulation(situation=situation([family(0), family(1_000), family(6_000)])) + award = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + work_allowance = sim.calculate("uc_work_allowance", YEAR) + assert work_allowance[0] > work_allowance[1] == work_allowance[2] + assert abs(award[1] - award[2]) < 0.01 + assert award[0] < award[1] From 779f5dcfd291fa1e38bcd5a0ec617d23ceca1372 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 22:03:52 -0400 Subject: [PATCH 025/250] Fix review r4: Class 1 in full, miscellaneous income unearned, one premium for one cared-for person - R4-1: primary Class 1 falls on pay only, so it comes off pay in full; only income tax is shared between pay and miscellaneous income. - R4-3: Part 5 miscellaneous income is unearned income (SSI 2021/249 reg 57(1)(o); WSI 2013/3029 Sch 6 para 17). It counts in both countries, its share of the tax is disregarded in Wales (Sch 9 para 4), and it leaves Scotland's UC earnings measure. - R4-2: partners caring for the same severely disabled person count once for the carer premium (SSI 2021/249 Sch 1 para 5(3)-(4)), as in carer_premium. - R4-4: the pension deduction rate's docs say the Welsh and Scottish UC route now exists. - Nits: CSP test header, adult premium docs. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_carer_support_payment.yaml | 7 +- ...il_tax_reduction_devolved_working_age.yaml | 138 ++++++++++++++++++ ...ion_pension_contribution_deduction_rate.py | 12 +- .../working_age/_earnings.py | 71 ++++++--- ...ax_reduction_working_age_adult_premiums.py | 11 +- ...ouncil_tax_reduction_working_age_carers.py | 8 +- ...x_reduction_working_age_unearned_income.py | 16 +- 7 files changed, 222 insertions(+), 41 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 af34199a3e..45a9f2ec1f 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 @@ -14,10 +14,9 @@ # 2012/319 reg 31(12) disregards the tax payable on income taken into # account, which the supplement is not. The pension-age route deducts all # income tax, so there the taxable supplement reaches the model's Council Tax -# Reduction income through income tax and in no other way. The working-age -# route (Scotland and Wales) deducts only the tax on earnings, and Scotland -# counts unearned income gross (reg 57), so the supplement's tax does not -# reach it. +# Reduction income through income tax and in no other way. Scotland's +# working-age route deducts only the tax on earnings and counts unearned +# income gross (reg 57), so the supplement's tax does not reach it. # 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_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index ec16bf9de2..c0b16a2648 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -565,6 +565,8 @@ benunit: members: [carer_one, carer_two] would_claim_uc: false + # Each partner cares for a different severely disabled person. + partners_care_for_same_severely_disabled_person: false households: household: members: [carer_one, carer_two] @@ -1154,3 +1156,139 @@ council_tax_reduction_head_applies_alone: true council_tax_reduction_working_age_passported: false council_tax_reduction_working_age_personal_allowance: 75.65 * 52 + +- name: Scotland, partners caring for the same person get one carer premium + # SSI 2021/249 Sch 1 para 5(3)-(4). Both partners care 35 hours and the + # model gives each Carer Support Payment (86.45 x 52 = 4,495.40), but they + # care for the same person, so one premium: (150.15 + 48.15) x 52 = + # 10,311.60. Income 10,000 pension + 2 x 4,495.40 = 18,990.80; award + # 3,000 - 0.2 x 8,679.20 = 1,264.16 (two premiums would give 1,764.92). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer_one: + age: 40 + care_hours: 35 + private_pension_income: 10_000 + council_tax_benefit_reported: 100 + carer_two: + age: 40 + care_hours: 35 + benunits: + benunit: + members: [carer_one, carer_two] + would_claim_uc: false + partners_care_for_same_severely_disabled_person: true + households: + household: + members: [carer_one, carer_two] + region: SCOTLAND + country: SCOTLAND + council_tax: 3_000 + savings: 0 + output: + council_tax_reduction_working_age_carers: 1 + council_tax_reduction_working_age_applicable_amount: (150.15 + 48.15) * 52 + council_tax_reduction_working_age_applicable_income: 18_990.80 + simulated_council_tax_reduction_benunit: 1_264.16 + +- name: Scotland, partners caring for different people get a carer premium each + # The control: two premiums, (150.15 + 2 x 48.15) x 52 = 12,815.40; award + # 3,000 - 0.2 x (18,990.80 - 12,815.40) = 1,764.92. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer_one: + age: 40 + care_hours: 35 + private_pension_income: 10_000 + council_tax_benefit_reported: 100 + carer_two: + age: 40 + care_hours: 35 + benunits: + benunit: + members: [carer_one, carer_two] + would_claim_uc: false + partners_care_for_same_severely_disabled_person: false + households: + household: + members: [carer_one, carer_two] + region: SCOTLAND + country: SCOTLAND + council_tax: 3_000 + savings: 0 + output: + council_tax_reduction_working_age_carers: 2 + simulated_council_tax_reduction_benunit: 1_764.92 + +- name: Wales, primary Class 1 comes off pay in full; miscellaneous income is unearned + # £15,000 pay and £3,000 Part 5 miscellaneous income, 20 hours, no UC. + # Income tax 0.2 x (18,000 - 12,570) = 1,086, all on the two (the lowest + # slice), shared pro rata: 905 on pay, 181 on the miscellaneous income. + # Class 1 0.08 x (15,000 - 12,570) = 194.40, on pay only (Sch 6 para + # 15(3)(a)). Net pay 15,000 - 905 - 194.40 = 13,900.60, less the £5 + # disregard (260). The miscellaneous income is gross income other than + # earnings (Sch 6 para 17), less the tax on it (Sch 9 para 4): 2,819. + # Income 13,640.60 + 2,819 = 16,459.60; applicable amount 5,296.20; award + # 4,000 - 0.2 x 11,163.40 = 1,767.32. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 15_000 + miscellaneous_income: 3_000 + weekly_hours: 20 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 4_000 + savings: 0 + output: + income_tax: 1_086 + council_tax_reduction_working_age_earned_income: 13_900.60 + council_tax_reduction_working_age_unearned_income: 3_000 + council_tax_reduction_working_age_unearned_income_tax: 181 + council_tax_reduction_working_age_applicable_income: 16_459.60 + simulated_council_tax_reduction_benunit: 1_767.32 + +- name: Scotland, Part 5 miscellaneous income counts as unearned income + # SSI 2021/249 reg 57(1)(o): "income that ... is taxable under Part 5 of + # the Income Tax (Trading and Other Income) Act 2005", counted gross. No + # earnings. Income 6,000; applicable amount 95.55 x 52 = 4,968.60; award + # 2,000 - 0.2 x 1,031.40 = 1,793.72. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + miscellaneous_income: 6_000 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_working_age_earned_income: 0 + council_tax_reduction_working_age_unearned_income: 6_000 + council_tax_reduction_working_age_applicable_income: 6_000 + simulated_council_tax_reduction_benunit: 1_793.72 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py index d18898ecfa..2c4bdb6484 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py @@ -36,11 +36,13 @@ class council_tax_reduction_pension_contribution_deduction_rate(Variable): "(UC Regs 2013 reg 55(5)(a) for employed earnings and reg 57(2) step 4 " "for self-employed earnings): in Wales (WSI 2013/3029 Sch 6 para 9), " "Scotland (SSI 2021/249 regs 49(6)(a) and 51(2)) and Oxford (its " - "scheme's Appendix 3). The model has no such route for them yet " - "(#1962 for Wales and Scotland, #2520 for Oxford), so the share here " - "reaches those applicants too. Merton, Kingston upon Thames, Newham " - "and Westminster already assess them on Universal Credit income " - "(_legacy.py)." + "scheme's Appendix 3). Welsh and Scottish working-age applicants " + "with Universal Credit take that route " + "(council_tax_reduction_devolved_working_age), so the share here " + "reaches only those without it. Oxford has no such route yet (#2520), " + "so the share reaches its Universal Credit applicants too. Merton, " + "Kingston upon Thames, Newham and Westminster already assess them on " + "Universal Credit income (_legacy.py)." ) definition_period = YEAR unit = "/1" diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py index c5a9ab2baa..dca397211a 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py @@ -72,6 +72,37 @@ def _self_employed_net_profit(person, period, parameters): return max_(0, profit - notional_tax - notional_class_4 - notional_class_2) +def _pay_and_miscellaneous_tax(person, period): + """Income tax and primary Class 1 on pay, and income tax on + miscellaneous income. + + The Universal Credit split of a person's income tax + (uc_income_tax_on_earnings, less the part on any trade) covers pay, + statutory sick, maternity and paternity pay and Part 5 miscellaneous + income together. The schemes count miscellaneous income as unearned + income (SSI 2021/249 reg 57(1)(o); WSI 2013/3029 Sch 6 para 17), so that + tax is shared between pay and miscellaneous income pro rata. Primary + Class 1 falls on pay only and comes off it in full. + Returns (tax on pay, Class 1 on pay, tax on miscellaneous income). + """ + pay = person("employment_income", period) + add(person, period, STATUTORY_PAY) + miscellaneous = max_(0, person("taxable_miscellaneous_income", period)) + income_tax = person("uc_income_tax_on_earnings", period) - person( + "uc_income_tax_on_trade", period + ) + class_1 = person("uc_national_insurance_on_earnings", period) - person( + "uc_national_insurance_on_trade", period + ) + miscellaneous_share = np.divide( + miscellaneous, + pay + miscellaneous, + out=np.zeros_like(pay, dtype=float), + where=(pay + miscellaneous) > 0, + ) + miscellaneous_tax = income_tax * miscellaneous_share + return income_tax - miscellaneous_tax, class_1, miscellaneous_tax + + def working_age_earnings(person, period, parameters): """Net earnings and the employed part of them, per person. @@ -83,14 +114,18 @@ def working_age_earnings(person, period, parameters): regs 49 and 51(2)): employed earnings including statutory sick, maternity and paternity pay, and self-employed profit, less the income tax and National Insurance paid in respect of them and relievable - pension contributions, with no minimum income floor. + pension contributions, with no minimum income floor. Miscellaneous + income, which the Universal Credit model counts as earnings, is + unearned income here (reg 57(1)(o)), so it comes out, net of its share + of the tax. Without Universal Credit: - employed earnings are pay and statutory sick, maternity and paternity pay, less the income tax and primary Class 1 contributions in respect of the employment (SSI 2021/249 reg 50(3)(a); WSI 2013/3029 Sch 6 para 15(3)(a)). The tax is the model's Universal Credit split of the person's income tax (earnings taken as the lowest slice of non-savings income), - less the part on any trade, shared pro rata with miscellaneous income; + less the part on any trade, shared pro rata with miscellaneous income + (_pay_and_miscellaneous_tax); Class 1 comes off in full; - self-employed earnings are the net profit with the prescribed notional tax and contributions (_self_employed_net_profit); - one half of pension contributions comes off @@ -103,27 +138,16 @@ def working_age_earnings(person, period, parameters): ) wales = is_wales_scheme(person.household("country", period)) wales_universal_credit = person("uc_individual_earned_income", period) - scotland_universal_credit = person("uc_individual_earned_income_before_mif", period) - - employment = person("employment_income", period) + add( - person, period, STATUTORY_PAY - ) + pay_tax, class_1, miscellaneous_tax = _pay_and_miscellaneous_tax(person, period) miscellaneous = max_(0, person("taxable_miscellaneous_income", period)) - employment_tax_and_national_insurance = ( - person("uc_income_tax_on_earnings", period) - - person("uc_income_tax_on_trade", period) - + person("uc_national_insurance_on_earnings", period) - - person("uc_national_insurance_on_trade", period) - ) - employment_share = np.divide( - employment, - employment + miscellaneous, - out=np.zeros_like(employment, dtype=float), - where=(employment + miscellaneous) > 0, - ) - employed = max_( - 0, employment - employment_tax_and_national_insurance * employment_share + scotland_universal_credit = max_( + 0, + person("uc_individual_earned_income_before_mif", period) + - (miscellaneous - miscellaneous_tax), ) + + pay = person("employment_income", period) + add(person, period, STATUTORY_PAY) + employed = max_(0, pay - pay_tax - class_1) self_employed = _self_employed_net_profit(person, period, parameters) pension_rate = person.benunit( "council_tax_reduction_pension_contribution_deduction_rate", period @@ -153,6 +177,8 @@ def working_age_unearned_income_tax(person, period): tax on earnings (uc_income_tax_on_earnings, earnings being the lowest slice), the rest is tax on pensions and benefits, shared pro rata with the taxable ones the schemes do not count (the Scottish Carer Supplement). + The tax on miscellaneous income, which the schemes count as unearned + income, is added (_pay_and_miscellaneous_tax). """ other_non_savings_tax = max_( 0, @@ -169,4 +195,5 @@ def working_age_unearned_income_tax(person, period): out=np.ones_like(base, dtype=float), where=base > 0, ) - return other_non_savings_tax * counted_share + _, _, miscellaneous_tax = _pay_and_miscellaneous_tax(person, period) + return other_non_savings_tax * counted_share + miscellaneous_tax diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py index ab08dc2769..4ac8bbbe35 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py @@ -17,10 +17,13 @@ class council_tax_reduction_working_age_adult_premiums(Variable): "premiums in a working-age applicable amount in Scotland or Wales, at " "each country's own amounts. Who qualifies follows the model's " "legacy-benefit premium variables (disability_premium, " - "enhanced_disability_premium, severe_disability_premium), including " - "whether the severe disability premium is at the single or couple " - "rate. The carer premium is paid for each qualifying applicant or " - "partner. The employment and support allowance components, and the " + "enhanced_disability_premium) and Council Tax Reduction's own severe " + "disability premium test (council_tax_reduction_severe_disability_" + "premium), including whether that premium is at the single or couple " + "rate; where the household head applies alone, the conditions are her " + "own, as in council_tax_reduction_premiums. The carer premium is paid " + "for each qualifying applicant or partner, once where partners care " + "for the same person. The employment and support allowance components, and the " "disability premium's exclusion for limited capability for work, are " "not modelled." ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py index b4f3500b9e..4ae02293f5 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_carers.py @@ -15,7 +15,8 @@ class council_tax_reduction_working_age_carers(Variable): "The number of applicants and partners who qualify for the working-age " "carer premium: entitled to Carer's Allowance or, in Scotland, Carer " "Support Payment, whether or not an overlapping benefit reduces the " - "payment to nil. In Scotland an award of Universal Credit that " + "payment to nil; partners caring for the same severely disabled " + "person count once. In Scotland an award of Universal Credit that " "includes the carer element also qualifies; when no one in the family " "is entitled to a carer benefit, it counts as one carer." ) @@ -30,6 +31,11 @@ def formula(benunit, period, parameters): claimant_or_partner = working_age_applicant_or_partner(person, period) entitled = person("is_entitled_to_carer_benefit", period) carers = benunit.sum(claimant_or_partner & entitled) + # Partners caring for the same severely disabled person get one + # premium (SSI 2021/249 Sch 1 para 5(3)-(4); SSCBA s.70(7ZA) allows + # one Carer's Allowance per cared-for person), as in carer_premium. + same_person = benunit("partners_care_for_same_severely_disabled_person", period) + carers = where(same_person, min_(carers, 1), carers) scotland = is_scotland_scheme(benunit.household("country", period)) # An award of Universal Credit that includes the carer element # (SSI 2021/249 Sch 1 para 6(1)(c)); uc_carer_element alone is the diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py index 82671370cb..562b8f868a 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income.py @@ -19,11 +19,14 @@ class council_tax_reduction_working_age_unearned_income(Variable): "reduction, as the pension-age route counts it; the Scottish Carer " "Supplement is a separate variable that does not count), " "maternity allowance, industrial injuries benefit, incapacity benefit, " - "severe disablement allowance and working and child tax credits " - "count. Child Benefit, the income-related benefits, disability " - "benefits, Housing Benefit and child maintenance do not. Actual " - "savings interest, dividends and rent are treated as capital, which " - "yields tariff income instead." + "severe disablement allowance, Part 5 miscellaneous income and " + "working and child tax credits count. Child Benefit, the " + "income-related benefits, disability benefits, Housing Benefit and " + "child maintenance do not. Actual savings interest, dividends and rent " + "are treated as capital, which yields tariff income instead. Scotland " + "counts a closed list (SSI 2021/249 reg 57(1)); Wales counts gross " + "income other than earnings less the Sch 9 disregards (WSI 2013/3029 " + "Sch 6 para 17), of which the model counts the sources above." ) definition_period = YEAR unit = GBP @@ -51,6 +54,9 @@ def formula(benunit, period, parameters): "iidb", "incapacity_benefit", "sda", + # Part 5 miscellaneous income (SSI 2021/249 reg 57(1)(o); in + # Wales, gross income other than earnings, Sch 6 para 17). + "taxable_miscellaneous_income", ], members, ) From 3737d29e9c4d7b2aff5217c099360d61b85dd67b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 21:28:38 -0400 Subject: [PATCH 026/250] 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 027/250] 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 028/250] 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 029/250] 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 030/250] 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 031/250] 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 032/250] 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 033/250] 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 034/250] 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 035/250] 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 036/250] 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 037/250] 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 038/250] 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 039/250] 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 040/250] 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 041/250] 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 042/250] 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 705b3d746b633c294c0521cb02c2503119d44268 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 18:42:06 -0400 Subject: [PATCH 043/250] Band Scottish taxpayers' savings and dividends on rest-of-UK limits tax_band compared a Scottish taxpayer's total taxable income, savings and dividends included, with the Scottish limits. The Scottish rates charge only income that is neither savings nor dividend income (ITA 2007 s. 11A), and savings and dividends take the rest-of-UK bands (ss. 11D(6), 13(5)). The band is now the higher of the Scottish band of non-savings income and, where any taxable income is savings or dividends, the rest-of-UK band of all taxable income. Income at a limit stays in the lower band. The same formula now runs from 2017-18: before, 2017-18 used the rest-of-UK limits for Scottish taxpayers and 2018-19 read the five-rate Scottish schedule as the three-rate one. Other taxpayers are unchanged. Fixes #2350 Co-Authored-By: Claude Opus 5.5 --- .../bracketized_liability/tax_band.py | 181 ++++++++++++------ 1 file changed, 120 insertions(+), 61 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 73f78c28d5..2aa4512393 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -11,68 +11,137 @@ class TaxBand(Enum): ADDITIONAL = "Additional" +def uk_tax_band_above_limits(person, period, parameters): + """The rest-of-UK band before 2018-19: income above a limit is in the + band above it.""" + allowances = person("allowances", period) + ani = person("net_income", period) + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + limits = [ + threshold + (gross_gift if index in (1, 2) else 0) + for index, threshold in enumerate(rates.uk.thresholds) + ] + basic = allowances + limits[0] + higher = allowances + limits[1] + add = allowances + limits[2] + return select( + [ani > add, ani > higher, ani > basic], + [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], + default=TaxBand.NONE, + ) + + +def scottish_tax_band(person, period, parameters): + """The highest band in which a Scottish taxpayer has taxable income. + + The Scottish rates charge only income that is neither savings nor + dividend income (ITA 2007 s. 11A and the Scottish rate resolutions), so + that income is banded on the Scottish limits. Savings and dividends are + the top slice of income (s. 16) and are charged at the rates they would + bear if the person were not a Scottish taxpayer (ss. 11D(6) and 13(5)), + so where any taxable income is savings or dividends, it is banded on + the rest-of-UK limits at the top of all taxable income. The band is the + higher of the two. Income up to a limit is charged at the lower rate + (s. 10(2) to (3A); each resolution charges a rate "on income above" one + limit "and up to" the next), so income at a limit stays in the lower + band. + """ + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + scale = rates.scotland.rates + if len(scale.thresholds) == 3: + # 2017-18: the Scottish basic, higher and additional rates. + scottish_bands = [TaxBand.BASIC, TaxBand.HIGHER, TaxBand.ADDITIONAL] + else: + # From 2018-19: the starter, basic, intermediate and higher rates, + # then the top rate, which the advanced rate precedes from 2024-25. + scottish_bands = [ + TaxBand.STARTER, + TaxBand.BASIC, + TaxBand.INTERMEDIATE, + TaxBand.HIGHER, + ] + [TaxBand.ADDITIONAL] * (len(scale.thresholds) - 4) + labels = np.array([band.index for band in [TaxBand.NONE] + scottish_bands]) + # Allowances go to this income first, as in earned_income_tax. Marriage + # Allowance is a tax reduction (s. 55B(1)), so it does not move the band. + non_savings = person("non_savings_income_after_allowances", period) + # s. 414(2)(b) raises the Scottish basic rate's upper limit and every + # limit above it by the grossed-up gift. + first_extended = first_extended_scottish_threshold(scale) + limits_exceeded = sum( + non_savings > threshold + (gross_gift if index >= first_extended else 0) + for index, threshold in enumerate(scale.thresholds) + ) + scottish_band = labels[limits_exceeded] + uk_limits = rates.uk.thresholds + taxable_income = person("net_income", period) - person("allowances", period) + # Taxable income above the non-savings income is savings or dividends. + # s. 414(2)(b) raises the basic and higher rate limits by the gift too. + uk_band = select( + [ + taxable_income <= non_savings, + taxable_income > uk_limits[2] + gross_gift, + taxable_income > uk_limits[1] + gross_gift, + ], + [TaxBand.NONE.index, TaxBand.ADDITIONAL.index, TaxBand.HIGHER.index], + default=TaxBand.BASIC.index, + ) + bands = np.array(list(TaxBand), dtype=object) + return bands[max_(scottish_band, uk_band)] + + class tax_band(Variable): value_type = Enum possible_values = TaxBand default_value = TaxBand.NONE entity = Person label = "Tax band of the individual" - definition_period = YEAR - reference = dict( - title="Income Tax Act 2007, s. 10", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/10", + documentation = ( + "The highest band in which the person has taxable income. A " + "Scottish taxpayer's band is the higher of the Scottish band of " + "their income other than savings and dividends and the rest-of-UK " + "band of their savings and dividends. Savings and dividends charged " + "at a nil rate count where they sit. ADDITIONAL covers the Scottish " + "advanced and top rates and the dividend additional rate; HIGHER " + "covers the dividend upper rate." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007, s. 10", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/10", + ), + dict( + title="Income Tax Act 2007, s. 11A", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/11A", + ), + dict( + title="Income Tax Act 2007, s. 13(5)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/13", + ), + dict( + title="Scottish Rate Resolution 2025-26 (S6M-16531)", + href="https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531", + ), + ] def formula(person, period, parameters): - allowances = person("allowances", period) - ani = person("net_income", period) - rates = parameters(period).gov.hmrc.income_tax.rates - gross_gift = person("gift_aid_grossed_up", period) - limits = [ - threshold + (gross_gift if index in (1, 2) else 0) - for index, threshold in enumerate(rates.uk.thresholds) - ] - basic = allowances + limits[0] - higher = allowances + limits[1] - add = allowances + limits[2] - return select( - [ani > add, ani > higher, ani > basic], - [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], - default=TaxBand.NONE, - ) + return uk_tax_band_above_limits(person, period, parameters) - def formula_2017_04_06(person, period, parameters): - allowances = person("allowances", period) - ani = person("net_income", period) - rates = parameters(period).gov.hmrc.income_tax.rates - scot = person("pays_scottish_income_tax", period) - income = ani - allowances - gross_gift = person("gift_aid_grossed_up", period) - # s.414(2)(b) extends the limits used to determine the tax band. - uk_band = select( - [ - income < threshold + (gross_gift if index > 0 else 0) - for index, threshold in enumerate(rates.uk.thresholds[:3]) - ], - [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], - default=TaxBand.ADDITIONAL, + def formula_2017(person, period, parameters): + # Annual formulas start on 1 January, so this runs from 2017-18, the + # first year of Scottish rates and limits. + return where( + person("pays_scottish_income_tax", period), + scottish_tax_band(person, period, parameters), + uk_tax_band_above_limits(person, period, parameters), ) - first_extended = first_extended_scottish_threshold(rates.scotland.rates) - scottish_band = select( - [ - income < threshold + (gross_gift if index >= first_extended else 0) - for index, threshold in enumerate(rates.scotland.rates.thresholds[:3]) - ], - [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], - default=TaxBand.ADDITIONAL, - ) - return where(scot, scottish_band, uk_band) - def formula_2018_06_01(person, period, parameters): + def formula_2018(person, period, parameters): allowances = person("allowances", period) ani = person("net_income", period) rates = parameters(period).gov.hmrc.income_tax.rates - scot = person("pays_scottish_income_tax", period) income = ani - allowances gross_gift = person("gift_aid_grossed_up", period) # s.414(2)(b) extends the limits used to determine the tax band. @@ -84,18 +153,8 @@ def formula_2018_06_01(person, period, parameters): [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], default=TaxBand.ADDITIONAL, ) - scottish_band = select( - [ - income < threshold + (gross_gift if index >= 2 else 0) - for index, threshold in enumerate(rates.scotland.rates.thresholds[:5]) - ], - [ - TaxBand.NONE, - TaxBand.STARTER, - TaxBand.BASIC, - TaxBand.INTERMEDIATE, - TaxBand.HIGHER, - ], - default=TaxBand.ADDITIONAL, + return where( + person("pays_scottish_income_tax", period), + scottish_tax_band(person, period, parameters), + uk_band, ) - return where(scot, scottish_band, uk_band) From 92be9ef15584f832f5c39390bbc6a0887fd1bb9d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 18:55:04 -0400 Subject: [PATCH 044/250] Test tax_band for Scottish savings, dividends and limits Cases with hand-worked arithmetic for the issue's Scottish incomes with dividends and with savings, both limits at once, one pound above the basic rate limit, Gift Aid, allowances beyond pay, starter-rate pay, 2017-18 and 2018-19, with rest-of-UK controls. Eleven fail on main. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2350.fixed.md | 1 + .../tax/income_tax/scottish_rates.yaml | 378 ++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 changelog.d/2350.fixed.md diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md new file mode 100644 index 0000000000..efb925e014 --- /dev/null +++ b/changelog.d/2350.fixed.md @@ -0,0 +1 @@ +`tax_band` now bands a Scottish taxpayer's income other than savings and dividends on the Scottish limits and their savings and dividends on the rest-of-UK limits, from 2017-18, so the Marriage Allowance conditions and counts by marginal rate no longer put them in a band too high. diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 973b2c42b2..d1f3520d2d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -127,3 +127,381 @@ earned_taxable_income: 130000 earned_income_tax: 50034.35 income_tax: 50034.35 + +# tax_band for a Scottish taxpayer with savings or dividends (#2350). The +# Scottish rates charge only income that is neither savings nor dividends +# (ITA 2007 s. 11A(1A); S6M-16531 for 2025-26). Savings and dividends are the +# top slice (s. 16) and take the rates and bands they would bear outside +# Scotland (ss. 11D(6) and 13(5)). The band is the higher of the two. In +# 2025-26 the personal allowance is £12,570, the Scottish limits on taxable +# income are £2,827, £14,921, £31,092, £62,430 and £125,140 (19%, 20%, 21%, +# 42%, 45%, 48%), the basic rate limit is £37,700, the dividend allowance is +# £500 (8.75% ordinary, 33.75% upper) and the Personal Savings Allowance is +# £1,000, or £500 with higher-rate income (s. 12B). Marriage Allowance needs +# no liability above the Scottish intermediate or dividend ordinary rate +# (s. 55B(2)(b)). + +- name: Scottish 2025 pay of 40000 and dividends of 5000 is intermediate + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 27,430 taxable, intermediate. 2,827 x 19% + 12,094 x 20% + # + 12,509 x 21% = 537.13 + 2,418.80 + 2,626.89 = 5,582.82. + # Dividends: 27,430 to 32,430, below 37,700: (5,000 - 500) x 8.75%. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_582.82 + dividend_income_tax: 393.75 + +- name: Scottish 2025 pay of 40000 and savings of 5000 is intermediate + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay as above. Savings: 27,430 to 32,430, below 37,700, so the £1,000 + # allowance and (5,000 - 1,000) x 20% = 800; the starting rate for + # savings is used up by 27,430 of other income (s. 12). + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_582.82 + savings_income_tax: 800 + +- name: England 2025 pay of 40000 and dividends of 5000 is basic + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: ENGLAND + output: + # 32,430 of taxable income, below 37,700. 27,430 x 20% = 5,486. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_486 + dividend_income_tax: 393.75 + +- name: Scottish 2025 pay of 60000 and dividends of 20000 is higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 60_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 47,430 taxable, higher, not advanced. 537.13 + 2,418.80 + # + 16,171 x 21% + 16,338 x 42% = 537.13 + 2,418.80 + 3,395.91 + # + 6,861.96 = 13,213.80. Dividends: 47,430 to 67,430, all above + # 37,700 and below 125,140: (20,000 - 500) x 33.75% = 6,581.25. No + # income reaches the advanced, top or dividend additional rate. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 13_213.80 + dividend_income_tax: 6_581.25 + +- name: Scottish 2025 pay of 60000 and savings of 20000 is higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 60_000 + savings_interest_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay as above. Savings: 47,430 to 67,430, all at the higher rate with + # a £500 allowance: 19,500 x 40% = 7,800. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 13_213.80 + savings_income_tax: 7_800 + +- name: England 2025 pay of 60000 and dividends of 20000 is higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 60_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: ENGLAND + output: + # 37,700 x 20% + 9,730 x 40% = 7,540 + 3,892 = 11,432. + tax_band: HIGHER + earned_income_tax: 11_432 + dividend_income_tax: 6_581.25 + +- name: Scottish 2025 pay of 30000 and dividends of 25000 stays higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 30_000 + dividend_income: 25_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 17,430 taxable, intermediate. 537.13 + 2,418.80 + 2,509 x 21% + # = 3,482.82. Dividends: 17,430 to 42,430. 20,270 below 37,700, the + # first 500 at nil: 19,770 x 8.75% = 1,729.875; 4,730 above it at the + # upper rate: 4,730 x 33.75% = 1,596.375. Total 3,326.25. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 3_482.82 + dividend_income_tax: 3_326.25 + +- name: Scottish 2025 pay of 30000 and savings of 25000 stays higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 30_000 + savings_interest_income: 25_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Savings: 17,430 to 42,430 with a £500 allowance. 19,770 x 20% + # + 4,730 x 40% = 3,954 + 1,892 = 5,846. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 3_482.82 + savings_income_tax: 5_846 + +- name: Scottish 2025 income at both limits stays intermediate + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_608 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 31,092 taxable, exactly the intermediate rate's limit, so none + # at 42%: 537.13 + 2,418.80 + 16,171 x 21% = 6,351.84. Dividends: + # 31,092 to 37,700, exactly the basic rate limit, so all at the + # ordinary rate: (6,608 - 500) x 8.75% = 534.45. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 6_351.84 + dividend_income_tax: 534.45 + +- name: Scottish 2025 dividends one pound above the basic rate limit are higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_609 + households: + household: + members: [person] + country: SCOTLAND + output: + # As above, with £1 of dividends above 37,700 at the upper rate: + # 534.45 + 1 x 33.75% = 534.7875. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 6_351.84 + dividend_income_tax: 534.79 + +- name: Scottish 2025 Gift Aid raises the Scottish and basic rate limits + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 44_462 + dividend_income: 5_000 + gift_aid: 800 + households: + household: + members: [person] + country: SCOTLAND + output: + # The gift grosses up to 1,000, which raises the Scottish limits from + # the basic rate's upper limit and the basic rate limit (s. 414(2)(b)). + # Pay: 31,892 taxable, below 31,092 + 1,000. 537.13 + 13,094 x 20% + # + 15,971 x 21% = 537.13 + 2,618.80 + 3,353.91 = 6,509.84. + # Dividends: 31,892 to 36,892, below 38,700: 4,500 x 8.75% = 393.75. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 6_509.84 + dividend_income_tax: 393.75 + +- name: Scottish 2025 allowance beyond pay goes to dividends, which are basic + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 5_000 + dividend_income: 30_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # The allowance takes all 5,000 of pay first, then 7,570 of dividends, + # leaving 22,430 of dividends below 37,700 and nothing on the Scottish + # rates: (22,430 - 500) x 8.75% = 1,918.875. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + earned_income_tax: 0 + dividend_income_tax: 1_918.88 + +- name: Scottish 2025 allowance beyond pay goes to savings, which are basic + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 5_000 + savings_interest_income: 30_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # 22,430 of savings remain, with no other taxable income: 5,000 at the + # starting rate for savings (s. 12), 1,000 of allowance, then + # 16,430 x 20% = 3,286. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + earned_income_tax: 0 + savings_income_tax: 3_286 + +- name: Scottish 2025 starter-rate pay without savings or dividends is starter + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 14_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # 1,430 taxable, all at 19% = 271.70. Nothing is savings or dividends, + # so the basic rate limit does not apply. + tax_band: STARTER + earned_income_tax: 271.70 + +- name: Scottish 2025 pay equal to the personal allowance has no band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_570 + households: + household: + members: [person] + country: SCOTLAND + output: + tax_band: NONE + earned_income_tax: 0 + +- name: Scottish 2018-19 pay of 40000 and dividends of 5000 is intermediate + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # gov.scot, Scottish income tax 2018-2019: starter to £13,850, basic to + # £24,000, intermediate to £43,430 of income with an £11,850 allowance. + # Pay: 28,150 taxable, intermediate. 2,000 x 19% + 10,150 x 20% + # + 16,000 x 21% = 380 + 2,030 + 3,360 = 5,770. Dividends: 28,150 to + # 33,150, below the 34,500 basic rate limit, with a £2,000 allowance: + # 3,000 x 7.5% = 225. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_770 + dividend_income_tax: 225 + +- name: Scottish 2017-18 pay above 43000 is higher + period: 2017 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 44_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # gov.scot, Income tax rates set (2017): the Scottish higher rate starts + # at £43,000 of income, £31,500 above the £11,500 allowance. 32,500 + # taxable: 31,500 x 20% + 1,000 x 40% = 6,700. The 2017-18 s. 55B(2)(b) + # allows the Scottish basic rate but not the Scottish higher rate. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 6_700 From a2d64eee674736d8f97491bae131788793a4076b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 19:55:12 -0400 Subject: [PATCH 045/250] Test tax_band for Scottish taxpayers in 2026-27 Co-Authored-By: Claude Opus 5.5 --- .../tax/income_tax/scottish_rates.yaml | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index d1f3520d2d..c881f6947d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -505,3 +505,98 @@ tax_band: HIGHER meets_marriage_allowance_income_conditions: false earned_income_tax: 6_700 + +# The same rules in 2026-27, with the Scottish limits of S6M-20844: £3,967, +# £16,956, £31,092, £62,430 and £125,140 of taxable income. The personal +# allowance stays £12,570 and the basic rate limit £37,700. + +- name: Scottish 2026 pay of 40000 and dividends of 5000 is intermediate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 27,430 taxable, intermediate. 3,967 x 19% + 12,989 x 20% + # + 10,474 x 21% = 753.73 + 2,597.80 + 2,199.54 = 5,551.07. + # Dividends: 27,430 to 32,430, below 37,700. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_551.07 + +- name: Scottish 2026 pay of 60000 and savings of 20000 is higher + period: 2026 + input: + people: + person: + age: 40 + employment_income: 60_000 + savings_interest_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 47,430 taxable, higher, below 62,430. Savings: 47,430 to 67,430, + # above 37,700 and below 125,140. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Scottish 2026 income at both limits stays intermediate + period: 2026 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_608 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 31,092 taxable, the intermediate rate's limit. Dividends: 31,092 + # to 37,700, the basic rate limit. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + +- name: Scottish 2026 dividends one pound above the basic rate limit are higher + period: 2026 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_609 + households: + household: + members: [person] + country: SCOTLAND + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Scottish 2026 allowance beyond pay goes to dividends, which are basic + period: 2026 + input: + people: + person: + age: 40 + employment_income: 5_000 + dividend_income: 30_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # 22,430 of dividends remain after the allowance, below 37,700, and no + # income is charged at the Scottish rates. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true From c8139c24727ba597e51c063e35dd9840f8de5de7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:25:16 -0400 Subject: [PATCH 046/250] Add property tests reading tax_band off the liability For Scottish taxpayers the tests raise one band's rates at a time and check that a person in a band pays tax at that band's rates and at no higher band's, that the Marriage Allowance conditions match, and that against the formula before #2350 the band rises only from starter to basic. They also show savings_allowance does not read tax_band. Co-Authored-By: Claude Opus 5.5 --- .../test_tax_band_scottish_properties.py | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 policyengine_uk/tests/test_tax_band_scottish_properties.py diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py new file mode 100644 index 0000000000..5df4c0be6c --- /dev/null +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -0,0 +1,302 @@ +"""Property tests for a Scottish taxpayer's tax_band (#2350). + +tax_band is the highest band in which a person has taxable income. For a +Scottish taxpayer the liability charges income other than savings and +dividends at the Scottish rates (earned_income_tax and property_income_tax, +ITA 2007 s. 11A) and savings and dividends at the rest-of-UK savings and +dividend rates (savings_income_tax and dividend_income_tax, ss. 11D(6) and +13(5)). These tests read the band off that liability: they raise the rates of +one band at a time by RISE and see which bands' rates each person pays. + +With the starting rate for savings, the Personal Savings Allowance and the +dividend allowance set to nothing, every pound of taxable income is charged at +a band's rate, and for every Scottish taxpayer: + +- a person in a band pays tax at that band's rates on some income and at no + higher band's rates, and a person with no band pays no tax; +- the Marriage Allowance income conditions hold exactly when the person pays + nothing at the higher or additional rates (s. 55B(2)(b)). + +Under the law in force the nil rates charge some income at 0% where it sits, +so the band can rest on that income alone, but no one pays tax at the higher +or additional rates above their band. + +Before #2350 the band compared all taxable income with the Scottish limits. +From 2019-20, when that formula last applied, the band never rises, except +from STARTER to BASIC where savings or dividends sit below the Scottish +starter limit: they are charged at the rest-of-UK basic rates, not at the +starter rate. +""" + +from contextlib import contextmanager + +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") + +# Whole-pound limits and allowances, with Scottish schedules of three (2017-18), +# five (2018-19 to 2023-24) and six rates (from 2024-25). +YEARS = list(range(2017, 2027)) +PEOPLE = 16 +# Raising a band's rates by 100 percentage points raises the tax by the income +# charged at them. Taxable income comes in multiples of 50p (the personal +# allowance taper halves pounds), so SOME separates any income in a band from +# float32 rounding. +RISE = 1.0 +SOME = 0.25 +BANDS = ["NONE", "STARTER", "BASIC", "INTERMEDIATE", "HIGHER", "ADDITIONAL"] +RANK = {band: rank for rank, band in enumerate(BANDS)} +SCOTTISH = "gov.hmrc.income_tax.rates.scotland.rates[{}].rate" +SAVINGS = "gov.hmrc.income_tax.rates.savings.{}" +DIVIDENDS = "gov.hmrc.income_tax.rates.dividends[{}].rate" +# The rates charged in each band: the Scottish rates on non-savings income, +# the savings rates and the dividend rates. ADDITIONAL covers the Scottish +# advanced and top rates. +BAND_RATES = { + "STARTER": [SCOTTISH.format(0)], + "BASIC": [SCOTTISH.format(1), SAVINGS.format("basic"), DIVIDENDS.format(0)], + "INTERMEDIATE": [SCOTTISH.format(2)], + "HIGHER": [SCOTTISH.format(3), SAVINGS.format("higher"), DIVIDENDS.format(1)], + "ADDITIONAL": [ + SCOTTISH.format(4), + SCOTTISH.format(5), + SAVINGS.format("additional"), + DIVIDENDS.format(2), + DIVIDENDS.format(3), + ], +} +# The starting rate for savings, the Personal Savings Allowance and the +# dividend allowance. +NIL_RATE_AMOUNTS = [ + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance", + "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic", + "gov.hmrc.income_tax.allowances.personal_savings_allowance.higher", + "gov.hmrc.income_tax.allowances.personal_savings_allowance.additional", + "gov.hmrc.income_tax.allowances.dividend_allowance", +] +LIABILITY = [ + "earned_income_tax", + "property_income_tax", + "savings_income_tax", + "dividend_income_tax", +] +INCOMES = [ + "employment_income", + "property_income", + "savings_interest_income", + "dividend_income", + "gift_aid", +] + +pounds = st.integers(min_value=0, max_value=60_000) +# Pay at the 2025-26 Scottish and rest-of-UK limits, the taper and the top. +pay = st.one_of( + st.integers(min_value=0, max_value=160_000), + st.sampled_from([12_570, 15_397, 27_491, 43_662, 50_270, 75_000, 100_001]), +) +person = st.tuples( + pay, + st.one_of(st.just(0), pounds), # property income + st.one_of(st.just(0), pounds), # savings interest + st.one_of(st.just(0), pounds), # dividends + # Gift Aid in multiples of £4 grosses up to whole pounds at 20%. + st.one_of( + st.just(0), st.integers(min_value=0, max_value=1_000).map(lambda n: 4 * n) + ), +) +people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) +year = st.sampled_from(YEARS) + +# The issue's cases, 2025-26: pay and dividends of 40,000 and 5,000, 60,000 and +# 20,000, and 30,000 and 25,000; the same with savings; both limits at once. +ISSUE = [ + (40_000, 0, 0, 5_000, 0), + (60_000, 0, 0, 20_000, 0), + (30_000, 0, 0, 25_000, 0), + (40_000, 0, 5_000, 0, 0), + (60_000, 0, 20_000, 0, 0), + (30_000, 0, 25_000, 0, 0), + (43_662, 0, 0, 6_608, 0), + (5_000, 0, 1_000, 1_000, 0), +] +ISSUE += [(0, 0, 0, 0, 0)] * (PEOPLE - len(ISSUE)) + + +def situation(): + return { + "people": {f"person_{i}": {"age": {YEARS[0]: 40}} for i in range(PEOPLE)}, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "country": {year: "SCOTLAND" for year in YEARS}, + } + for i in range(PEOPLE) + }, + } + + +@pytest.fixture(scope="module") +def sim(): + return Simulation(situation=situation()) + + +def set_people(sim, drawn, period): + for variable, values in zip(INCOMES, zip(*drawn)): + sim.set_input(variable, period, np.array(values, dtype=float)) + sim.reset_calculations() + + +def set_law(sim, period, values): + """Set parameter values for the year on the simulation's own system.""" + parameters = sim.tax_benefit_system.parameters + for path, value in values.items(): + parameters.get_child(path).update(period=str(period), value=value) + sim.tax_benefit_system.reset_parameter_caches() + sim.reset_calculations() + + +@contextmanager +def law(sim, period, values): + parameters = sim.tax_benefit_system.parameters + before = {path: parameters.get_child(path)(str(period)) for path in values} + set_law(sim, period, values) + try: + yield + finally: + set_law(sim, period, before) + + +def liability(sim, period): + return sum(sim.calculate(v, period).astype(float) for v in LIABILITY) + + +def charged(sim, period, bands): + """The income charged at each band's rates: the rise in tax when only + that band's rates rise by RISE, divided by RISE.""" + parameters = sim.tax_benefit_system.parameters + before = liability(sim, period) + income_at = {} + for band in bands: + rates = { + path: parameters.get_child(path)(str(period)) for path in BAND_RATES[band] + } + # A rate with no value that year (a Scottish rate not yet set) stays so. + raised = {path: rate + RISE for path, rate in rates.items() if rate is not None} + with law(sim, period, raised): + income_at[band] = (liability(sim, period) - before) / RISE + return income_at + + +def band_of(sim, period): + return np.asarray(sim.calculate("tax_band", period), dtype=str) + + +SETTINGS = settings( + max_examples=8, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(people, year) +@example(ISSUE, 2025) +def test_band_is_the_highest_rate_charged_without_nil_rates(sim, drawn, period): + set_people(sim, drawn, period) + with law(sim, period, {path: 0 for path in NIL_RATE_AMOUNTS}): + band = band_of(sim, period) + meets = np.asarray( + sim.calculate("meets_marriage_allowance_income_conditions", period) + ) + income_at = charged(sim, period, BAND_RATES) + for i, b in enumerate(band): + case = f"{drawn[i]} in {period}: {b}, {[income_at[x][i] for x in income_at]}" + if b == "NONE": + assert all(abs(income_at[x][i]) < SOME for x in income_at), case + continue + assert income_at[b][i] > SOME, case + for above in BANDS[RANK[b] + 1 :]: + assert abs(income_at[above][i]) < SOME, case + above_intermediate = (income_at["HIGHER"] > SOME) | (income_at["ADDITIONAL"] > SOME) + np.testing.assert_array_equal(meets, ~above_intermediate, err_msg=str(drawn)) + + +@SETTINGS +@given(people, year) +@example(ISSUE, 2025) +def test_no_one_pays_higher_rates_above_their_band(sim, drawn, period): + set_people(sim, drawn, period) + band = band_of(sim, period) + tax = liability(sim, period) + # The Marriage Allowance and counts by marginal rate turn on these. + income_at = charged(sim, period, ["HIGHER", "ADDITIONAL"]) + for i, b in enumerate(band): + case = f"{drawn[i]} in {period}: {b}, {[income_at[x][i] for x in income_at]}" + if b == "NONE": + assert abs(tax[i]) < SOME, case + for above in income_at: + if RANK[above] > RANK[b]: + assert abs(income_at[above][i]) < SOME, case + + +def band_before_2350(sim, period): + """tax_band's Scottish formula from 2019-20 before #2350: all taxable + income against the Scottish limits, the next band from each limit on.""" + scale = sim.tax_benefit_system.parameters( + str(period) + ).gov.hmrc.income_tax.rates.scotland.rates + income = sim.calculate("net_income", period) - sim.calculate("allowances", period) + gift = sim.calculate("gift_aid_grossed_up", period) + return np.select( + [ + income < threshold + (gift if index >= 2 else 0) + for index, threshold in enumerate(scale.thresholds[:5]) + ], + BANDS[:5], + default="ADDITIONAL", + ) + + +@SETTINGS +@given(people, st.sampled_from(YEARS[2:])) +@example(ISSUE, 2025) +def test_band_never_rises_except_from_starter_to_basic(sim, drawn, period): + set_people(sim, drawn, period) + band = band_of(sim, period) + before = band_before_2350(sim, period) + taxable = sim.calculate("net_income", period) - sim.calculate("allowances", period) + non_savings = sim.calculate("non_savings_income_after_allowances", period) + starter_limit = sim.tax_benefit_system.parameters( + str(period) + ).gov.hmrc.income_tax.rates.scotland.rates.thresholds[1] + for i in range(PEOPLE): + if RANK[band[i]] <= RANK[before[i]]: + continue + # Savings or dividends below the starter limit are basic, not starter. + case = f"{drawn[i]} in {period}: {before[i]} to {band[i]}" + assert (before[i], band[i]) == ("STARTER", "BASIC"), case + assert non_savings[i] < taxable[i] < starter_limit, case + + +def test_savings_allowance_does_not_read_the_band(): + # ITA 2007 s. 12B(8) sets the Personal Savings Allowance on the + # rest-of-UK bands for everyone, so savings_allowance (#2347) never reads + # tax_band: forcing the band changes nothing. + sim = Simulation(situation=situation()) + set_people(sim, ISSUE, 2025) + allowance = sim.calculate("savings_allowance", 2025) + np.testing.assert_array_equal(allowance[:6], [1_000, 500, 500, 1_000, 500, 500]) + for band in BANDS: + sim.set_input("tax_band", 2025, np.array([band] * PEOPLE)) + sim.reset_calculations() + np.testing.assert_array_equal( + sim.calculate("savings_allowance", 2025), allowance + ) From 196fea803bceb68354f25deb708f48dc6e3a8b87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:26:15 -0400 Subject: [PATCH 047/250] 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 174a9b2382e06e459b46b2d2e3d35aee08ee63c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:27:00 -0400 Subject: [PATCH 048/250] Correct the property tests' note on the earlier formula Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_tax_band_scottish_properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 5df4c0be6c..271190d5b8 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -22,7 +22,7 @@ or additional rates above their band. Before #2350 the band compared all taxable income with the Scottish limits. -From 2019-20, when that formula last applied, the band never rises, except +Against that formula, which ran from 2019-20, the band never rises, except from STARTER to BASIC where savings or dividends sit below the Scottish starter limit: they are charged at the rest-of-UK basic rates, not at the starter rate. From 1983fd25e917b4290469dcbd93c3c0917d349cb2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:07:12 -0400 Subject: [PATCH 049/250] Move the Gift Aid snapshot's Scottish mixed-income band to basic The tenth snapshot row is a Scottish taxpayer with no pay and 10,000 of property income, 20,000 of savings and 10,000 of dividends. The allowance takes all the property income, so nothing is charged at a Scottish rate (ITA 2007 s. 11A), and the 27,430 of savings and dividends left sit below the basic rate limit (ss. 11D(6) and 13(5)). The band is basic. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_gift_aid.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 34d7bb182c..621d1e062c 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -371,7 +371,12 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): "HIGHER", "ADDITIONAL", "HIGHER", - "INTERMEDIATE", + # The allowance takes all 10,000 of property income, so nothing is + # charged at a Scottish rate (ITA 2007 s. 11A). The 27,430 of + # savings and dividends left sit below the 37,700 basic rate limit + # (ss. 11D(6) and 13(5)), so the band is basic, not intermediate + # (#2350). + "BASIC", "ADDITIONAL", ] From d08ed19f6caa916b209ee4c8568123e65ed3ebec Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:39:04 -0400 Subject: [PATCH 050/250] 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 db76188bb242951a19213790cca1360e4f15bc27 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:40:15 -0400 Subject: [PATCH 051/250] Scope the band notes and name tax_band as a reader Co-Authored-By: Claude Opus 5.5 --- changelog.d/2350.fixed.md | 2 +- .../tests/test_tax_band_scottish_properties.py | 17 ++++++++++------- .../non_savings_income_after_allowances.py | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md index efb925e014..7d9d305281 100644 --- a/changelog.d/2350.fixed.md +++ b/changelog.d/2350.fixed.md @@ -1 +1 @@ -`tax_band` now bands a Scottish taxpayer's income other than savings and dividends on the Scottish limits and their savings and dividends on the rest-of-UK limits, from 2017-18, so the Marriage Allowance conditions and counts by marginal rate no longer put them in a band too high. +`tax_band` now bands a Scottish taxpayer's income other than savings and dividends on the Scottish limits and their savings and dividends on the rest-of-UK limits, from 2017-18, so the Marriage Allowance conditions and counts by marginal rate follow the rates at which their income is charged. diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 271190d5b8..c476d99417 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -8,9 +8,11 @@ 13(5)). These tests read the band off that liability: they raise the rates of one band at a time by RISE and see which bands' rates each person pays. +The people are single, so the Marriage Allowance, which the model takes off +earned income rather than giving as a tax reduction (#1947), plays no part. With the starting rate for savings, the Personal Savings Allowance and the dividend allowance set to nothing, every pound of taxable income is charged at -a band's rate, and for every Scottish taxpayer: +a band's rate, and for every single Scottish taxpayer: - a person in a band pays tax at that band's rates on some income and at no higher band's rates, and a person with no band pays no tax; @@ -24,8 +26,9 @@ Before #2350 the band compared all taxable income with the Scottish limits. Against that formula, which ran from 2019-20, the band never rises, except from STARTER to BASIC where savings or dividends sit below the Scottish -starter limit: they are charged at the rest-of-UK basic rates, not at the -starter rate. +starter limit: they sit in the rest-of-UK basic band (charged at the starting +rate for savings, the dividend nil rate or the dividend ordinary rate), not at +the Scottish starter rate. """ from contextlib import contextmanager @@ -44,9 +47,9 @@ YEARS = list(range(2017, 2027)) PEOPLE = 16 # Raising a band's rates by 100 percentage points raises the tax by the income -# charged at them. Taxable income comes in multiples of 50p (the personal -# allowance taper halves pounds), so SOME separates any income in a band from -# float32 rounding. +# charged at them. Taxable income comes in whole pounds (the tapered personal +# allowance is rounded up to a pound), so SOME separates any income in a band +# from float32 rounding. RISE = 1.0 SOME = 0.25 BANDS = ["NONE", "STARTER", "BASIC", "INTERMEDIATE", "HIGHER", "ADDITIONAL"] @@ -280,7 +283,7 @@ def test_band_never_rises_except_from_starter_to_basic(sim, drawn, period): for i in range(PEOPLE): if RANK[band[i]] <= RANK[before[i]]: continue - # Savings or dividends below the starter limit are basic, not starter. + # Savings or dividends below the starter limit sit in the basic band. case = f"{drawn[i]} in {period}: {before[i]} to {band[i]}" assert (before[i], band[i]) == ("STARTER", "BASIC"), case assert non_savings[i] < taxable[i] < starter_limit, case 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..d8485dbc20 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 @@ -11,8 +11,8 @@ class non_savings_income_after_allowances(Variable): "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 " - "both read it." + "property_income_tax, taxable_property_income_after_allowances and, " + "for Scottish taxpayers, tax_band read it." ) definition_period = YEAR reference = dict( From 0220a0c643729e10661a9c4297465282a2fd895a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:13:25 -0400 Subject: [PATCH 052/250] 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 053/250] 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 054/250] 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 055/250] 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 056/250] 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 057/250] 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 058/250] 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 059/250] 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 060/250] 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 061/250] 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 062/250] 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 063/250] 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 4c011bc9164cdfa7ee14e0b6d4c6858bdc8ac8cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:37:58 -0400 Subject: [PATCH 064/250] Apply the s. 55B rate conditions to income for the Marriage Allowance meets_marriage_allowance_income_conditions stops reading tax_band. It ports liable_only_at_marriage_allowance_rates from #1963 and computes it from income before the Marriage Allowance, since this head still takes the allowance off earned_taxable_income: non-savings income up to the start of the Scottish higher rate or the basic rate limit, savings above the limit only where the savings nil rate and the starting rate for savings cover them, and dividends counted without the dividend allowance (s. 55B(2)(ba)). The CPS expanded reform reads the same helper, and the eligible_bands parameter, which nothing else read, goes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2350.fixed.md | 2 +- .../marriage_allowance/eligible_bands.yaml | 14 -- .../reforms/cps/marriage_tax_reforms.py | 17 +- .../tax/income_tax/scottish_rates.yaml | 103 +++++++- ..._marriage_allowance_income_conditions.yaml | 230 +++++++++++++++++- policyengine_uk/tests/test_gift_aid.py | 10 +- .../test_marriage_allowance_cps_switch.py | 40 ++- ...iage_allowance_eligible_bands_parameter.py | 36 +++ .../test_tax_band_scottish_properties.py | 149 ++++++++++-- policyengine_uk/utils/parameters.py | 9 + ...ts_marriage_allowance_income_conditions.py | 105 +++++++- .../bracketized_liability/tax_band.py | 23 +- 12 files changed, 638 insertions(+), 100 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml create mode 100644 policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md index 7d9d305281..d9124170c2 100644 --- a/changelog.d/2350.fixed.md +++ b/changelog.d/2350.fixed.md @@ -1 +1 @@ -`tax_band` now bands a Scottish taxpayer's income other than savings and dividends on the Scottish limits and their savings and dividends on the rest-of-UK limits, from 2017-18, so the Marriage Allowance conditions and counts by marginal rate follow the rates at which their income is charged. +The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends, and Marriage Allowance eligibility applies the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) to income directly, so income at a rate limit and savings at the savings nil rate no longer stop it. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml deleted file mode 100644 index 526252c4ed..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: Tax bands whose members may transfer personal allowance under the Marriage Allowance election (ITA 2007 s.55B). A transferor is eligible if they are not liable at the higher rate or above; the Scottish intermediate rate is treated as within the basic-rate envelope for this purpose. -values: - 0000-01-01: - - NONE - - STARTER - - BASIC - - INTERMEDIATE -metadata: - unit: list - label: Marriage Allowance eligible tax bands - period: year - reference: - - title: "Income Tax Act 2007, s. 55B" - href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 16ddaa7960..943c237bb8 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,6 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.income_tax import net_income_from_adjusted_net_income from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.hmrc.income_tax.allowances.meets_marriage_allowance_income_conditions import ( + liable_only_at_marriage_allowance_rates, +) from typing import Union, Optional @@ -41,16 +44,12 @@ class meets_marriage_allowance_income_conditions(Variable): reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - base_eligible = np.isin(band.decode_to_str(), eligible_bands) - # Expand to higher bands if the reform's expansion conditions are met. - bands = band.possible_values - expansion = person("meets_expanded_ma_conditions", period) & ( - (band == bands.HIGHER) | (band == bands.ADDITIONAL) + base_eligible = liable_only_at_marriage_allowance_rates( + person, period, parameters ) + # Expand to people liable above those rates if the reform's + # expansion conditions are met. + expansion = person("meets_expanded_ma_conditions", period) & ~base_eligible return base_eligible | expansion class marriage_allowance(Variable): diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index c881f6947d..79a99c54e6 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -137,9 +137,9 @@ # income are £2,827, £14,921, £31,092, £62,430 and £125,140 (19%, 20%, 21%, # 42%, 45%, 48%), the basic rate limit is £37,700, the dividend allowance is # £500 (8.75% ordinary, 33.75% upper) and the Personal Savings Allowance is -# £1,000, or £500 with higher-rate income (s. 12B). Marriage Allowance needs -# no liability above the Scottish intermediate or dividend ordinary rate -# (s. 55B(2)(b)). +# £1,000, or £500 with higher-rate income (s. 12B). Marriage Allowance +# applies the rate conditions of s. 55B(2)(b) and the dividend counterfactual +# of s. 55B(2)(ba); savings charged at the savings nil rate leave it open. - name: Scottish 2025 pay of 40000 and dividends of 5000 is intermediate period: 2025 @@ -178,8 +178,9 @@ country: SCOTLAND output: # Pay as above. Savings: 27,430 to 32,430, below 37,700, so the £1,000 - # allowance and (5,000 - 1,000) x 20% = 800; the starting rate for - # savings is used up by 27,430 of other income (s. 12). + # allowance and (5,000 - 1,000) x 20% = 800. The model assigns no savings + # to the starting rate because other taxable income exceeds £5,000 + # (s. 12). tax_band: INTERMEDIATE meets_marriage_allowance_income_conditions: true earned_income_tax: 5_582.82 @@ -596,7 +597,95 @@ members: [person] country: SCOTLAND output: - # 22,430 of dividends remain after the allowance, below 37,700, and no - # income is charged at the Scottish rates. + # After allowances, the taxpayer has 22,430 of taxable dividends below + # 37,700 and pays no tax at Scottish rates. tax_band: BASIC meets_marriage_allowance_income_conditions: true + +# Boundaries the cases above leave open, 2025-26. + +- name: Scottish 2025 dividends at the Gift Aid limit stay basic + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + dividend_income: 50_271 + gift_aid: 0.8 + households: + household: + members: [person] + country: SCOTLAND + output: + # The 80p gift grosses up to 1 (s. 415) and raises the basic rate limit + # to 37,701 (s. 414(2)(b)). The allowance takes 12,570 of the dividends, + # which leaves 37,701, all within that limit: + # (37,701 - 500) x 8.75% = 3,255.0875. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + dividend_income_tax: 3_255.09 + +- name: Scottish 2025 savings at the higher rate limit stay higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + savings_interest_income: 125_140 + households: + household: + members: [person] + country: SCOTLAND + output: + # Income of 125,140 withdraws the whole personal allowance (s. 35), so + # all 125,140 is taxable, up to the 125,140 higher rate limit and not + # above it. With no other income, 5,000 is at the starting rate for + # savings (s. 12) and the higher-rate allowance covers 500 (s. 12B); the + # rest of the 37,700 is at 20% and the 87,440 above it at 40%: + # 32,200 x 20% + 87,440 x 40% = 6,440 + 34,976 = 41,416. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + savings_income_tax: 41_416 + +- name: Scottish 2025 property income at the higher rate limit stays intermediate + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_570 + property_income: 32_092 + households: + household: + members: [person] + country: SCOTLAND + output: + # The 1,000 property allowance leaves 31,092 of property income, and pay + # takes the personal allowance. Property income bears the Scottish rates + # (s. 11A): 2,827 x 19% + 12,094 x 20% + 16,171 x 21% = 537.13 + # + 2,418.80 + 3,395.91 = 6,351.84, with none above 31,092. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + property_income_tax: 6_351.84 + +- name: Scottish 2025 property income one pound above the higher rate limit is higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_570 + property_income: 32_093 + households: + household: + members: [person] + country: SCOTLAND + output: + # As above with 1 at the Scottish higher rate: 6,351.84 + 0.42. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + property_income_tax: 6_352.26 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 064d9679fc..7fa7fca824 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -1,41 +1,247 @@ -- name: Basic rate band is eligible for Marriage Allowance +# ITA 2007 s. 55B(2)(b) and (ba): the person receiving the allowance may be +# liable only at the basic rates, the Scottish starter and intermediate rates +# (from 2018-19, SI 2018/459 art. 6(2)), the dividend ordinary rate and the +# nil and starting rates for savings, and dividends count as if the dividend +# nil rate (s. 13A) did not exist. From 2021-22 to 2026-27 the personal +# allowance is £12,570 and the basic rate limit £37,700, and the Scottish +# higher rate starts at £31,092 of taxable income (S6M-16531 for 2025-26, +# S6M-20844 for 2026-27). In 2026-27 the dividend allowance is £500 and the +# Personal Savings Allowance £1,000, or £500 with income above the basic rate +# limit (s. 12B). + +- name: Basic-rate earner meets the Marriage Allowance income conditions period: 2021 input: - tax_band: BASIC + # 30,000 - 12,570 = 17,430, all at the basic rate. + employment_income: 30_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: true -- name: Higher rate band is ineligible for Marriage Allowance +- name: Higher-rate earner fails the Marriage Allowance income conditions period: 2021 input: - tax_band: HIGHER + # 60,000 - 12,570 = 47,430, so 9,730 is at the higher rate. + employment_income: 60_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: false -- name: Additional rate band is ineligible for Marriage Allowance +- name: Additional-rate earner fails the Marriage Allowance income conditions period: 2021 input: - tax_band: ADDITIONAL + # No personal allowance above 125,140 of income; 10,000 is above the + # 150,000 higher rate limit. + employment_income: 160_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: false -- name: Below-PA earner (band NONE) is eligible for Marriage Allowance (regression for #395) +- name: Earner below the personal allowance meets the conditions (regression for #395) period: 2021 input: - tax_band: NONE + employment_income: 10_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: true -- name: Scottish starter rate band is eligible for Marriage Allowance +- name: Scottish starter-rate earner meets the Marriage Allowance income conditions period: 2021 input: - tax_band: STARTER + # 430 of taxable income, all at the Scottish starter rate. + employment_income: 13_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true -- name: Scottish intermediate rate band is eligible for Marriage Allowance +- name: Scottish intermediate-rate earner meets the Marriage Allowance income conditions period: 2021 input: - tax_band: INTERMEDIATE + # 27,430 of taxable income, below the Scottish higher rate at 31,092. + employment_income: 40_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Scottish higher-rate threshold reform keeps nil-rate savings eligible + period: 2025 + input: + # The Scottish higher rate moves to 37,702 of taxable income. Pay leaves + # 50,270 - 12,570 = 37,700, all below it. The 1 of interest takes taxable + # income to 37,701, above the basic rate limit, so the Personal Savings + # Allowance is 500 (s. 12B) and charges it at the savings nil rate + # (s. 12A), which s. 55B(2)(b) allows. The band rests on that pound. + gov.hmrc.income_tax.rates.scotland.rates[3].threshold: 37_702 + employment_income: 50_270 + savings_interest_income: 1 + region: SCOTLAND + output: + tax_band: HIGHER + savings_income_tax: 0 + meets_marriage_allowance_income_conditions: true + +- name: Savings covered by the savings nil rate above the basic rate limit meet the conditions + period: 2026 + input: + # 50,070 - 12,570 = 37,500 of pay, then 500 of interest to 38,000. Income + # above the basic rate limit sets the Personal Savings Allowance at 500 + # (s. 12B), which covers all 500 at the savings nil rate (s. 12A). + employment_income: 50_070 + savings_interest_income: 500 + region: NORTH_EAST + output: + tax_band: HIGHER + savings_income_tax: 0 + meets_marriage_allowance_income_conditions: true + +- name: One pound of savings at the savings higher rate fails the conditions + period: 2026 + input: + # As above with 501 of interest: 1 is at the savings higher rate, 40p. + employment_income: 50_070 + savings_interest_income: 501 + region: NORTH_EAST + absolute_error_margin: 0.01 + output: + savings_income_tax: 0.4 + meets_marriage_allowance_income_conditions: false + +- name: Taxable income at the basic rate limit meets the conditions + period: 2026 + input: + # 50,270 - 12,570 = 37,700: s. 10(2) charges income up to the basic rate + # limit at the basic rate. + employment_income: 50_270 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the basic rate limit fails the conditions + period: 2026 + input: + # 37,701 of taxable income: 1 at the higher rate (s. 10(3)). + employment_income: 50_271 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: Scottish taxable income at the start of the higher rate meets the conditions + period: 2026 + input: + # 43,662 - 12,570 = 31,092: S6M-20844 charges the intermediate rate on + # income up to 31,092 and the higher rate only above it. + employment_income: 43_662 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound into the Scottish higher rate fails the conditions + period: 2026 + input: + employment_income: 43_663 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: Dividends covered by the dividend allowance above the basic rate limit fail the conditions + period: 2026 + input: + # 37,500 of pay, then 500 of dividends, all within the 500 dividend + # allowance (s. 13A). Without s. 13A, 300 of them would be at the dividend + # upper rate (37,500 + 500 = 38,000), so s. 55B(2)(ba) fails, though the + # person pays no dividend tax. + employment_income: 50_070 + dividend_income: 500 + region: NORTH_EAST + output: + dividend_income_tax: 0 + meets_marriage_allowance_income_conditions: false + +- name: Dividends that reach the basic rate limit exactly meet the conditions + period: 2026 + input: + # 37,500 + 200 = 37,700: none would be at the dividend upper rate. + employment_income: 50_070 + dividend_income: 200 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: Gift Aid raises the basic rate limit for the conditions + period: 2026 + input: + # A 1,600 gift grosses up to 2,000 (s. 415), which raises the basic rate + # limit to 39,700 (s. 414(2)(b)). 52,270 - 12,570 = 39,700. + employment_income: 52_270 + gift_aid: 1_600 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the Gift Aid limit fails the conditions + period: 2026 + input: + employment_income: 52_271 + gift_aid: 1_600 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: Gift Aid raises the start of the Scottish higher rate for the conditions + period: 2026 + input: + # The 2,000 grossed-up gift moves the Scottish higher rate to 33,092 + # (s. 414(2)(b)). 45,662 - 12,570 = 33,092. + employment_income: 45_662 + gift_aid: 1_600 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the Scottish Gift Aid limit fails the conditions + period: 2026 + input: + employment_income: 45_663 + gift_aid: 1_600 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: In 2017-18 the Scottish higher rate fails the conditions + period: 2017 + input: + # The 2017-18 s. 55B(2)(b) allows the Scottish basic rate only. With an + # 11,500 allowance, the Scottish higher rate starts at 31,500 of taxable + # income: 43,001 - 11,500 = 31,501. + employment_income: 43_001 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: In 2017-18 Scottish income up to the higher rate meets the conditions + period: 2017 + input: + # 43,000 - 11,500 = 31,500, all at the Scottish basic rate. + employment_income: 43_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2017-18 the same income outside Scotland meets the conditions + period: 2017 + input: + # 31,501 of taxable income, below the 33,500 basic rate limit. + employment_income: 43_001 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2018-19 the Scottish intermediate rate meets the conditions + period: 2018 + input: + # SI 2018/459 art. 6(2) adds the Scottish starter and intermediate rates + # to s. 55B(2)(b). 40,000 - 11,850 = 28,150, at the intermediate rate. + employment_income: 40_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 621d1e062c..16df5f01dc 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -371,11 +371,11 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): "HIGHER", "ADDITIONAL", "HIGHER", - # The allowance takes all 10,000 of property income, so nothing is - # charged at a Scottish rate (ITA 2007 s. 11A). The 27,430 of - # savings and dividends left sit below the 37,700 basic rate limit - # (ss. 11D(6) and 13(5)), so the band is basic, not intermediate - # (#2350). + # The model allocates the allowance to all 10,000 of property + # income, so no income bears a Scottish rate (ITA 2007 s. 11A). The + # remaining 27,430 of savings and dividends falls below the 37,700 + # UK basic rate limit (ss. 11D(6) and 13(5)), so the band is basic, + # not intermediate (#2350). "BASIC", "ADDITIONAL", ] diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index caeacd7ce2..5c40b29c71 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -24,9 +24,10 @@ one has a Marriage Allowance, and income tax is what it is at current law when no one claims the allowance. 3. With any statutory rate and the CPS parameters at their defaults, no one - in the higher or additional band meets the income conditions or has the - allowance, and no one has more than the statutory share of the personal - allowance, rounded up to the next £10. + liable above the basic rates (above the Scottish intermediate rate for a + Scottish taxpayer) meets the income conditions or has the allowance, + everyone else meets the conditions, and no one has more than the statutory + share of the personal allowance, rounded up to the next £10. 4. The allowance never falls when the statutory rate rises. 5. Switched on by its own parameters, the reform behaves as it did before this change (regression values computed before the fix). @@ -69,6 +70,12 @@ # (marriage_allowance/rounding_increment.yaml). PERSONAL_ALLOWANCE = 12_570 ROUNDING_INCREMENT = 10 +# 2025-26 taxable income above which non-savings income is charged above the +# rates that allow the allowance: the basic rate limit (ITA 2007 s. 10), and +# for a Scottish taxpayer the start of the Scottish higher rate, £43,662 of +# income less the personal allowance (Scottish Rate Resolution 2025). +BASIC_RATE_LIMIT = 37_700 +SCOTTISH_HIGHER_RATE_START = 31_092 REFORM_VARIABLE = "meets_expanded_ma_conditions" # Only the reform adds it. @@ -281,8 +288,8 @@ def test_the_reform_switches_on_from_its_own_parameters_only( # parameters, reused: each example writes its own inputs. COUPLES = 20 -# Every fourth couple lives in Scotland, whose starter and intermediate bands -# are eligible bands. +# Every fourth couple lives in Scotland, whose starter and intermediate rates +# allow the allowance (ITA 2007 s. 55B(2)(b)). REGIONS = ["SCOTLAND", "LONDON", "NORTH_EAST", "WALES"] @@ -368,7 +375,6 @@ def run(simulation, drawn, anyone_claims=True): simulation.set_input("is_married", YEAR, married) simulation.set_input("would_claim_marriage_allowance", YEAR, claims) simulation.reset_calculations() - p = simulation.tax_benefit_system.parameters(YEAR) return dict( allowance=simulation.calculate("marriage_allowance", YEAR), meets_income_conditions=simulation.calculate( @@ -376,8 +382,15 @@ def run(simulation, drawn, anyone_claims=True): ), # Simulation.calculate returns an enum's names. band=np.asarray(simulation.calculate("tax_band", YEAR), dtype=str), - eligible_bands=p.gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands, income_tax=simulation.calculate("income_tax", YEAR), + # The earners have only employment income, so all their taxable + # income is non-savings income. + taxable=np.maximum( + simulation.calculate("employment_income", YEAR) + - simulation.calculate("personal_allowance", YEAR), + 0, + ), + scottish=simulation.calculate("pays_scottish_income_tax", YEAR), ) @@ -408,11 +421,14 @@ def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( simulation = simulation_for((STATUTORY_RATE, rate_permille / 1_000)) assert REFORM_VARIABLE not in simulation.tax_benefit_system.variables r = run(simulation, drawn) - outside = ~np.isin(r["band"], r["eligible_bands"]) - higher = np.isin(r["band"], ["HIGHER", "ADDITIONAL"]) - assert outside[higher].all() - assert not r["meets_income_conditions"][outside].any() - assert (r["allowance"][outside] == 0).all() + # Liable above those rates on any of their income, however little (ITA + # 2007 s. 55B(2)(b)). Income at the limit bears the lower rate (s. 10(2)). + limit = np.where(r["scottish"], SCOTTISH_HIGHER_RATE_START, BASIC_RATE_LIMIT) + above = r["taxable"] > limit + assert np.isin(r["band"], ["HIGHER", "ADDITIONAL"])[above].all() + assert not r["meets_income_conditions"][above].any() + assert r["meets_income_conditions"][~above].all() + assert (r["allowance"][above] == 0).all() assert (r["allowance"] >= 0).all() assert (r["allowance"] <= statutory_cap(rate_permille)).all() diff --git a/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py new file mode 100644 index 0000000000..20d308d795 --- /dev/null +++ b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py @@ -0,0 +1,36 @@ +"""Saved policies naming the Marriage Allowance eligible bands get directions. + +The income conditions apply the rate conditions of ITA 2007 s. 55B(2)(b) and +(ba) directly, so nothing reads +``gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands`` (#2350). +""" + +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario + +REMOVED = "gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands" + + +@pytest.fixture(scope="module", autouse=True) +def reuse_country_model(): + with pytest.MonkeyPatch.context() as patch: + patch.setattr( + "policyengine_uk.simulation.CountryTaxBenefitSystem", system.clone + ) + yield + + +def test_removed_eligible_bands_give_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 40}}}}, + scenario=Scenario(parameter_changes={REMOVED: {"2025": ["BASIC"]}}), + ) + message = str(error.value) + assert f"{REMOVED} has been removed" in message + assert "s. 55B(2)(b)" in message + assert "gov.hmrc.income_tax.rates.uk[1].threshold" in message + assert "gov.hmrc.income_tax.rates.scotland.rates[3].threshold" in message diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index c476d99417..9b75edd351 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -1,4 +1,5 @@ -"""Property tests for a Scottish taxpayer's tax_band (#2350). +"""Property tests for a Scottish taxpayer's tax_band and the Marriage +Allowance income conditions (#2350). tax_band is the highest band in which a person has taxable income. For a Scottish taxpayer the liability charges income other than savings and @@ -19,9 +20,17 @@ - the Marriage Allowance income conditions hold exactly when the person pays nothing at the higher or additional rates (s. 55B(2)(b)). -Under the law in force the nil rates charge some income at 0% where it sits, -so the band can rest on that income alone, but no one pays tax at the higher -or additional rates above their band. +With baseline nil rates restored, the model keeps nil-rate income in its +underlying band, and no one pays tax at the higher or additional rates above +their band. + +The Marriage Allowance income conditions do not read the band. With the nil +rates in force, under the law in force or with its rate limits and nil-rate +amounts changed, for every single person in Scotland or England they hold +exactly when the liability charges no income at a rate s. 55B(2)(b) forbids +and, with the dividend allowance removed (s. 55B(2)(ba)), none at the +dividend upper or additional rate. So savings at the savings nil rate or the +starting rate for savings never stop the allowance. Before #2350 the band compared all taxable income with the Scottish limits. Against that formula, which ran from 2019-20, the band never rises, except @@ -46,15 +55,16 @@ # five (2018-19 to 2023-24) and six rates (from 2024-25). YEARS = list(range(2017, 2027)) PEOPLE = 16 -# Raising a band's rates by 100 percentage points raises the tax by the income -# charged at them. Taxable income comes in whole pounds (the tapered personal -# allowance is rounded up to a pound), so SOME separates any income in a band -# from float32 rounding. +# Raising a band's rates by 100 percentage points measures the income the +# model taxes at those rates. These inputs use whole pounds, and the model +# rounds the tapered personal allowance up to a pound. SOME allows for float32 +# rounding. RISE = 1.0 SOME = 0.25 BANDS = ["NONE", "STARTER", "BASIC", "INTERMEDIATE", "HIGHER", "ADDITIONAL"] RANK = {band: rank for rank, band in enumerate(BANDS)} SCOTTISH = "gov.hmrc.income_tax.rates.scotland.rates[{}].rate" +UK = "gov.hmrc.income_tax.rates.uk[{}].rate" SAVINGS = "gov.hmrc.income_tax.rates.savings.{}" DIVIDENDS = "gov.hmrc.income_tax.rates.dividends[{}].rate" # The rates charged in each band: the Scottish rates on non-savings income, @@ -181,21 +191,21 @@ def liability(sim, period): return sum(sim.calculate(v, period).astype(float) for v in LIABILITY) -def charged(sim, period, bands): - """The income charged at each band's rates: the rise in tax when only - that band's rates rise by RISE, divided by RISE.""" +def charged_at(sim, period, paths): + """The income charged at the rates at these paths: the rise in tax when + only those rates rise by RISE, divided by RISE.""" parameters = sim.tax_benefit_system.parameters before = liability(sim, period) - income_at = {} - for band in bands: - rates = { - path: parameters.get_child(path)(str(period)) for path in BAND_RATES[band] - } - # A rate with no value that year (a Scottish rate not yet set) stays so. - raised = {path: rate + RISE for path, rate in rates.items() if rate is not None} - with law(sim, period, raised): - income_at[band] = (liability(sim, period) - before) / RISE - return income_at + rates = {path: parameters.get_child(path)(str(period)) for path in paths} + # A rate with no value that year (a Scottish rate not yet set) stays so. + raised = {path: rate + RISE for path, rate in rates.items() if rate is not None} + with law(sim, period, raised): + return (liability(sim, period) - before) / RISE + + +def charged(sim, period, bands): + """The income charged at each band's rates.""" + return {band: charged_at(sim, period, BAND_RATES[band]) for band in bands} def band_of(sim, period): @@ -303,3 +313,100 @@ def test_savings_allowance_does_not_read_the_band(): np.testing.assert_array_equal( sim.calculate("savings_allowance", 2025), allowance ) + + +# The Marriage Allowance income conditions against the liability, with the nil +# rates in force: people alternate between Scotland and England, from 2016-17, +# the first year of the savings and dividend nil rates. +MA_YEARS = list(range(2016, 2027)) +MA_COUNTRIES = ["SCOTLAND", "ENGLAND"] +# The rates s. 55B(2)(b) forbids, other than the dividend rates: the Scottish +# higher, advanced and top rates, the higher and additional rates, and the +# savings higher and additional rates. Each person pays at most one of the +# Scottish and rest-of-UK schedules on non-savings income. +FORBIDDEN = ( + [SCOTTISH.format(i) for i in (3, 4, 5)] + + [UK.format(i) for i in (1, 2, 3)] + + [SAVINGS.format("higher"), SAVINGS.format("additional")] +) +DIVIDEND_UPPER = [DIVIDENDS.format(i) for i in (1, 2, 3)] +DIVIDEND_ALLOWANCE = "gov.hmrc.income_tax.allowances.dividend_allowance" +# Rate limits and nil-rate amounts a reform might change, each drawn or left +# at the law in force. +LIMITS = { + "gov.hmrc.income_tax.rates.scotland.rates[3].threshold": st.integers( + 20_000, 50_000 + ), + "gov.hmrc.income_tax.rates.uk[1].threshold": st.integers(25_000, 50_000), + "gov.hmrc.income_tax.rates.dividends[1].threshold": st.integers(25_000, 50_000), + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance": st.integers( + 0, 60_000 + ), + "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic": st.integers( + 0, 10_000 + ), + "gov.hmrc.income_tax.allowances.personal_savings_allowance.higher": st.integers( + 0, 10_000 + ), + DIVIDEND_ALLOWANCE: st.integers(0, 10_000), +} +limits = st.fixed_dictionaries({}, optional=LIMITS) + +# The Scottish witness from the #2350 review: pay that leaves 37,700 of +# taxable income and 1 of interest, under a Scottish higher rate moved to +# 37,702. Then, in England, 500 of interest above 37,500 of taxable pay, all +# covered by the higher-rate Personal Savings Allowance, and pay at the basic +# rate limit. +NIL_RATE = [(0, 0, 0, 0, 0)] * PEOPLE +NIL_RATE[0] = (50_270, 0, 1, 0, 0) +NIL_RATE[1] = (50_070, 0, 500, 0, 0) +NIL_RATE[3] = (50_270, 0, 0, 0, 0) +WITNESS_LIMITS = {"gov.hmrc.income_tax.rates.scotland.rates[3].threshold": 37_702} + + +@pytest.fixture(scope="module") +def mixed_sim(): + situation = { + "people": {f"person_{i}": {"age": {MA_YEARS[0]: 40}} for i in range(PEOPLE)}, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "country": { + year: MA_COUNTRIES[i % len(MA_COUNTRIES)] for year in MA_YEARS + }, + } + for i in range(PEOPLE) + }, + } + return Simulation(situation=situation) + + +@SETTINGS +@given(people, st.sampled_from(MA_YEARS), limits) +@example(NIL_RATE, 2025, WITNESS_LIMITS) +@example(NIL_RATE, 2025, {}) +def test_nil_rates_never_stop_the_marriage_allowance(mixed_sim, drawn, period, changes): + sim = mixed_sim + set_people(sim, drawn, period) + parameters = sim.tax_benefit_system.parameters + # A limit with no value that year (a Scottish rate not yet set) stays so. + changes = { + path: value + for path, value in changes.items() + if parameters.get_child(path)(str(period)) is not None + } + with law(sim, period, changes): + meets = np.asarray( + sim.calculate("meets_marriage_allowance_income_conditions", period) + ) + forbidden = charged_at(sim, period, FORBIDDEN) + with law(sim, period, {DIVIDEND_ALLOWANCE: 0}): + dividends_above_basic = charged_at(sim, period, DIVIDEND_UPPER) + np.testing.assert_array_equal( + meets, + (forbidden < SOME) & (dividends_above_basic < SOME), + err_msg=f"{drawn} in {period} with {changes}", + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 2e408f7ee4..e4f8682274 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -254,6 +254,15 @@ class RemovedParameter(NamedTuple): "(the 16), or gov.dwp.ESA.exempt_work.fixed_higher_earnings_limit " "for dates before 11 April 2011.", ), + "gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands": RemovedParameter( + "removed Marriage Allowance eligible tax bands", + "The Marriage Allowance income conditions no longer read tax_band. " + "They apply the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) to " + "the person's income directly, so they follow the rate limits: change " + "gov.hmrc.income_tax.rates.uk[1].threshold (the basic rate limit) or " + "gov.hmrc.income_tax.rates.scotland.rates[3].threshold (the start of " + "the Scottish higher rate).", + ), } diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index e91f14cbd3..ecd663d3a2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -1,18 +1,107 @@ from policyengine_uk.model_api import * -import numpy as np + + +def liable_only_at_marriage_allowance_rates(person, period, parameters): + """Whether the person pays no income tax above the basic rates. + + ITA 2007 s. 55B(2)(b) and s. 55C(1)(c) allow only the basic rate, the + property, default, savings, Scottish and Welsh basic rates, the Scottish + starter and intermediate rates, the dividend ordinary rate and the nil and + starting rates for savings. So no non-savings income may reach the higher + rate (the Scottish higher rate for a Scottish taxpayer), and no savings the + savings higher rate. Dividends covered by the dividend nil rate still + count: s. 55B(2)(ba) and s. 55C(1)(ca) test the dividend upper rate as if + s. 13A were omitted. + + The bands are the ones the model taxes with: Gift Aid extends the rate + limits by the grossed-up gift (s. 414(2)(b)), as in earned_income_tax, + and does not come off the income. + + The test reads income before the Marriage Allowance. The rates a person + is liable at come from Step 3 of s. 23, before the tax reductions of + Step 6, of which the allowance is one (s. 55B(1)). The model still takes + the allowance off earned_taxable_income (#1947), so + non_savings_taxable_income and the savings rate bands built on it would + read this test back. + """ + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + # Earnings, pensions, property and other non-savings income after + # allowances, which go to it first: the income below savings and + # dividends (s. 16). It is non_savings_taxable_income before the + # Marriage Allowance. + non_savings = person("non_savings_income_after_allowances", period) + # The Scottish higher rate follows the starter, basic and intermediate + # rates; in 2017-18 the Scottish scale had only the basic rate below it. + # Before 2017-18 the Scottish rates used the UK limits. + scottish = rates.scotland.rates + basic_rate_limit = rates.uk.thresholds[1] + gross_gift + if len(scottish.rates) > 3: + scottish_limit = scottish.thresholds[3] + gross_gift + elif len(scottish.rates) == 3: + scottish_limit = scottish.thresholds[1] + gross_gift + else: + scottish_limit = basic_rate_limit + non_savings_limit = where( + person("pays_scottish_income_tax", period), + scottish_limit, + basic_rate_limit, + ) + # Savings sit above non-savings income (s. 16) and take the rest-of-UK + # bands (s. 11D(6)). The starting rate for savings and then the savings + # nil rate take the first of them, as in higher_rate_savings_income; the + # model keeps the starting rate within the basic rate band. Savings above + # those are charged at a positive rate, and fail the test once they pass + # the basic rate limit. + savings = max_( + 0, + person("taxable_savings_interest_income", period) + - person("received_allowances_savings_income", period), + ) + starter = rates.savings_starter_rate + savings_in_basic_band = clip( + non_savings + savings, rates.uk.thresholds[0], basic_rate_limit + ) - clip(non_savings, rates.uk.thresholds[0], basic_rate_limit) + starting_rate_savings = clip( + min_(savings, savings_in_basic_band), + 0, + max_(0, starter.allowance - non_savings * starter.phase_out), + ) + nil_rate_savings = starting_rate_savings + person("savings_allowance", period) + savings_above_basic = (savings > nil_rate_savings) & ( + non_savings + savings > basic_rate_limit + ) + # Dividends are the top slice, above all non-savings income and savings + # (s. 16), counted before the dividend allowance. The dividend upper rate + # starts at the basic rate limit (s. 13(2)), extended by the gross gift; + # dividend_income_tax reads that limit from the dividend scale. + dividends = max_( + 0, + person("taxable_dividend_income", period) + - person("received_allowances_dividend_income", period), + ) + dividend_upper_limit = rates.dividends.thresholds[1] + gross_gift + dividends_above_basic = (dividends > 0) & ( + non_savings + savings + dividends > dividend_upper_limit + ) + return (non_savings <= non_savings_limit) & ~savings_above_basic & ~dividends_above_basic class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person pays income tax only at the rates that let them " + "receive the Marriage Allowance (ITA 2007 s. 55B(2)(b) and (ba))." + ) entity = Person definition_period = YEAR value_type = bool - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(2)(b) and (ba)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + ] def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - return np.isin(band.decode_to_str(), eligible_bands) + return liable_only_at_marriage_allowance_rates(person, period, parameters) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 2aa4512393..8d3df164b2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -35,17 +35,18 @@ def uk_tax_band_above_limits(person, period, parameters): def scottish_tax_band(person, period, parameters): """The highest band in which a Scottish taxpayer has taxable income. - The Scottish rates charge only income that is neither savings nor - dividend income (ITA 2007 s. 11A and the Scottish rate resolutions), so - that income is banded on the Scottish limits. Savings and dividends are - the top slice of income (s. 16) and are charged at the rates they would - bear if the person were not a Scottish taxpayer (ss. 11D(6) and 13(5)), - so where any taxable income is savings or dividends, it is banded on - the rest-of-UK limits at the top of all taxable income. The band is the - higher of the two. Income up to a limit is charged at the lower rate - (s. 10(2) to (3A); each resolution charges a rate "on income above" one - limit "and up to" the next), so income at a limit stays in the lower - band. + Use the Scottish limits for income other than savings and dividends, + the only income the Scottish rates charge (ITA 2007 s. 11A and the + Scottish rate resolutions). Treat savings and dividends as the top + slices of income (s. 16) and apply the rest-of-UK limits, since they + bear the rates they would bear outside Scotland (ss. 11D(6) and 13(5)). + Compare the rest-of-UK band only when taxable savings or dividends + remain, then return the higher band. Keep income at each upper limit in + the lower band: s. 10(2) to (3A) charges income "up to" a limit at the + lower rate, and each resolution charges a rate "on income above" one + limit "and up to" the next. As for every taxpayer, income charged at a + nil rate stays in the band where it sits; the Marriage Allowance applies + its own rate conditions (liable_only_at_marriage_allowance_rates). """ rates = parameters(period).gov.hmrc.income_tax.rates gross_gift = person("gift_aid_grossed_up", period) From 60de3303b49d2c16b28303a92b1392f9c3e3fc87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:43:00 -0400 Subject: [PATCH 065/250] 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 066/250] 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 067/250] 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 068/250] 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 069/250] 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 319f197e2c2c3e4ed0c47481cf750f957a2c123e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:59:17 -0500 Subject: [PATCH 070/250] Wrap the Marriage Allowance rate test's result for the line limit Co-Authored-By: Claude Opus 5.5 --- .../meets_marriage_allowance_income_conditions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index ecd663d3a2..b84131e703 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -84,7 +84,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): dividends_above_basic = (dividends > 0) & ( non_savings + savings + dividends > dividend_upper_limit ) - return (non_savings <= non_savings_limit) & ~savings_above_basic & ~dividends_above_basic + return ( + (non_savings <= non_savings_limit) + & ~savings_above_basic + & ~dividends_above_basic + ) class meets_marriage_allowance_income_conditions(Variable): From c477b5d9d1acbd4d99ecb762b5a4c250782ea710 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 03:22:44 -0500 Subject: [PATCH 071/250] 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 072/250] 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 073/250] 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 d73ecfb4241ba8db6cc2b9b3a2f440e5739ac71b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 04:11:21 -0500 Subject: [PATCH 074/250] Cite Step 4 of s. 23 and name who acts in the rate-test notes Co-Authored-By: Claude Opus 5.5 --- ..._marriage_allowance_income_conditions.yaml | 3 ++- ...ts_marriage_allowance_income_conditions.py | 19 ++++++++++--------- .../bracketized_liability/tax_band.py | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 7fa7fca824..e8d792b6ea 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -70,7 +70,8 @@ # 50,270 - 12,570 = 37,700, all below it. The 1 of interest takes taxable # income to 37,701, above the basic rate limit, so the Personal Savings # Allowance is 500 (s. 12B) and charges it at the savings nil rate - # (s. 12A), which s. 55B(2)(b) allows. The band rests on that pound. + # (s. 12A), which s. 55B(2)(b) allows. The model bands the person HIGHER + # on that pound alone. gov.hmrc.income_tax.rates.scotland.rates[3].threshold: 37_702 employment_income: 50_270 savings_interest_income: 1 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index b84131e703..8458f00c17 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -17,9 +17,9 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): limits by the grossed-up gift (s. 414(2)(b)), as in earned_income_tax, and does not come off the income. - The test reads income before the Marriage Allowance. The rates a person - is liable at come from Step 3 of s. 23, before the tax reductions of - Step 6, of which the allowance is one (s. 55B(1)). The model still takes + The test reads income before the Marriage Allowance. Step 4 of s. 23 + charges tax at each rate before Step 6 deducts the tax reductions, and + s. 55B(1) gives the allowance as a tax reduction. The model still takes the allowance off earned_taxable_income (#1947), so non_savings_taxable_income and the savings rate bands built on it would read this test back. @@ -33,7 +33,8 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): non_savings = person("non_savings_income_after_allowances", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. - # Before 2017-18 the Scottish rates used the UK limits. + # The model holds no Scottish scale before 2017-18, and in 2016-17 + # s. 11A(1) charged the Scottish basic rate up to the basic rate limit. scottish = rates.scotland.rates basic_rate_limit = rates.uk.thresholds[1] + gross_gift if len(scottish.rates) > 3: @@ -48,11 +49,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): basic_rate_limit, ) # Savings sit above non-savings income (s. 16) and take the rest-of-UK - # bands (s. 11D(6)). The starting rate for savings and then the savings - # nil rate take the first of them, as in higher_rate_savings_income; the - # model keeps the starting rate within the basic rate band. Savings above - # those are charged at a positive rate, and fail the test once they pass - # the basic rate limit. + # bands (s. 11D(6)). As in higher_rate_savings_income, the model charges + # the first of them at the starting rate for savings, which it keeps + # within the basic rate band, and then at the savings nil rate. Savings + # above those bear a positive rate, and the person fails the test once + # they pass the basic rate limit. savings = max_( 0, person("taxable_savings_interest_income", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 8d3df164b2..457cd7f9c7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -44,9 +44,9 @@ def scottish_tax_band(person, period, parameters): remain, then return the higher band. Keep income at each upper limit in the lower band: s. 10(2) to (3A) charges income "up to" a limit at the lower rate, and each resolution charges a rate "on income above" one - limit "and up to" the next. As for every taxpayer, income charged at a - nil rate stays in the band where it sits; the Marriage Allowance applies - its own rate conditions (liable_only_at_marriage_allowance_rates). + limit "and up to" the next. Keep income charged at a nil rate in the + band where it sits, as for every taxpayer; the Marriage Allowance has its + own rate test (liable_only_at_marriage_allowance_rates). """ rates = parameters(period).gov.hmrc.income_tax.rates gross_gift = person("gift_aid_grossed_up", period) From 3fdf2368d5a07d374a91064bd7ad1c4e257ee46f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 04:14:55 -0500 Subject: [PATCH 075/250] Name the actor in the rate-test labels and the changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/2350.fixed.md | 2 +- .../meets_marriage_allowance_income_conditions.yaml | 2 +- policyengine_uk/tests/test_tax_band_scottish_properties.py | 4 +++- policyengine_uk/utils/parameters.py | 6 +++--- .../meets_marriage_allowance_income_conditions.py | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md index d9124170c2..9f9b9e9129 100644 --- a/changelog.d/2350.fixed.md +++ b/changelog.d/2350.fixed.md @@ -1 +1 @@ -The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends, and Marriage Allowance eligibility applies the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) to income directly, so income at a rate limit and savings at the savings nil rate no longer stop it. +The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends. It now tests Marriage Allowance eligibility against the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) directly, so income at a rate limit and savings at the savings nil rate no longer block it. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index e8d792b6ea..6203a66726 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -63,7 +63,7 @@ output: meets_marriage_allowance_income_conditions: true -- name: Scottish higher-rate threshold reform keeps nil-rate savings eligible +- name: Under a raised Scottish higher rate, nil-rate savings meet the conditions period: 2025 input: # The Scottish higher rate moves to 37,702 of taxable income. Pay leaves diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 9b75edd351..9ab2ceb4a9 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -388,7 +388,9 @@ def mixed_sim(): @given(people, st.sampled_from(MA_YEARS), limits) @example(NIL_RATE, 2025, WITNESS_LIMITS) @example(NIL_RATE, 2025, {}) -def test_nil_rates_never_stop_the_marriage_allowance(mixed_sim, drawn, period, changes): +def test_marriage_allowance_follows_the_liability_with_nil_rates( + mixed_sim, drawn, period, changes +): sim = mixed_sim set_people(sim, drawn, period) parameters = sim.tax_benefit_system.parameters diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index e4f8682274..1b1aa8f821 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -256,9 +256,9 @@ class RemovedParameter(NamedTuple): ), "gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands": RemovedParameter( "removed Marriage Allowance eligible tax bands", - "The Marriage Allowance income conditions no longer read tax_band. " - "They apply the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) to " - "the person's income directly, so they follow the rate limits: change " + "The model no longer reads tax_band for the Marriage Allowance income " + "conditions. It applies the rate conditions of ITA 2007 s. 55B(2)(b) " + "and (ba) to the person's income directly, so change " "gov.hmrc.income_tax.rates.uk[1].threshold (the basic rate limit) or " "gov.hmrc.income_tax.rates.scotland.rates[3].threshold (the start of " "the Scottish higher rate).", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 8458f00c17..37e214300a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -95,8 +95,8 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" documentation = ( - "Whether this person pays income tax only at the rates that let them " - "receive the Marriage Allowance (ITA 2007 s. 55B(2)(b) and (ba))." + "Whether this person pays income tax only at rates that ITA 2007 " + "s. 55B(2)(b) and (ba) allow for receiving the Marriage Allowance." ) entity = Person definition_period = YEAR From 53cfaf4b11e33b8e667e2e61999863e7d1fd6baa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 04:49:49 -0500 Subject: [PATCH 076/250] Use the basic rate limit for Scots in the rate test before 2017-18 The model's Scottish scale carries its 2017-18 values back to 2015-16 and 2016-17, so the rate test's empty-scale branch never ran and it read the 31,500 Scottish higher-rate start for those years. In 2016-17 s. 11A(1) charged the Scottish basic rate on income that would otherwise bear the basic rate, and 2015-16 had no Scottish rate, so the test now uses the basic rate limit before 2017-18. The property test leaves Scottish taxpayers out before 2017-18, where the model's liability uses the carried-back scale, and Ruff formats its limits table. Co-Authored-By: Claude Opus 5.5 --- ..._marriage_allowance_income_conditions.yaml | 24 ++++++++++++++ .../test_tax_band_scottish_properties.py | 32 ++++++++++++------- ...ts_marriage_allowance_income_conditions.py | 13 +++++--- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 6203a66726..32f9daff2b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -208,6 +208,30 @@ output: meets_marriage_allowance_income_conditions: false +- name: In 2015-16 a Scottish taxpayer's limit is the basic rate limit + period: 2015 + input: + # 2015-16 had no Scottish rate, and the 2015-16 s. 55B(2)(b) allows the + # basic rate. 42,200 - 10,600 = 31,600, below the basic rate limit, + # though above the 31,500 the model's Scottish scale carries back from + # 2017-18. + employment_income: 42_200 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2016-17 a Scottish taxpayer's limit is the basic rate limit + period: 2016 + input: + # SI 2015/1810 art. 14(3) adds the Scottish basic rate to s. 55B(2)(b), + # and the 2016-17 s. 11A(1) charges it on non-savings income that would + # otherwise bear the basic rate. 42,700 - 11,000 = 31,700, below the + # basic rate limit. + employment_income: 42_700 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + - name: In 2017-18 the Scottish higher rate fails the conditions period: 2017 input: diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 9ab2ceb4a9..7902b73277 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -26,11 +26,12 @@ The Marriage Allowance income conditions do not read the band. With the nil rates in force, under the law in force or with its rate limits and nil-rate -amounts changed, for every single person in Scotland or England they hold -exactly when the liability charges no income at a rate s. 55B(2)(b) forbids -and, with the dividend allowance removed (s. 55B(2)(ba)), none at the -dividend upper or additional rate. So savings at the savings nil rate or the -starting rate for savings never stop the allowance. +amounts changed, for every single person in England, and in Scotland from +2017-18, they hold exactly when the liability charges no income at a rate +s. 55B(2)(b) forbids and, with the dividend allowance removed +(s. 55B(2)(ba)), none at the dividend upper or additional rate. So savings at +the savings nil rate or the starting rate for savings never stop the +allowance. Before #2350 the band compared all taxable income with the Scottish limits. Against that formula, which ran from 2019-20, the band never rises, except @@ -317,9 +318,13 @@ def test_savings_allowance_does_not_read_the_band(): # The Marriage Allowance income conditions against the liability, with the nil # rates in force: people alternate between Scotland and England, from 2016-17, -# the first year of the savings and dividend nil rates. +# the first year of the savings and dividend nil rates. Before 2017-18 the +# model charges a Scottish taxpayer's non-savings income on the Scottish scale +# it carries back from 2017-18, where s. 11A(1) charged it on the rest-of-UK +# limits. The conditions follow the law, so the test leaves those people out. MA_YEARS = list(range(2016, 2027)) MA_COUNTRIES = ["SCOTLAND", "ENGLAND"] +FIRST_SCOTTISH_LIMITS_YEAR = 2017 # The rates s. 55B(2)(b) forbids, other than the dividend rates: the Scottish # higher, advanced and top rates, the higher and additional rates, and the # savings higher and additional rates. Each person pays at most one of the @@ -339,9 +344,7 @@ def test_savings_allowance_does_not_read_the_band(): ), "gov.hmrc.income_tax.rates.uk[1].threshold": st.integers(25_000, 50_000), "gov.hmrc.income_tax.rates.dividends[1].threshold": st.integers(25_000, 50_000), - "gov.hmrc.income_tax.rates.savings_starter_rate.allowance": st.integers( - 0, 60_000 - ), + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance": st.integers(0, 60_000), "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic": st.integers( 0, 10_000 ), @@ -407,8 +410,15 @@ def test_marriage_allowance_follows_the_liability_with_nil_rates( forbidden = charged_at(sim, period, FORBIDDEN) with law(sim, period, {DIVIDEND_ALLOWANCE: 0}): dividends_above_basic = charged_at(sim, period, DIVIDEND_UPPER) + checked = np.array( + [ + period >= FIRST_SCOTTISH_LIMITS_YEAR + or MA_COUNTRIES[i % len(MA_COUNTRIES)] != "SCOTLAND" + for i in range(PEOPLE) + ] + ) np.testing.assert_array_equal( - meets, - (forbidden < SOME) & (dividends_above_basic < SOME), + meets[checked], + ((forbidden < SOME) & (dividends_above_basic < SOME))[checked], err_msg=f"{drawn} in {period} with {changes}", ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 37e214300a..d65aebd3e8 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -33,16 +33,19 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): non_savings = person("non_savings_income_after_allowances", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. - # The model holds no Scottish scale before 2017-18, and in 2016-17 - # s. 11A(1) charged the Scottish basic rate up to the basic rate limit. + # Scottish limits start in 2017-18: in 2016-17 s. 11A(1) charged the + # Scottish basic rate on income that would otherwise bear the basic rate, + # and 2015-16 had no Scottish rate. The model's Scottish scale carries its + # 2017-18 values back to earlier years, so those years use the basic rate + # limit here. scottish = rates.scotland.rates basic_rate_limit = rates.uk.thresholds[1] + gross_gift - if len(scottish.rates) > 3: - scottish_limit = scottish.thresholds[3] + gross_gift + if period.start.year < 2017 or len(scottish.rates) < 3: + scottish_limit = basic_rate_limit elif len(scottish.rates) == 3: scottish_limit = scottish.thresholds[1] + gross_gift else: - scottish_limit = basic_rate_limit + scottish_limit = scottish.thresholds[3] + gross_gift non_savings_limit = where( person("pays_scottish_income_tax", period), scottish_limit, From 466519e490f3538abba639c7aba4754252d5ac16 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:06:47 -0500 Subject: [PATCH 077/250] 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 6d517bb95a88a3648f6438c0520b8e644e03723a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:10:09 -0500 Subject: [PATCH 078/250] Give the Scottish property-income cases profit at the limit property_income is profit, and with no receipts given the model applies no property allowance to a profit above 1,000, so the two cases now give 31,092 and 31,093 of profit directly. Co-Authored-By: Claude Opus 5.5 --- .../finance/tax/income_tax/scottish_rates.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 79a99c54e6..7e5ed398f2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -657,14 +657,16 @@ person: age: 40 employment_income: 12_570 - property_income: 32_092 + property_income: 31_092 households: household: members: [person] country: SCOTLAND output: - # The 1,000 property allowance leaves 31,092 of property income, and pay - # takes the personal allowance. Property income bears the Scottish rates + # property_income is profit. With no receipts given, the model takes a + # profit above the 1,000 property allowance to reflect the better of + # expenses and the allowance already, and taxes all 31,092. Pay takes + # the personal allowance. Property income bears the Scottish rates # (s. 11A): 2,827 x 19% + 12,094 x 20% + 16,171 x 21% = 537.13 # + 2,418.80 + 3,395.91 = 6,351.84, with none above 31,092. tax_band: INTERMEDIATE @@ -679,7 +681,7 @@ person: age: 40 employment_income: 12_570 - property_income: 32_093 + property_income: 31_093 households: household: members: [person] From 7e70a75d5db35d84c01801b5c084594c3e25de37 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 05:50:42 -0500 Subject: [PATCH 079/250] Test the removed eligible bands through the reform entry points Scenario's parameter_changes takes numbers only, so a list of bands failed validation before the removal check. The test now passes the saved list through Reform.from_dict and Scenario.from_reform, which check the path first, and checks the alias stays in the parameter tree. Co-Authored-By: Claude Opus 5.5 --- ...iage_allowance_eligible_bands_parameter.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py index 20d308d795..d2cd2bc862 100644 --- a/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py +++ b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py @@ -6,12 +6,17 @@ """ import pytest +from policyengine_core.reforms import Reform from policyengine_uk import Simulation from policyengine_uk.system import system +from policyengine_uk.utils.parameters import RemovedParameterNode from policyengine_uk.utils.scenario import Scenario REMOVED = "gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands" +SITUATION = {"people": {"person": {"age": {2025: 40}}}} +# The parameter held a list of band names, as a saved policy would. +SAVED = {REMOVED: {"2025": ["NONE", "STARTER", "BASIC"]}} @pytest.fixture(scope="module", autouse=True) @@ -23,14 +28,29 @@ def reuse_country_model(): yield -def test_removed_eligible_bands_give_directions(): +@pytest.fixture(scope="module") +def baseline(): + return Simulation(situation=SITUATION) + + +@pytest.mark.parametrize("entry_point", ["core_reform", "scenario_reform"]) +def test_removed_eligible_bands_give_directions(baseline, entry_point): + simulation = baseline.clone() with pytest.raises(ValueError) as error: - Simulation( - situation={"people": {"person": {"age": {2025: 40}}}}, - scenario=Scenario(parameter_changes={REMOVED: {"2025": ["BASIC"]}}), - ) + if entry_point == "core_reform": + Reform.from_dict(SAVED)(simulation.tax_benefit_system) + else: + Scenario.from_reform(SAVED).apply(simulation) message = str(error.value) assert f"{REMOVED} has been removed" in message assert "s. 55B(2)(b)" in message assert "gov.hmrc.income_tax.rates.uk[1].threshold" in message assert "gov.hmrc.income_tax.rates.scotland.rates[3].threshold" in message + + +def test_removed_eligible_bands_stay_discoverable(baseline): + marriage_allowance = baseline.tax_benefit_system.parameters.gov.hmrc.income_tax.allowances.marriage_allowance + alias = marriage_allowance.children["eligible_bands"] + assert isinstance(alias, RemovedParameterNode) + assert alias.metadata["removed"] is True + assert "s. 55B(2)(b)" in alias.description From cd974a44ec6e5201a4780221e0bd8fc74b005333 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:04:40 -0500 Subject: [PATCH 080/250] Cite the 2016-17 start of s. 11A in the rate test's notes FA 2014 Sch. 38 para. 12 gives s. 11A effect from the year the Treasury appointed under s. 25(5) of the Scotland Act 2012, and SI 2015/2000 art. 3 appoints 2016-17. Co-Authored-By: Claude Opus 5.5 --- .../meets_marriage_allowance_income_conditions.yaml | 8 ++++---- .../meets_marriage_allowance_income_conditions.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 32f9daff2b..7ff02f1210 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -211,10 +211,10 @@ - name: In 2015-16 a Scottish taxpayer's limit is the basic rate limit period: 2015 input: - # 2015-16 had no Scottish rate, and the 2015-16 s. 55B(2)(b) allows the - # basic rate. 42,200 - 10,600 = 31,600, below the basic rate limit, - # though above the 31,500 the model's Scottish scale carries back from - # 2017-18. + # s. 11A takes effect in 2016-17 (SI 2015/2000 art. 3), and the 2015-16 + # s. 55B(2)(b) allows the basic rate. 42,200 - 10,600 = 31,600, below the + # basic rate limit, though above the 31,500 the model's Scottish scale + # carries back from 2017-18. employment_income: 42_200 region: SCOTLAND output: diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index d65aebd3e8..37d4af4c66 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -33,11 +33,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): non_savings = person("non_savings_income_after_allowances", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. - # Scottish limits start in 2017-18: in 2016-17 s. 11A(1) charged the - # Scottish basic rate on income that would otherwise bear the basic rate, - # and 2015-16 had no Scottish rate. The model's Scottish scale carries its - # 2017-18 values back to earlier years, so those years use the basic rate - # limit here. + # Scottish limits start in 2017-18. s. 11A takes effect in 2016-17 (FA + # 2014 Sch. 38 para. 12, SI 2015/2000 art. 3), when s. 11A(1) charged the + # Scottish basic rate on income that would otherwise bear the basic rate. + # The model's Scottish scale carries its 2017-18 values back to earlier + # years, so those years use the basic rate limit here. scottish = rates.scotland.rates basic_rate_limit = rates.uk.thresholds[1] + gross_gift if period.start.year < 2017 or len(scottish.rates) < 3: From e6ba856af975f8a88062b45a9884461b46f76c29 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:15:18 -0500 Subject: [PATCH 081/250] 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 c27c6bb1ea9c5c8536901cff1ce2ae715563995d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:54:28 -0500 Subject: [PATCH 082/250] Test the starting rate for savings in the Marriage Allowance conditions Under a starting rate limit raised to 40,000, the model charges 37,700 of 38,000 of interest at the starting rate for savings and the rest at the savings nil rate, both of which s. 55B(2)(b) allows. Baseline limits never separate the starting rate from the savings nil rate in this test. Co-Authored-By: Claude Opus 5.5 --- ...ts_marriage_allowance_income_conditions.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 7ff02f1210..d733658e7e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -107,6 +107,23 @@ savings_income_tax: 0.4 meets_marriage_allowance_income_conditions: false +- name: Under a raised starting rate limit, savings at the starting rate meet the conditions + period: 2026 + input: + # The starting rate limit for savings moves to 40,000. Pay takes the + # personal allowance, so all 38,000 of interest is taxable. The model + # charges the 37,700 of it within the basic rate band at the starting + # rate for savings (s. 12), and the 500 savings allowance for income + # above the basic rate limit (s. 12B(3)) covers the other 300 at the + # savings nil rate (s. 12A). s. 55B(2)(b) allows both rates. + gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 40_000 + employment_income: 12_570 + savings_interest_income: 38_000 + region: NORTH_EAST + output: + savings_income_tax: 0 + meets_marriage_allowance_income_conditions: true + - name: Taxable income at the basic rate limit meets the conditions period: 2026 input: From 09e0c0872b8e07737714fc1ea11ec95ee2b64afa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:59:44 -0500 Subject: [PATCH 083/250] 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 084/250] 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 085/250] 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 0d1e2798a1d7863c602ac46c9f0ad98fd34b4723 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:01:19 -0500 Subject: [PATCH 086/250] List the Marriage Allowance rate test among the readers of non-savings income Co-Authored-By: Claude Opus 5.5 --- .../non_savings_income_after_allowances.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 d8485dbc20..65abfc9182 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 @@ -11,8 +11,9 @@ class non_savings_income_after_allowances(Variable): "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, taxable_property_income_after_allowances and, " - "for Scottish taxpayers, tax_band read it." + "property_income_tax, taxable_property_income_after_allowances, the " + "Marriage Allowance rate test and, for Scottish taxpayers, tax_band " + "read it." ) definition_period = YEAR reference = dict( From cf7153e53b519feb25693201252e3c3dc7e59b0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 07:40:07 -0500 Subject: [PATCH 087/250] 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 bb37aa7d44f8c5f6b2e4c2ab409999c7c7f21980 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:12:08 -0500 Subject: [PATCH 088/250] Name who acts in the pre-2017 and starting-rate notes Comments only: the Treasury adds the Scottish basic rate, a reform raises the starting rate limit, and the model reads its 2017-18 Scottish scale in earlier years. Co-Authored-By: Claude Opus 5.5 --- .../tax/income_tax/scottish_rates.yaml | 8 +++--- ..._marriage_allowance_income_conditions.yaml | 28 +++++++++---------- ...iage_allowance_eligible_bands_parameter.py | 4 +-- .../test_tax_band_scottish_properties.py | 7 +++-- ...ts_marriage_allowance_income_conditions.py | 11 ++++---- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 7e5ed398f2..36e2960a49 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -663,10 +663,10 @@ members: [person] country: SCOTLAND output: - # property_income is profit. With no receipts given, the model takes a - # profit above the 1,000 property allowance to reflect the better of - # expenses and the allowance already, and taxes all 31,092. Pay takes - # the personal allowance. Property income bears the Scottish rates + # The model reads property_income as profit and, with no receipts given, + # takes a profit above the 1,000 property allowance to reflect the better + # of expenses and the allowance already, so it taxes all 31,092. Pay + # takes the personal allowance. Property income bears the Scottish rates # (s. 11A): 2,827 x 19% + 12,094 x 20% + 16,171 x 21% = 537.13 # + 2,418.80 + 3,395.91 = 6,351.84, with none above 31,092. tax_band: INTERMEDIATE diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index d733658e7e..aaf6d25972 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -110,12 +110,12 @@ - name: Under a raised starting rate limit, savings at the starting rate meet the conditions period: 2026 input: - # The starting rate limit for savings moves to 40,000. Pay takes the - # personal allowance, so all 38,000 of interest is taxable. The model - # charges the 37,700 of it within the basic rate band at the starting - # rate for savings (s. 12), and the 500 savings allowance for income - # above the basic rate limit (s. 12B(3)) covers the other 300 at the - # savings nil rate (s. 12A). s. 55B(2)(b) allows both rates. + # A reform raises the starting rate limit for savings to 40,000. Pay + # takes the personal allowance, so the model taxes all 38,000 of + # interest. It charges the 37,700 within the basic rate band at the + # starting rate for savings (s. 12), and the 500 savings allowance for + # income above the basic rate limit (s. 12B(3)) covers the other 300 at + # the savings nil rate (s. 12A). Section 55B(2)(b) allows both rates. gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 40_000 employment_income: 12_570 savings_interest_income: 38_000 @@ -228,10 +228,10 @@ - name: In 2015-16 a Scottish taxpayer's limit is the basic rate limit period: 2015 input: - # s. 11A takes effect in 2016-17 (SI 2015/2000 art. 3), and the 2015-16 - # s. 55B(2)(b) allows the basic rate. 42,200 - 10,600 = 31,600, below the - # basic rate limit, though above the 31,500 the model's Scottish scale - # carries back from 2017-18. + # Section 11A takes effect in 2016-17 (SI 2015/2000 art. 3), and the + # 2015-16 s. 55B(2)(b) allows the basic rate. 42,200 - 10,600 = 31,600, + # below the basic rate limit, though above the 31,500 the model reads + # from its 2017-18 Scottish scale. employment_income: 42_200 region: SCOTLAND output: @@ -240,10 +240,10 @@ - name: In 2016-17 a Scottish taxpayer's limit is the basic rate limit period: 2016 input: - # SI 2015/1810 art. 14(3) adds the Scottish basic rate to s. 55B(2)(b), - # and the 2016-17 s. 11A(1) charges it on non-savings income that would - # otherwise bear the basic rate. 42,700 - 11,000 = 31,700, below the - # basic rate limit. + # The Treasury added the Scottish basic rate to s. 55B(2)(b) (SI + # 2015/1810 art. 14(3)), and the 2016-17 s. 11A(1) charges it on + # non-savings income that would otherwise bear the basic rate. + # 42,700 - 11,000 = 31,700, below the basic rate limit. employment_income: 42_700 region: SCOTLAND output: diff --git a/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py index d2cd2bc862..661341fbcf 100644 --- a/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py +++ b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py @@ -1,7 +1,7 @@ """Saved policies naming the Marriage Allowance eligible bands get directions. -The income conditions apply the rate conditions of ITA 2007 s. 55B(2)(b) and -(ba) directly, so nothing reads +The model applies the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) to +income directly, so nothing reads ``gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands`` (#2350). """ diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 7902b73277..101e494110 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -319,9 +319,10 @@ def test_savings_allowance_does_not_read_the_band(): # The Marriage Allowance income conditions against the liability, with the nil # rates in force: people alternate between Scotland and England, from 2016-17, # the first year of the savings and dividend nil rates. Before 2017-18 the -# model charges a Scottish taxpayer's non-savings income on the Scottish scale -# it carries back from 2017-18, where s. 11A(1) charged it on the rest-of-UK -# limits. The conditions follow the law, so the test leaves those people out. +# model charges a Scottish taxpayer's non-savings income on its 2017-18 +# Scottish scale, where s. 11A(1) charged it on the rest-of-UK limits. The +# Marriage Allowance test uses the basic rate limit there, as the law did, so +# this test leaves those people out. MA_YEARS = list(range(2016, 2027)) MA_COUNTRIES = ["SCOTLAND", "ENGLAND"] FIRST_SCOTTISH_LIMITS_YEAR = 2017 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 37d4af4c66..fc7bbd2f8d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -33,11 +33,12 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): non_savings = person("non_savings_income_after_allowances", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. - # Scottish limits start in 2017-18. s. 11A takes effect in 2016-17 (FA - # 2014 Sch. 38 para. 12, SI 2015/2000 art. 3), when s. 11A(1) charged the - # Scottish basic rate on income that would otherwise bear the basic rate. - # The model's Scottish scale carries its 2017-18 values back to earlier - # years, so those years use the basic rate limit here. + # Before 2017-18 a Scottish taxpayer's limit is the basic rate limit. + # Section 11A takes effect in 2016-17 (FA 2014 Sch. 38 para. 12, SI + # 2015/2000 art. 3), when s. 11A(1) charged the Scottish basic rate on + # income that would otherwise bear the basic rate. The model reads its + # Scottish scale's 2017-18 values in earlier years too, so the test sets + # the limit by the year. scottish = rates.scotland.rates basic_rate_limit = rates.uk.thresholds[1] + gross_gift if period.start.year < 2017 or len(scottish.rates) < 3: From 3af8cb7854a7b790cb003156f2d42f889a068143 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:14:57 -0500 Subject: [PATCH 089/250] Say the model picks a Scottish taxpayer's band in its documentation Co-Authored-By: Claude Opus 5.5 --- .../gov/hmrc/income_tax/bracketized_liability/tax_band.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 457cd7f9c7..a2ed900f38 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -99,9 +99,9 @@ class tax_band(Variable): entity = Person label = "Tax band of the individual" documentation = ( - "The highest band in which the person has taxable income. A " - "Scottish taxpayer's band is the higher of the Scottish band of " - "their income other than savings and dividends and the rest-of-UK " + "The highest band in which the person has taxable income. For a " + "Scottish taxpayer the model takes the higher of the Scottish band " + "of their income other than savings and dividends and the rest-of-UK " "band of their savings and dividends. Savings and dividends charged " "at a nil rate count where they sit. ADDITIONAL covers the Scottish " "advanced and top rates and the dividend additional rate; HIGHER " From eec9f6480ceaa5990d8f4420477cf9995da8ac45 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:17:57 -0500 Subject: [PATCH 090/250] 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 091/250] 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 092/250] 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 093/250] 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 094/250] 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 095/250] 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 096/250] 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 097/250] 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 e380c79a779adceeb63202314373781bc897a3b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:41:34 -0500 Subject: [PATCH 098/250] Start the dividend upper rate at the basic rate limit in the rate test Section 13(2) charges the dividend upper rate on dividends that would otherwise bear the higher rate, which s. 10(3) starts at the basic rate limit. The model's dividend scale holds 37,500 in 2015-16, against a 32,000 basic rate limit, so the test read it there and admitted dividends the statute charges at the upper rate. The property ties the dividend scale to a reformed basic rate limit instead of drawing it on its own. Co-Authored-By: Claude Opus 5.5 --- ..._marriage_allowance_income_conditions.yaml | 28 +++++++++++++++++++ .../test_tax_band_scottish_properties.py | 11 ++++++-- ...ts_marriage_allowance_income_conditions.py | 9 +++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index aaf6d25972..b3ba7d9ce1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -287,3 +287,31 @@ region: SCOTLAND output: meets_marriage_allowance_income_conditions: true + +- name: In 2015-16 dividends above the basic rate limit fail the conditions + period: 2015 + input: + # 42,600 - 10,600 = 32,000 of pay, at the model's basic rate limit, then + # 1 of dividends. Section 13(2) charges the dividend upper rate on + # dividends that would otherwise bear the higher rate, which starts at + # the basic rate limit (s. 10(3)). The 2015-16 s. 55B(2)(b) permits the + # dividend ordinary rate only. The model's dividend scale starts the + # upper rate at 37,500 that year. + employment_income: 42_600 + dividend_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: At the statutory 2015-16 basic rate limit a pound of dividends fails the conditions + period: 2015 + input: + # A reform sets the basic rate limit to the statutory 31,785 (s. 10(5) + # for 2015-16). 42,385 - 10,600 = 31,785 of pay, then 1 of dividends, + # which s. 13(2) charges at the dividend upper rate. + gov.hmrc.income_tax.rates.uk[1].threshold: 31_785 + employment_income: 42_385 + dividend_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 101e494110..45bd0b4a28 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -337,14 +337,15 @@ def test_savings_allowance_does_not_read_the_band(): ) DIVIDEND_UPPER = [DIVIDENDS.format(i) for i in (1, 2, 3)] DIVIDEND_ALLOWANCE = "gov.hmrc.income_tax.allowances.dividend_allowance" +BASIC_RATE_LIMIT = "gov.hmrc.income_tax.rates.uk[1].threshold" +DIVIDEND_UPPER_START = "gov.hmrc.income_tax.rates.dividends[1].threshold" # Rate limits and nil-rate amounts a reform might change, each drawn or left # at the law in force. LIMITS = { "gov.hmrc.income_tax.rates.scotland.rates[3].threshold": st.integers( 20_000, 50_000 ), - "gov.hmrc.income_tax.rates.uk[1].threshold": st.integers(25_000, 50_000), - "gov.hmrc.income_tax.rates.dividends[1].threshold": st.integers(25_000, 50_000), + BASIC_RATE_LIMIT: st.integers(25_000, 50_000), "gov.hmrc.income_tax.rates.savings_starter_rate.allowance": st.integers(0, 60_000), "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic": st.integers( 0, 10_000 @@ -404,6 +405,12 @@ def test_marriage_allowance_follows_the_liability_with_nil_rates( for path, value in changes.items() if parameters.get_child(path)(str(period)) is not None } + basic_rate_limit = changes.get( + BASIC_RATE_LIMIT, parameters.get_child(BASIC_RATE_LIMIT)(str(period)) + ) + # Section 13(2) starts the dividend upper rate at the basic rate limit + # (s. 10(3)), so a reformed limit moves the model's dividend scale too. + changes[DIVIDEND_UPPER_START] = basic_rate_limit with law(sim, period, changes): meets = np.asarray( sim.calculate("meets_marriage_allowance_income_conditions", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index fc7bbd2f8d..14f919324e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -77,15 +77,16 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): non_savings + savings > basic_rate_limit ) # Dividends are the top slice, above all non-savings income and savings - # (s. 16), counted before the dividend allowance. The dividend upper rate - # starts at the basic rate limit (s. 13(2)), extended by the gross gift; - # dividend_income_tax reads that limit from the dividend scale. + # (s. 16), counted before the dividend allowance. Section 13(2) charges + # the dividend upper rate on dividends that would otherwise bear the + # higher rate, which starts at the basic rate limit (s. 10(3)), extended + # by the gross gift. dividends = max_( 0, person("taxable_dividend_income", period) - person("received_allowances_dividend_income", period), ) - dividend_upper_limit = rates.dividends.thresholds[1] + gross_gift + dividend_upper_limit = basic_rate_limit dividends_above_basic = (dividends > 0) & ( non_savings + savings + dividends > dividend_upper_limit ) From 743b277e39a613d0be7004388d79a1a99599a6a2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:41:39 -0500 Subject: [PATCH 099/250] Count no savings nil rate in the rate test before 2016-17 FA 2016 s. 4 inserted the savings nil rate (ss. 12A and 12B) and added it to s. 55B(2)(b), with effect for 2016-17 and later years (s. 4(17)). The model's Personal Savings Allowance amounts start in 2005, so the test counted a nil rate in 2015-16 that the law didn't have. Co-Authored-By: Claude Opus 5.5 --- ..._marriage_allowance_income_conditions.yaml | 25 +++++++++++++++++++ ...ts_marriage_allowance_income_conditions.py | 7 +++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index b3ba7d9ce1..ea9f8cf2e1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -315,3 +315,28 @@ region: NORTH_EAST output: meets_marriage_allowance_income_conditions: false + +- name: In 2015-16 savings above the basic rate limit fail the conditions + period: 2015 + input: + # 32,000 of pay, then 1 of interest. FA 2016 s. 4(17) gives the savings + # nil rate effect from 2016-17, so the 2015-16 s. 55B(2)(b) permits no + # savings nil rate, and the pound bears the higher rate. The model + # applies its Personal Savings Allowance in 2015-16 too. + employment_income: 42_600 + savings_interest_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: At the statutory 2015-16 basic rate limit a pound of savings fails the conditions + period: 2015 + input: + # As above with 1 of interest, which bears the higher rate: 2015-16 has + # no savings nil rate. + gov.hmrc.income_tax.rates.uk[1].threshold: 31_785 + employment_income: 42_385 + savings_interest_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 14f919324e..65142de572 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -72,7 +72,12 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): 0, max_(0, starter.allowance - non_savings * starter.phase_out), ) - nil_rate_savings = starting_rate_savings + person("savings_allowance", period) + # FA 2016 s. 4(17) gives the savings nil rate effect from 2016-17. + if period.start.year < 2016: + savings_allowance = 0 + else: + savings_allowance = person("savings_allowance", period) + nil_rate_savings = starting_rate_savings + savings_allowance savings_above_basic = (savings > nil_rate_savings) & ( non_savings + savings > basic_rate_limit ) From d96eb3d7ae838b5607a99d64461f9b7ab1afb76e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:42:48 -0500 Subject: [PATCH 100/250] Place the savings nil rate above the starting rate limit in the rate test Section 12(1) charges the starting rate for savings only on savings that would otherwise bear a basic rate, so it never reaches past the basic rate limit. Section 12A(1) to (4) charges the savings nil rate on the lowest savings above the starting rate limit, up to the savings allowance. The test now fails a person when any savings above the basic rate limit lie outside that slice, instead of starting the slice where the starting rate ends. Under a starting rate limit of 40,000, 12,570 of pay and 38,000 of interest leave 300 above the basic rate limit at the savings higher rate, so that case now fails the conditions. A new case under a 37,500 limit meets them: s. 12 charges 37,500 at the starting rate and s. 12A the 500 above it at the savings nil rate. The property keeps a reformed starting rate limit at or below the basic rate limit, where the model's savings liability places the nil rate as s. 12A does. Co-Authored-By: Claude Opus 5.5 --- ..._marriage_allowance_income_conditions.yaml | 34 +++++++++++++---- .../test_tax_band_scottish_properties.py | 10 ++++- ...ts_marriage_allowance_income_conditions.py | 37 +++++++++---------- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index ea9f8cf2e1..23c84aeda7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -107,23 +107,41 @@ savings_income_tax: 0.4 meets_marriage_allowance_income_conditions: false -- name: Under a raised starting rate limit, savings at the starting rate meet the conditions +- name: Savings at a raised starting rate limit and the savings nil rate meet the conditions period: 2026 input: - # A reform raises the starting rate limit for savings to 40,000. Pay - # takes the personal allowance, so the model taxes all 38,000 of - # interest. It charges the 37,700 within the basic rate band at the - # starting rate for savings (s. 12), and the 500 savings allowance for - # income above the basic rate limit (s. 12B(3)) covers the other 300 at - # the savings nil rate (s. 12A). Section 55B(2)(b) allows both rates. - gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 40_000 + # A reform raises the starting rate limit for savings to 37,500. Pay + # uses the personal allowance. Section 12 charges 37,500 of interest at + # the starting rate, and s. 12A charges the 500 above that limit at the + # savings nil rate. Section 55B(2)(b) permits both. + gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 37_500 employment_income: 12_570 savings_interest_income: 38_000 region: NORTH_EAST output: + savings_starter_rate_income: 37_500 + savings_allowance: 500 savings_income_tax: 0 meets_marriage_allowance_income_conditions: true +- name: Savings below a raised starting rate limit above the basic rate limit fail the conditions + period: 2026 + input: + # A reform raises the starting rate limit for savings to 40,000. Pay + # uses the personal allowance. Section 12(1)(b) charges the starting + # rate only on income that would otherwise bear the basic rate, so it + # covers 37,700 of the 38,000 of interest. Section 12A applies only to + # savings above the 40,000 limit, and the interest stops below it, so + # the other 300 bears the savings higher rate, which s. 55B(2)(b) + # forbids. The model's savings liability charges these 300 at the + # savings nil rate. + gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 40_000 + employment_income: 12_570 + savings_interest_income: 38_000 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + - name: Taxable income at the basic rate limit meets the conditions period: 2026 input: diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 45bd0b4a28..6158279e59 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -339,6 +339,7 @@ def test_savings_allowance_does_not_read_the_band(): DIVIDEND_ALLOWANCE = "gov.hmrc.income_tax.allowances.dividend_allowance" BASIC_RATE_LIMIT = "gov.hmrc.income_tax.rates.uk[1].threshold" DIVIDEND_UPPER_START = "gov.hmrc.income_tax.rates.dividends[1].threshold" +STARTING_RATE_LIMIT = "gov.hmrc.income_tax.rates.savings_starter_rate.allowance" # Rate limits and nil-rate amounts a reform might change, each drawn or left # at the law in force. LIMITS = { @@ -346,7 +347,7 @@ def test_savings_allowance_does_not_read_the_band(): 20_000, 50_000 ), BASIC_RATE_LIMIT: st.integers(25_000, 50_000), - "gov.hmrc.income_tax.rates.savings_starter_rate.allowance": st.integers(0, 60_000), + STARTING_RATE_LIMIT: st.integers(0, 60_000), "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic": st.integers( 0, 10_000 ), @@ -411,6 +412,13 @@ def test_marriage_allowance_follows_the_liability_with_nil_rates( # Section 13(2) starts the dividend upper rate at the basic rate limit # (s. 10(3)), so a reformed limit moves the model's dividend scale too. changes[DIVIDEND_UPPER_START] = basic_rate_limit + # The model's savings liability starts the savings nil rate where the + # starting rate for savings ends. That matches s. 12A only while the + # starting rate limit stays at or below the basic rate limit. + if STARTING_RATE_LIMIT in changes: + changes[STARTING_RATE_LIMIT] = min( + changes[STARTING_RATE_LIMIT], basic_rate_limit + ) with law(sim, period, changes): meets = np.asarray( sim.calculate("meets_marriage_allowance_income_conditions", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 65142de572..8d965f5f12 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -53,34 +53,33 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): basic_rate_limit, ) # Savings sit above non-savings income (s. 16) and take the rest-of-UK - # bands (s. 11D(6)). As in higher_rate_savings_income, the model charges - # the first of them at the starting rate for savings, which it keeps - # within the basic rate band, and then at the savings nil rate. Savings - # above those bear a positive rate, and the person fails the test once - # they pass the basic rate limit. + # limits (s. 11D(6)). Up to the basic rate limit they bear a basic rate + # or the starting rate for savings, both permitted: s. 12(1) charges the + # starting rate only on savings that would otherwise bear a basic rate + # (s. 12(1)(b) from 2017-18). Above the limit s. 55B(2)(b) permits only + # the savings nil rate. Section 12A(1) to (4) charges it on the lowest + # savings above the starting rate limit for savings, up to the savings + # allowance; dividends sit above savings (s. 16) and leave this slice + # where it is. The person fails the test if any savings above the basic + # rate limit lie outside the slice. savings = max_( 0, person("taxable_savings_interest_income", period) - person("received_allowances_savings_income", period), ) - starter = rates.savings_starter_rate - savings_in_basic_band = clip( - non_savings + savings, rates.uk.thresholds[0], basic_rate_limit - ) - clip(non_savings, rates.uk.thresholds[0], basic_rate_limit) - starting_rate_savings = clip( - min_(savings, savings_in_basic_band), - 0, - max_(0, starter.allowance - non_savings * starter.phase_out), - ) # FA 2016 s. 4(17) gives the savings nil rate effect from 2016-17. if period.start.year < 2016: savings_allowance = 0 else: savings_allowance = person("savings_allowance", period) - nil_rate_savings = starting_rate_savings + savings_allowance - savings_above_basic = (savings > nil_rate_savings) & ( - non_savings + savings > basic_rate_limit + below_dividends = non_savings + savings + nil_rate_start = max_(non_savings, rates.savings_starter_rate.allowance) + nil_rate_top = min_(below_dividends, nil_rate_start + savings_allowance) + savings_above_basic = max_(0, below_dividends - max_(non_savings, basic_rate_limit)) + nil_rate_above_basic = max_( + 0, nil_rate_top - max_(nil_rate_start, basic_rate_limit) ) + savings_at_forbidden_rates = savings_above_basic - nil_rate_above_basic > 0 # Dividends are the top slice, above all non-savings income and savings # (s. 16), counted before the dividend allowance. Section 13(2) charges # the dividend upper rate on dividends that would otherwise bear the @@ -93,11 +92,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): ) dividend_upper_limit = basic_rate_limit dividends_above_basic = (dividends > 0) & ( - non_savings + savings + dividends > dividend_upper_limit + below_dividends + dividends > dividend_upper_limit ) return ( (non_savings <= non_savings_limit) - & ~savings_above_basic + & ~savings_at_forbidden_rates & ~dividends_above_basic ) From 1d6df1a43cfbf7c445d16def69181a02797b088b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:58:23 -0500 Subject: [PATCH 101/250] 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 102/250] 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 09d216d51850cef0623d12d60ef7ed03e601c8c9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 12:03:00 -0500 Subject: [PATCH 103/250] Describe the rate test and the band in current terms The rate test's notes now say which rates s. 55B(2)(b) permits each year and where the test follows the statute against the model's liability. tax_band, savings_allowance, non_savings_income_after_allowances and the CPS reform's conditions variable describe what each now does, and the Scottish rates cases name the band each reaches. The changelog fragment names the income conditions. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2350.fixed.md | 2 +- .../reforms/cps/marriage_tax_reforms.py | 5 ++- .../tax/income_tax/scottish_rates.yaml | 39 ++++++++++--------- ..._marriage_allowance_income_conditions.yaml | 6 +-- policyengine_uk/tests/test_gift_aid.py | 4 +- .../test_tax_band_scottish_properties.py | 39 ++++++++++--------- ...ts_marriage_allowance_income_conditions.py | 31 ++++++++------- .../allowances/savings_allowance.py | 6 +-- .../non_savings_income_after_allowances.py | 10 ++--- .../bracketized_liability/tax_band.py | 12 +++--- 10 files changed, 81 insertions(+), 73 deletions(-) diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md index 9f9b9e9129..dbcee5972f 100644 --- a/changelog.d/2350.fixed.md +++ b/changelog.d/2350.fixed.md @@ -1 +1 @@ -The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends. It now tests Marriage Allowance eligibility against the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) directly, so income at a rate limit and savings at the savings nil rate no longer block it. +The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends. It tests the Marriage Allowance income conditions directly under ITA 2007 s. 55B(2)(b) and (ba), allowing income at a rate limit and savings at a permitted nil rate. diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 943c237bb8..2ac8da36ac 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -37,7 +37,10 @@ def formula(person, period): class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person meets the statutory Marriage Allowance income " + "conditions or this reform's expansion conditions." + ) entity = Person definition_period = YEAR value_type = bool diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 36e2960a49..6eafafd7d8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -132,16 +132,17 @@ # Scottish rates charge only income that is neither savings nor dividends # (ITA 2007 s. 11A(1A); S6M-16531 for 2025-26). Savings and dividends are the # top slice (s. 16) and take the rates and bands they would bear outside -# Scotland (ss. 11D(6) and 13(5)). The band is the higher of the two. In +# Scotland (ss. 11D(6) and 13(5)). The model returns the higher band. In # 2025-26 the personal allowance is £12,570, the Scottish limits on taxable # income are £2,827, £14,921, £31,092, £62,430 and £125,140 (19%, 20%, 21%, # 42%, 45%, 48%), the basic rate limit is £37,700, the dividend allowance is # £500 (8.75% ordinary, 33.75% upper) and the Personal Savings Allowance is # £1,000, or £500 with higher-rate income (s. 12B). Marriage Allowance # applies the rate conditions of s. 55B(2)(b) and the dividend counterfactual -# of s. 55B(2)(ba); savings charged at the savings nil rate leave it open. +# of s. 55B(2)(ba). Savings at the savings nil rate do not bar the Marriage +# Allowance. -- name: Scottish 2025 pay of 40000 and dividends of 5000 is intermediate +- name: Scottish 2025 pay of 40000 and dividends of 5000 reaches the intermediate band period: 2025 absolute_error_margin: 0.01 input: @@ -163,7 +164,7 @@ earned_income_tax: 5_582.82 dividend_income_tax: 393.75 -- name: Scottish 2025 pay of 40000 and savings of 5000 is intermediate +- name: Scottish 2025 pay of 40000 and savings of 5000 reaches the intermediate band period: 2025 absolute_error_margin: 0.01 input: @@ -186,7 +187,7 @@ earned_income_tax: 5_582.82 savings_income_tax: 800 -- name: England 2025 pay of 40000 and dividends of 5000 is basic +- name: England 2025 pay of 40000 and dividends of 5000 reaches the basic band period: 2025 absolute_error_margin: 0.01 input: @@ -206,7 +207,7 @@ earned_income_tax: 5_486 dividend_income_tax: 393.75 -- name: Scottish 2025 pay of 60000 and dividends of 20000 is higher +- name: Scottish 2025 pay of 60000 and dividends of 20000 reaches the higher band period: 2025 absolute_error_margin: 0.01 input: @@ -230,7 +231,7 @@ earned_income_tax: 13_213.80 dividend_income_tax: 6_581.25 -- name: Scottish 2025 pay of 60000 and savings of 20000 is higher +- name: Scottish 2025 pay of 60000 and savings of 20000 reaches the higher band period: 2025 absolute_error_margin: 0.01 input: @@ -251,7 +252,7 @@ earned_income_tax: 13_213.80 savings_income_tax: 7_800 -- name: England 2025 pay of 60000 and dividends of 20000 is higher +- name: England 2025 pay of 60000 and dividends of 20000 reaches the higher band period: 2025 absolute_error_margin: 0.01 input: @@ -337,7 +338,7 @@ earned_income_tax: 6_351.84 dividend_income_tax: 534.45 -- name: Scottish 2025 dividends one pound above the basic rate limit are higher +- name: Scottish 2025 dividends one pound above the basic rate limit reach the higher band period: 2025 absolute_error_margin: 0.01 input: @@ -383,7 +384,7 @@ earned_income_tax: 6_509.84 dividend_income_tax: 393.75 -- name: Scottish 2025 allowance beyond pay goes to dividends, which are basic +- name: Scottish 2025 allowance beyond pay goes to dividends, which reach the basic band period: 2025 absolute_error_margin: 0.01 input: @@ -405,7 +406,7 @@ earned_income_tax: 0 dividend_income_tax: 1_918.88 -- name: Scottish 2025 allowance beyond pay goes to savings, which are basic +- name: Scottish 2025 allowance beyond pay goes to savings, which reach the basic band period: 2025 absolute_error_margin: 0.01 input: @@ -427,7 +428,7 @@ earned_income_tax: 0 savings_income_tax: 3_286 -- name: Scottish 2025 starter-rate pay without savings or dividends is starter +- name: Scottish 2025 starter-rate pay without savings or dividends reaches the starter band period: 2025 absolute_error_margin: 0.01 input: @@ -461,7 +462,7 @@ tax_band: NONE earned_income_tax: 0 -- name: Scottish 2018-19 pay of 40000 and dividends of 5000 is intermediate +- name: Scottish 2018-19 pay of 40000 and dividends of 5000 reaches the intermediate band period: 2018 absolute_error_margin: 0.01 input: @@ -486,7 +487,7 @@ earned_income_tax: 5_770 dividend_income_tax: 225 -- name: Scottish 2017-18 pay above 43000 is higher +- name: Scottish 2017-18 pay above 43000 reaches the higher band period: 2017 absolute_error_margin: 0.01 input: @@ -511,7 +512,7 @@ # £16,956, £31,092, £62,430 and £125,140 of taxable income. The personal # allowance stays £12,570 and the basic rate limit £37,700. -- name: Scottish 2026 pay of 40000 and dividends of 5000 is intermediate +- name: Scottish 2026 pay of 40000 and dividends of 5000 reaches the intermediate band period: 2026 absolute_error_margin: 0.01 input: @@ -532,7 +533,7 @@ meets_marriage_allowance_income_conditions: true earned_income_tax: 5_551.07 -- name: Scottish 2026 pay of 60000 and savings of 20000 is higher +- name: Scottish 2026 pay of 60000 and savings of 20000 reaches the higher band period: 2026 input: people: @@ -568,7 +569,7 @@ tax_band: INTERMEDIATE meets_marriage_allowance_income_conditions: true -- name: Scottish 2026 dividends one pound above the basic rate limit are higher +- name: Scottish 2026 dividends one pound above the basic rate limit reach the higher band period: 2026 input: people: @@ -584,7 +585,7 @@ tax_band: HIGHER meets_marriage_allowance_income_conditions: false -- name: Scottish 2026 allowance beyond pay goes to dividends, which are basic +- name: Scottish 2026 allowance beyond pay goes to dividends, which reach the basic band period: 2026 input: people: @@ -673,7 +674,7 @@ meets_marriage_allowance_income_conditions: true property_income_tax: 6_351.84 -- name: Scottish 2025 property income one pound above the higher rate limit is higher +- name: Scottish 2025 property income one pound above the higher rate limit reaches the higher band period: 2025 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 23c84aeda7..ca5a8512e5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -21,7 +21,7 @@ - name: Higher-rate earner fails the Marriage Allowance income conditions period: 2021 input: - # 60,000 - 12,570 = 47,430, so 9,730 is at the higher rate. + # 60,000 - 12,570 = 47,430. The model taxes 9,730 at the higher rate. employment_income: 60_000 region: NORTH_EAST output: @@ -30,8 +30,8 @@ - name: Additional-rate earner fails the Marriage Allowance income conditions period: 2021 input: - # No personal allowance above 125,140 of income; 10,000 is above the - # 150,000 higher rate limit. + # The personal allowance falls to zero at 125,140 of income, and 10,000 + # of the income exceeds the 150,000 higher rate limit. employment_income: 160_000 region: NORTH_EAST output: diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 16df5f01dc..7beb9f00b4 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -374,8 +374,8 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): # The model allocates the allowance to all 10,000 of property # income, so no income bears a Scottish rate (ITA 2007 s. 11A). The # remaining 27,430 of savings and dividends falls below the 37,700 - # UK basic rate limit (ss. 11D(6) and 13(5)), so the band is basic, - # not intermediate (#2350). + # UK basic rate limit (ss. 11D(6) and 13(5)), so tax_band returns + # BASIC (#2350). "BASIC", "ADDITIONAL", ] diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 6158279e59..e9aa8ee7a4 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -9,29 +9,30 @@ 13(5)). These tests read the band off that liability: they raise the rates of one band at a time by RISE and see which bands' rates each person pays. -The people are single, so the Marriage Allowance, which the model takes off -earned income rather than giving as a tax reduction (#1947), plays no part. -With the starting rate for savings, the Personal Savings Allowance and the -dividend allowance set to nothing, every pound of taxable income is charged at -a band's rate, and for every single Scottish taxpayer: +These tests use single adults, so the model grants them no Marriage +Allowance. We set the starting-rate amount, Personal Savings Allowance and +dividend allowance to zero, so the model taxes every taxable pound at a band's +positive rate, and for every single Scottish taxpayer: - a person in a band pays tax at that band's rates on some income and at no higher band's rates, and a person with no band pays no tax; - the Marriage Allowance income conditions hold exactly when the person pays nothing at the higher or additional rates (s. 55B(2)(b)). -With baseline nil rates restored, the model keeps nil-rate income in its +With the baseline nil-rate amounts, the model keeps nil-rate income in its underlying band, and no one pays tax at the higher or additional rates above their band. -The Marriage Allowance income conditions do not read the band. With the nil -rates in force, under the law in force or with its rate limits and nil-rate -amounts changed, for every single person in England, and in Scotland from -2017-18, they hold exactly when the liability charges no income at a rate -s. 55B(2)(b) forbids and, with the dividend allowance removed -(s. 55B(2)(ba)), none at the dividend upper or additional rate. So savings at -the savings nil rate or the starting rate for savings never stop the -allowance. +The Marriage Allowance income conditions do not read the band. With the +baseline nil-rate amounts and with reformed limits and amounts, for every +single person in England, and in Scotland from 2017-18, they hold exactly when +the liability charges no income at a rate s. 55B(2)(b) forbids and, with the +dividend allowance removed (s. 55B(2)(ba)), none at the dividend upper or +additional rate. So savings at the savings nil rate or the starting rate for +savings never stop the allowance. The reforms start the dividend upper rate at +the basic rate limit, as s. 13(2) does, and hold the starting rate limit for +savings at or below the basic rate limit, the range in which the model's +savings liability places the savings nil rate as s. 12A does. Before #2350 the band compared all taxable income with the Scottish limits. Against that formula, which ran from 2019-20, the band never rises, except @@ -193,12 +194,12 @@ def liability(sim, period): def charged_at(sim, period, paths): - """The income charged at the rates at these paths: the rise in tax when - only those rates rise by RISE, divided by RISE.""" + """Measure the income the model taxes at these rates by raising only + these rates by RISE and dividing the tax increase by RISE.""" parameters = sim.tax_benefit_system.parameters before = liability(sim, period) rates = {path: parameters.get_child(path)(str(period)) for path in paths} - # A rate with no value that year (a Scottish rate not yet set) stays so. + # Skip Scottish rates that Parliament had not set that year. raised = {path: rate + RISE for path, rate in rates.items() if rate is not None} with law(sim, period, raised): return (liability(sim, period) - before) / RISE @@ -250,7 +251,7 @@ def test_no_one_pays_higher_rates_above_their_band(sim, drawn, period): set_people(sim, drawn, period) band = band_of(sim, period) tax = liability(sim, period) - # The Marriage Allowance and counts by marginal rate turn on these. + # Counts by marginal rate use these bands. income_at = charged(sim, period, ["HIGHER", "ADDITIONAL"]) for i, b in enumerate(band): case = f"{drawn[i]} in {period}: {b}, {[income_at[x][i] for x in income_at]}" @@ -400,7 +401,7 @@ def test_marriage_allowance_follows_the_liability_with_nil_rates( sim = mixed_sim set_people(sim, drawn, period) parameters = sim.tax_benefit_system.parameters - # A limit with no value that year (a Scottish rate not yet set) stays so. + # Skip Scottish rates that Parliament had not set that year. changes = { path: value for path, value in changes.items() diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 8d965f5f12..ac03c3c674 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -4,18 +4,21 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): """Whether the person pays no income tax above the basic rates. - ITA 2007 s. 55B(2)(b) and s. 55C(1)(c) allow only the basic rate, the - property, default, savings, Scottish and Welsh basic rates, the Scottish - starter and intermediate rates, the dividend ordinary rate and the nil and - starting rates for savings. So no non-savings income may reach the higher - rate (the Scottish higher rate for a Scottish taxpayer), and no savings the - savings higher rate. Dividends covered by the dividend nil rate still - count: s. 55B(2)(ba) and s. 55C(1)(ca) test the dividend upper rate as if + Apply the rates that s. 55B(2)(b) and s. 55C(1)(c) permit for the year + being calculated. From 2016-17 these include the savings nil rate; from + 2018-19 the Scottish list includes rates below basic and the intermediate + rate. Where s. 55B(2)(ba) and s. 55C(1)(ca) apply, test dividends as if s. 13A were omitted. - The bands are the ones the model taxes with: Gift Aid extends the rate - limits by the grossed-up gift (s. 414(2)(b)), as in earned_income_tax, - and does not come off the income. + Gift Aid extends the rate limits by the grossed-up gift (s. 414(2)(b)); + it does not reduce taxable income. Before 2017-18 this test uses the + basic rate limit for Scottish taxpayers, even though the model's + historical Scottish liability currently reads the 2017-18 schedule. The + test also follows the statute where the model's liability departs from + it: it starts the dividend upper rate at the basic rate limit (ss. 10 + and 13), counts no savings nil rate before 2016-17 (FA 2016 s. 4(17)), + and places the nil-rate slice above the starting rate limit for savings + (s. 12A). The test reads income before the Marriage Allowance. Step 4 of s. 23 charges tax at each rate before Step 6 deducts the tax reductions, and @@ -28,14 +31,14 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): gross_gift = person("gift_aid_grossed_up", period) # Earnings, pensions, property and other non-savings income after # allowances, which go to it first: the income below savings and - # dividends (s. 16). It is non_savings_taxable_income before the + # dividends (s. 16). This reads non-savings taxable income before the # Marriage Allowance. non_savings = person("non_savings_income_after_allowances", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. - # Before 2017-18 a Scottish taxpayer's limit is the basic rate limit. - # Section 11A takes effect in 2016-17 (FA 2014 Sch. 38 para. 12, SI - # 2015/2000 art. 3), when s. 11A(1) charged the Scottish basic rate on + # Before 2017-18 the test holds a Scottish taxpayer to the basic rate + # limit. Section 11A takes effect in 2016-17 (FA 2014 Sch. 38 para. 12, + # SI 2015/2000 art. 3), when s. 11A(1) charged the Scottish basic rate on # income that would otherwise bear the basic rate. The model reads its # Scottish scale's 2017-18 values in earlier years too, so the test sets # the limit by the year. diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py index 99fe833270..128ab66674 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py @@ -13,9 +13,9 @@ class savings_allowance(Variable): "12B(8)(a)(iv) and (b)(iv) count as higher- or additional-rate income " "what would be charged at those rates if they were not a Scottish or " "Welsh taxpayer, so the bands are the rest-of-UK ones for everyone. " - "tax_band follows the Scottish bands for a Scottish taxpayer, as the " - "Marriage Allowance conditions need, so it is not read here. Taxable " - "income is taken, as in tax_band, as net income less allowances, and " + "For Scottish taxpayers, tax_band also uses Scottish limits for " + "non-savings income. This calculation uses the rest-of-UK limits " + "directly. Taxable income is taken, as in tax_band, as net income less allowances, and " "a Gift Aid gift raises the basic and higher rate limits by its " "grossed-up amount (ITA 2007 s. 414(2)(b))." ) 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 65abfc9182..16ef82ac61 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 @@ -6,11 +6,11 @@ class non_savings_income_after_allowances(Variable): entity = Person label = "Non-savings, non-dividend income for Income Tax after allowances" 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. " + "The model subtracts savings income, dividend income and allowances " + "from net income and floors the result at zero, so it sets allowances " + "against this income first. Section 16 of ITA 2007 treats savings and " + "dividends as the highest part of income, above this income. Gift Aid " + "raises the rate limits and does not reduce this taxable income. " "property_income_tax, taxable_property_income_after_allowances, the " "Marriage Allowance rate test and, for Scottish taxpayers, tax_band " "read it." diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index a2ed900f38..32c242be75 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -12,8 +12,8 @@ class TaxBand(Enum): def uk_tax_band_above_limits(person, period, parameters): - """The rest-of-UK band before 2018-19: income above a limit is in the - band above it.""" + """Return the rest-of-UK band before 2018-19, counting income above each + limit in the next band.""" allowances = person("allowances", period) ani = person("net_income", period) rates = parameters(period).gov.hmrc.income_tax.rates @@ -35,8 +35,8 @@ def uk_tax_band_above_limits(person, period, parameters): def scottish_tax_band(person, period, parameters): """The highest band in which a Scottish taxpayer has taxable income. - Use the Scottish limits for income other than savings and dividends, - the only income the Scottish rates charge (ITA 2007 s. 11A and the + Use Scottish limits for income other than savings and dividends. + Parliament applies Scottish rates to this income (ITA 2007 s. 11A and the Scottish rate resolutions). Treat savings and dividends as the top slices of income (s. 16) and apply the rest-of-UK limits, since they bear the rates they would bear outside Scotland (ss. 11D(6) and 13(5)). @@ -131,8 +131,8 @@ def formula(person, period, parameters): return uk_tax_band_above_limits(person, period, parameters) def formula_2017(person, period, parameters): - # Annual formulas start on 1 January, so this runs from 2017-18, the - # first year of Scottish rates and limits. + # Annual formulas start on 1 January, so this runs from 2017-18, when + # the Scottish Parliament first set its own band limits. return where( person("pays_scottish_income_tax", period), scottish_tax_band(person, period, parameters), From 9d8d3e08bcb0a84b43ac1451b913fe08c0c9cebc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 12:14:36 -0500 Subject: [PATCH 104/250] 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 7ad623f0c74609e350fef7b041bb7c518af0f4e9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 13:15:38 -0500 Subject: [PATCH 105/250] Name who acts in the new rate-test case notes Comments and one case name only: the 2015-16 cases say which rates s. 55B(2)(b) permits, and the property's Marriage Allowance section names its nil-rate amounts and reforms. Co-Authored-By: Claude Opus 5.5 --- ...s_marriage_allowance_income_conditions.yaml | 18 +++++++++--------- .../tests/test_tax_band_scottish_properties.py | 7 ++++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index ca5a8512e5..6ce9196d4c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -124,7 +124,7 @@ savings_income_tax: 0 meets_marriage_allowance_income_conditions: true -- name: Savings below a raised starting rate limit above the basic rate limit fail the conditions +- name: Under a starting rate limit above the basic rate limit, savings past the basic rate limit fail the conditions period: 2026 input: # A reform raises the starting rate limit for savings to 40,000. Pay @@ -312,9 +312,9 @@ # 42,600 - 10,600 = 32,000 of pay, at the model's basic rate limit, then # 1 of dividends. Section 13(2) charges the dividend upper rate on # dividends that would otherwise bear the higher rate, which starts at - # the basic rate limit (s. 10(3)). The 2015-16 s. 55B(2)(b) permits the - # dividend ordinary rate only. The model's dividend scale starts the - # upper rate at 37,500 that year. + # the basic rate limit (s. 10(3)). Among the dividend rates, the 2015-16 + # s. 55B(2)(b) permits only the dividend ordinary rate. The model's + # dividend scale starts the upper rate at 37,500 that year. employment_income: 42_600 dividend_income: 1 region: NORTH_EAST @@ -338,9 +338,9 @@ period: 2015 input: # 32,000 of pay, then 1 of interest. FA 2016 s. 4(17) gives the savings - # nil rate effect from 2016-17, so the 2015-16 s. 55B(2)(b) permits no - # savings nil rate, and the pound bears the higher rate. The model - # applies its Personal Savings Allowance in 2015-16 too. + # nil rate effect from 2016-17, so in 2015-16 the pound bears the higher + # rate, which s. 55B(2)(b) forbids. The model applies its Personal + # Savings Allowance in 2015-16 too. employment_income: 42_600 savings_interest_income: 1 region: NORTH_EAST @@ -350,8 +350,8 @@ - name: At the statutory 2015-16 basic rate limit a pound of savings fails the conditions period: 2015 input: - # As above with 1 of interest, which bears the higher rate: 2015-16 has - # no savings nil rate. + # As above with 1 of interest, which bears the higher rate in a year + # before the savings nil rate. gov.hmrc.income_tax.rates.uk[1].threshold: 31_785 employment_income: 42_385 savings_interest_income: 1 diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index e9aa8ee7a4..04e35fcd3e 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -317,9 +317,10 @@ def test_savings_allowance_does_not_read_the_band(): ) -# The Marriage Allowance income conditions against the liability, with the nil -# rates in force: people alternate between Scotland and England, from 2016-17, -# the first year of the savings and dividend nil rates. Before 2017-18 the +# The Marriage Allowance income conditions against the liability, with the +# baseline nil-rate amounts and with reformed limits and amounts: people +# alternate between Scotland and England, from 2016-17, the first year of the +# savings and dividend nil rates. Before 2017-18 the # model charges a Scottish taxpayer's non-savings income on its 2017-18 # Scottish scale, where s. 11A(1) charged it on the rest-of-UK limits. The # Marriage Allowance test uses the basic rate limit there, as the law did, so From e6c2465b44311a69c7a45f53d6abda52a52b2fdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:52:04 +0000 Subject: [PATCH 106/250] 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 60ead1e19905c256521a70ce0e6cddb0d8bc74d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:16:42 +0000 Subject: [PATCH 107/250] Apply SI 2014/1230 reg 28 in the UC qualifying young person test Exclude a person who reports their own Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit, and a claimant or partner on the couple's joint tax credit award, from is_qualifying_young_person_for_universal_credit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HNNv4UarCy16Lmxzo --- changelog.d/2804.fixed.md | 1 + ...ifying_young_person_existing_benefits.yaml | 269 ++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 32 ++- ...sion_credit_young_person_income_support.py | 8 +- ...ung_person_existing_benefits_properties.py | 257 +++++++++++++++++ ...fying_young_person_for_universal_credit.py | 39 ++- 6 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 changelog.d/2804.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml create mode 100644 policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md new file mode 100644 index 0000000000..8f324eac71 --- /dev/null +++ b/changelog.d/2804.fixed.md @@ -0,0 +1 @@ +Excluded a person who reports their own Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit, or a claimant or partner on the couple's tax credit award, from the Universal Credit qualifying young person test (Universal Credit (Transitional Provisions) Regulations 2014 reg. 28). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml new file mode 100644 index 0000000000..569875fddd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -0,0 +1,269 @@ +# The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 +# excludes from the UC qualifying young person test a person entitled to an +# existing benefit (reg. 2(1)): income-based JSA, income-related ESA, Income +# Support, Housing Benefit, Child Tax Credit and Working Tax Credit. The +# predicate reads the person's own reports and, for a claimant or partner, +# the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). The +# first seven cases follow the table in issue 2804: 2026, North East, owned +# outright, no income. + +- name: A parent's young person who reports their own Income Support attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own Housing Benefit attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own income-related ESA attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own income-based JSA attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports no award attracts the child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A parent's young person flagged as receiving benefits in their own right attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + receives_benefits_in_own_right: true + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: An 18-year-old claimant on a non-advanced course who reports Housing Benefit is not a qualifying young person. + period: 2026 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false] + is_child_or_qualifying_young_person_for_universal_credit: [false] + uc_child_element: 0 + universal_credit: 4_062.96 + +- name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. + period: 2024 + input: + age: [17, 17, 17] + current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] + child_tax_credit_reported: [0.01, 0, 0] + working_tax_credit_reported: [0, 0.01, 0] + output: + is_qualifying_young_person_for_universal_credit: [false, false, true] + +- name: A parent's own reports of every existing benefit leave their young person a qualifying young person. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + housing_benefit_reported: 3_000 + child_tax_credit_reported: 3_000 + working_tax_credit_reported: 3_000 + esa_income_reported: 3_000 + jsa_income_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: A couple's Working Tax Credit award reported by one partner excludes both 18-year-old partners. + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + partner: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: A couple's Child Tax Credit award does not exclude a dependant who reports none. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 2_000 + partner: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 3fd69d83e7..d33c9c12e0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -3,7 +3,9 @@ # is omitted or explicitly false. SPC reg. 4A(5) also lists income support, # which UC reg. 5(5) does not: is_on_income_support reads it, from the # calculated award for a claimant or partner and from their own report for -# anyone else. +# anyone else. For Universal Credit, the Transitional Provisions Regulations +# 2014 reg. 28 excludes a person entitled to Income Support, and the UC +# predicate reads the person's own report. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -428,9 +430,10 @@ is_qualifying_young_person_for_pension_credit: false is_qualifying_young_person_for_child_benefit: true -- name: A 16-year-old reporting their own Income Support does not attract the Pension Credit child addition, but stays a UC young person. - # SPC reg. 4A(5) lists income support; UC reg. 5(5) does not. Income - # Support is in payment in 2025 (gov.dwp.income_support.active). +- name: A 16-year-old reporting their own Income Support is neither a Pension Credit nor a UC young person. + # SPC reg. 4A(5) lists income support; for UC, Transitional Provisions + # reg. 28 excludes it. Income Support is in payment in 2025 + # (gov.dwp.income_support.active). period: 2025 input: people: @@ -459,9 +462,9 @@ is_qualifying_young_person_for_pension_credit: [false, false] is_child_or_qualifying_young_person_for_pension_credit: [false, false] child_minimum_guarantee_addition: 0 - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] -- name: A member's own reported Income Support excludes the Pension Credit education route only. +- name: A member's own reported Income Support excludes the Pension Credit and UC education routes. period: 2025 input: people: @@ -484,11 +487,12 @@ output: is_on_income_support: [false, true] is_qualifying_young_person_for_pension_credit: [false, false] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] - name: A member's Income Support report leaves the Pension Credit route available once Income Support stops paying. # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a - # report then is no award in payment. + # report then is no award in payment for Pension Credit. The UC predicate + # reads the raw report under Transitional Provisions reg. 28. period: 2026 input: people: @@ -511,7 +515,7 @@ output: is_on_income_support: [false, false] is_qualifying_young_person_for_pension_credit: [false, true] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] - name: A claimant's calculated Income Support excludes them from the Pension Credit status. period: 2025 @@ -524,9 +528,9 @@ income_support: 3_790.80 is_on_income_support: true is_qualifying_young_person_for_pension_credit: false - is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_universal_credit: false -- name: A claimant's report without a calculated award leaves the Pension Credit status available. +- name: A claimant's report without a calculated award leaves the Pension Credit status available but not the UC one. # The claimant falls within no prescribed category, so the model calculates # no Income Support for them. period: 2025 @@ -538,7 +542,7 @@ income_support: 0 is_on_income_support: false is_qualifying_young_person_for_pension_credit: true - is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_universal_credit: false - name: The partner of an Income Support claimant keeps the Pension Credit status. # A couple's award goes to the claimant: the partner who reports nothing is @@ -582,7 +586,7 @@ is_qualifying_young_person_for_universal_credit: [false, false, false, false, false] is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] -- name: A member's report of one penny of Income Support excludes them from the Pension Credit status alone. +- name: A member's report of one penny of Income Support excludes them from the Pension Credit and UC statuses. period: 2025 input: people: @@ -605,4 +609,4 @@ output: is_on_income_support: [false, true] is_qualifying_young_person_for_pension_credit: [false, false] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] diff --git a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py index 18e2464fd6..a4aaa042af 100644 --- a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py +++ b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py @@ -3,8 +3,10 @@ A 16-year-old outside education, in a 70-year-old's benefit unit, reports their own Income Support in 2025, when it is in payment. They are not the claimant's qualifying young person, so the claimant gets no child addition, -until a reform removes Income Support. UC reg. 5(5) does not list income -support, so they stay a UC qualifying young person throughout. +until a reform removes Income Support. For Universal Credit, the +Transitional Provisions Regulations 2014 reg. 28 excludes them through their +own Income Support report, which a reform that removes the award leaves in +place. A young carer who claims Income Support in their own benefit unit follows the calculated award. While they receive it they are no qualifying young @@ -191,7 +193,7 @@ def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): for sim in (kept, removed): assert sim.calculate( "is_qualifying_young_person_for_universal_credit", YEAR - ).tolist() == [False, True] + ).tolist() == [False, False] @pytest.mark.usefixtures("cloned_uk_tax_benefit_system") diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py new file mode 100644 index 0000000000..f37d27ef17 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -0,0 +1,257 @@ +"""Properties of the UC qualifying young person test under SI 2014/1230 reg. 28. + +The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 +excludes from the UC qualifying young person test a person entitled to an +existing benefit (reg. 2(1)). The predicate reads the person's own reports of +Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, +for a claimant or partner, the couple's joint tax credit award (Tax Credits +Act 2002 s.3(3)). These tests check that: + +- any positive own report of an existing benefit makes the predicate false; +- with every such report at zero, the predicate equals the UC Regs 2013 + reg. 5 test with the reg. 5(5) exclusions alone; +- a claimant's or partner's reports never change a dependant's predicate; +- a parent's Universal Credit never rises when their young person's own + report turns positive. +""" + +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +YEARS = (2024, 2026) +EXISTING_BENEFITS = ( + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +) +TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") +REG_5_5_REPORTS = ( + "universal_credit_reported", + "esa_income_reported", + "esa_contrib_reported", + "jsa_income_reported", + "jsa_contrib_reported", +) +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +amount = st.one_of(st.just(0.0), st.floats(0.01, 20_000)) +young_member = st.fixed_dictionaries( + { + "age": st.integers(15, 20), + "current_education": st.sampled_from(EDUCATIONS), + "is_in_approved_training": st.booleans(), + "receives_benefits_in_own_right": st.booleans(), + **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, + } +) +adult = st.fixed_dictionaries( + { + "age": st.integers(16, 70), + "current_education": st.sampled_from(EDUCATIONS), + "is_in_approved_training": st.just(False), + "receives_benefits_in_own_right": st.just(False), + **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, + } +) +INPUTS = ( + ( + "age", + "current_education", + "is_in_approved_training", + "receives_benefits_in_own_right", + "is_claimant_or_partner", + "is_parent", + ) + + REG_5_5_REPORTS + + EXISTING_BENEFITS +) + + +UNITS = 4 + + +@st.composite +def families(draw): + """A claimant, then a partner or a dependant, then two dependants.""" + couple = draw(st.booleans()) + claimant = dict(draw(adult), is_claimant_or_partner=True, is_parent=True) + second = draw(adult if couple else young_member) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict(draw(young_member), is_claimant_or_partner=False, is_parent=False) + for _ in range(2) + ] + return [claimant, second] + dependants + + +units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) +_SIMULATIONS = {} + + +def simulate(units, year): + """Set the inputs on one cached simulation per shape and year.""" + shape = (tuple(len(members) for members in units), year) + if shape not in _SIMULATIONS: + situation = {"people": {}, "benunits": {}, "households": {}} + for i, members in enumerate(units): + names = [f"p{i}_{j}" for j in range(len(members))] + for name in names: + situation["people"][name] = {} + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + _SIMULATIONS[shape] = Simulation(situation=situation) + sim = _SIMULATIONS[shape] + people = flatten(units) + for variable in INPUTS: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(INPUTS) + sim.reset_calculations() + return sim + + +def flatten(units): + return [p for members in units for p in members] + + +def with_zero(units, names, who): + return [ + [dict(p, **{n: 0.0 for n in names}) if who(p) else p for p in members] + for members in units + ] + + +def predicate(sim, year): + return sim.calculate("is_qualifying_young_person_for_universal_credit", year).copy() + + +def reg_5_test(sim, year): + """UC Regs 2013 reg. 5 with the reg. 5(5) exclusions alone.""" + + def get(name): + return sim.calculate(name, year) + + routes = get("is_before_first_september_following_16th_birthday") | ( + get( + "meets_qualifying_young_person_education_or_training_condition_for_universal_credit" + ) + & get("meets_qualifying_young_person_entry_condition_for_universal_credit") + & get( + "meets_qualifying_young_person_terminal_date_condition_for_universal_credit" + ) + ) + excluded = get("receives_benefits_in_own_right") + for name in REG_5_5_REPORTS: + excluded = excluded | (get(name) > 0) + return ( + get("meets_qualifying_young_person_age_condition_for_universal_credit") + & routes + & ~excluded + ) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_any_own_existing_benefit_report_excludes_the_person(units, year): + sim = simulate(units, year) + people = flatten(units) + own = np.array([any(p[n] > 0 for n in EXISTING_BENEFITS) for p in people]) + couple_tax_credits = np.array( + [ + p["is_claimant_or_partner"] + and any( + q["is_claimant_or_partner"] and q[n] > 0 + for q in members + for n in TAX_CREDITS + ) + for members in units + for p in members + ] + ) + assert not np.any(predicate(sim, year) & (own | couple_tax_credits)) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): + zeroed = with_zero(units, EXISTING_BENEFITS, lambda p: True) + sim = simulate(zeroed, year) + assert predicate(sim, year).tolist() == reg_5_test(sim, year).tolist() + # With reports present, reg. 28 only removes people from the test. + sim = simulate(units, year) + assert not np.any(predicate(sim, year) & ~reg_5_test(sim, year)) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_a_claimants_reports_never_change_a_dependants_predicate(units, year): + reported = predicate(simulate(units, year), year) + cleared = predicate( + simulate( + with_zero(units, EXISTING_BENEFITS, lambda p: p["is_claimant_or_partner"]), + year, + ), + year, + ) + dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) + assert reported[dependant].tolist() == cleared[dependant].tolist() + + +@SETTINGS +@given( + parent_age=st.integers(25, 60), + young_age=st.integers(16, 19), + education=st.sampled_from(EDUCATIONS), + benefit=st.sampled_from(EXISTING_BENEFITS), + report=st.floats(0.01, 20_000), + year=st.sampled_from(YEARS), +) +def test_a_young_persons_report_never_raises_the_parents_award( + parent_age, young_age, education, benefit, report, year +): + def family(young_report): + zeros = {n: 0.0 for n in REG_5_5_REPORTS + EXISTING_BENEFITS} + parent = dict( + zeros, + age=parent_age, + current_education="NOT_IN_EDUCATION", + is_in_approved_training=False, + receives_benefits_in_own_right=False, + is_claimant_or_partner=True, + is_parent=True, + ) + young = dict( + zeros, + age=young_age, + current_education=education, + is_in_approved_training=False, + receives_benefits_in_own_right=False, + is_claimant_or_partner=False, + is_parent=False, + ) + young[benefit] = young_report + return [[parent, young]] + + without = simulate(family(0.0), year).calculate("universal_credit", year)[0] + with_report = simulate(family(report), year).calculate("universal_credit", year)[0] + assert with_report <= without + 1e-6 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 4281403300..d731218ad1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -11,10 +11,20 @@ class is_qualifying_young_person_for_universal_credit(Variable): "non-advanced education or approved training that meets the entry and " "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who is not " "receiving Universal Credit, Employment and Support Allowance or " - "Jobseeker's Allowance (reg. 5(5))." + "Jobseeker's Allowance (reg. 5(5)), or an existing benefit: Income " + "Support, Housing Benefit, Child Tax Credit or Working Tax Credit " + "(Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 " + "and reg. 2(1)). The formula reads the person's own reported awards, " + "and, for a claimant or partner, the couple's joint tax credit award " + "(Tax Credits Act 2002 s.3(3))." ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + ] def formula(person, period, parameters): # Reg. 5(1)(a): no education or training condition. @@ -36,9 +46,25 @@ def formula(person, period, parameters): period, ) ) - # The exclusions apply to both routes (UC reg. 5(5); SPC reg. 4A(5)). - # Raw person-level reports identify own awards without calculating - # family benefits, which depend on QYP status and could recurse. + # Reg. 5(5) and Transitional Provisions reg. 28 exclude a person + # receiving Universal Credit, any ESA or JSA, or an existing benefit + # (TP reg. 2(1)), on both routes. Reg. 5(5) already covers + # income-related ESA and income-based JSA. Raw person-level reports + # identify own awards without calculating family benefits, which + # depend on QYP status and could recurse. A dependant's own report + # counts; a parent's report does not. + tax_credits_reported = person("child_tax_credit_reported", period) + person( + "working_tax_credit_reported", period + ) + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, + # so the couple's award belongs to the claimant and the partner. + claimant_or_partner = person("is_claimant_or_partner", period) + couple_tax_credits_reported = person.benunit.sum( + where(claimant_or_partner, tax_credits_reported, 0) + ) + receives_tax_credits = (tax_credits_reported > 0) | ( + claimant_or_partner & (couple_tax_credits_reported > 0) + ) receives_own_benefits = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) @@ -46,6 +72,9 @@ def formula(person, period, parameters): | (person("esa_contrib_reported", period) > 0) | (person("jsa_income_reported", period) > 0) | (person("jsa_contrib_reported", period) > 0) + | (person("income_support_reported", period) > 0) + | (person("housing_benefit_reported", period) > 0) + | receives_tax_credits ) return ( person( From c29c2c40b74eddd3ca43664eda0898d78d95f9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:17:31 +0000 Subject: [PATCH 108/250] 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 109/250] 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 dbaf71872870f26988c802cab622919c450d95f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:39:58 +0000 Subject: [PATCH 110/250] Bias property inputs so reg 28 decides the outcome Draw reg 5(5) reports rarely and each existing benefit report one time in three, and let claimants and partners be 16 to 19, so the properties reach people whom only reg 28 excludes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HNNv4UarCy16Lmxzo --- ...ung_person_existing_benefits_properties.py | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index f37d27ef17..21c832c529 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -51,25 +51,42 @@ suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -amount = st.one_of(st.just(0.0), st.floats(0.01, 20_000)) -young_member = st.fixed_dictionaries( - { - "age": st.integers(15, 20), - "current_education": st.sampled_from(EDUCATIONS), - "is_in_approved_training": st.booleans(), - "receives_benefits_in_own_right": st.booleans(), - **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, - } -) -adult = st.fixed_dictionaries( - { - "age": st.integers(16, 70), - "current_education": st.sampled_from(EDUCATIONS), - "is_in_approved_training": st.just(False), - "receives_benefits_in_own_right": st.just(False), - **{name: amount for name in REG_5_5_REPORTS + EXISTING_BENEFITS}, +amount = st.floats(0.01, 20_000) + + +@st.composite +def sometimes(draw): + """A positive amount one time in three, otherwise zero.""" + return draw(amount) if draw(st.sampled_from((False, False, True))) else 0.0 + + +@st.composite +def member(draw, ages, training): + """A person who usually reports no reg. 5(5) award. + + Reg. 5(5) excludes anyone with such a report, so drawing them rarely + leaves people for reg. 28 to exclude. Each existing benefit report is + positive one time in three. + """ + reg_5_5_report = draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + return { + "age": draw(ages), + "current_education": draw(st.sampled_from(EDUCATIONS)), + "is_in_approved_training": draw(training), + "receives_benefits_in_own_right": draw( + st.sampled_from((False, False, False, True)) + ), + **{ + name: draw(amount) if name == reg_5_5_report else 0.0 + for name in REG_5_5_REPORTS + }, + **{name: draw(sometimes()) for name in EXISTING_BENEFITS}, } -) + + +young_member = member(st.integers(15, 20), st.booleans()) +# Claimants and partners aged 16 to 19 can be qualifying young people. +adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) INPUTS = ( ( "age", From f0f576a4451adb83eee5f7bcccbf58f7ffdb81ea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 16:27:19 -0500 Subject: [PATCH 111/250] Give the Gift Aid rise to s. 414(2)(b) in the rate-test notes Documentation only: the rate test's docstring and non_savings_income_after_allowances name s. 414(2)(b) as what raises the rate limits, and the docstring drops a passive phrase. Co-Authored-By: Claude Opus 5.5 --- ...ts_marriage_allowance_income_conditions.py | 23 +++++++++---------- .../non_savings_income_after_allowances.py | 5 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index ac03c3c674..c7b2dfdd9c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -4,21 +4,20 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): """Whether the person pays no income tax above the basic rates. - Apply the rates that s. 55B(2)(b) and s. 55C(1)(c) permit for the year - being calculated. From 2016-17 these include the savings nil rate; from - 2018-19 the Scottish list includes rates below basic and the intermediate - rate. Where s. 55B(2)(ba) and s. 55C(1)(ca) apply, test dividends as if - s. 13A were omitted. + Apply the rates that s. 55B(2)(b) and s. 55C(1)(c) permit in the tax year. + From 2016-17 these include the savings nil rate; from 2018-19 the Scottish + list includes rates below basic and the intermediate rate. Where + s. 55B(2)(ba) and s. 55C(1)(ca) apply, test dividends as if s. 13A were + omitted. - Gift Aid extends the rate limits by the grossed-up gift (s. 414(2)(b)); - it does not reduce taxable income. Before 2017-18 this test uses the + Section 414(2)(b) extends the rate limits by the grossed-up Gift Aid gift, + which does not reduce taxable income. Before 2017-18 this test uses the basic rate limit for Scottish taxpayers, even though the model's historical Scottish liability currently reads the 2017-18 schedule. The - test also follows the statute where the model's liability departs from - it: it starts the dividend upper rate at the basic rate limit (ss. 10 - and 13), counts no savings nil rate before 2016-17 (FA 2016 s. 4(17)), - and places the nil-rate slice above the starting rate limit for savings - (s. 12A). + test also follows the statute where the model's liability departs from it: + it starts the dividend upper rate at the basic rate limit (ss. 10 and 13), + counts no savings nil rate before 2016-17 (FA 2016 s. 4(17)), and places + the nil-rate slice above the starting rate limit for savings (s. 12A). The test reads income before the Marriage Allowance. Step 4 of s. 23 charges tax at each rate before Step 6 deducts the tax reductions, and 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 16ef82ac61..e60edea382 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 @@ -9,8 +9,9 @@ class non_savings_income_after_allowances(Variable): "The model subtracts savings income, dividend income and allowances " "from net income and floors the result at zero, so it sets allowances " "against this income first. Section 16 of ITA 2007 treats savings and " - "dividends as the highest part of income, above this income. Gift Aid " - "raises the rate limits and does not reduce this taxable income. " + "dividends as the highest part of income, above this income. Section " + "414(2)(b) raises the rate limits by the grossed-up Gift Aid gift, " + "which does not reduce this taxable income. " "property_income_tax, taxable_property_income_after_allowances, the " "Marriage Allowance rate test and, for Scottish taxpayers, tax_band " "read it." From 5d9ce134348c1ebf7ce183c7239d5ebdba8582fa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 19:41:54 -0500 Subject: [PATCH 112/250] 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 113/250] 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 114/250] 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 115/250] 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 116/250] 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 117/250] 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 118/250] 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 119/250] 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 0713b7c9cdcf701ab01242260db5af907d571291 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:01:23 +0000 Subject: [PATCH 120/250] Keep the pound-of-savings case at the savings higher rate under any allocation With pay of 50,070 the least-tax allocation of the personal allowance (ITA 2007 s. 25(2)) can move a pound of allowance onto the savings and take the last pound of interest into the savings nil rate. Pay at the basic rate limit leaves no allocation that lowers the tax, so the case gives 40p of savings tax and fails the conditions either way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GDsfRzbSb9XEm12wG --- .../meets_marriage_allowance_income_conditions.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 6ce9196d4c..289da95f8f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -98,8 +98,11 @@ - name: One pound of savings at the savings higher rate fails the conditions period: 2026 input: - # As above with 501 of interest: 1 is at the savings higher rate, 40p. - employment_income: 50_070 + # 50,270 - 12,570 = 37,700 of pay, at the basic rate limit, then 501 of + # interest. The higher-rate allowance covers 500 at the savings nil rate + # (s. 12A) and the last pound bears the savings higher rate, 40p. With pay + # at the limit, no other share of the allowance lowers the tax (s. 25(2)). + employment_income: 50_270 savings_interest_income: 501 region: NORTH_EAST absolute_error_margin: 0.01 From c74ca6424849beb39525a83230785ce0fab37ed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:01:29 +0000 Subject: [PATCH 121/250] Name Parliament, the Scottish Parliament and the Treasury as the actors Rewrite the rate-test, tax_band and test notes so that the body that makes each rule acts, with the section in brackets, and keep statutory quotations as they are. Rename the two pre-2017-18 Scottish cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GDsfRzbSb9XEm12wG --- .../reforms/cps/marriage_tax_reforms.py | 4 +- .../tax/income_tax/scottish_rates.yaml | 47 +++---- ..._marriage_allowance_income_conditions.yaml | 117 +++++++++--------- .../test_marriage_allowance_cps_switch.py | 16 +-- .../test_tax_band_scottish_properties.py | 49 ++++---- ...ts_marriage_allowance_income_conditions.py | 77 ++++++------ .../allowances/savings_allowance.py | 6 +- .../non_savings_income_after_allowances.py | 8 +- .../bracketized_liability/tax_band.py | 24 ++-- 9 files changed, 181 insertions(+), 167 deletions(-) diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 2ac8da36ac..2dd2427fa5 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -50,8 +50,8 @@ def formula(person, period, parameters): base_eligible = liable_only_at_marriage_allowance_rates( person, period, parameters ) - # Expand to people liable above those rates if the reform's - # expansion conditions are met. + # Expand to people liable above those rates who meet the reform's + # expansion conditions. expansion = person("meets_expanded_ma_conditions", period) & ~base_eligible return base_eligible | expansion diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 6eafafd7d8..0bfc033c01 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -128,19 +128,20 @@ earned_income_tax: 50034.35 income_tax: 50034.35 -# tax_band for a Scottish taxpayer with savings or dividends (#2350). The -# Scottish rates charge only income that is neither savings nor dividends -# (ITA 2007 s. 11A(1A); S6M-16531 for 2025-26). Savings and dividends are the -# top slice (s. 16) and take the rates and bands they would bear outside -# Scotland (ss. 11D(6) and 13(5)). The model returns the higher band. In -# 2025-26 the personal allowance is £12,570, the Scottish limits on taxable -# income are £2,827, £14,921, £31,092, £62,430 and £125,140 (19%, 20%, 21%, -# 42%, 45%, 48%), the basic rate limit is £37,700, the dividend allowance is -# £500 (8.75% ordinary, 33.75% upper) and the Personal Savings Allowance is -# £1,000, or £500 with higher-rate income (s. 12B). Marriage Allowance -# applies the rate conditions of s. 55B(2)(b) and the dividend counterfactual -# of s. 55B(2)(ba). Savings at the savings nil rate do not bar the Marriage -# Allowance. +# tax_band for a Scottish taxpayer with savings or dividends (#2350). +# Parliament charges the Scottish rates only on income that is neither +# savings nor dividends (ITA 2007 s. 11A(1A); S6M-16531 for 2025-26). +# Savings and dividends form the top slice (s. 16) and take the rates and +# bands they would bear outside Scotland (ss. 11D(6) and 13(5)). The model +# returns the higher band. In 2025-26 Parliament sets the personal allowance +# at £12,570, the basic rate limit at £37,700, the dividend allowance at £500 +# (8.75% ordinary, 33.75% upper) and the Personal Savings Allowance at +# £1,000, or £500 with higher-rate income (s. 12B). The Scottish Parliament +# sets the Scottish limits on taxable income at £2,827, £14,921, £31,092, +# £62,430 and £125,140 (19%, 20%, 21%, 42%, 45%, 48%). The Marriage +# Allowance test applies the rate conditions of s. 55B(2)(b) and the +# dividend counterfactual of s. 55B(2)(ba). Savings at the savings nil rate +# do not bar the Marriage Allowance. - name: Scottish 2025 pay of 40000 and dividends of 5000 reaches the intermediate band period: 2025 @@ -441,8 +442,8 @@ members: [person] country: SCOTLAND output: - # 1,430 taxable, all at 19% = 271.70. Nothing is savings or dividends, - # so the basic rate limit does not apply. + # 1,430 taxable, all at 19% = 271.70. The person has no savings or + # dividends, so the basic rate limit does not apply. tax_band: STARTER earned_income_tax: 271.70 @@ -502,8 +503,9 @@ output: # gov.scot, Income tax rates set (2017): the Scottish higher rate starts # at £43,000 of income, £31,500 above the £11,500 allowance. 32,500 - # taxable: 31,500 x 20% + 1,000 x 40% = 6,700. The 2017-18 s. 55B(2)(b) - # allows the Scottish basic rate but not the Scottish higher rate. + # taxable: 31,500 x 20% + 1,000 x 40% = 6,700. In 2017-18 Parliament + # allows the Scottish basic rate but not the Scottish higher rate + # (s. 55B(2)(b)). tax_band: HIGHER meets_marriage_allowance_income_conditions: false earned_income_tax: 6_700 @@ -640,11 +642,12 @@ members: [person] country: SCOTLAND output: - # Income of 125,140 withdraws the whole personal allowance (s. 35), so - # all 125,140 is taxable, up to the 125,140 higher rate limit and not - # above it. With no other income, 5,000 is at the starting rate for - # savings (s. 12) and the higher-rate allowance covers 500 (s. 12B); the - # rest of the 37,700 is at 20% and the 87,440 above it at 40%: + # At income of 125,140 Parliament withdraws the whole personal allowance + # (s. 35), so the person pays tax on all 125,140, up to the 125,140 + # higher rate limit and not above it. With no other income, 5,000 bears + # the starting rate for savings (s. 12) and the higher-rate allowance + # covers 500 (s. 12B); the rest of the 37,700 bears 20% and the 87,440 + # above it 40%: # 32,200 x 20% + 87,440 x 40% = 6,440 + 34,976 = 41,416. tax_band: HIGHER meets_marriage_allowance_income_conditions: false diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 289da95f8f..981bdc61be 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -1,13 +1,13 @@ # ITA 2007 s. 55B(2)(b) and (ba): the person receiving the allowance may be # liable only at the basic rates, the Scottish starter and intermediate rates -# (from 2018-19, SI 2018/459 art. 6(2)), the dividend ordinary rate and the -# nil and starting rates for savings, and dividends count as if the dividend -# nil rate (s. 13A) did not exist. From 2021-22 to 2026-27 the personal -# allowance is £12,570 and the basic rate limit £37,700, and the Scottish -# higher rate starts at £31,092 of taxable income (S6M-16531 for 2025-26, -# S6M-20844 for 2026-27). In 2026-27 the dividend allowance is £500 and the -# Personal Savings Allowance £1,000, or £500 with income above the basic rate -# limit (s. 12B). +# (from 2018-19, SI 2018/459 art. 6(2)), the dividend ordinary rate and the nil +# and starting rates for savings, and dividends count as if the dividend nil +# rate (s. 13A) did not exist. From 2021-22 to 2026-27 Parliament sets the +# personal allowance at £12,570 and the basic rate limit at £37,700, and the +# Scottish Parliament starts the Scottish higher rate at £31,092 of taxable +# income (S6M-16531 for 2025-26, S6M-20844 for 2026-27). In 2026-27 Parliament +# sets the dividend allowance at £500 and the Personal Savings Allowance at +# £1,000, or £500 with income above the basic rate limit (s. 12B). - name: Basic-rate earner meets the Marriage Allowance income conditions period: 2021 @@ -68,10 +68,10 @@ input: # The Scottish higher rate moves to 37,702 of taxable income. Pay leaves # 50,270 - 12,570 = 37,700, all below it. The 1 of interest takes taxable - # income to 37,701, above the basic rate limit, so the Personal Savings - # Allowance is 500 (s. 12B) and charges it at the savings nil rate - # (s. 12A), which s. 55B(2)(b) allows. The model bands the person HIGHER - # on that pound alone. + # income to 37,701, above the basic rate limit, so Parliament sets the + # Personal Savings Allowance at 500 (s. 12B) and charges the pound at the + # savings nil rate (s. 12A), which it allows for the Marriage Allowance + # (s. 55B(2)(b)). The model bands the person HIGHER on that pound alone. gov.hmrc.income_tax.rates.scotland.rates[3].threshold: 37_702 employment_income: 50_270 savings_interest_income: 1 @@ -113,10 +113,11 @@ - name: Savings at a raised starting rate limit and the savings nil rate meet the conditions period: 2026 input: - # A reform raises the starting rate limit for savings to 37,500. Pay - # uses the personal allowance. Section 12 charges 37,500 of interest at - # the starting rate, and s. 12A charges the 500 above that limit at the - # savings nil rate. Section 55B(2)(b) permits both. + # A reform raises the starting rate limit for savings to 37,500. Pay uses + # the personal allowance. Parliament charges 37,500 of interest at the + # starting rate (s. 12) and the 500 above that limit at the savings nil + # rate (s. 12A), and permits both for the Marriage Allowance + # (s. 55B(2)(b)). gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 37_500 employment_income: 12_570 savings_interest_income: 38_000 @@ -130,14 +131,14 @@ - name: Under a starting rate limit above the basic rate limit, savings past the basic rate limit fail the conditions period: 2026 input: - # A reform raises the starting rate limit for savings to 40,000. Pay - # uses the personal allowance. Section 12(1)(b) charges the starting - # rate only on income that would otherwise bear the basic rate, so it - # covers 37,700 of the 38,000 of interest. Section 12A applies only to - # savings above the 40,000 limit, and the interest stops below it, so - # the other 300 bears the savings higher rate, which s. 55B(2)(b) - # forbids. The model's savings liability charges these 300 at the - # savings nil rate. + # A reform raises the starting rate limit for savings to 40,000. Pay uses + # the personal allowance. Parliament charges the starting rate only on + # income that would otherwise bear the basic rate (s. 12(1)(b)), so it + # covers 37,700 of the 38,000 of interest, and the savings nil rate only on + # savings above the 40,000 limit (s. 12A). The interest stops below that + # limit, so the other 300 bears the savings higher rate, which Parliament + # forbids for the Marriage Allowance (s. 55B(2)(b)). The model's savings + # liability charges these 300 at the savings nil rate. gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 40_000 employment_income: 12_570 savings_interest_income: 38_000 @@ -148,8 +149,8 @@ - name: Taxable income at the basic rate limit meets the conditions period: 2026 input: - # 50,270 - 12,570 = 37,700: s. 10(2) charges income up to the basic rate - # limit at the basic rate. + # 50,270 - 12,570 = 37,700: Parliament charges income up to the basic rate + # limit at the basic rate (s. 10(2)). employment_income: 50_270 region: NORTH_EAST output: @@ -167,8 +168,9 @@ - name: Scottish taxable income at the start of the higher rate meets the conditions period: 2026 input: - # 43,662 - 12,570 = 31,092: S6M-20844 charges the intermediate rate on - # income up to 31,092 and the higher rate only above it. + # 43,662 - 12,570 = 31,092: the Scottish Parliament charges the + # intermediate rate on income up to 31,092 and the higher rate only above + # it (S6M-20844). employment_income: 43_662 region: SCOTLAND output: @@ -186,7 +188,7 @@ period: 2026 input: # 37,500 of pay, then 500 of dividends, all within the 500 dividend - # allowance (s. 13A). Without s. 13A, 300 of them would be at the dividend + # allowance (s. 13A). Without s. 13A, 300 of them would bear the dividend # upper rate (37,500 + 500 = 38,000), so s. 55B(2)(ba) fails, though the # person pays no dividend tax. employment_income: 50_070 @@ -199,7 +201,7 @@ - name: Dividends that reach the basic rate limit exactly meet the conditions period: 2026 input: - # 37,500 + 200 = 37,700: none would be at the dividend upper rate. + # 37,500 + 200 = 37,700: none would bear the dividend upper rate. employment_income: 50_070 dividend_income: 200 region: NORTH_EAST @@ -246,24 +248,24 @@ output: meets_marriage_allowance_income_conditions: false -- name: In 2015-16 a Scottish taxpayer's limit is the basic rate limit +- name: In 2015-16 the basic rate limit bounds a Scottish taxpayer's income period: 2015 input: - # Section 11A takes effect in 2016-17 (SI 2015/2000 art. 3), and the - # 2015-16 s. 55B(2)(b) allows the basic rate. 42,200 - 10,600 = 31,600, - # below the basic rate limit, though above the 31,500 the model reads - # from its 2017-18 Scottish scale. + # The Scottish rates of s. 11A start in 2016-17 (SI 2015/2000 art. 3), and + # in 2015-16 Parliament allows the basic rate (s. 55B(2)(b)). + # 42,200 - 10,600 = 31,600, below the basic rate limit, though above the + # 31,500 the model reads from its 2017-18 Scottish scale. employment_income: 42_200 region: SCOTLAND output: meets_marriage_allowance_income_conditions: true -- name: In 2016-17 a Scottish taxpayer's limit is the basic rate limit +- name: In 2016-17 the basic rate limit bounds a Scottish taxpayer's income period: 2016 input: - # The Treasury added the Scottish basic rate to s. 55B(2)(b) (SI - # 2015/1810 art. 14(3)), and the 2016-17 s. 11A(1) charges it on - # non-savings income that would otherwise bear the basic rate. + # The Treasury added the Scottish basic rate to s. 55B(2)(b) (SI 2015/1810 + # art. 14(3)), and in 2016-17 Parliament charges it on non-savings income + # that would otherwise bear the basic rate (s. 11A(1)). # 42,700 - 11,000 = 31,700, below the basic rate limit. employment_income: 42_700 region: SCOTLAND @@ -273,9 +275,9 @@ - name: In 2017-18 the Scottish higher rate fails the conditions period: 2017 input: - # The 2017-18 s. 55B(2)(b) allows the Scottish basic rate only. With an - # 11,500 allowance, the Scottish higher rate starts at 31,500 of taxable - # income: 43,001 - 11,500 = 31,501. + # In 2017-18 Parliament allows only the Scottish basic rate (s. 55B(2)(b)). + # With an 11,500 allowance, the Scottish higher rate starts at 31,500 of + # taxable income: 43,001 - 11,500 = 31,501. employment_income: 43_001 region: SCOTLAND output: @@ -302,8 +304,9 @@ - name: In 2018-19 the Scottish intermediate rate meets the conditions period: 2018 input: - # SI 2018/459 art. 6(2) adds the Scottish starter and intermediate rates - # to s. 55B(2)(b). 40,000 - 11,850 = 28,150, at the intermediate rate. + # The Treasury adds the Scottish starter and intermediate rates to + # s. 55B(2)(b) (SI 2018/459 art. 6(2)). 40,000 - 11,850 = 28,150, at the + # intermediate rate. employment_income: 40_000 region: SCOTLAND output: @@ -312,12 +315,12 @@ - name: In 2015-16 dividends above the basic rate limit fail the conditions period: 2015 input: - # 42,600 - 10,600 = 32,000 of pay, at the model's basic rate limit, then - # 1 of dividends. Section 13(2) charges the dividend upper rate on - # dividends that would otherwise bear the higher rate, which starts at - # the basic rate limit (s. 10(3)). Among the dividend rates, the 2015-16 - # s. 55B(2)(b) permits only the dividend ordinary rate. The model's - # dividend scale starts the upper rate at 37,500 that year. + # 42,600 - 10,600 = 32,000 of pay, at the model's basic rate limit, then 1 + # of dividends. Parliament charges the dividend upper rate on dividends + # that would otherwise bear the higher rate (s. 13(2)), and the higher rate + # starts at the basic rate limit (s. 10(3)). In 2015-16 Parliament permits + # only the dividend ordinary rate among the dividend rates (s. 55B(2)(b)). + # The model's dividend scale starts the upper rate at 37,500 that year. employment_income: 42_600 dividend_income: 1 region: NORTH_EAST @@ -327,9 +330,9 @@ - name: At the statutory 2015-16 basic rate limit a pound of dividends fails the conditions period: 2015 input: - # A reform sets the basic rate limit to the statutory 31,785 (s. 10(5) - # for 2015-16). 42,385 - 10,600 = 31,785 of pay, then 1 of dividends, - # which s. 13(2) charges at the dividend upper rate. + # A reform sets the basic rate limit to the statutory 31,785 (s. 10(5) for + # 2015-16). 42,385 - 10,600 = 31,785 of pay, then 1 of dividends, which + # Parliament charges at the dividend upper rate (s. 13(2)). gov.hmrc.income_tax.rates.uk[1].threshold: 31_785 employment_income: 42_385 dividend_income: 1 @@ -340,10 +343,10 @@ - name: In 2015-16 savings above the basic rate limit fail the conditions period: 2015 input: - # 32,000 of pay, then 1 of interest. FA 2016 s. 4(17) gives the savings - # nil rate effect from 2016-17, so in 2015-16 the pound bears the higher - # rate, which s. 55B(2)(b) forbids. The model applies its Personal - # Savings Allowance in 2015-16 too. + # 32,000 of pay, then 1 of interest. Parliament gave the savings nil rate + # effect from 2016-17 (FA 2016 s. 4(17)), so in 2015-16 the pound bears the + # higher rate, which it forbids for the Marriage Allowance (s. 55B(2)(b)). + # The model applies its Personal Savings Allowance in 2015-16 too. employment_income: 42_600 savings_interest_income: 1 region: NORTH_EAST diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index 5c40b29c71..3825f11522 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -70,10 +70,10 @@ # (marriage_allowance/rounding_increment.yaml). PERSONAL_ALLOWANCE = 12_570 ROUNDING_INCREMENT = 10 -# 2025-26 taxable income above which non-savings income is charged above the -# rates that allow the allowance: the basic rate limit (ITA 2007 s. 10), and -# for a Scottish taxpayer the start of the Scottish higher rate, £43,662 of -# income less the personal allowance (Scottish Rate Resolution 2025). +# 2025-26 taxable income above which the model charges non-savings income at +# rates that bar the allowance: the basic rate limit (ITA 2007 s. 10), and for +# a Scottish taxpayer the start of the Scottish higher rate, £43,662 of income +# less the personal allowance (Scottish Rate Resolution 2025). BASIC_RATE_LIMIT = 37_700 SCOTTISH_HIGHER_RATE_START = 31_092 REFORM_VARIABLE = "meets_expanded_ma_conditions" # Only the reform adds it. @@ -288,8 +288,8 @@ def test_the_reform_switches_on_from_its_own_parameters_only( # parameters, reused: each example writes its own inputs. COUPLES = 20 -# Every fourth couple lives in Scotland, whose starter and intermediate rates -# allow the allowance (ITA 2007 s. 55B(2)(b)). +# Every fourth couple lives in Scotland, where Parliament allows the allowance +# at the Scottish starter and intermediate rates (ITA 2007 s. 55B(2)(b)). REGIONS = ["SCOTLAND", "LONDON", "NORTH_EAST", "WALES"] @@ -383,8 +383,8 @@ def run(simulation, drawn, anyone_claims=True): # Simulation.calculate returns an enum's names. band=np.asarray(simulation.calculate("tax_band", YEAR), dtype=str), income_tax=simulation.calculate("income_tax", YEAR), - # The earners have only employment income, so all their taxable - # income is non-savings income. + # The earners have only employment income, so they have no savings or + # dividend income. taxable=np.maximum( simulation.calculate("employment_income", YEAR) - simulation.calculate("personal_allowance", YEAR), diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py index 04e35fcd3e..e8e7dcae64 100644 --- a/policyengine_uk/tests/test_tax_band_scottish_properties.py +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -1,7 +1,7 @@ """Property tests for a Scottish taxpayer's tax_band and the Marriage Allowance income conditions (#2350). -tax_band is the highest band in which a person has taxable income. For a +tax_band returns the highest band in which a person has taxable income. For a Scottish taxpayer the liability charges income other than savings and dividends at the Scottish rates (earned_income_tax and property_income_tax, ITA 2007 s. 11A) and savings and dividends at the rest-of-UK savings and @@ -30,16 +30,17 @@ dividend allowance removed (s. 55B(2)(ba)), none at the dividend upper or additional rate. So savings at the savings nil rate or the starting rate for savings never stop the allowance. The reforms start the dividend upper rate at -the basic rate limit, as s. 13(2) does, and hold the starting rate limit for -savings at or below the basic rate limit, the range in which the model's -savings liability places the savings nil rate as s. 12A does. +the basic rate limit, where Parliament starts it (s. 13(2)), and hold the +starting rate limit for savings at or below the basic rate limit. In that +range the model's savings liability places the savings nil rate where +Parliament does (s. 12A). Before #2350 the band compared all taxable income with the Scottish limits. Against that formula, which ran from 2019-20, the band never rises, except from STARTER to BASIC where savings or dividends sit below the Scottish -starter limit: they sit in the rest-of-UK basic band (charged at the starting -rate for savings, the dividend nil rate or the dividend ordinary rate), not at -the Scottish starter rate. +starter limit: they sit in the rest-of-UK basic band (where the model charges +the starting rate for savings, the dividend nil rate or the dividend ordinary +rate), not at the Scottish starter rate. """ from contextlib import contextmanager @@ -302,9 +303,9 @@ def test_band_never_rises_except_from_starter_to_basic(sim, drawn, period): def test_savings_allowance_does_not_read_the_band(): - # ITA 2007 s. 12B(8) sets the Personal Savings Allowance on the - # rest-of-UK bands for everyone, so savings_allowance (#2347) never reads - # tax_band: forcing the band changes nothing. + # Parliament sets the Personal Savings Allowance on the rest-of-UK bands + # for everyone (ITA 2007 s. 12B(8)), so savings_allowance (#2347) never + # reads tax_band: forcing the band changes nothing. sim = Simulation(situation=situation()) set_people(sim, ISSUE, 2025) allowance = sim.calculate("savings_allowance", 2025) @@ -320,18 +321,18 @@ def test_savings_allowance_does_not_read_the_band(): # The Marriage Allowance income conditions against the liability, with the # baseline nil-rate amounts and with reformed limits and amounts: people # alternate between Scotland and England, from 2016-17, the first year of the -# savings and dividend nil rates. Before 2017-18 the -# model charges a Scottish taxpayer's non-savings income on its 2017-18 -# Scottish scale, where s. 11A(1) charged it on the rest-of-UK limits. The -# Marriage Allowance test uses the basic rate limit there, as the law did, so -# this test leaves those people out. +# savings and dividend nil rates. Before 2017-18 the model charges a Scottish +# taxpayer's non-savings income on its 2017-18 Scottish scale, where Parliament +# charged it on the rest-of-UK limits (s. 11A(1)). The Marriage Allowance test +# uses the basic rate limit there, as Parliament did, so this test leaves those +# people out. MA_YEARS = list(range(2016, 2027)) MA_COUNTRIES = ["SCOTLAND", "ENGLAND"] FIRST_SCOTTISH_LIMITS_YEAR = 2017 -# The rates s. 55B(2)(b) forbids, other than the dividend rates: the Scottish -# higher, advanced and top rates, the higher and additional rates, and the -# savings higher and additional rates. Each person pays at most one of the -# Scottish and rest-of-UK schedules on non-savings income. +# The rates Parliament forbids in s. 55B(2)(b), other than the dividend rates: +# the Scottish higher, advanced and top rates, the higher and additional rates, +# and the savings higher and additional rates. Each person pays at most one of +# the Scottish and rest-of-UK schedules on non-savings income. FORBIDDEN = ( [SCOTTISH.format(i) for i in (3, 4, 5)] + [UK.format(i) for i in (1, 2, 3)] @@ -411,12 +412,14 @@ def test_marriage_allowance_follows_the_liability_with_nil_rates( basic_rate_limit = changes.get( BASIC_RATE_LIMIT, parameters.get_child(BASIC_RATE_LIMIT)(str(period)) ) - # Section 13(2) starts the dividend upper rate at the basic rate limit - # (s. 10(3)), so a reformed limit moves the model's dividend scale too. + # Parliament starts the dividend upper rate at the basic rate limit + # (ss. 10(3) and 13(2)), so a reformed limit moves the model's dividend + # scale too. changes[DIVIDEND_UPPER_START] = basic_rate_limit # The model's savings liability starts the savings nil rate where the - # starting rate for savings ends. That matches s. 12A only while the - # starting rate limit stays at or below the basic rate limit. + # starting rate for savings ends. That matches where Parliament places it + # (s. 12A) only while the starting rate limit stays at or below the basic + # rate limit. if STARTING_RATE_LIMIT in changes: changes[STARTING_RATE_LIMIT] = min( changes[STARTING_RATE_LIMIT], basic_rate_limit diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index c7b2dfdd9c..34e837bbcc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -4,25 +4,26 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): """Whether the person pays no income tax above the basic rates. - Apply the rates that s. 55B(2)(b) and s. 55C(1)(c) permit in the tax year. - From 2016-17 these include the savings nil rate; from 2018-19 the Scottish - list includes rates below basic and the intermediate rate. Where - s. 55B(2)(ba) and s. 55C(1)(ca) apply, test dividends as if s. 13A were - omitted. + Apply the rates Parliament permits for the tax year (s. 55B(2)(b) and + s. 55C(1)(c)). From 2016-17 these include the savings nil rate; from + 2018-19 the Scottish list includes rates below basic and the intermediate + rate. Where s. 55B(2)(ba) and s. 55C(1)(ca) apply, test dividends as if + s. 13A were omitted. - Section 414(2)(b) extends the rate limits by the grossed-up Gift Aid gift, - which does not reduce taxable income. Before 2017-18 this test uses the - basic rate limit for Scottish taxpayers, even though the model's - historical Scottish liability currently reads the 2017-18 schedule. The - test also follows the statute where the model's liability departs from it: - it starts the dividend upper rate at the basic rate limit (ss. 10 and 13), - counts no savings nil rate before 2016-17 (FA 2016 s. 4(17)), and places - the nil-rate slice above the starting rate limit for savings (s. 12A). + Parliament extends the rate limits by the grossed-up Gift Aid gift + (s. 414(2)(b)); the gift does not reduce taxable income. Before 2017-18 + this test uses the basic rate limit for Scottish taxpayers, even though + the model's historical Scottish liability currently reads the 2017-18 + schedule. The test also follows the statute where the model's liability + departs from it: it starts the dividend upper rate at the basic rate + limit (ss. 10 and 13), counts no savings nil rate before 2016-17 (FA 2016 + s. 4(17)), and places the nil-rate slice above the starting rate limit + for savings (s. 12A). - The test reads income before the Marriage Allowance. Step 4 of s. 23 - charges tax at each rate before Step 6 deducts the tax reductions, and - s. 55B(1) gives the allowance as a tax reduction. The model still takes - the allowance off earned_taxable_income (#1947), so + The test reads income before the Marriage Allowance. Parliament charges + tax at each rate at Step 4 and takes tax reductions off at Step 6 + (s. 23), and it gives the allowance as a tax reduction (s. 55B(1)). The + model still takes the allowance off earned_taxable_income (#1947), so non_savings_taxable_income and the savings rate bands built on it would read this test back. """ @@ -36,11 +37,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. # Before 2017-18 the test holds a Scottish taxpayer to the basic rate - # limit. Section 11A takes effect in 2016-17 (FA 2014 Sch. 38 para. 12, - # SI 2015/2000 art. 3), when s. 11A(1) charged the Scottish basic rate on - # income that would otherwise bear the basic rate. The model reads its - # Scottish scale's 2017-18 values in earlier years too, so the test sets - # the limit by the year. + # limit. The Scottish rates of s. 11A start in 2016-17 (FA 2014 Sch. 38 + # para. 12, SI 2015/2000 art. 3). That year Parliament charged the Scottish + # basic rate on income that would otherwise bear the basic rate + # (s. 11A(1)). The model reads its Scottish scale's 2017-18 values in + # earlier years too, so the test sets the limit by the year. scottish = rates.scotland.rates basic_rate_limit = rates.uk.thresholds[1] + gross_gift if period.start.year < 2017 or len(scottish.rates) < 3: @@ -55,21 +56,22 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): basic_rate_limit, ) # Savings sit above non-savings income (s. 16) and take the rest-of-UK - # limits (s. 11D(6)). Up to the basic rate limit they bear a basic rate - # or the starting rate for savings, both permitted: s. 12(1) charges the + # limits (s. 11D(6)). Up to the basic rate limit they bear a basic rate or + # the starting rate for savings, both permitted: Parliament charges the # starting rate only on savings that would otherwise bear a basic rate - # (s. 12(1)(b) from 2017-18). Above the limit s. 55B(2)(b) permits only - # the savings nil rate. Section 12A(1) to (4) charges it on the lowest + # (s. 12(1), and s. 12(1)(b) from 2017-18). Above the limit it permits only + # the savings nil rate (s. 55B(2)(b)), which it charges on the lowest # savings above the starting rate limit for savings, up to the savings - # allowance; dividends sit above savings (s. 16) and leave this slice - # where it is. The person fails the test if any savings above the basic - # rate limit lie outside the slice. + # allowance (s. 12A(1) to (4)); dividends sit above savings (s. 16) and + # don't move this slice. The person fails the test if any savings above the + # basic rate limit lie outside the slice. savings = max_( 0, person("taxable_savings_interest_income", period) - person("received_allowances_savings_income", period), ) - # FA 2016 s. 4(17) gives the savings nil rate effect from 2016-17. + # Parliament gave the savings nil rate effect from 2016-17 (FA 2016 + # s. 4(17)). if period.start.year < 2016: savings_allowance = 0 else: @@ -82,11 +84,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): 0, nil_rate_top - max_(nil_rate_start, basic_rate_limit) ) savings_at_forbidden_rates = savings_above_basic - nil_rate_above_basic > 0 - # Dividends are the top slice, above all non-savings income and savings - # (s. 16), counted before the dividend allowance. Section 13(2) charges - # the dividend upper rate on dividends that would otherwise bear the - # higher rate, which starts at the basic rate limit (s. 10(3)), extended - # by the gross gift. + # Dividends form the top slice, above all non-savings income and savings + # (s. 16); count them before the dividend allowance. Parliament charges the + # dividend upper rate on dividends that would otherwise bear the higher + # rate (s. 13(2)), and the higher rate starts at the basic rate limit plus + # the gross gift (ss. 10(3) and 414(2)(b)). dividends = max_( 0, person("taxable_dividend_income", period) @@ -106,8 +108,9 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" documentation = ( - "Whether this person pays income tax only at rates that ITA 2007 " - "s. 55B(2)(b) and (ba) allow for receiving the Marriage Allowance." + "Whether this person pays income tax only at rates that Parliament " + "allows for receiving the Marriage Allowance (ITA 2007 s. 55B(2)(b) " + "and (ba))." ) entity = Person definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py index 128ab66674..e3cfc8e3cc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py @@ -15,9 +15,9 @@ class savings_allowance(Variable): "Welsh taxpayer, so the bands are the rest-of-UK ones for everyone. " "For Scottish taxpayers, tax_band also uses Scottish limits for " "non-savings income. This calculation uses the rest-of-UK limits " - "directly. Taxable income is taken, as in tax_band, as net income less allowances, and " - "a Gift Aid gift raises the basic and higher rate limits by its " - "grossed-up amount (ITA 2007 s. 414(2)(b))." + "directly. Like tax_band's rest-of-UK band, it takes taxable income " + "as net income less allowances, and Parliament raises the basic and " + "higher rate limits by the grossed-up gift (ITA 2007 s. 414(2)(b))." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/12B" 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 e60edea382..67ad30d77d 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,10 +8,10 @@ class non_savings_income_after_allowances(Variable): documentation = ( "The model subtracts savings income, dividend income and allowances " "from net income and floors the result at zero, so it sets allowances " - "against this income first. Section 16 of ITA 2007 treats savings and " - "dividends as the highest part of income, above this income. Section " - "414(2)(b) raises the rate limits by the grossed-up Gift Aid gift, " - "which does not reduce this taxable income. " + "against this income first. Parliament treats savings and dividends " + "as the highest part of income, above this income (ITA 2007 s. 16). " + "It raises the rate limits by the grossed-up Gift Aid gift " + "(s. 414(2)(b)), which does not reduce this taxable income. " "property_income_tax, taxable_property_income_after_allowances, the " "Marriage Allowance rate test and, for Scottish taxpayers, tax_band " "read it." diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 32c242be75..6f2b41cabb 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -42,11 +42,11 @@ def scottish_tax_band(person, period, parameters): bear the rates they would bear outside Scotland (ss. 11D(6) and 13(5)). Compare the rest-of-UK band only when taxable savings or dividends remain, then return the higher band. Keep income at each upper limit in - the lower band: s. 10(2) to (3A) charges income "up to" a limit at the - lower rate, and each resolution charges a rate "on income above" one - limit "and up to" the next. Keep income charged at a nil rate in the - band where it sits, as for every taxpayer; the Marriage Allowance has its - own rate test (liable_only_at_marriage_allowance_rates). + the lower band. Parliament charges income "up to" a limit at the lower + rate (s. 10(2) to (3A)), and the Scottish Parliament charges each rate + "on income above" one limit "and up to" the next. Keep income charged at + a nil rate in the band where it sits, as for every taxpayer; the Marriage + Allowance has its own rate test (liable_only_at_marriage_allowance_rates). """ rates = parameters(period).gov.hmrc.income_tax.rates gross_gift = person("gift_aid_grossed_up", period) @@ -64,11 +64,12 @@ def scottish_tax_band(person, period, parameters): TaxBand.HIGHER, ] + [TaxBand.ADDITIONAL] * (len(scale.thresholds) - 4) labels = np.array([band.index for band in [TaxBand.NONE] + scottish_bands]) - # Allowances go to this income first, as in earned_income_tax. Marriage - # Allowance is a tax reduction (s. 55B(1)), so it does not move the band. + # Allowances go to this income first, as in earned_income_tax. Parliament + # gives the Marriage Allowance as a tax reduction (s. 55B(1)), so it does + # not move the band. non_savings = person("non_savings_income_after_allowances", period) - # s. 414(2)(b) raises the Scottish basic rate's upper limit and every - # limit above it by the grossed-up gift. + # Parliament raises the Scottish basic rate's upper limit and every limit + # above it by the grossed-up gift (s. 414(2)(b)). first_extended = first_extended_scottish_threshold(scale) limits_exceeded = sum( non_savings > threshold + (gross_gift if index >= first_extended else 0) @@ -77,8 +78,9 @@ def scottish_tax_band(person, period, parameters): scottish_band = labels[limits_exceeded] uk_limits = rates.uk.thresholds taxable_income = person("net_income", period) - person("allowances", period) - # Taxable income above the non-savings income is savings or dividends. - # s. 414(2)(b) raises the basic and higher rate limits by the gift too. + # Savings and dividends make up the taxable income above the non-savings + # income. Parliament raises the basic and higher rate limits by the gift + # too (s. 414(2)(b)). uk_band = select( [ taxable_income <= non_savings, From 71299640b42aa078e956284a7adf0742243cefe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:07:52 +0000 Subject: [PATCH 122/250] Read reg 28 as entitlement and keep Scottish CTR on reg 5 Split the UC qualifying young person test: a new reg. 5 variable applies UC Regs reg. 5 with its own reg. 5(5) exclusions (Universal Credit, contributory ESA, contribution-based JSA), and the UC predicate is that variable less SI 2014/1230 reg. 28. The predicate reads a report of Income Support, income-based JSA or a tax credit only in a year the model pays that benefit and no reform neutralises it. Scotland's working-age Council Tax Reduction reads the reg. 5 variable, since SSI 2021/249 reg. 6(1) imports reg. 5 and reg. 28 applies only for the UC Regulations and WRA 2012 Part 1. Tests: closed-benefit cases, the two couple-rule cases, an exact reg. 5 less reg. 28 property, Scottish CTR cases, and the knock-on changes to the reported-awards YAML and the Pension Credit test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JZXfdURcTxpkfm2gf --- changelog.d/2804.fixed.md | 2 +- ...ifying_young_person_existing_benefits.yaml | 181 ++++++++++++++++-- ...ying_young_person_reported_own_awards.yaml | 44 +++-- .../council_tax_reduction_young_person.yaml | 58 +++++- ...l_tax_reduction_young_person_properties.py | 19 +- ...sion_credit_young_person_income_support.py | 18 +- ...ung_person_existing_benefits_properties.py | 143 ++++++++++---- ...fying_young_person_for_universal_credit.py | 96 ++++------ ...son_under_universal_credit_regulation_5.py | 70 +++++++ ..._young_person_for_council_tax_reduction.py | 29 ++- 10 files changed, 518 insertions(+), 142 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index 8f324eac71..f6bb42efa8 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded a person who reports their own Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit, or a claimant or partner on the couple's tax credit award, from the Universal Credit qualifying young person test (Universal Credit (Transitional Provisions) Regulations 2014 reg. 28). +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA or Housing Benefit, or their own income-based JSA, Income Support, Child Tax Credit or Working Tax Credit while the model pays it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). Scotland's working-age Council Tax Reduction now reads the Universal Credit Regulations reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 569875fddd..2f581d2bc1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -1,14 +1,18 @@ -# The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 -# excludes from the UC qualifying young person test a person entitled to an -# existing benefit (reg. 2(1)): income-based JSA, income-related ESA, Income -# Support, Housing Benefit, Child Tax Credit and Working Tax Credit. The -# predicate reads the person's own reports and, for a claimant or partner, -# the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). The -# first seven cases follow the table in issue 2804: 2026, North East, owned -# outright, no income. +# Under the Universal Credit (Transitional Provisions) Regulations 2014 +# reg. 28, DWP doesn't count a person entitled to an existing benefit (reg. +# 2(1)) as a UC qualifying young person: income-based JSA, income-related +# ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax +# Credit. The predicate reads the person's own reports and, for a claimant or +# partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). +# It reads a report of Income Support, income-based JSA or a tax credit only +# in a year the model pays that benefit: Income Support and income-based JSA +# until 31 March 2026, tax credits until 5 April 2025. The first six cases, +# and the four after the seventh, share one household: a parent aged 45 and +# a 17-year-old on a non-advanced course, North East, owned outright, no +# income. - name: A parent's young person who reports their own Income Support attracts no child element. - period: 2026 + period: 2025 input: people: parent: @@ -32,7 +36,7 @@ is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 - universal_credit: 5_098.80 + universal_credit: 4_801.68 - name: A parent's young person who reports their own Housing Benefit attracts no child element. period: 2026 @@ -89,7 +93,7 @@ universal_credit: 5_098.80 - name: A parent's young person who reports their own income-based JSA attracts no child element. - period: 2026 + period: 2025 input: people: parent: @@ -113,7 +117,7 @@ is_qualifying_young_person_for_universal_credit: [false, false] is_child_or_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 - universal_credit: 5_098.80 + universal_credit: 4_801.68 - name: A parent's young person who reports no award attracts the child element. period: 2026 @@ -191,6 +195,117 @@ uc_child_element: 0 universal_credit: 4_062.96 +- name: A young person's own Income Support report after Income Support stops paying leaves the child element. + # Income Support stops paying on 1 April 2026 (gov.dwp.income_support.active), so nobody is entitled to it in 2026. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-based JSA report after income-based JSA stops paying leaves the child element. + # Income-based JSA stops paying on 1 April 2026 (gov.dwp.JSA.income.active). + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own Child Tax Credit report while tax credits pay removes the child element. + period: 2024 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_721.40 + +- name: A young person's own Child Tax Credit report after tax credits stop paying leaves the child element. + # Tax credits stop paying on 6 April 2025 (gov.dwp.tax_credits.active). + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + - name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. period: 2024 input: @@ -267,3 +382,45 @@ members: [claimant, partner, young_person] output: is_qualifying_young_person_for_universal_credit: [false, false, true] + +- name: A partner's own Housing Benefit report leaves an 18-year-old claimant a qualifying young person. + # Housing Benefit is paid to the claimant of that benefit, so the + # partner's report is their own and the couple branch reads only tax + # credits. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + partner: + age: 30 + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + output: + is_qualifying_young_person_for_universal_credit: [true, false] + +- name: A dependant's own Child Tax Credit award leaves an 18-year-old claimant a qualifying young person. + # Tax Credits Act 2002 s.3(3)(b): the dependant claims as a single person, + # so their award is not the claimant's. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [claimant, young_person] + output: + is_qualifying_young_person_for_universal_credit: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index d33c9c12e0..8de6ae1fc5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -1,11 +1,14 @@ -# UC reg. 5(5) and SPC reg. 4A(5) exclude own awards from both QYP routes. -# The reports identify the recipient whether receives_benefits_in_own_right -# is omitted or explicitly false. SPC reg. 4A(5) also lists income support, -# which UC reg. 5(5) does not: is_on_income_support reads it, from the -# calculated award for a claimant or partner and from their own report for -# anyone else. For Universal Credit, the Transitional Provisions Regulations -# 2014 reg. 28 excludes a person entitled to Income Support, and the UC -# predicate reads the person's own report. +# UC reg. 5(5) excludes a person receiving Universal Credit, contributory ESA +# or contribution-based JSA; UC Regs reg. 2 defines ESA and JSA without their +# income-related and income-based forms. DWP doesn't count a person entitled +# to income-related ESA, income-based JSA or Income Support as a UC +# qualifying young person (SI 2014/1230 reg. 28), and the UC predicate reads +# a report of income-based JSA or Income Support only while the model pays +# it, until 31 March 2026. SPC reg. 4A(5) excludes own awards from the +# Pension Credit routes, including income support: is_on_income_support reads +# it, from the calculated award for a claimant or partner and from their own +# report for anyone else. The reports identify the recipient whether +# receives_benefits_in_own_right is omitted or explicitly false. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -43,7 +46,8 @@ child_minimum_guarantee_addition: 0 - name: A 16-year-old reporting income-based JSA does not attract child additions for a parent aged 40. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: people: parent: @@ -218,7 +222,8 @@ child_minimum_guarantee_addition: 0 - name: A 16-year-old reporting income-based JSA does not attract child additions for a parent aged 70. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: people: parent: @@ -358,7 +363,8 @@ child_minimum_guarantee_addition: 0 - name: A reported award also excludes the education route. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: age: [18, 18, 18, 18, 18] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] @@ -430,9 +436,9 @@ is_qualifying_young_person_for_pension_credit: false is_qualifying_young_person_for_child_benefit: true -- name: A 16-year-old reporting their own Income Support is neither a Pension Credit nor a UC young person. - # SPC reg. 4A(5) lists income support; for UC, Transitional Provisions - # reg. 28 excludes it. Income Support is in payment in 2025 +- name: A 16-year-old reporting their own Income Support counts as neither a Pension Credit nor a UC young person. + # SPC reg. 4A(5) lists income support; for UC, TP reg. 28 excludes a + # person entitled to it. Income Support is in payment in 2025 # (gov.dwp.income_support.active). period: 2025 input: @@ -489,10 +495,10 @@ is_qualifying_young_person_for_pension_credit: [false, false] is_qualifying_young_person_for_universal_credit: [false, false] -- name: A member's Income Support report leaves the Pension Credit route available once Income Support stops paying. +- name: A member's Income Support report leaves the Pension Credit and UC routes available once Income Support stops paying. # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a - # report then is no award in payment for Pension Credit. The UC predicate - # reads the raw report under Transitional Provisions reg. 28. + # report then is no award in payment for Pension Credit, and nobody is + # entitled to it for TP reg. 28. period: 2026 input: people: @@ -515,9 +521,9 @@ output: is_on_income_support: [false, false] is_qualifying_young_person_for_pension_credit: [false, true] - is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] -- name: A claimant's calculated Income Support excludes them from the Pension Credit status. +- name: A claimant's calculated Income Support excludes them from the Pension Credit and UC statuses. period: 2025 input: age: 17 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml index 1abdbeba87..0e378492c4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml @@ -1,6 +1,6 @@ # Each Council Tax Reduction scheme's own "young person". Scotland's -# working-age scheme, from 1 April 2022, uses the Universal Credit qualifying -# young person (SSI 2021/249 reg. 6(1); UC Regs 2013 reg. 5). England, Wales +# working-age scheme, from 1 April 2022, uses the qualifying young person of +# UC Regs 2013 reg. 5 (SSI 2021/249 reg. 6(1)). England, Wales # and Scotland's pension-age scheme use the Child Benefit qualifying young # person (SI 2012/2885 reg. 2; WSI 2013/3029 reg. 2; SSI 2012/319 reg. 4), as # did Scotland's working-age scheme until 31 March 2022 (SSI 2012/303 reg. 4). @@ -351,3 +351,57 @@ is_council_tax_reduction_applicant_or_partner: [true, false, false] is_child_or_young_person_for_council_tax_reduction: [true, false, false] council_tax_reduction_applicable_income: 10_000 + +- name: Case 11, Scotland working age, a 19-year-old's own Housing Benefit leaves them a young person. + # The Secretary of State excludes a person entitled to an existing benefit + # from the qualifying young person only for the purposes of the UC + # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). SSI 2021/249 + # reg. 6(1) reads UC Regs reg. 5, so the 19-year-old stays in the family. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 22 + is_claimant_or_partner: true + student: + age: 19 + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, student] + would_claim_uc: false + households: + household: + members: [claimant, student] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + output: + is_qualifying_young_person_under_universal_credit_regulation_5: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_young_person_for_council_tax_reduction: [false, true] + is_council_tax_reduction_lone_parent: true + # Lone parent aged 18 or over: 95.55 * 52. + council_tax_reduction_applicable_amount: 4_968.60 + +- name: Case 12, Scotland working age, a 17-year-old's own Income Support, income-based JSA or income-related ESA leaves them a young person, and contribution-based JSA does not. + period: 2025 + input: + age: [17, 17, 17, 17] + current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] + is_claimant_or_partner: [false, false, false, false] + income_support_reported: [3_000, 0, 0, 0] + jsa_income_reported: [0, 3_000, 0, 0] + esa_income_reported: [0, 0, 3_000, 0] + jsa_contrib_reported: [0, 0, 0, 3_000] + country: [SCOTLAND, SCOTLAND, SCOTLAND, SCOTLAND] + output: + is_qualifying_young_person_for_universal_credit: [false, false, false, false] + # Contribution-based JSA is a reg. 5(5) benefit, so it alone excludes. + is_qualifying_young_person_under_universal_credit_regulation_5: [true, true, true, false] + is_child_or_young_person_for_council_tax_reduction: [true, true, true, false] diff --git a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py index 1bf2efbf8f..07f0378bf4 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py @@ -1,8 +1,11 @@ """Each Council Tax Reduction scheme's own young person. -Scotland's working-age scheme, from 1 April 2022, defines a young person as a -Universal Credit qualifying young person (Council Tax Reduction (Scotland) -Regulations 2021 reg. 6(1); UC Regs 2013 reg. 5). England, Wales and +Scotland's working-age scheme, from 1 April 2022, defines a young person by +the qualifying young person of UC Regs 2013 reg. 5 (Council Tax Reduction +(Scotland) Regulations 2021 reg. 6(1)). The Secretary of State excludes a +person entitled to an existing benefit only for the purposes of the UC +Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28), so that exclusion +stays out. England, Wales and Scotland's pension-age scheme use the Child Benefit qualifying young person (SI 2012/2885 reg. 2; WSI 2013/3029 reg. 2; SSI 2012/319 reg. 4), as Scotland's working-age scheme did until 31 March 2022 (SSI 2012/303 reg. 4). Every scheme @@ -158,7 +161,9 @@ def assert_scheme_selection(units, year): universal_credit = ( ( under_16 - | sim.calculate("is_qualifying_young_person_for_universal_credit", year) + | sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ) ) & ~looked_after & ~claimant_or_partner @@ -255,7 +260,9 @@ def test_scheme_selection_over_every_combination(): # Not vacuous: some of those lone claimants are qualifying young # persons by status, in Scotland's working-age scheme too. sim = simulate(alone, year) - status = sim.calculate("is_qualifying_young_person_for_universal_credit", year) + status = sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ) scottish_alone = np.array( [follows_universal_credit(unit, year) for unit in alone] ) @@ -280,7 +287,7 @@ def test_schemes_differ_only_where_the_two_definitions_differ(units): sim = simulate(variants, year) flag = sim.calculate("is_child_or_young_person_for_council_tax_reduction", year) agree = sim.calculate( - "is_qualifying_young_person_for_universal_credit", year + "is_qualifying_young_person_under_universal_credit_regulation_5", year ) == sim.calculate("is_qualifying_young_person_for_child_benefit", year) offset = 0 for unit in units: diff --git a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py index a4aaa042af..7f31244d41 100644 --- a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py +++ b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py @@ -3,10 +3,10 @@ A 16-year-old outside education, in a 70-year-old's benefit unit, reports their own Income Support in 2025, when it is in payment. They are not the claimant's qualifying young person, so the claimant gets no child addition, -until a reform removes Income Support. For Universal Credit, the -Transitional Provisions Regulations 2014 reg. 28 excludes them through their -own Income Support report, which a reform that removes the award leaves in -place. +until a reform removes Income Support. DWP doesn't count them as a UC +qualifying young person while they're entitled to Income Support (SI +2014/1230 reg. 28), so the reform that removes Income Support restores both +statuses. A young carer who claims Income Support in their own benefit unit follows the calculated award. While they receive it they are no qualifying young @@ -190,10 +190,12 @@ def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): ).tolist() == [False, True] assert kept.calculate("child_minimum_guarantee_addition", YEAR).tolist() == [0] assert removed.calculate("child_minimum_guarantee_addition", YEAR)[0] > 0 - for sim in (kept, removed): - assert sim.calculate( - "is_qualifying_young_person_for_universal_credit", YEAR - ).tolist() == [False, False] + assert kept.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [False, False] + assert removed.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [False, True] @pytest.mark.usefixtures("cloned_uk_tax_benefit_system") diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 21c832c529..d4bdd34426 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -1,23 +1,27 @@ """Properties of the UC qualifying young person test under SI 2014/1230 reg. 28. -The Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 -excludes from the UC qualifying young person test a person entitled to an -existing benefit (reg. 2(1)). The predicate reads the person's own reports of -Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, -for a claimant or partner, the couple's joint tax credit award (Tax Credits -Act 2002 s.3(3)). These tests check that: - -- any positive own report of an existing benefit makes the predicate false; +DWP doesn't count a person entitled to an existing benefit (SI 2014/1230 +reg. 2(1)) as a UC qualifying young person (reg. 28). The predicate reads the +person's own reports of income-based JSA, income-related ESA, Income Support, +Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant +or partner, the couple's joint tax credit award (Tax Credits Act 2002 +s.3(3)). It reads a report of Income Support, income-based JSA or a tax +credit only in a year the model pays that benefit. These tests check that: + +- any positive own report of an existing benefit the model pays that year + makes the predicate false; - with every such report at zero, the predicate equals the UC Regs 2013 - reg. 5 test with the reg. 5(5) exclusions alone; + reg. 5 test less the person's reported UC, ESA and JSA awards; - a claimant's or partner's reports never change a dependant's predicate; - a parent's Universal Credit never rises when their young person's own - report turns positive. + report turns positive; +- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly. """ from unittest.mock import patch import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -27,23 +31,33 @@ EducationType, ) -_PROPERTY_SYSTEM = system.clone() EDUCATIONS = tuple(item.name for item in EducationType) -YEARS = (2024, 2026) +NON_ADVANCED = ("UPPER_SECONDARY", "POST_SECONDARY") +YEARS = (2024, 2025, 2026) EXISTING_BENEFITS = ( + "jsa_income_reported", + "esa_income_reported", "income_support_reported", "housing_benefit_reported", "child_tax_credit_reported", "working_tax_credit_reported", ) TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") +# UC Regs reg. 2 defines ESA and JSA without their income-related and +# income-based forms, so reg. 5(5) reaches only these. REG_5_5_REPORTS = ( "universal_credit_reported", - "esa_income_reported", "esa_contrib_reported", - "jsa_income_reported", "jsa_contrib_reported", ) +# The existing benefits the model pays in each year: tax credits until 5 April +# 2025 (gov.dwp.tax_credits.active), Income Support and income-based JSA until +# 31 March 2026 (gov.dwp.income_support.active, gov.dwp.JSA.income.active). +PAYING = { + 2024: set(EXISTING_BENEFITS), + 2025: set(EXISTING_BENEFITS) - set(TAX_CREDITS), + 2026: {"esa_income_reported", "housing_benefit_reported"}, +} SETTINGS = settings( max_examples=25, deadline=None, @@ -61,8 +75,8 @@ def sometimes(draw): @st.composite -def member(draw, ages, training): - """A person who usually reports no reg. 5(5) award. +def member(draw, ages, training, educations=EDUCATIONS, own_right=(False, True)): + """A person who usually reports no UC, ESA or JSA award under reg. 5(5). Reg. 5(5) excludes anyone with such a report, so drawing them rarely leaves people for reg. 28 to exclude. Each existing benefit report is @@ -71,10 +85,10 @@ def member(draw, ages, training): reg_5_5_report = draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) return { "age": draw(ages), - "current_education": draw(st.sampled_from(EDUCATIONS)), + "current_education": draw(st.sampled_from(educations)), "is_in_approved_training": draw(training), "receives_benefits_in_own_right": draw( - st.sampled_from((False, False, False, True)) + st.sampled_from((False,) * 3 + own_right[1:]) ), **{ name: draw(amount) if name == reg_5_5_report else 0.0 @@ -87,6 +101,11 @@ def member(draw, ages, training): young_member = member(st.integers(15, 20), st.booleans()) # Claimants and partners aged 16 to 19 can be qualifying young people. adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) +# Someone who meets reg. 5 unless a report excludes them: 16 to 19, on a +# non-advanced course and not flagged as receiving benefits in their own right. +qualifying = member( + st.integers(16, 19), st.just(False), NON_ADVANCED, own_right=(False,) +) INPUTS = ( ( "age", @@ -118,14 +137,49 @@ def families(draw): return [claimant, second] + dependants +@st.composite +def qualifying_families(draw): + """Families where reg. 28 alone decides most members' status. + + The claimant, the partner or dependant, and both dependants would meet + reg. 5 two times in three. A partner is otherwise an older adult whose own + reports reg. 28 must not pass to the claimant, except for the couple's + tax credit award. + """ + couple = draw(st.booleans()) + claimant = draw(st.one_of(qualifying, qualifying, adult)) + claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + second = draw(st.one_of(qualifying, qualifying, adult if couple else young_member)) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict( + draw(st.one_of(qualifying, qualifying, young_member)), + is_claimant_or_partner=False, + is_parent=False, + ) + for _ in range(2) + ] + return [claimant, second] + dependants + + units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) -_SIMULATIONS = {} +qualifying_units_strategy = st.lists( + qualifying_families(), min_size=UNITS, max_size=UNITS +) +_CACHE = {} + + +@pytest.fixture(autouse=True, scope="module") +def _release_simulations(): + """Drop the cloned system and cached simulations when the module ends.""" + yield + _CACHE.clear() def simulate(units, year): """Set the inputs on one cached simulation per shape and year.""" shape = (tuple(len(members) for members in units), year) - if shape not in _SIMULATIONS: + if shape not in _CACHE: situation = {"people": {}, "benunits": {}, "households": {}} for i, members in enumerate(units): names = [f"p{i}_{j}" for j in range(len(members))] @@ -133,12 +187,14 @@ def simulate(units, year): situation["people"][name] = {} situation["benunits"][f"b{i}"] = {"members": names} situation["households"][f"h{i}"] = {"members": names} + if "system" not in _CACHE: + _CACHE["system"] = system.clone() with patch( "policyengine_uk.simulation.CountryTaxBenefitSystem", - side_effect=_PROPERTY_SYSTEM.clone, + side_effect=_CACHE["system"].clone, ): - _SIMULATIONS[shape] = Simulation(situation=situation) - sim = _SIMULATIONS[shape] + _CACHE[shape] = Simulation(situation=situation) + sim = _CACHE[shape] people = flatten(units) for variable in INPUTS: sim.set_input(variable, year, [p[variable] for p in people]) @@ -163,7 +219,7 @@ def predicate(sim, year): def reg_5_test(sim, year): - """UC Regs 2013 reg. 5 with the reg. 5(5) exclusions alone.""" + """UC Regs 2013 reg. 5 less the person's reported UC, ESA and JSA awards.""" def get(name): return sim.calculate(name, year) @@ -187,25 +243,32 @@ def get(name): ) -@SETTINGS -@given(units=units_strategy, year=st.sampled_from(YEARS)) -def test_any_own_existing_benefit_report_excludes_the_person(units, year): - sim = simulate(units, year) - people = flatten(units) - own = np.array([any(p[n] > 0 for n in EXISTING_BENEFITS) for p in people]) +def reg_28_exclusions(units, year): + """Who reg. 28 excludes: own reports of benefits the model pays that year, + and, for a claimant or partner, the couple's tax credit award.""" + paying = PAYING[year] + own = np.array([any(p[n] > 0 for n in paying) for p in flatten(units)], dtype=bool) couple_tax_credits = np.array( [ p["is_claimant_or_partner"] and any( q["is_claimant_or_partner"] and q[n] > 0 for q in members - for n in TAX_CREDITS + for n in paying.intersection(TAX_CREDITS) ) for members in units for p in members - ] + ], + dtype=bool, ) - assert not np.any(predicate(sim, year) & (own | couple_tax_credits)) + return own | couple_tax_credits + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_any_own_existing_benefit_report_excludes_the_person(units, year): + sim = simulate(units, year) + assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year)) @SETTINGS @@ -214,6 +277,12 @@ def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): zeroed = with_zero(units, EXISTING_BENEFITS, lambda p: True) sim = simulate(zeroed, year) assert predicate(sim, year).tolist() == reg_5_test(sim, year).tolist() + assert ( + reg_5_test(sim, year).tolist() + == sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ).tolist() + ) # With reports present, reg. 28 only removes people from the test. sim = simulate(units, year) assert not np.any(predicate(sim, year) & ~reg_5_test(sim, year)) @@ -272,3 +341,11 @@ def family(young_report): without = simulate(family(0.0), year).calculate("universal_credit", year)[0] with_report = simulate(family(report), year).calculate("universal_credit", year)[0] assert with_report <= without + 1e-6 + + +@SETTINGS +@given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) +def test_the_predicate_is_reg_5_less_reg_28(units, year): + sim = simulate(units, year) + expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year) + assert predicate(sim, year).tolist() == expected.tolist() diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index d731218ad1..f4a73f9416 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -6,58 +6,54 @@ class is_qualifying_young_person_for_universal_credit(Variable): entity = Person label = "Qualifying young person for Universal Credit" documentation = ( - "A person aged 16 to 19 who is either before the 1 September following " - "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " - "non-advanced education or approved training that meets the entry and " - "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who is not " - "receiving Universal Credit, Employment and Support Allowance or " - "Jobseeker's Allowance (reg. 5(5)), or an existing benefit: Income " - "Support, Housing Benefit, Child Tax Credit or Working Tax Credit " - "(Universal Credit (Transitional Provisions) Regulations 2014 reg. 28 " - "and reg. 2(1)). The formula reads the person's own reported awards, " - "and, for a claimant or partner, the couple's joint tax credit award " - "(Tax Credits Act 2002 s.3(3))." + "A qualifying young person under UC Regs 2013 reg. 5 " + "(is_qualifying_young_person_under_universal_credit_regulation_5): a " + "person aged 16 to 19 who meets reg. 5(1)(a) or (b) and receives no " + "Universal Credit, contributory ESA or contribution-based JSA (reg. " + "5(5)) and no existing benefit: income-based JSA, income-related ESA, " + "Income Support, Housing Benefit, Child Tax Credit or Working Tax " + "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " + "29). Reg. 28 turns on entitlement, so the formula reads a person's own " + "report of Income Support, income-based JSA or a tax credit only in a " + "year the model pays that benefit (gov.dwp.income_support.active, " + "gov.dwp.JSA.income.active, gov.dwp.tax_credits.active) and no reform " + "neutralises it. It reads Housing Benefit and income-related ESA " + "reports in every year. For a claimant or partner it also reads the " + "couple's joint tax credit award (Tax Credits Act 2002 s.3(3))." ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", ] def formula(person, period, parameters): - # Reg. 5(1)(a): no education or training condition. - before_first_september_following_16th_birthday = person( - "is_before_first_september_following_16th_birthday", period - ) - # Reg. 5(1)(b), (2) and (3). - in_education_or_training = ( - person( - "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_universal_credit", - period, - ) - & person( - "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", - period, - ) - ) - # Reg. 5(5) and Transitional Provisions reg. 28 exclude a person - # receiving Universal Credit, any ESA or JSA, or an existing benefit - # (TP reg. 2(1)), on both routes. Reg. 5(5) already covers - # income-related ESA and income-based JSA. Raw person-level reports - # identify own awards without calculating family benefits, which - # depend on QYP status and could recurse. A dependant's own report - # counts; a parent's report does not. - tax_credits_reported = person("child_tax_credit_reported", period) + person( - "working_tax_credit_reported", period + # TP reg. 28 excludes a person entitled to an existing benefit (TP + # reg. 2(1)). The formula reads raw person-level reports, so it + # calculates no family benefit that depends on QYP status. It reads a + # dependant's own reports, never a parent's. + dwp = parameters(period).gov.dwp + system = person.simulation.tax_benefit_system + + def paying(active, name): + # Nobody is entitled to a benefit the model no longer pays, or one + # a reform neutralises. + return bool(active) and not system.get_variable(name).is_neutralized + + income_support_paying = paying(dwp.income_support.active, "income_support") + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") + tax_credits_paying = paying(dwp.tax_credits.active, "tax_credits") + tax_credits_reported = tax_credits_paying * ( + person("child_tax_credit_reported", period) + + person("working_tax_credit_reported", period) ) # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, - # so the couple's award belongs to the claimant and the partner. + # so the couple's award belongs to the claimant and the partner. A + # dependant claims as a single person, so their award stays theirs. claimant_or_partner = person("is_claimant_or_partner", period) couple_tax_credits_reported = person.benunit.sum( where(claimant_or_partner, tax_credits_reported, 0) @@ -65,25 +61,17 @@ def formula(person, period, parameters): receives_tax_credits = (tax_credits_reported > 0) | ( claimant_or_partner & (couple_tax_credits_reported > 0) ) - receives_own_benefits = ( - person("receives_benefits_in_own_right", period) - | (person("universal_credit_reported", period) > 0) - | (person("esa_income_reported", period) > 0) - | (person("esa_contrib_reported", period) > 0) - | (person("jsa_income_reported", period) > 0) - | (person("jsa_contrib_reported", period) > 0) - | (person("income_support_reported", period) > 0) + entitled_to_existing_benefit = ( + (person("esa_income_reported", period) > 0) + | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) + | (income_support_paying & (person("income_support_reported", period) > 0)) | (person("housing_benefit_reported", period) > 0) | receives_tax_credits ) return ( person( - "meets_qualifying_young_person_age_condition_for_universal_credit", + "is_qualifying_young_person_under_universal_credit_regulation_5", period, ) - & ( - before_first_september_following_16th_birthday - | in_education_or_training - ) - & ~receives_own_benefits + & ~entitled_to_existing_benefit ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py new file mode 100644 index 0000000000..3e1e48929b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -0,0 +1,70 @@ +from policyengine_uk.model_api import * + + +class is_qualifying_young_person_under_universal_credit_regulation_5(Variable): + value_type = bool + entity = Person + label = "Qualifying young person under Universal Credit Regulations reg. 5" + documentation = ( + "A person aged 16 to 19 who is either before the 1 September following " + "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " + "non-advanced education or approved training that meets the entry and " + "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who receives " + "no Universal Credit, contributory ESA or contribution-based JSA (reg. " + "5(5)). Universal Credit Regulations 2013 reg. 2 defines employment and " + "support allowance and jobseeker's allowance without their " + "income-related and income-based forms, so this test leaves those to " + "SI 2014/1230 reg. 28, which is_qualifying_young_person_for_universal_credit " + "applies. Northern Ireland's twin is UC Regs (NI) 2016 reg. 6. Scotland's " + "working-age Council Tax Reduction scheme defines its young person by " + "this regulation (SSI 2021/249 reg. 6(1))." + ) + definition_period = YEAR + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", + ] + + def formula(person, period, parameters): + # Reg. 5(1)(a): no education or training condition. + before_first_september_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(1)(b), (2) and (3). + in_education_or_training = ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_universal_credit", + period, + ) + & person( + "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", + period, + ) + ) + # Reg. 5(5) excludes a person receiving Universal Credit, contributory + # ESA or contribution-based JSA, on both routes. The formula reads raw + # person-level reports, so it calculates no family benefit that + # depends on qualifying young person status. + receives_reg_5_5_benefit = ( + person("receives_benefits_in_own_right", period) + | (person("universal_credit_reported", period) > 0) + | (person("esa_contrib_reported", period) > 0) + | (person("jsa_contrib_reported", period) > 0) + ) + return ( + person( + "meets_qualifying_young_person_age_condition_for_universal_credit", + period, + ) + & ( + before_first_september_following_16th_birthday + | in_education_or_training + ) + & ~receives_reg_5_5_benefit + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py index 476e395c44..dadb1f9777 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py @@ -9,8 +9,13 @@ class is_child_or_young_person_for_council_tax_reduction(Variable): young person: - Scotland's working-age scheme, from 1 April 2022, uses the Universal - Credit qualifying young person (Council Tax Reduction (Scotland) - Regulations 2021 reg. 6(1); UC Regs 2013 reg. 5). The applicant is + Credit child and the qualifying young person of UC Regs 2013 reg. 5 + (Council Tax Reduction (Scotland) Regulations 2021 reg. 6(1)). The + Secretary of State excludes a person entitled to an existing benefit + only for the purposes of the UC Regulations and WRA 2012 Part 1 (SI + 2014/1230 reg. 28), so the formula reads reg. 5 alone and keeps a + young person who receives Income Support, Housing Benefit, a tax + credit, income-related ESA or income-based JSA. The applicant is responsible for one who normally lives with them, never for one they live with as a couple, and no one is responsible for one placed with a foster carer (reg. 7). @@ -34,6 +39,8 @@ class is_child_or_young_person_for_council_tax_reduction(Variable): definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ssi/2021/249/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", "https://www.legislation.gov.uk/ssi/2021/249/regulation/7", "https://www.legislation.gov.uk/ssi/2012/319/regulation/4", @@ -50,10 +57,18 @@ def formula(person, period, parameters): & ~person.benunit("council_tax_reduction_pensioner", period) & p.young_person_follows_universal_credit ) - # SSI 2021/249 regs 4, 6 and 7. The Universal Credit flag already - # leaves out a child or young person looked after by a local authority. - universal_credit = person( - "is_child_or_qualifying_young_person_for_universal_credit", period - ) & ~person("is_claimant_or_partner", period) + # SSI 2021/249 regs 4, 6 and 7: a child under UC Regs reg. 4, or a + # qualifying young person under reg. 5 without TP reg. 28. + universal_credit = ( + ( + person("is_child_for_universal_credit", period) + | person( + "is_qualifying_young_person_under_universal_credit_regulation_5", + period, + ) + ) + & ~person("is_looked_after_by_local_authority", period) + & ~person("is_claimant_or_partner", period) + ) child_benefit = person("is_child_or_young_person_for_legacy_benefits", period) return where(scottish_working_age, universal_credit, child_benefit) From 6429896f9a2c0c45375c2a8c8f17c3d94c4dcc6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:25:42 +0000 Subject: [PATCH 123/250] 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 124/250] 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 125/250] 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 126/250] 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 127/250] 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 4b4d30df264e3951def2e597aed0aeca15f943ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:10:00 +0000 Subject: [PATCH 128/250] Supply the reg 5 status in the Scottish CTR family-mask test Scotland's working-age Council Tax Reduction reads the reg. 5 variable, so the input grid sets that status rather than the UC predicate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JZXfdURcTxpkfm2gf --- ...ouncil_tax_reduction_severe_disability_family.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py index 4334138476..f2e0d4279e 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py +++ b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py @@ -1,10 +1,11 @@ """CTR family-mask input contracts, separate from derived eligibility tests. SSI 2021/249 regs 6 and 8(2)(a), Sch 1 para 13 retain an applicant's -UC young person in their family. These finite grids deliberately supply the -two young-person statuses to exercise both directions independently of the -upstream eligibility rules. The policy YAML uses school/course inputs and -the annual terminal-date input, plus actual PIP, pension and council tax. +young person under UC Regs 2013 reg. 5 in their family. These finite grids +deliberately supply the two young-person statuses to exercise both directions +independently of the upstream eligibility rules. The policy YAML uses +school/course inputs and the annual terminal-date input, plus actual PIP, +pension and council tax. Looked-after and other-benefit-unit treatment remains the existing model baseline, not a certification of reg 8(2)(b) and its paragraph (3) overrides. @@ -31,7 +32,9 @@ def input_grid(rows, country="SCOTLAND", year=2026, applicant_age=45): "age": row.get("age", 19), "is_claimant_or_partner": False, "is_child_or_qualifying_young_person_for_child_benefit": row["cb"], - "is_qualifying_young_person_for_universal_credit": row["uc"], + # SSI 2021/249 reg. 6(1) reads UC Regs reg. 5, without SI + # 2014/1230 reg. 28. + "is_qualifying_young_person_under_universal_credit_regulation_5": row["uc"], "is_looked_after_by_local_authority": row.get("looked_after", False), "is_blind": row.get("blind", False), "pip_dl_category": "STANDARD" if row.get("benefit", False) else "NONE", From f9645848eed39299a609e9f235523f8ac5028467 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:10:52 +0000 Subject: [PATCH 129/250] Update CTR tests for main's couple and carer rules Main reads a couple 20 or more years apart without roles as a single claimant (#2082), so the two mixed-age-couple cases name both members with is_claimant_or_partner. Main pays Carer Support Payment to one partner of a couple caring for the same person (#2632), so the one-premium case counts one payment. Main's Scottish calculator take-up case now reads this branch's working-age route, which counts no Universal Credit for a household without children. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FV8AYEtMTrdjE2EDX --- ...il_tax_reduction_devolved_working_age.yaml | 25 ++++++++++++------- .../council_tax_reduction/ctr_takeup.yaml | 7 +++++- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index c0b16a2648..414cedc5ca 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -318,16 +318,19 @@ - name: Wales, a mixed-age couple on Universal Credit claims under the working-age rules # WSI 2013/3029 reg 3(1)(b)(ii): a person over the qualifying age whose - # partner has an award of Universal Credit is not a pensioner. + # partner has an award of Universal Credit is not a pensioner. The two are + # 20 years apart, so the inputs name them as the couple. period: 2026 absolute_error_margin: 0.01 input: people: older: age: 70 + is_claimant_or_partner: true council_tax_benefit_reported: 100 younger: age: 50 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] @@ -1002,7 +1005,8 @@ # Sch 3 para 4(3): "either the applicant or the applicant's partner has not # reached pensionable age and at least one of them is engaged in # employment"; the two need not be the same person. A mixed-age couple on - # Universal Credit is on the working-age route. Earnings 12,000 (no tax, + # Universal Credit is on the working-age route. The two are 30 years + # apart, so the inputs name them as the couple. Earnings 12,000 (no tax, # and no NI over State Pension age) - 1,040 = 10,960; no children, so no # Universal Credit counts; applicable amount 150.15 x 52 = 7,807.80; award # 1,800 - 0.2 x 3,152.20 = 1,169.56. @@ -1012,10 +1016,12 @@ people: claimant: age: 40 + is_claimant_or_partner: true uc_limited_capability_for_WRA: true council_tax_benefit_reported: 100 partner: age: 70 + is_claimant_or_partner: true employment_income: 12_000 weekly_hours: 20 state_pension: 0 @@ -1158,11 +1164,12 @@ council_tax_reduction_working_age_personal_allowance: 75.65 * 52 - name: Scotland, partners caring for the same person get one carer premium - # SSI 2021/249 Sch 1 para 5(3)-(4). Both partners care 35 hours and the - # model gives each Carer Support Payment (86.45 x 52 = 4,495.40), but they - # care for the same person, so one premium: (150.15 + 48.15) x 52 = - # 10,311.60. Income 10,000 pension + 2 x 4,495.40 = 18,990.80; award - # 3,000 - 0.2 x 8,679.20 = 1,264.16 (two premiums would give 1,764.92). + # SSI 2021/249 Sch 1 para 5(3)-(4). Both partners care 35 hours for the + # same person. The model pays Carer Support Payment (86.45 x 52 = + # 4,495.40) to one of them, and the scheme gives one premium: + # (150.15 + 48.15) x 52 = 10,311.60. Income 10,000 pension + 4,495.40 = + # 14,495.40; award 3,000 - 0.2 x 4,183.80 = 2,163.24 (two premiums would + # give 2,664.00). period: 2026 absolute_error_margin: 0.01 input: @@ -1190,8 +1197,8 @@ output: council_tax_reduction_working_age_carers: 1 council_tax_reduction_working_age_applicable_amount: (150.15 + 48.15) * 52 - council_tax_reduction_working_age_applicable_income: 18_990.80 - simulated_council_tax_reduction_benunit: 1_264.16 + council_tax_reduction_working_age_applicable_income: 14_495.40 + simulated_council_tax_reduction_benunit: 2_163.24 - name: Scotland, partners caring for different people get a carer premium each # The control: two premiums, (150.15 + 2 x 48.15) x 52 = 12,815.40; award diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml index be3dbcbc9c..d46e349bde 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/ctr_takeup.yaml @@ -140,6 +140,11 @@ council_tax_reduction: 1_800 - name: Scottish working-age calculator household retains CTR without reports + # The household claims Universal Credit by default. The Scottish + # working-age scheme counts only an award's child and childcare elements + # (council_tax_reduction_relevant_universal_credit_payments, SSI 2021/249 + # reg 57), so with no children income is nil and the reduction is the full + # council tax. period: 2026 absolute_error_margin: 0.01 input: @@ -153,4 +158,4 @@ council_tax_benefit_reported: 0 claims_all_entitled_benefits: true would_claim_council_tax_reduction: true - council_tax_reduction: 633.96 + council_tax_reduction: 1_800 From e5b16d14bbf9456945986565798902b720d222f3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 11:17:54 -0500 Subject: [PATCH 130/250] Cite the Scottish rate resolutions and s. 11A(5) in two comments The tax_band docstring names each Scottish rate resolution as the source of "on income above" one limit "and up to" the next, and the Scottish rates YAML comment cites ITA 2007 s. 11A(1A), (4) and (5) and s. 13, since s. 11A itself excludes only savings income. Text only. Co-Authored-By: Claude Opus 5.5 --- .../baseline/finance/tax/income_tax/scottish_rates.yaml | 3 ++- .../hmrc/income_tax/bracketized_liability/tax_band.py | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 0bfc033c01..75b461d549 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -130,7 +130,8 @@ # tax_band for a Scottish taxpayer with savings or dividends (#2350). # Parliament charges the Scottish rates only on income that is neither -# savings nor dividends (ITA 2007 s. 11A(1A); S6M-16531 for 2025-26). +# savings nor dividends (ITA 2007 s. 11A(1A), (4) and (5), and s. 13; +# S6M-16531 for 2025-26). # Savings and dividends form the top slice (s. 16) and take the rates and # bands they would bear outside Scotland (ss. 11D(6) and 13(5)). The model # returns the higher band. In 2025-26 Parliament sets the personal allowance diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 6f2b41cabb..320fd79c12 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -43,10 +43,11 @@ def scottish_tax_band(person, period, parameters): Compare the rest-of-UK band only when taxable savings or dividends remain, then return the higher band. Keep income at each upper limit in the lower band. Parliament charges income "up to" a limit at the lower - rate (s. 10(2) to (3A)), and the Scottish Parliament charges each rate - "on income above" one limit "and up to" the next. Keep income charged at - a nil rate in the band where it sits, as for every taxpayer; the Marriage - Allowance has its own rate test (liable_only_at_marriage_allowance_rates). + rate (s. 10(2) to (3A)), and the Scottish Parliament charges each Scottish + rate "on income above" one limit "and up to" the next (each Scottish rate + resolution). Keep income charged at a nil rate in the band where it sits, + as for every taxpayer; the Marriage Allowance has its own rate test + (liable_only_at_marriage_allowance_rates). """ rates = parameters(period).gov.hmrc.income_tax.rates gross_gift = person("gift_aid_grossed_up", period) From 36bbbf42f34a23968159406d1b6d8942ebcd6e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:24:01 +0000 Subject: [PATCH 131/250] 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 132/250] 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 133/250] 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 134/250] 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 135/250] 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 136/250] 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 a013f520a39340f832e9c024fbdd681944a5d53f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:42:59 +0000 Subject: [PATCH 137/250] Check the devolved working-age route in main's CTR property tests The carer and Pension Credit property tests asserted that every national scheme claim follows the shared formula. Scottish and Welsh working-age claims take the devolved route, so the shared-formula assertions now cover only claims where council_tax_reduction_devolved_working_age is false, and the devolved claims are checked against the route's own invariants: the scheme test, the Carer Support Payment component, the Scottish Carer Supplement, the taper on the working-age measures, the passport and the Welsh Universal Credit income. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JLcnbZvgYEHVqWBBg --- ...l_tax_reduction_carer_income_properties.py | 169 ++++++++++++++++-- ...tax_reduction_pension_credit_properties.py | 86 +++++++++ 2 files changed, 244 insertions(+), 11 deletions(-) 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..b27915d2ef 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 @@ -62,6 +62,43 @@ Council Tax Reduction income is nil; where it is in receipt of savings credit only, it equals Pension Credit income plus the Pension Credit paid. +Invariants 1 to 4 concern claims that follow the shared formula (Council Tax +Reduction income and the Housing Benefit applicable amounts). A working-age +Scottish or Welsh claim (council_tax_reduction_devolved_working_age) follows +the devolved working-age route instead, which has its own income, applicable +amount and earnings disregards, so invariants 1 and 2 are checked there only +where council_tax_reduction_devolved_working_age is false, and these hold +where it is true: + +5. Counts the component on the devolved route: claiming Carer Support Payment + raises the working-age unearned income by exactly the Carer Support Payment + component before any overlapping-benefit reduction (SSI 2021/249 reg + 57(1)(b)(iva)), and, where the claim is not passported, raises the + working-age income less net earnings after the disregard by the same + amount: without children or Universal Credit that income is net earnings + less the disregard (never more than the earnings), plus unearned income, + plus tariff income, and the claim changes no capital. The working-age + applicable amount rises by exactly one Scottish carer premium (Sch 1 para + 6(1)). The reduction never rises, and where both awards are partial it + falls by 20% of the rise in working-age income less the rise in the + working-age applicable amount. +6. The supplement on the devolved route: setting the Scottish Carer + Supplement to zero leaves the working-age unearned income, the working-age + applicable amount, and the working-age income less net earnings after the + disregard unchanged. Reg 57(1) is a closed list that leaves the supplement + out, and the route counts unearned income gross (reg 57): it deducts income + tax only from earnings (reg 50(3)(a)), taking earnings as the lowest slice + of the earner's income. So where the net earnings are unchanged, the + income and the reduction are unchanged, and the supplement never lowers + the reduction. + + Net earnings can move: the model's marriage allowance transfers the + transferor's unused personal allowance, up to its cap + (partners_unused_personal_allowance), so a taxable supplement or Carer + Support Payment paid to a transferor shrinks the transfer and raises the + income tax the recipient partner pays on earnings. That is tax on the + partner's earnings, which the route deducts, not tax on the supplement. + These compare the model's own income measures, so they hold whatever carer's benefit the model pays. The model reduces Carer Support Payment by overlapping benefits such as State Pension (the Carer's Assistance (Carer @@ -77,6 +114,7 @@ from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.system import system YEAR = 2026 PENSIONS = np.arange(0, 40_001, 2_000) @@ -84,6 +122,12 @@ CARER_SUPPORT_PAYMENT = 86.45 * WEEKS SCOTTISH_CARER_SUPPLEMENT = 11.70 * WEEKS WITHDRAWAL_RATE = 0.2 +# A year of the Scottish working-age carer premium in the model year (SSI +# 2021/249 Sch 1, as uprated), read from the parameter the route uses. +_SCOTTISH_CTR = system.parameters( + str(YEAR) +).gov.local_authorities.scotland.council_tax_reduction +SCOTTISH_CARER_PREMIUM = _SCOTTISH_CTR.working_age.adult_premiums.carer_premium * WEEKS # The income tax Council Tax Reduction income deducts: the liability before # the winter fuel payment charge. INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" @@ -125,6 +169,13 @@ "in_receipt_of_savings_credit_only", "pension_credit_income", "pension_credit", + "council_tax_reduction_devolved_working_age", + "council_tax_reduction_working_age_applicable_income", + "council_tax_reduction_working_age_applicable_amount", + "council_tax_reduction_working_age_unearned_income", + "council_tax_reduction_working_age_earnings_disregard", + "council_tax_reduction_working_age_earned_income", + "council_tax_reduction_working_age_passported", *OTHER_COUNTED_BENEFITS, ] PERSON_VARIABLES = [ @@ -256,6 +307,10 @@ def grid(families, reform=None): g["in_receipt_of_guarantee_credit"].astype(bool) | g["in_receipt_of_savings_credit_only"].astype(bool) ) + # Cells on the devolved working-age route, and those on the shared + # formula. + g["devolved"] = g["council_tax_reduction_devolved_working_age"].astype(bool) + g["shared"] = ~g["devolved"] return g @@ -265,6 +320,59 @@ def partial(g, i): return (award > 0.01) & (award < np.max(award) - 0.01) +def income_other_than_earnings(g): + """Working-age income less net earnings after the disregard.""" + return ( + g["council_tax_reduction_working_age_applicable_income"] + - g["council_tax_reduction_working_age_earned_income"] + + g["council_tax_reduction_working_age_earnings_disregard"] + ) + + +def check_devolved_carer_claim(g, i, fam): + """Invariant 5: claiming Carer Support Payment on the devolved + working-age route.""" + devolved = g["devolved"][i, :, 0] & g["devolved"][i, :, 1] + if not devolved.any(): + return + # The scheme follows the family's ages, not the claim. + assert np.all(g["devolved"][i, :, 0] == g["devolved"][i, :, 1]), fam + unearned = g["council_tax_reduction_working_age_unearned_income"][i] + assert np.allclose( + (unearned[:, 0] - unearned[:, 1])[devolved], + g["carer_support_payment_pre_overlap"][i, :, 0][devolved], + atol=0.01, + ), (fam, unearned) + assert np.allclose( + g["carer_support_payment_pre_overlap"][i, :, 0][devolved], + CARER_SUPPORT_PAYMENT, + atol=0.01, + ), fam + income = g["council_tax_reduction_working_age_applicable_income"][i] + passported = g["council_tax_reduction_working_age_passported"][i].astype(bool) + assessed = devolved & ~passported[:, 0] & ~passported[:, 1] + other_income = income_other_than_earnings(g)[i] + other_income_rise = other_income[:, 0] - other_income[:, 1] + assert np.allclose(other_income_rise[assessed], CARER_SUPPORT_PAYMENT, atol=0.05), ( + fam, + other_income_rise[assessed], + ) + amount = g["council_tax_reduction_working_age_applicable_amount"][i] + amount_rise = amount[:, 0] - amount[:, 1] + assert np.allclose(amount_rise[devolved], SCOTTISH_CARER_PREMIUM, atol=0.01), ( + fam, + amount_rise[devolved], + ) + award = g["council_tax_benefit"][i] + assert np.all((award[:, 0] <= award[:, 1] + 0.01)[devolved]), fam + both_partial = partial(g, i)[:, 0] & partial(g, i)[:, 1] & assessed + excess_rise = (income[:, 0] - amount[:, 0]) - (income[:, 1] - amount[:, 1]) + fall = award[:, 1] - award[:, 0] + assert np.allclose( + fall[both_partial], WITHDRAWAL_RATE * excess_rise[both_partial], atol=0.05 + ), (fam, fall[both_partial], excess_rise[both_partial]) + + @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=3)) @example([SAVINGS_CREDIT_CARER]) @@ -287,13 +395,17 @@ def test_council_tax_reduction_income_counts_the_carer_support_payment_component atol=0.01, ), fam assert np.all(g["carers_allowance"][i] == 0), fam - # Only where the zero floor on income does not bind in either run, and - # both runs follow the general rules. - general = g["general_rules"][i, :, 0] & g["general_rules"][i, :, 1] + # Invariant 1 on the shared formula: both runs on it, and only where + # the zero floor on income does not bind in either run and both runs + # follow the general rules. + shared = g["shared"][i, :, 0] & g["shared"][i, :, 1] + general = g["general_rules"][i, :, 0] & g["general_rules"][i, :, 1] & shared compared = (income[i, :, 0] > 0) & (income[i, :, 1] > 0) & general - # At the top of the pension grid every family follows the general - # rules with positive income, so the comparison is never empty. - assert compared.any(), fam + # At the top of the pension grid every family on the shared formula + # follows the general rules with positive income, so the comparison + # is never empty. + assert compared.any() or not shared.any(), fam + check_devolved_carer_claim(g, i, fam) rise = g["income_before_tax"][i, :, 0] - g["income_before_tax"][i, :, 1] assert np.allclose(rise[compared], CARER_SUPPORT_PAYMENT, atol=0.05), ( fam, @@ -338,13 +450,46 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( fam, variable, ) + # Invariant 6: on the devolved working-age route the supplement + # reaches neither the unearned income nor the applicable amount, and + # moves the income only through net earnings. + devolved = g["devolved"][i] & without["devolved"][i] + assert np.all(g["devolved"][i] == without["devolved"][i]), fam + for variable in [ + "council_tax_reduction_working_age_unearned_income", + "council_tax_reduction_working_age_applicable_amount", + ]: + assert np.allclose( + g[variable][i][devolved], without[variable][i][devolved], atol=0.01 + ), (fam, variable) + assert np.allclose( + income_other_than_earnings(g)[i][devolved], + income_other_than_earnings(without)[i][devolved], + atol=0.05, + ), fam + same_earnings = devolved & np.isclose( + g["council_tax_reduction_working_age_earned_income"][i], + without["council_tax_reduction_working_age_earned_income"][i], + atol=0.01, + ) + for variable in [ + "council_tax_reduction_working_age_applicable_income", + "council_tax_benefit", + ]: + assert np.allclose( + g[variable][i][same_earnings], + without[variable][i][same_earnings], + atol=0.01, + ), (fam, variable) + # Invariant 2 on the shared formula: both runs on it, and only where + # the zero floor on income does not bind in either run and both runs + # follow the general rules. + shared = g["shared"][i] & without["shared"][i] 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. - general = g["general_rules"][i] & without["general_rules"][i] + general = g["general_rules"][i] & without["general_rules"][i] & shared compared = (income > 0) & (income_without > 0) & general - assert compared.any(), fam + assert compared.any() or not shared.any(), fam assert np.allclose( (income + g[INCOME_TAX][i])[compared], (income_without + without[INCOME_TAX][i])[compared], @@ -364,7 +509,9 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( atol=0.05, ), fam # Without a claim there is no supplement, so nothing moves. - assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam + assert np.allclose( + income[:, 1][shared[:, 1]], income_without[:, 1][shared[:, 1]], atol=0.01 + ), fam @PROPERTY_SETTINGS 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..a277a21cc1 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 @@ -35,6 +35,33 @@ 8. Under the Pension Credit freeze, receipt and the savings credit payable follow the frozen (baseline) award, even where the reform alone would change which credit is received. + +Invariants 2 to 4 and 7's assessment under invariant 4 are the shared +formula's, so they apply where council_tax_reduction_devolved_working_age is +false. A Scottish or Welsh claim that is not a pensioner's takes the devolved +working-age route instead, where these hold: + +9. Scheme: in Scotland and Wales a claim takes the working-age route exactly + when no applicant or partner has reached the qualifying age for State + Pension Credit, or one has but the family has an award of Universal Credit + with a member under that age, or is on Income Support, income-based JSA or + income-related ESA (WSI 2013/3029 reg 3; SSI 2021/249 reg 3). So a + mixed-age couple on Universal Credit takes it. Recomputed here from the + generated ages (67 or over, or 60 or under) and Universal Credit + entitlement. +10. No Pension Credit route: a working-age claim is never in receipt of a + guarantee credit or of savings credit only. +11. Taper on the route's own measures: CTR is max(0, L - 0.2 x max(0, I_wa - + A_wa) - ND) when the working-age capital is within 16,000, and nothing + otherwise, where I_wa and A_wa are the working-age income and applicable + amount. +12. Passport: a claimant on Income Support, income-based JSA or + income-related ESA without Universal Credit has nil working-age income + (SSI 2021/249 reg 13(11); WSI 2013/3029 Sch 9 and 10 para 8). +13. Welsh Universal Credit: a Welsh claim with Universal Credit counts the + Secretary of State's earned income plus Universal Credit unearned income + plus the award, against the Universal Credit maximum amount (WSI + 2013/3029 Sch 6 paras 3 and 9). """ import numpy as np @@ -258,6 +285,18 @@ def situation( "pension_credit_income", "pension_credit_assessable_capital", "council_tax_reduction_pensioner", + "council_tax_reduction_devolved_working_age", + "council_tax_reduction_working_age_applicable_amount", + "council_tax_reduction_working_age_applicable_income", + "council_tax_reduction_working_age_capital", + "council_tax_reduction_working_age_passported", + "council_tax_reduction_working_age_has_universal_credit", + "council_tax_reduction_working_age_earned_income", + "council_tax_reduction_working_age_universal_credit_award", + "council_tax_reduction_relevant_income_based_benefit", + "is_uc_entitled", + "uc_unearned_income", + "uc_maximum_amount", ] # council_tax_reduction_applicable_income on main before this change (1c5b4d04): # these incomes and benefits, less income tax, National Insurance and half of @@ -417,11 +456,58 @@ def test_savings_credit_only_awards_use_the_pension_credit_assessment(units): check_routes(units, values) +def check_devolved_route(unit, values, i): + """Invariants 9 to 13 for a Scottish or Welsh claim.""" + pension_age = any(age >= 67 for age in unit["ages"]) + working_age = any(age <= 60 for age in unit["ages"]) + universal_credit = bool(values["is_uc_entitled"][i]) + income_based = bool( + values["council_tax_reduction_relevant_income_based_benefit"][i] + ) + expected = not pension_age or (universal_credit and working_age) or income_based + devolved = bool(values["council_tax_reduction_devolved_working_age"][i]) + assert devolved == expected, (unit, devolved) + if not devolved: + return + assert not values["in_receipt_of_guarantee_credit"][i], unit + assert not values["in_receipt_of_savings_credit_only"][i], unit + ctr = values["simulated_council_tax_reduction_benunit"][i] + liability = values["council_tax_reduction_maximum_eligible_liability"][i] + non_dep = values["council_tax_reduction_non_dep_deductions"][i] + income = values["council_tax_reduction_working_age_applicable_income"][i] + amount = values["council_tax_reduction_working_age_applicable_amount"][i] + has_universal_credit = values[ + "council_tax_reduction_working_age_has_universal_credit" + ][i] + if values["council_tax_reduction_working_age_passported"][i]: + assert not has_universal_credit, unit + assert income == 0, unit + if unit["country"] == "WALES" and has_universal_credit: + assert ( + abs( + income + - values["council_tax_reduction_working_age_earned_income"][i] + - values["uc_unearned_income"][i] + - values["council_tax_reduction_working_age_universal_credit_award"][i] + ) + < 0.01 + ), unit + assert abs(amount - values["uc_maximum_amount"][i]) < 0.01, unit + expected_ctr = tapered(liability, income, amount, non_dep) * ( + values["council_tax_reduction_working_age_capital"][i] <= CAPITAL_LIMIT + ) + assert abs(ctr - expected_ctr) < 0.01, (unit, ctr, expected_ctr) + + def check_routes(units, values): check_structural(values) for i, unit in enumerate(units): if not values["national"][i]: continue + if unit["country"] != "ENGLAND": + check_devolved_route(unit, values, i) + if values["council_tax_reduction_devolved_working_age"][i]: + continue ctr = values["simulated_council_tax_reduction_benunit"][i] liability = values["council_tax_reduction_maximum_eligible_liability"][i] non_dep = values["council_tax_reduction_non_dep_deductions"][i] From 056a674b9348c6656de2f64fb218eed6c374f938 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:06:30 +0000 Subject: [PATCH 138/250] 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 139/250] 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 140/250] 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 141/250] 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 142/250] 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 143/250] 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 144/250] 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 145/250] 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 146/250] 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 147/250] 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 148/250] 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 149/250] 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 150/250] 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 151/250] 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 ab1b6f0eb594d89941020ad5d44bcf248b5a3da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:17:14 +0000 Subject: [PATCH 152/250] Lift reg 28 exclusions a reform neutralises, benefit by benefit The UC qualifying young person predicate now skips a report of income-related ESA or Housing Benefit when a reform neutralises esa_income or housing_benefit, and checks Child Tax Credit and Working Tax Credit each against its own variable or tax_credits, in the couple branch too. New Python tests cover each benefit under a reform that neutralises it. Also: Case 13 pins a Scottish CTR award that the reg 5 reading raises; the qualifying generator draws no reg 5(5) report; review wording rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HB6mSfknnmvFfWQja --- ...ifying_young_person_existing_benefits.yaml | 12 +- ...ying_young_person_reported_own_awards.yaml | 24 +-- .../council_tax_reduction_young_person.yaml | 48 ++++- ...nefit_cap_specified_benefits_properties.py | 8 +- .../test_legacy_award_readers_properties.py | 4 +- ...ung_person_existing_benefits_properties.py | 53 ++++-- ...fying_young_person_neutralised_benefits.py | 170 ++++++++++++++++++ ...fying_young_person_for_universal_credit.py | 54 ++++-- ...son_under_universal_credit_regulation_5.py | 18 +- 9 files changed, 326 insertions(+), 65 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 2f581d2bc1..0d608f2111 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -4,9 +4,10 @@ # ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax # Credit. The predicate reads the person's own reports and, for a claimant or # partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). -# It reads a report of Income Support, income-based JSA or a tax credit only -# in a year the model pays that benefit: Income Support and income-based JSA -# until 31 March 2026, tax credits until 5 April 2025. The first six cases, +# It reads a report only in a year the model pays that benefit, and only if +# no reform neutralises it. The model pays Income Support and income-based +# JSA until 31 March 2026 and tax credits until 5 April 2025, and has no +# closure date for Housing Benefit or income-related ESA. The first six cases, # and the four after the seventh, share one household: a parent aged 45 and # a 17-year-old on a non-advanced course, North East, owned outright, no # income. @@ -384,9 +385,8 @@ is_qualifying_young_person_for_universal_credit: [false, false, true] - name: A partner's own Housing Benefit report leaves an 18-year-old claimant a qualifying young person. - # Housing Benefit is paid to the claimant of that benefit, so the - # partner's report is their own and the couple branch reads only tax - # credits. + # The partner reports their own Housing Benefit, and the formula reads + # only tax credits across the couple. period: 2024 input: people: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 8de6ae1fc5..859fab063e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -1,14 +1,16 @@ -# UC reg. 5(5) excludes a person receiving Universal Credit, contributory ESA -# or contribution-based JSA; UC Regs reg. 2 defines ESA and JSA without their -# income-related and income-based forms. DWP doesn't count a person entitled -# to income-related ESA, income-based JSA or Income Support as a UC -# qualifying young person (SI 2014/1230 reg. 28), and the UC predicate reads -# a report of income-based JSA or Income Support only while the model pays -# it, until 31 March 2026. SPC reg. 4A(5) excludes own awards from the -# Pension Credit routes, including income support: is_on_income_support reads -# it, from the calculated award for a claimant or partner and from their own -# report for anyone else. The reports identify the recipient whether -# receives_benefits_in_own_right is omitted or explicitly false. +# The Secretary of State excludes a person receiving Universal Credit, +# contributory ESA or contribution-based JSA (UC reg. 5(5)), and defines ESA +# and JSA without their income-related and income-based forms (UC Regs reg. +# 2). DWP doesn't count a person entitled to income-related ESA, income-based +# JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. +# 28). The UC predicate reads a report only in a year the model pays that +# benefit (income-based JSA and Income Support until 31 March 2026), and only +# if no reform neutralises it. The Secretary of State excludes own awards +# from the Pension Credit routes, including income support (SPC reg. 4A(5)): +# is_on_income_support reads it, from the calculated award for a claimant or +# partner and from their own report for anyone else. The reports identify the +# recipient whether receives_benefits_in_own_right is omitted or explicitly +# false. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml index 0e378492c4..a1672030f8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml @@ -355,8 +355,9 @@ - name: Case 11, Scotland working age, a 19-year-old's own Housing Benefit leaves them a young person. # The Secretary of State excludes a person entitled to an existing benefit # from the qualifying young person only for the purposes of the UC - # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). SSI 2021/249 - # reg. 6(1) reads UC Regs reg. 5, so the 19-year-old stays in the family. + # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). The Scottish + # Ministers define the young person by UC Regs reg. 5 (SSI 2021/249 reg. + # 6(1)), so the 19-year-old stays in the family. period: 2026 absolute_error_margin: 0.01 input: @@ -402,6 +403,47 @@ country: [SCOTLAND, SCOTLAND, SCOTLAND, SCOTLAND] output: is_qualifying_young_person_for_universal_credit: [false, false, false, false] - # Contribution-based JSA is a reg. 5(5) benefit, so it alone excludes. + # The Secretary of State excludes a person receiving contribution-based + # JSA (reg. 5(5)), so only that report removes the young person. is_qualifying_young_person_under_universal_credit_regulation_5: [true, true, true, false] is_child_or_young_person_for_council_tax_reduction: [true, true, true, false] + +- name: Case 13, Scotland working age, a 19-year-old's own income-related ESA leaves the earning applicant a lone parent and raises the award. + # The Scottish Ministers define the young person by UC Regs reg. 5 (SSI + # 2021/249 reg. 6(1)), so the 19-year-old stays in the family and the + # 22-year-old applicant is a lone parent: 95.55 * 52 = 4,968.60. Applicable + # income is the applicant's earnings, 5,000, and the family's Child Benefit, + # 27.05 * 52 = 1,406.60: 6,406.60. The formula withdraws 20% of the excess, + # 0.2 * (6,406.60 - 4,968.60) = 287.60, from the 1,800 liability. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 22 + is_claimant_or_partner: true + employment_income: 5_000 + student: + age: 19 + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant, student] + would_claim_uc: false + would_claim_council_tax_reduction: true + households: + household: + members: [claimant, student] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + output: + is_child_or_young_person_for_council_tax_reduction: [false, true] + is_council_tax_reduction_lone_parent: true + council_tax_reduction_applicable_amount: 4_968.60 + council_tax_reduction_applicable_income: 6_406.60 + council_tax_reduction: 1_512.40 diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 076d8ffa2c..759a86cd38 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -31,7 +31,8 @@ Each scheme applies its own exceptions. UC reg 83(1)(f) names a "child or qualifying young person"; HB reg 75F(1)(e) names a "child or young person". UC includes a 16-year-old school leaver (reg 5(1)(a)) who does not receive -benefits in their own right (reg 5(5)) and is not looked after (reg 4(6)). +benefits in their own right (reg 5(5); SI 2014/1230 reg. 28) and is not +looked after (reg 4(6)). HB young persons follow Child Benefit (reg 19), so only the education-based young-person role counts for Housing Benefit below. UC reg 83(1)(c) names "a claimant" for attendance allowance, which includes armed forces @@ -278,7 +279,10 @@ def own_esa(m): def own_benefits(m): - # UC reg 5(5): a personal report identifies an award in the person's own right. + # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI + # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it + # pays, as in 2025): a personal report identifies an award in the + # person's own right. return any(m.get(report, 0) > 0 for report in OWN_RIGHT_REPORTS) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 9fa4db56d9..f884cec685 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -687,7 +687,9 @@ def test_other_members_awards_never_change_the_readers_for_young_singles(drawn): @pytest.mark.parametrize("report", ["esa_income_reported", "jsa_income_reported"]) def test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged(report): # A natural route-(a) case: no route or own-right Boolean is supplied. - # UC reg 5(5) and SPC reg 4A(5) still exclude the member's own award. + # SI 2014/1230 reg. 28 excludes the member's own income-related ESA or + # income-based JSA (paying in 2025) from the UC test, and SPC reg. 4A(5) + # from the Pension Credit test. family = ( [ { diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index d4bdd34426..2428d63449 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -5,8 +5,10 @@ person's own reports of income-based JSA, income-related ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant or partner, the couple's joint tax credit award (Tax Credits Act 2002 -s.3(3)). It reads a report of Income Support, income-based JSA or a tax -credit only in a year the model pays that benefit. These tests check that: +s.3(3)). It reads a report only in a year the model pays that benefit, and +only if no reform neutralises it; these tests run without a reform, and +test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. +These tests check that: - any positive own report of an existing benefit the model pays that year makes the predicate false; @@ -43,8 +45,8 @@ "working_tax_credit_reported", ) TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") -# UC Regs reg. 2 defines ESA and JSA without their income-related and -# income-based forms, so reg. 5(5) reaches only these. +# The Secretary of State defines ESA and JSA without their income-related and +# income-based forms (UC Regs reg. 2), so reg. 5(5) reaches only these. REG_5_5_REPORTS = ( "universal_credit_reported", "esa_contrib_reported", @@ -75,14 +77,26 @@ def sometimes(draw): @st.composite -def member(draw, ages, training, educations=EDUCATIONS, own_right=(False, True)): +def member( + draw, + ages, + training, + educations=EDUCATIONS, + own_right=(False, True), + reg_5_5_reports=True, +): """A person who usually reports no UC, ESA or JSA award under reg. 5(5). Reg. 5(5) excludes anyone with such a report, so drawing them rarely - leaves people for reg. 28 to exclude. Each existing benefit report is - positive one time in three. + leaves people for reg. 28 to exclude; with reg_5_5_reports false, the + person reports none. Each existing benefit report is positive one time in + three. """ - reg_5_5_report = draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + reg_5_5_report = ( + draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + if reg_5_5_reports + else None + ) return { "age": draw(ages), "current_education": draw(st.sampled_from(educations)), @@ -101,10 +115,17 @@ def member(draw, ages, training, educations=EDUCATIONS, own_right=(False, True)) young_member = member(st.integers(15, 20), st.booleans()) # Claimants and partners aged 16 to 19 can be qualifying young people. adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) -# Someone who meets reg. 5 unless a report excludes them: 16 to 19, on a -# non-advanced course and not flagged as receiving benefits in their own right. +# Someone who meets reg. 5 unless reg. 28 excludes them: 16 to 18, on a +# non-advanced course, not flagged as receiving benefits in their own right +# and reporting no UC, ESA or JSA award under reg. 5(5). A 19-year-old needs +# the entry and terminal-date inputs (reg. 5(1)(b) and (2)), which these +# draws don't set. qualifying = member( - st.integers(16, 19), st.just(False), NON_ADVANCED, own_right=(False,) + st.integers(16, 18), + st.just(False), + NON_ADVANCED, + own_right=(False,), + reg_5_5_reports=False, ) INPUTS = ( ( @@ -141,10 +162,12 @@ def families(draw): def qualifying_families(draw): """Families where reg. 28 alone decides most members' status. - The claimant, the partner or dependant, and both dependants would meet - reg. 5 two times in three. A partner is otherwise an older adult whose own - reports reg. 28 must not pass to the claimant, except for the couple's - tax credit award. + Each slot is drawn from qualifying, which meets reg. 5, or from the + general strategies. Over 10,000 derandomised draws for 2025, the + claimant met reg. 5 in 61% of draws, the partner or dependant in 61%, + and each dependant in 65%. A partner is otherwise an older adult whose + own reports reg. 28 must not pass to the claimant, except for the + couple's tax credit award. """ couple = draw(st.booleans()) claimant = draw(st.one_of(qualifying, qualifying, adult)) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py new file mode 100644 index 0000000000..c1d64e1807 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py @@ -0,0 +1,170 @@ +"""The UC qualifying young person test under reforms that neutralise a benefit. + +The Secretary of State excludes a person entitled to an existing benefit +from the UC qualifying young person test (SI 2014/1230 regs. 2(1) and 28). +A reform that neutralises a benefit's variable abolishes that benefit, so +nobody can be entitled to it and reg. 28 can't exclude on it. The predicate +checks each report against its own variable: + +- Income Support: ``income_support``; +- income-based JSA: ``jsa_income``; +- income-related ESA: ``esa_income``; +- Housing Benefit: ``housing_benefit``; +- Child Tax Credit: ``child_tax_credit`` or ``tax_credits``; +- Working Tax Credit: ``working_tax_credit`` or ``tax_credits``. + +The model pays all six in 2024, so the tests read 2024 unless they name +another year. +""" + +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation + +PREDICATE = "is_qualifying_young_person_for_universal_credit" +# Each report, and the variables whose neutralisation lifts its exclusion. +ROWS = { + "income_support_reported": {"income_support"}, + "jsa_income_reported": {"jsa_income"}, + "esa_income_reported": {"esa_income"}, + "housing_benefit_reported": {"housing_benefit"}, + "child_tax_credit_reported": {"child_tax_credit", "tax_credits"}, + "working_tax_credit_reported": {"working_tax_credit", "tax_credits"}, +} +NEUTRALISED = sorted(set().union(*ROWS.values())) + + +def neutralising(*names): + class neutralise(Reform): + def apply(self): + for name in names: + self.neutralize_variable(name) + + return neutralise + + +def young_people(reports, year, reform=None, amount=3_000): + """One household per report: a parent aged 45 and a 17-year-old on a + non-advanced course who reports that benefit (£3,000 a year unless the + test says otherwise). North East, owned outright, no income.""" + people, benunits, households = {}, {}, {} + for index, report in enumerate(reports): + parent, young = f"parent_{index}", f"young_{index}" + people[parent] = { + "age": {year: 45}, + "is_parent": {year: True}, + "is_claimant_or_partner": {year: True}, + } + people[young] = { + "age": {year: 17}, + "current_education": {year: "POST_SECONDARY"}, + "is_claimant_or_partner": {year: False}, + report: {year: amount}, + } + benunits[f"benunit_{index}"] = {"members": [parent, young]} + households[f"household_{index}"] = { + "members": [parent, young], + "region": {year: "NORTH_EAST"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": households}, + reform=reform, + ) + + +def young_flags(sim, year): + return sim.calculate(PREDICATE, year)[1::2].tolist() + + +def parent_awards(sim, year): + return [round(float(x), 2) for x in sim.calculate("universal_credit", year)] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("neutralised", [None] + NEUTRALISED) +def test_a_reform_lifts_only_the_exclusions_on_the_benefit_it_neutralises( + neutralised, +): + year = 2024 + reform = neutralising(neutralised) if neutralised else None + sim = young_people(list(ROWS), year, reform) + expected = [neutralised in variables for variables in ROWS.values()] + assert young_flags(sim, year) == expected + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("neutralised", [None] + NEUTRALISED) +def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( + neutralised, +): + """Two 18-year-olds on non-advanced courses form a couple, and the + partner reports the couple's Child Tax Credit or Working Tax Credit + award (Tax Credits Act 2002 s.3(3)(a)).""" + year = 2024 + people, benunits, households = {}, {}, {} + credits = ("child_tax_credit_reported", "working_tax_credit_reported") + for index, report in enumerate(credits): + claimant, partner = f"claimant_{index}", f"partner_{index}" + for name in (claimant, partner): + people[name] = { + "age": {year: 18}, + "current_education": {year: "UPPER_SECONDARY"}, + "is_claimant_or_partner": {year: True}, + } + people[partner][report] = {year: 1_000} + benunits[f"benunit_{index}"] = {"members": [claimant, partner]} + households[f"household_{index}"] = {"members": [claimant, partner]} + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households}, + reform=neutralising(neutralised) if neutralised else None, + ) + lifted = [neutralised in ROWS[report] for report in credits] + expected = [flag for flag in lifted for _ in range(2)] + assert sim.calculate(PREDICATE, year).tolist() == expected + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_child_tax_credit_alone_leaves_a_working_tax_credit_exclusion(): + year = 2024 + credits = ["child_tax_credit_reported", "working_tax_credit_reported"] + alone = young_people(credits, year, neutralising("child_tax_credit")) + both = young_people(credits, year, neutralising("tax_credits")) + assert young_flags(alone, year) == [True, False] + assert young_flags(both, year) == [True, True] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_housing_benefit_restores_an_own_housing_benefit_young_person(): + """Household G: the 17-year-old reports their own Housing Benefit in 2026.""" + year = 2026 + kept = young_people(["housing_benefit_reported"], year) + removed = young_people( + ["housing_benefit_reported"], year, neutralising("housing_benefit") + ) + assert young_flags(kept, year) == [False] + assert young_flags(removed, year) == [True] + assert parent_awards(kept, year)[0] == pytest.approx(5_098.80) + assert parent_awards(removed, year)[0] == pytest.approx(9_321.36) + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_child_tax_credit_restores_an_own_child_tax_credit_young_person(): + """Household D: the 17-year-old reports their own Child Tax Credit in + 2024, £1,000.""" + year = 2024 + sims = {} + for name, reform in ( + ("kept", None), + ("child_tax_credit", neutralising("child_tax_credit")), + ("tax_credits", neutralising("tax_credits")), + ): + sims[name] = young_people( + ["child_tax_credit_reported"], year, reform, amount=1_000 + ) + assert young_flags(sims["kept"], year) == [False] + assert parent_awards(sims["kept"], year)[0] == pytest.approx(4_721.40) + for name in ("child_tax_credit", "tax_credits"): + assert young_flags(sims[name], year) == [True] + assert parent_awards(sims[name], year)[0] == pytest.approx(8_721.36) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index f4a73f9416..94ddd31351 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -13,13 +13,18 @@ class is_qualifying_young_person_for_universal_credit(Variable): "5(5)) and no existing benefit: income-based JSA, income-related ESA, " "Income Support, Housing Benefit, Child Tax Credit or Working Tax " "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " - "29). Reg. 28 turns on entitlement, so the formula reads a person's own " - "report of Income Support, income-based JSA or a tax credit only in a " - "year the model pays that benefit (gov.dwp.income_support.active, " - "gov.dwp.JSA.income.active, gov.dwp.tax_credits.active) and no reform " - "neutralises it. It reads Housing Benefit and income-related ESA " - "reports in every year. For a claimant or partner it also reads the " - "couple's joint tax credit award (Tax Credits Act 2002 s.3(3))." + "29). Reg. 28 turns on entitlement, so the formula reads a report only " + "in a year the model pays that benefit, and only if no reform " + "neutralises it. The model pays Income Support, income-based JSA and " + "tax credits only while gov.dwp.income_support.active, " + "gov.dwp.JSA.income.active and gov.dwp.tax_credits.active hold, and " + "has no closure date for Housing Benefit or income-related ESA. The " + "formula checks each report against its own variable: income_support, " + "jsa_income, esa_income, housing_benefit, child_tax_credit or " + "tax_credits for Child Tax Credit, and working_tax_credit or " + "tax_credits for Working Tax Credit. For a claimant or partner it also " + "reads the couple's joint tax credit award (Tax Credits Act 2002 " + "s.3(3)), each credit by its own check." ) definition_period = YEAR reference = [ @@ -32,25 +37,33 @@ class is_qualifying_young_person_for_universal_credit(Variable): ] def formula(person, period, parameters): - # TP reg. 28 excludes a person entitled to an existing benefit (TP - # reg. 2(1)). The formula reads raw person-level reports, so it - # calculates no family benefit that depends on QYP status. It reads a - # dependant's own reports, never a parent's. + # The Secretary of State excludes a person entitled to an existing + # benefit (TP regs. 2(1) and 28). The formula reads raw person-level + # reports, so it calculates no family benefit that depends on QYP + # status. It reads a dependant's own reports, never a parent's. dwp = parameters(period).gov.dwp system = person.simulation.tax_benefit_system - def paying(active, name): + def paying(active, *names): # Nobody is entitled to a benefit the model no longer pays, or one # a reform neutralises. - return bool(active) and not system.get_variable(name).is_neutralized + return bool(active) and not any( + system.get_variable(name).is_neutralized for name in names + ) income_support_paying = paying(dwp.income_support.active, "income_support") jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") - tax_credits_paying = paying(dwp.tax_credits.active, "tax_credits") - tax_credits_reported = tax_credits_paying * ( - person("child_tax_credit_reported", period) - + person("working_tax_credit_reported", period) + esa_income_paying = paying(True, "esa_income") + housing_benefit_paying = paying(True, "housing_benefit") + child_tax_credit_paying = paying( + dwp.tax_credits.active, "child_tax_credit", "tax_credits" + ) + working_tax_credit_paying = paying( + dwp.tax_credits.active, "working_tax_credit", "tax_credits" ) + tax_credits_reported = child_tax_credit_paying * person( + "child_tax_credit_reported", period + ) + working_tax_credit_paying * person("working_tax_credit_reported", period) # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, # so the couple's award belongs to the claimant and the partner. A # dependant claims as a single person, so their award stays theirs. @@ -62,10 +75,13 @@ def paying(active, name): claimant_or_partner & (couple_tax_credits_reported > 0) ) entitled_to_existing_benefit = ( - (person("esa_income_reported", period) > 0) + (esa_income_paying & (person("esa_income_reported", period) > 0)) | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) | (income_support_paying & (person("income_support_reported", period) > 0)) - | (person("housing_benefit_reported", period) > 0) + | ( + housing_benefit_paying + & (person("housing_benefit_reported", period) > 0) + ) | receives_tax_credits ) return ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py index 3e1e48929b..034e6772fe 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -11,13 +11,14 @@ class is_qualifying_young_person_under_universal_credit_regulation_5(Variable): "non-advanced education or approved training that meets the entry and " "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who receives " "no Universal Credit, contributory ESA or contribution-based JSA (reg. " - "5(5)). Universal Credit Regulations 2013 reg. 2 defines employment and " - "support allowance and jobseeker's allowance without their " - "income-related and income-based forms, so this test leaves those to " + "5(5)). The Secretary of State defines employment and support " + "allowance and jobseeker's allowance without their income-related and " + "income-based forms (UC Regs 2013 reg. 2), so this test leaves those to " "SI 2014/1230 reg. 28, which is_qualifying_young_person_for_universal_credit " - "applies. Northern Ireland's twin is UC Regs (NI) 2016 reg. 6. Scotland's " - "working-age Council Tax Reduction scheme defines its young person by " - "this regulation (SSI 2021/249 reg. 6(1))." + "applies. Northern Ireland's twin is reg. 6 of the Universal Credit " + "Regulations (Northern Ireland) 2016 (SR 2016/216). The Scottish " + "Ministers define the young person for Scotland's working-age Council " + "Tax Reduction by this regulation (SSI 2021/249 reg. 6(1))." ) definition_period = YEAR reference = [ @@ -47,8 +48,9 @@ def formula(person, period, parameters): period, ) ) - # Reg. 5(5) excludes a person receiving Universal Credit, contributory - # ESA or contribution-based JSA, on both routes. The formula reads raw + # The Secretary of State excludes a person receiving Universal Credit, + # contributory ESA or contribution-based JSA (reg. 5(5)), on both + # routes. The formula reads raw # person-level reports, so it calculates no family benefit that # depends on qualifying young person status. receives_reg_5_5_benefit = ( From 9ad6f2f553b35711397a48637ebe46303e583c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:34:56 +0000 Subject: [PATCH 153/250] Read income-based JSA in the benefit cap oracle only while it pays The specified-benefit oracle now counts a young person's income-based JSA report as an own award only in a year the model pays income-based JSA, as the UC predicate does under SI 2014/1230 reg 28. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HB6mSfknnmvFfWQja --- ...st_benefit_cap_specified_benefits_properties.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 759a86cd38..1c328e968b 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -87,6 +87,7 @@ from policyengine_uk import Simulation from policyengine_uk.model_api import BenUnit, Variable from policyengine_uk.scenarios.uc_reform import REBALANCING_YEARS +from policyengine_uk.tax_benefit_system import system as baseline_system from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input YEAR = 2025 @@ -278,12 +279,19 @@ def own_esa(m): return m["esa_contrib_reported"] > 0 or m["esa_income_reported"] > 0 -def own_benefits(m): +def own_benefits(m, year=YEAR): # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it # pays, as in 2025): a personal report identifies an award in the - # person's own right. - return any(m.get(report, 0) > 0 for report in OWN_RIGHT_REPORTS) + # person's own right. Reg. 28 turns on entitlement, so an income-based + # JSA report counts only in a year the model pays it. + dwp = baseline_system.parameters(f"{year}-01-01").gov.dwp + reports = [ + report + for report in OWN_RIGHT_REPORTS + if report != "jsa_income_reported" or dwp.JSA.income.active + ] + return any(m.get(report, 0) > 0 for report in reports) def reference_specified_benefit(members, benunit_inputs, scheme): From d5d32074d569331db3cda217701cdee5ed152d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:42:59 +0000 Subject: [PATCH 154/250] Draw at most one existing benefit for a qualifying property member The exact reg 28 property now gives each qualifying member at most one existing benefit report, so reg 28 leaves more of them in the test and the property catches a couple-sum mutant again. The changelog fragment says the predicate reads a report only while the model pays the benefit and no reform neutralises it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HB6mSfknnmvFfWQja --- changelog.d/2804.fixed.md | 2 +- ...ung_person_existing_benefits_properties.py | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index f6bb42efa8..1585e60f48 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA or Housing Benefit, or their own income-based JSA, Income Support, Child Tax Credit or Working Tax Credit while the model pays it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). Scotland's working-age Council Tax Reduction now reads the Universal Credit Regulations reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays it and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 2428d63449..452f61f63b 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -84,13 +84,15 @@ def member( educations=EDUCATIONS, own_right=(False, True), reg_5_5_reports=True, + one_existing_benefit=False, ): """A person who usually reports no UC, ESA or JSA award under reg. 5(5). Reg. 5(5) excludes anyone with such a report, so drawing them rarely leaves people for reg. 28 to exclude; with reg_5_5_reports false, the person reports none. Each existing benefit report is positive one time in - three. + three; with one_existing_benefit true, the person reports at most one, + and none one time in three. """ reg_5_5_report = ( draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) @@ -108,10 +110,17 @@ def member( name: draw(amount) if name == reg_5_5_report else 0.0 for name in REG_5_5_REPORTS }, - **{name: draw(sometimes()) for name in EXISTING_BENEFITS}, + **existing_benefit_reports(draw, one_existing_benefit), } +def existing_benefit_reports(draw, one_existing_benefit): + if not one_existing_benefit: + return {name: draw(sometimes()) for name in EXISTING_BENEFITS} + report = draw(st.sampled_from((None,) * 3 + EXISTING_BENEFITS)) + return {name: draw(amount) if name == report else 0.0 for name in EXISTING_BENEFITS} + + young_member = member(st.integers(15, 20), st.booleans()) # Claimants and partners aged 16 to 19 can be qualifying young people. adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) @@ -119,13 +128,15 @@ def member( # non-advanced course, not flagged as receiving benefits in their own right # and reporting no UC, ESA or JSA award under reg. 5(5). A 19-year-old needs # the entry and terminal-date inputs (reg. 5(1)(b) and (2)), which these -# draws don't set. +# draws don't set. They report at most one existing benefit, so reg. 28 +# often leaves them in the test. qualifying = member( st.integers(16, 18), st.just(False), NON_ADVANCED, own_right=(False,), reg_5_5_reports=False, + one_existing_benefit=True, ) INPUTS = ( ( @@ -164,8 +175,8 @@ def qualifying_families(draw): Each slot is drawn from qualifying, which meets reg. 5, or from the general strategies. Over 10,000 derandomised draws for 2025, the - claimant met reg. 5 in 61% of draws, the partner or dependant in 61%, - and each dependant in 65%. A partner is otherwise an older adult whose + claimant met reg. 5 in 59% of draws, the partner or dependant in 62%, + and the dependants in 65% and 69%. A partner is otherwise an older adult whose own reports reg. 28 must not pass to the claimant, except for the couple's tax credit award. """ From 419bccb447a8fbb005c2ade0304cad43046dad28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:16:28 +0000 Subject: [PATCH 155/250] 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 156/250] 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 157/250] 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 158/250] 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 159/250] 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 160/250] 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 161/250] 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 162/250] 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 163/250] 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 164/250] 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 165/250] 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 4fd88bd67f721b4d515e428b1a912d4555a27f9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:50:40 +0000 Subject: [PATCH 166/250] Give boarders' and lodgers' rent to the resolved household head rent_from_boarders_and_lodgers and legacy_benefits_home_letting_income read is_resolved_household_head and benunit_contains_household_head, as every other programme does on main, instead of is_household_head. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KubCAd9d7yLvRkRTg --- .../dwp/legacy_benefits_home_letting_income.py | 16 +++++++--------- .../income/rent_from_boarders_and_lodgers.py | 15 ++++++--------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py b/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py index aa9a4be53a..5a45ab690a 100644 --- a/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py +++ b/policyengine_uk/variables/gov/dwp/legacy_benefits_home_letting_income.py @@ -59,11 +59,11 @@ def formula(benunit, period, parameters): ) counted_sublet = max_(0, weekly_sublet - sublet_disregard) - # Boarders and lodgers are household members outside the head's - # benefit unit; what each pays counts for the head's benefit unit when - # the head is its claimant or partner. - head = person("is_household_head", period) - pays_head = ~person.benunit.any(head) + # Boarders and lodgers are household members outside the household + # head's benefit unit; what each pays counts for the head's benefit + # unit when the head is its claimant or partner. + head = person("is_resolved_household_head", period) + pays_head = ~person.benunit("benunit_contains_household_head", period) boarder = person("rent_paid_as_boarder", period) / WEEKS_IN_YEAR * pays_head lodger = person("rent_paid_as_lodger", period) / WEEKS_IN_YEAR * pays_head counted_board = (1 - board.excess_rate) * max_(0, boarder - board.amount) @@ -84,8 +84,6 @@ def formula(benunit, period, parameters): benunit.max(household.sum(counted_board + counted_lodger_pension_age)), benunit.max(household.sum(counted_board + counted_lodger_working_age)), ) - # Split by heads if more than one is flagged. - head_share = benunit.sum(head & claimant_or_partner) / max_( - benunit.max(household.sum(head)), 1 - ) + # Each household has one resolved household head. + head_share = benunit.any(head & claimant_or_partner) return (counted_sublet + from_occupiers * head_share) * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py b/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py index e2aa086a3f..ae5dcc3713 100644 --- a/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py +++ b/policyengine_uk/variables/household/income/rent_from_boarders_and_lodgers.py @@ -6,11 +6,9 @@ class rent_from_boarders_and_lodgers(Variable): entity = Person label = "rent received from boarders and lodgers" documentation = ( - "Rent the household head receives from boarders and lodgers: the " - "rent paid as a boarder or lodger by household members outside the " - "head's benefit unit, shared equally where more than one person is " - "flagged as household head (the survey data have one; with more, the " - "means tests share the rent after the disregards, not before). The " + "Rent the household head (is_resolved_household_head) receives from " + "boarders and lodgers: the rent paid as a boarder or lodger by " + "household members outside the head's benefit unit. The " "Family Resources Survey records this rent only on the payer. It is a transfer within the household, so " "household income measures do not include it; it enters income tax " "as rent-a-room receipts and the legacy means tests through the " @@ -21,8 +19,7 @@ class rent_from_boarders_and_lodgers(Variable): def formula(person, period, parameters): paid = add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) - head = person("is_household_head", period) - in_head_benunit = person.benunit.any(head) + head = person("is_resolved_household_head", period) + in_head_benunit = person.benunit("benunit_contains_household_head", period) paid_to_head = person.household.sum(paid * ~in_head_benunit) - heads = person.household.sum(head) - return where(head, paid_to_head / max_(heads, 1), 0) + return where(head, paid_to_head, 0) From a7d14c243780eb757320961a21255bc3ce2185ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:50:40 +0000 Subject: [PATCH 167/250] Pin the rent-a-room tax split, the finance-cost cap and the CTR routes Hand-worked cases: the finance-cost reduction capped at the property tax on income from capital; taxable rent-a-room income reaching total income, adjusted net income and post-tax income once; UC's tax on earnings unchanged by the rent; and council tax reduction on the Universal Credit route and the Scottish working-age scheme, with and without pay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KubCAd9d7yLvRkRTg --- .../boarders_and_lodgers.yaml | 231 +++++++++++++++++- 1 file changed, 229 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml index d689efd5ba..b273991838 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml @@ -327,8 +327,11 @@ # 2026-27, England. Earnings 20,000: tax (20,000 - 12,570) x 20% = 1,486, # NI 7,430 x 8% = 594.40. Lodger rent 10,000 gives taxable rent-a-room income # 2,500 and tax 500, so income tax is 1,986. UC counts neither the rent - # (reg 66(1)) nor its tax (reg 55(5)(b)): earned income - # 20,000 - 1,486 - 594.40 = 17,919.60, as without the lodger. + # (reg 66(1)) nor its tax (reg 55(5)(b)). uc_income_tax_on_earnings takes + # earnings as the lowest slice, so the taxable rent raises + # earned_taxable_income and the other income above earnings alike and the + # tax on earnings stays 1,486: earned income 20,000 - 1,486 - 594.40 = + # 17,919.60, as without the lodger. period: 2026 absolute_error_margin: 0.01 input: @@ -353,6 +356,7 @@ output: income_tax: [1_986, 0] rent_a_room_income_tax_after_reductions: [500, 0] + uc_income_tax_on_earnings: [1_486, 0] uc_earned_income: [17_919.60, 0] - name: Welsh CTR for a UC recipient does not add rent from a lodger @@ -383,3 +387,226 @@ output: legacy_benefits_home_letting_income: [4_160, 0] council_tax_reduction_applicable_income: [5_000, 0] + +- name: The finance-cost reduction comes off no more than the property tax on income from capital + # 2026-27, England. Private pension 30,000: tax (30,000 - 12,570) x 20% = + # 3,486. Property income 2,000 with finance costs 10,000, and a lodger + # paying 15,000: taxable rent-a-room income 15,000 - 7,500 = 7,500, so + # taxable property income 9,500 and property income tax 1,900, of which + # 7,500 / 9,500 x 1,900 = 1,500 falls on the rent-a-room income. The + # finance-cost reduction is 20% x min(10,000, 9,500) = 1,900. Counted tax + # 3,486 + 1,500 = 4,986; capital tax 1,900 - 1,500 = 400. The reduction + # comes off the capital side's 400 only; the other 1,500 is shared in + # proportion to the Step 4 tax left (4,986 counted, 0 capital). So the + # capital side keeps nil and the tax on counted income is + # 4,986 - 1,500 = 3,486, which is the whole income tax. The rent-a-room + # tax keeps 1,500 - 1,500 x 1,500 / 4,986 = 1,048.74. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 50 + is_household_head: true + private_pension_income: 30_000 + property_income: 2_000 + property_finance_costs: 10_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 15_000 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + region: LONDON + output: + taxable_property_income: [9_500, 0] + property_income_tax: [1_900, 0] + rent_a_room_income_tax: [1_500, 0] + property_finance_cost_relief: [1_900, 0] + income_tax_before_winter_fuel_payment_charge: [3_486, 0] + legacy_means_test_income_tax: [3_486, 0] + rent_a_room_income_tax_after_reductions: [1_048.74, 0] + +- name: Taxable rent-a-room income reaches total income and adjusted net income once + # 2026-27, England. Earnings 20,000 and a lodger paying 10,000: taxable + # rent-a-room income 2,500. Total income and adjusted net income both take + # it through taxable_property_income only: 20,000 + 2,500 = 22,500. + # Household post-tax income adds it once through gross_taxable_inputs: + # 22,500 less income tax (1,486 + 500 = 1,986) and NI 594.40 = 19,919.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 20_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 10_000 + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [lodger] + households: + household: + members: [householder, lodger] + region: NORTH_WEST + output: + taxable_rent_a_room_income: [2_500, 0] + taxable_property_income: [2_500, 0] + total_income: [22_500, 0] + adjusted_net_income: [22_500, 0] + gross_taxable_inputs: [22_500, 0] + post_tax_income: 19_919.60 + +- name: CTR on the Universal Credit route with pay deducts the extra tax earnings cause on uncounted rent + # 2026-27, England, working age, with a Universal Credit award (set as an + # input; Universal Credit itself is nil at this income). Pay 30,000: tax + # (30,000 - 12,570) x 20% = 3,486, NI 17,430 x 8% = 1,394.40. A lodger pays + # 15,000: taxable rent-a-room income 7,500, taxed at 20%: 1,500. The route + # does not count the rent. Without the pay, 7,500 falls inside the 12,570 + # personal allowance, so the rent bears no tax and nil comes off; the + # 1,500 that earnings cause stays deducted. Income: 30,000 - (3,486 + + # 1,500) - 1,394.40 = 23,619.60, which is 1,500 below the 25,119.60 + # without the lodger. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 15_000 + benunits: + householder_unit: + members: [householder] + council_tax_reduction_universal_credit_award: true + lodger_unit: + members: [lodger] + would_claim_uc: false + households: + household: + members: [householder, lodger] + region: NORTH_WEST + output: + rent_a_room_income_tax: [1_500, 0] + rent_a_room_income_tax_without_earnings: [0, 0] + legacy_means_test_deducted_income_tax: [4_986, 0] + council_tax_reduction_applicable_income: [23_619.60, 0] + +- name: CTR on the Universal Credit route without pay keeps uncounted rent out of income + # The same household without the pay. Taxable rent-a-room income 7,500 + # falls inside the personal allowance, so there is no tax, and the route + # counts neither the rent nor any tax on it. Income is the Universal Credit + # award alone, the single standard allowance of 424.90 a month: 424.90 x 12 + # = 5,098.80. The general rules would have added + # (15,000 / 52 - 20) x 52 = 13,960. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 15_000 + benunits: + householder_unit: + members: [householder] + council_tax_reduction_universal_credit_award: true + lodger_unit: + members: [lodger] + would_claim_uc: false + households: + household: + members: [householder, lodger] + region: NORTH_WEST + output: + legacy_benefits_home_letting_income: [13_960, 0] + rent_a_room_income_tax_without_earnings: [0, 0] + universal_credit: [5_098.80, 0] + council_tax_reduction_applicable_income: [5_098.80, 0] + +- name: Scottish working-age CTR with pay deducts the extra tax earnings cause on uncounted rent + # 2026-27, Scotland, working age, no Universal Credit. Scottish rates above + # the 12,570 personal allowance: 19% to 3,967, 20% to 16,956, 21% to + # 31,092. Pay 30,000 leaves 17,430 taxable: 3,967 x 19% + 12,989 x 20% + + # 474 x 21% = 753.73 + 2,597.80 + 99.54 = 3,451.07. The rent's 7,500 + # taxable falls at 21%: 1,575. NI 17,430 x 8% = 1,394.40. The scheme does + # not list the rent (SSI 2021/249 reg 57(1)). Without the pay the rent + # bears no tax, so nil comes off; the deduction keeps the rent's whole + # 1,575 at Scottish rates. Income: 30,000 - (3,451.07 + 1,575) - 1,394.40 + # = 23,579.53, which is 1,575 below the 25,154.53 without the lodger. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 15_000 + benunits: + householder_unit: + members: [householder] + would_claim_uc: false + lodger_unit: + members: [lodger] + would_claim_uc: false + households: + household: + members: [householder, lodger] + region: SCOTLAND + output: + earned_income_tax: [3_451.07, 0] + rent_a_room_income_tax: [1_575, 0] + rent_a_room_income_tax_without_earnings: [0, 0] + legacy_means_test_deducted_income_tax: [5_026.07, 0] + council_tax_reduction_applicable_income: [23_579.53, 0] + +- name: Scottish working-age CTR without pay keeps uncounted rent out of income + # The same household without the pay: no tax, and the scheme counts + # neither the rent nor any tax on it: income nil. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 15_000 + benunits: + householder_unit: + members: [householder] + would_claim_uc: false + lodger_unit: + members: [lodger] + would_claim_uc: false + households: + household: + members: [householder, lodger] + region: SCOTLAND + output: + rent_a_room_income_tax_without_earnings: [0, 0] + council_tax_reduction_applicable_income: [0, 0] From 8410e116179b01f3a6e6ab62a00f57b69a513a5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:50:40 +0000 Subject: [PATCH 168/250] Take commercial liability to the householder person by person pays_rent_to_householder holds for the boarder or lodger who pays and, as an assumption across schemes (HB Regs 2006 reg 8(1)(b)), for their partner; nobody else in their benefit unit. The HB, UC and household-head non-dependant gates and the UC Pension Credit exemption read the new benunit_is_liable_for_household_rent, which is false for a family whose only rent is paid to the householder. Adds the review's case through final Housing Benefit, UC and CTR cases, and person-level properties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KubCAd9d7yLvRkRTg --- changelog.d/boarder-lodger-rent.added.md | 2 +- .../boarders_and_lodgers_non_dependants.yaml | 324 ++++++++++++++++++ .../test_boarder_lodger_rent_properties.py | 309 ++++++++++++++--- ...est_household_head_agreement_properties.py | 19 +- ...sharers_boarders_and_lodgers_properties.py | 19 +- ...t_individual_non_dep_deduction_eligible.py | 8 +- .../dwp/is_non_dependant_of_household_head.py | 9 +- ...c_individual_non_dep_deduction_eligible.py | 8 +- .../uc_non_dep_receives_pension_credit.py | 20 +- .../benunit_is_liable_for_household_rent.py | 29 ++ .../income/pays_rent_to_householder.py | 38 +- 11 files changed, 719 insertions(+), 66 deletions(-) create mode 100644 policyengine_uk/variables/household/consumption/rent/benunit_is_liable_for_household_rent.py diff --git a/changelog.d/boarder-lodger-rent.added.md b/changelog.d/boarder-lodger-rent.added.md index 10b0575397..7546d6b98a 100644 --- a/changelog.d/boarder-lodger-rent.added.md +++ b/changelog.d/boarder-lodger-rent.added.md @@ -1 +1 @@ -Model rent from boarders, lodgers and sub-tenants for the householder: rent-a-room relief in income tax, the £20 and board-and-lodging disregards in the legacy means tests (not Universal Credit), and no non-dependant deductions for people who pay the householder rent. +Model rent from boarders, lodgers and sub-tenants for the householder: rent-a-room relief in income tax, the £20 and board-and-lodging disregards in the legacy means tests (not Universal Credit), and no non-dependant deductions for a boarder or lodger and their partner, while a non-paying adult in their family stays a non-dependant. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml index 59a7ed2215..54a449768d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml @@ -56,3 +56,327 @@ uc_individual_non_dep_deduction_eligible: [false, true] housing_benefit_individual_non_dep_deduction_eligible: [false, true] council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + +# Person-level liability. HB Regs 2006 reg 8(1)(b) treats the partner of a +# liable person as liable; the model applies that rule to UC and CTR too, as +# a stated assumption. Nobody else in a payer's benefit unit is liable, so a +# lodger's adult son who pays nothing stays the householder's non-dependant. +# The household head's own benefit unit never pays the householder. + +- name: A lodger's non-paying adult son is the householder's non-dependant for Housing Benefit + # Scotland, 2026-27. The household head (60) claims Housing Benefit on an + # eligible rent of 10,400 a year. An unrelated lodger (50) pays 5,200 a year + # (100 a week); her son (30), in her benefit unit, pays nothing and earns + # 15,600 a year (300 a week) over 20 hours a week, with no exemption. + # The lodger is liable on a commercial basis (reg 3(2)(e)(i)); her son is + # not, so he is a non-dependant. He works 20 hours, at least the 16-hour + # remunerative work threshold, so his deduction is banded: 300 a week lies + # in the band from 279 to 365, whose 2026-27 deduction is 64.35 a week + # (non_dep_deduction/amount.yaml): 64.35 x 52 = 3,346.20 a year. + # Counted home-letting income: one lodger family at working age, + # (100 - 20) x 52 = 4,160 a year. The applicable amount, 95.55 x 52 = + # 4,968.60, exceeds it, so Housing Benefit is the eligible rent less the + # deduction: 10,400 - 3,346.20 = 7,053.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + is_claimant_or_partner: true + housing_benefit_reported: 1 + lodger: + age: 50 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + son: + age: 30 + is_household_head: false + # The inference would make an adult of 30 the partner of a claimant + # of 50, so the roles are supplied. + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 20 + benunits: + householder_unit: + members: [householder] + would_claim_uc: false + lodger_unit: + members: [lodger, son] + households: + household: + members: [householder, lodger, son] + rent: 10_400 + tenure_type: RENT_FROM_COUNCIL + region: SCOTLAND + output: + pays_rent_to_householder: [false, true, false] + benunit_is_liable_for_household_rent: [true, false] + is_non_dependant_of_household_head: [false, false, true] + housing_benefit_individual_non_dep_deduction_eligible: [false, false, true] + housing_benefit_non_dep_deductions: [3_346.20, 0] + legacy_benefits_home_letting_income: [4_160, 0] + housing_benefit_applicable_income: [4_160, 0] + housing_benefit: [7_053.80, 0] + +- name: A lodger's non-paying adult son is the householder's non-dependant for Universal Credit and CTR + # The same household, with the household head claiming Universal Credit. + # The son is a non-dependant (Sch 4 para 9(2)(d) excludes only a person + # liable to pay on a commercial basis), aged 21 or over and not exempt, so + # the housing cost contribution is 96.55 a month in 2026-27 + # (elements/housing/non_dep_deduction/amount.yaml): 96.55 x 12 = 1,158.60. + # The housing element is 10,400 - 1,158.60 = 9,241.40. Council tax + # reduction takes the same non-dependant flag and, in the model, the + # Housing Benefit scale: 64.35 x 52 = 3,346.20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 50 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + son: + age: 30 + is_household_head: false + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 20 + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + lodger_unit: + members: [lodger, son] + households: + household: + members: [householder, lodger, son] + rent: 10_400 + tenure_type: RENT_FROM_COUNCIL + region: SCOTLAND + output: + uc_individual_non_dep_deduction_eligible: [false, false, true] + uc_non_dep_deductions: [1_158.60, 0] + uc_housing_costs_element: [9_241.40, 5_200] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + council_tax_reduction_non_dep_deductions: [3_346.20, 0] + +- name: A working-age lodger couple where one pays is one family for the disregard and neither is a non-dependant + # England, 2026-27. One partner pays 6,240 a year (120 a week); the other + # pays nothing. Both are liable: the payer directly, the partner as the + # payer's partner (HB reg 8(1)(b), and the model's assumption for UC and + # CTR). At working age the disregard is 20 on the aggregate paid by the + # occupier and their family (HB Sch 5 para 22): (120 - 20) x 52 = 5,200. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + is_claimant_or_partner: true + payer: + age: 30 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 6_240 + partner: + age: 30 + is_household_head: false + is_claimant_or_partner: true + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [payer, partner] + households: + household: + members: [householder, payer, partner] + rent: 9_000 + tenure_type: RENT_PRIVATELY + region: LONDON + output: + pays_rent_to_householder: [false, true, true] + is_non_dependant_of_household_head: [false, false, false] + uc_individual_non_dep_deduction_eligible: [false, false, false] + housing_benefit_individual_non_dep_deduction_eligible: [false, false, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] + legacy_benefits_home_letting_income: [5_200, 0] + +- name: A lodger couple at pension age where one pays keeps the per-person disregard + # England, 2026-27, householder over State Pension age. The same couple: + # the pension-age rules disregard 20 of "the amount paid by that person" + # (HB (SPC) Sch 5 para 10; SPC Regs Sch IV para 9), so the payer's 120 + # gives (120 - 20) x 52 = 5,200, and the partner, who pays nothing, adds + # no disregard. Neither is a non-dependant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 70 + is_household_head: true + is_claimant_or_partner: true + payer: + age: 30 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 6_240 + partner: + age: 30 + is_household_head: false + is_claimant_or_partner: true + benunits: + householder_unit: + members: [householder] + lodger_unit: + members: [payer, partner] + households: + household: + members: [householder, payer, partner] + rent: 9_000 + tenure_type: RENT_PRIVATELY + region: LONDON + output: + pays_rent_to_householder: [false, true, true] + is_non_dependant_of_household_head: [false, false, false] + housing_benefit_individual_non_dep_deduction_eligible: [false, false, false] + legacy_benefits_home_letting_income: [5_200, 0] + +- name: A son who pays leaves the count and his non-paying claimant mother stays in it + # Scotland, 2026-27. In the lodger's benefit unit the claimant mother (50) + # pays nothing and earns 15,600 a year over 20 hours a week; her son (25), + # who is not a claimant or partner, pays 5,200 a year as a lodger. A payer + # who is not a claimant or partner makes nobody else liable, so the son + # leaves the count and his mother is a non-dependant: 64.35 x 52 = + # 3,346.20, as in the first case. Counted home-letting income is + # (100 - 20) x 52 = 4,160, and Housing Benefit 10,400 - 3,346.20 = + # 7,053.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + is_claimant_or_partner: true + housing_benefit_reported: 1 + mother: + age: 50 + is_household_head: false + is_claimant_or_partner: true + employment_income: 15_600 + weekly_hours: 20 + son: + age: 25 + is_household_head: false + is_claimant_or_partner: false + rent_paid_as_lodger: 5_200 + benunits: + householder_unit: + members: [householder] + would_claim_uc: false + lodger_unit: + members: [mother, son] + households: + household: + members: [householder, mother, son] + rent: 10_400 + tenure_type: RENT_FROM_COUNCIL + region: SCOTLAND + output: + pays_rent_to_householder: [false, false, true] + benunit_is_liable_for_household_rent: [true, false] + is_non_dependant_of_household_head: [false, true, false] + housing_benefit_individual_non_dep_deduction_eligible: [false, true, false] + uc_individual_non_dep_deduction_eligible: [false, true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true, false] + housing_benefit_non_dep_deductions: [3_346.20, 0] + legacy_benefits_home_letting_income: [4_160, 0] + housing_benefit: [7_053.80, 0] + +- name: Rent entered for a member of the household head's benefit unit is not paid to the householder + # The household head's partner has rent_paid_as_lodger set. The household + # head's benefit unit is never liable to the householder, so neither member + # pays rent to the householder and the head receives none. + period: 2026 + input: + people: + householder: + age: 45 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 45 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 4_000 + benunits: + householder_unit: + members: [householder, partner] + households: + household: + members: [householder, partner] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + pays_rent_to_householder: [false, false] + is_non_dependant_of_household_head: [false, false] + rent_from_boarders_and_lodgers: [0, 0] + +- name: A lodger's son keeps the Pension Credit exemption from the UC housing cost contribution + # England, 2026-27. The household head (40) claims Universal Credit. The + # lodger (75) pays 5,200 a year and has a State Pension of 6,000 a year; + # her son (40), in her benefit unit, pays nothing and earns 15,600 over 20 + # hours. He is a non-dependant, but no housing cost contribution applies + # where the non-dependant's family receives Pension Credit (UC Regs 2013 + # Sch 4 para 16). Her guarantee credit is the 238 a week standard minimum + # guarantee less her income: 238 x 52 - 6,000 = 6,376 (her son's earnings + # are not hers). uc_non_dep_receives_pension_credit computes it with her + # family's UC housing element at nil; a family over the qualifying age + # claims no UC, so that is her actual award. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 75 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + state_pension: 6_000 + son: + age: 40 + is_household_head: false + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 20 + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + lodger_unit: + members: [lodger, son] + households: + household: + members: [householder, lodger, son] + rent: 10_400 + tenure_type: RENT_FROM_COUNCIL + region: LONDON + output: + is_non_dependant_of_household_head: [false, false, true] + pension_credit: [0, 6_376] + uc_non_dep_receives_pension_credit: [false, true] + uc_individual_non_dep_deduction_eligible: [false, false, false] + uc_non_dep_deductions: [0, 0] diff --git a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py index e1b963bfa2..68b38ea818 100644 --- a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py +++ b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py @@ -3,7 +3,9 @@ A household head may receive rent from boarders and lodgers who live in the household in their own benefit units (rent_paid_as_boarder and rent_paid_as_lodger, recorded on the payer), and from a sub-tenant outside it -(sublet_income). +(sublet_income). A paying benefit unit may also hold a partner or an adult +who pays nothing; the generator supplies each person's role +(is_claimant_or_partner). Invariants, for any generated household: @@ -25,11 +27,33 @@ board and lodging than when paid for lodging only. 6. Universal Credit unearned income and household market income do not depend on the rent paid between members of the household. -7. Where a programme does not count this rent, it does not deduct the tax on - it either: UC earned income, and council tax reduction income under the - Scottish working-age scheme and the Welsh universal credit route, do not - change when the rent changes. -8. Tax: legacy_means_test_income_tax is between nil and income tax. +7. Rent a programme does not count: UC earned income does not change with the + rent. On the council tax reduction routes that do not count it (the + Universal Credit route, and the Scottish working-age scheme), income does + not change with the rent where the applicant and partner have no + earnings; with earnings it falls by the extra tax earnings cause on the + rent and by nothing else, so by no more than the top rate on the change in + taxable rent-a-room income. +8. Tax: legacy_means_test_income_tax is between nil and income tax; + rent_a_room_income_tax_after_reductions is between nil and + rent_a_room_income_tax; the tax on income from capital left after + reductions inside legacy_means_test_income_tax never goes negative; and + for a person whose only counted income is rent-a-room income, + rent_a_room_income_tax_after_reductions equals + legacy_means_test_income_tax. + +Person-level liability: + +9. pays_rent_to_householder holds exactly for a person outside the household + head's benefit unit who pays, or who is a claimant or partner in a benefit + unit where a claimant or partner pays. +10. Moving a non-paying adult who is not a claimant or partner out of the + payer's benefit unit into one of their own leaves the household head's + Housing Benefit and council tax reduction non-dependant deductions + unchanged. +11. Adding such an adult never lowers the household head's Housing Benefit + non-dependant deductions, and never raises the head's Housing Benefit + unless it raises the head's bedroom count under the size criteria. """ import numpy as np @@ -37,6 +61,7 @@ from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.legacy_means_test_tax import legacy_means_test_tax_split PROPERTY_SETTINGS = settings( max_examples=10, @@ -48,8 +73,33 @@ WEEKS = 52 DISREGARD = 20 BASIC_AMOUNT = 7_500 +TOP_RATE = 0.48 +KINDS = ("rent_paid_as_boarder", "rent_paid_as_lodger") AWARDS = ["income_support", "housing_benefit", "pension_credit", "council_tax_benefit"] rent = st.one_of(st.just(0.0), st.floats(100, 20_000)) +positive_rent = st.floats(100, 20_000) + + +@st.composite +def member(draw, claimant_or_partner, pays=None): + kind = draw(st.sampled_from(KINDS)) + amount = draw(rent) if pays is None else (draw(positive_rent) if pays else 0.0) + return { + "age": draw(st.integers(19, 60)), + "is_claimant_or_partner": claimant_or_partner, + "employment_income": draw(st.one_of(st.just(0.0), st.floats(0, 25_000))), + kind: amount, + } + + +@st.composite +def payer_units(draw): + """A benefit unit outside the head's: one or two claimants or partners, + and possibly an adult who is neither, each paying or not.""" + unit = [draw(member(True)) for _ in range(draw(st.integers(1, 2)))] + if draw(st.booleans()): + unit.append(draw(member(False))) + return unit @st.composite @@ -60,22 +110,14 @@ def households(draw): dict(age=draw(age), employment_income=draw(st.floats(0, 25_000))) for _ in range(draw(st.integers(1, 2))) ] - payers = [] - for _ in range(draw(st.integers(0, 2))): - kind = draw(st.sampled_from(["rent_paid_as_boarder", "rent_paid_as_lodger"])) - payers.append( - [ - {"age": draw(st.integers(19, 60)), kind: draw(rent)} - for _ in range(draw(st.integers(1, 2))) - ] - ) return dict( head_unit=head_unit, - payers=payers, + payers=[draw(payer_units()) for _ in range(draw(st.integers(0, 2)))], sublet=draw(st.one_of(st.just(0.0), st.floats(0, 8_000))), partner_sublet=draw(st.one_of(st.just(0.0), st.floats(100, 8_000))), tenure=draw(st.sampled_from(["RENT_PRIVATELY", "OWNED_OUTRIGHT"])), region=draw(st.sampled_from(["NORTH_WEST", "LONDON", "WALES", "SCOTLAND"])), + claims_housing_benefit=draw(st.booleans()), ) @@ -88,16 +130,26 @@ def situation(h, year, scale=1.0, as_kind=None): "age": {year: attrs["age"]}, "employment_income": {year: attrs["employment_income"]}, "is_household_head": {year: i == 0}, + "is_claimant_or_partner": {year: True}, "sublet_income": {year: h["sublet"] if i == 0 else h["partner_sublet"]}, } + if h.get("claims_housing_benefit") and i == 0: + people[name]["housing_benefit_reported"] = {year: 1.0} names.append(name) benunits["head_unit"] = {"members": list(names)} + if h.get("claims_housing_benefit"): + benunits["head_unit"]["would_claim_uc"] = {year: False} for j, unit in enumerate(h["payers"]): members = [] for k, attrs in enumerate(unit): name = f"payer_{j}_{k}" - person = {"age": {year: attrs["age"]}, "is_household_head": {year: False}} - for kind in ("rent_paid_as_boarder", "rent_paid_as_lodger"): + person = { + "age": {year: attrs["age"]}, + "is_household_head": {year: False}, + "is_claimant_or_partner": {year: attrs["is_claimant_or_partner"]}, + "employment_income": {year: attrs.get("employment_income", 0.0)}, + } + for kind in KINDS: if kind in attrs: target = as_kind or kind person[target] = {year: attrs[kind] * scale} @@ -129,14 +181,36 @@ def per_week(annual): return annual / WEEKS +def paid(attrs, kind=None): + if kind: + return attrs.get(kind, 0.0) + return sum(attrs.get(k, 0.0) for k in KINDS) + + def payments(h, kind=None): - return [ - [ - a.get(kind, 0.0) if kind else sum(v for k, v in a.items() if k != "age") - for a in unit + return [[paid(a, kind) for a in unit] for unit in h["payers"]] + + +def expected_pays_rent_to_householder(h): + """The person-level liability rule, from the inputs, in person order.""" + expected = [False] * len(h["head_unit"]) + for unit in h["payers"]: + partner_pays = any(a["is_claimant_or_partner"] and paid(a) > 0 for a in unit) + expected += [ + paid(a) > 0 or (a["is_claimant_or_partner"] and partner_pays) for a in unit ] - for unit in h["payers"] - ] + return np.array(expected) + + +def head_taxable_rent_a_room(h, scale=1.0): + """Taxable rent-a-room income of the head's benefit unit, from the inputs.""" + head = sum(sum(unit) for unit in payments(h)) * scale + h["sublet"] + receipts = [head] + if len(h["head_unit"]) > 1: + receipts.append(h["partner_sublet"]) + shared = sum(r > 0 for r in receipts) > 1 + limit = BASIC_AMOUNT / 2 if shared else BASIC_AMOUNT + return sum(max(0, r - limit) for r in receipts) @PROPERTY_SETTINGS @@ -144,8 +218,8 @@ def payments(h, kind=None): def test_rent_is_received_by_the_head_and_conserved(h, year): v = calculate(h, year, ["rent_from_boarders_and_lodgers"]) received = v["rent_from_boarders_and_lodgers"] - paid = sum(sum(unit) for unit in payments(h)) - np.testing.assert_allclose(received.sum(), paid, rtol=1e-6, atol=0.01) + total = sum(sum(unit) for unit in payments(h)) + np.testing.assert_allclose(received.sum(), total, rtol=1e-6, atol=0.01) assert np.all(received[1:] == 0) @@ -248,21 +322,86 @@ def test_rent_within_the_household_changes_no_uc_income_or_market_income(h, year @PROPERTY_SETTINGS @given(households(), st.sampled_from(YEARS)) -def test_legacy_means_test_income_tax_bounds(h, year): - v = calculate(h, year, ["legacy_means_test_income_tax", "income_tax"]) +def test_tax_bounds(h, year): + sim = Simulation(situation=situation(h, year)) + v = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "legacy_means_test_income_tax", + "income_tax", + "rent_a_room_income_tax", + "rent_a_room_income_tax_after_reductions", + ] + } assert np.all(v["legacy_means_test_income_tax"] >= 0) assert np.all(v["legacy_means_test_income_tax"] <= v["income_tax"] + 0.01) + after = v["rent_a_room_income_tax_after_reductions"] + assert np.all(after >= -0.01) + assert np.all(after <= v["rent_a_room_income_tax"] + 0.01) + split = legacy_means_test_tax_split(sim.populations["person"], str(year)) + assert np.all(split["capital_after_reductions"] >= -0.01) + + +@st.composite +def rent_a_room_only(draw): + """A household head whose only counted income is rent-a-room income, with + income from capital, finance costs and a Step 6 reduction.""" + return dict( + rent=draw(st.floats(0, 60_000)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 30_000))), + dividends=draw(st.one_of(st.just(0.0), st.floats(0, 30_000))), + property=draw(st.one_of(st.just(0.0), st.floats(0, 40_000))), + finance_costs=draw(st.one_of(st.just(0.0), st.floats(0, 30_000))), + other_tax_credits=draw(st.one_of(st.just(0.0), st.floats(0, 5_000))), + region=draw(st.sampled_from(["LONDON", "SCOTLAND"])), + ) + + +@PROPERTY_SETTINGS +@given(rent_a_room_only(), st.sampled_from(YEARS)) +def test_rent_a_room_tax_after_reductions_matches_the_counted_tax(h, year): + situation = { + "people": { + "head": { + "age": {year: 45}, + "is_household_head": {year: True}, + "savings_interest_income": {year: h["savings"]}, + "dividend_income": {year: h["dividends"]}, + "property_income": {year: h["property"]}, + "property_finance_costs": {year: h["finance_costs"]}, + "other_tax_credits": {year: h["other_tax_credits"]}, + }, + "lodger": { + "age": {year: 30}, + "is_household_head": {year: False}, + "rent_paid_as_lodger": {year: h["rent"]}, + }, + }, + "benunits": { + "head_unit": {"members": ["head"]}, + "lodger": {"members": ["lodger"]}, + }, + "households": { + "household": {"members": ["head", "lodger"], "region": {year: h["region"]}} + }, + } + sim = Simulation(situation=situation) + counted = sim.calculate("legacy_means_test_income_tax", year)[0] + rent_a_room = sim.calculate("rent_a_room_income_tax_after_reductions", year)[0] + np.testing.assert_allclose(rent_a_room, counted, rtol=1e-5, atol=0.01) @PROPERTY_SETTINGS @given(households(), st.sampled_from(YEARS), st.floats(0.3, 0.9)) -def test_programmes_that_exclude_the_rent_ignore_its_tax(h, year, scale): +def test_programmes_that_exclude_the_rent_deduct_only_the_tax_earnings_cause( + h, year, scale +): # Both runs keep every payment positive, so non-dependant status is fixed. variables = [ "uc_earned_income", "council_tax_reduction_applicable_income", - "universal_credit", - "is_SP_age", + "council_tax_reduction_universal_credit_award", + "council_tax_reduction_pensioner", ] full = calculate(h, year, variables) part = calculate(h, year, variables, scale=scale) @@ -270,14 +409,102 @@ def test_programmes_that_exclude_the_rent_ignore_its_tax(h, year, scale): full["uc_earned_income"], part["uc_earned_income"], atol=0.01 ) head_unit = 0 - working_age = not full["is_SP_age"][: len(h["head_unit"])].any() - on_uc = full["universal_credit"][head_unit] > 0 - excluded = (h["region"] == "SCOTLAND" and (working_age or on_uc)) or ( - h["region"] == "WALES" and on_uc + working_age = not full["council_tax_reduction_pensioner"][head_unit] + uc_route = working_age and bool( + full["council_tax_reduction_universal_credit_award"][head_unit] + ) + same_route = ( + full["council_tax_reduction_universal_credit_award"][head_unit] + == part["council_tax_reduction_universal_credit_award"][head_unit] + ) + excluded = uc_route or (h["region"] == "SCOTLAND" and working_age) + if not (excluded and same_route): + return + fall = ( + part["council_tax_reduction_applicable_income"][head_unit] + - full["council_tax_reduction_applicable_income"][head_unit] + ) + earnings = sum(attrs["employment_income"] for attrs in h["head_unit"]) + if earnings == 0: + np.testing.assert_allclose(fall, 0, atol=0.01) + else: + more_taxable = head_taxable_rent_a_room(h) - head_taxable_rent_a_room(h, scale) + assert -0.01 <= fall <= TOP_RATE * more_taxable + 0.01 + + +# Person-level liability. + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_pays_rent_to_householder_is_person_level(h, year): + v = calculate(h, year, ["pays_rent_to_householder"]) + np.testing.assert_array_equal( + v["pays_rent_to_householder"].astype(bool), + expected_pays_rent_to_householder(h), + ) + + +@st.composite +def households_with_a_non_paying_adult(draw): + """A household whose first paying unit has a payer who is a claimant, and + an adult who is neither claimant nor partner and pays nothing.""" + h = draw(households()) + payer = draw(member(True, pays=True)) + adult = draw(member(False, pays=False)) + adult["employment_income"] = draw(st.floats(0, 40_000)) + h["payers"] = [[payer], *h["payers"]] + h["tenure"] = "RENT_PRIVATELY" + return h, adult + + +def with_adult(h, adult, own_unit): + h = dict(h, payers=[list(unit) for unit in h["payers"]]) + if own_unit: + h["payers"].append([dict(adult, is_claimant_or_partner=True)]) + else: + h["payers"][0].append(adult) + return h + + +@PROPERTY_SETTINGS +@given(households_with_a_non_paying_adult(), st.sampled_from(YEARS)) +def test_a_non_paying_adult_counts_the_same_in_or_out_of_the_payers_unit(case, year): + h, adult = case + variables = [ + "housing_benefit_non_dep_deductions", + "council_tax_reduction_non_dep_deductions", + ] + inside = calculate(with_adult(h, adult, own_unit=False), year, variables) + outside = calculate(with_adult(h, adult, own_unit=True), year, variables) + for v in variables: + np.testing.assert_allclose(inside[v][0], outside[v][0], atol=0.01, err_msg=v) + + +@PROPERTY_SETTINGS +@given(households_with_a_non_paying_adult(), st.sampled_from(YEARS)) +def test_adding_a_non_paying_adult_never_raises_housing_benefit(case, year): + # A non-dependant also occupies a bedroom under the size criteria (HB + # Regs 2006 reg 13D(12)), which can lift the maximum rent, for example + # off the shared accommodation rate. So the property holds where the + # household head's bedroom count does not change, and the non-dependant + # deductions never fall. + h, adult = case + h = dict(h, claims_housing_benefit=True) + variables = [ + "housing_benefit", + "housing_benefit_LHA_allowed_bedrooms", + "housing_benefit_non_dep_deductions", + ] + without = calculate(h, year, variables) + with_them = calculate(with_adult(h, adult, own_unit=False), year, variables) + assert ( + with_them["housing_benefit_non_dep_deductions"][0] + >= without["housing_benefit_non_dep_deductions"][0] - 0.01 + ) + same_rooms = ( + with_them["housing_benefit_LHA_allowed_bedrooms"][0] + == without["housing_benefit_LHA_allowed_bedrooms"][0] ) - if excluded and (part["universal_credit"][head_unit] > 0) == on_uc: - np.testing.assert_allclose( - full["council_tax_reduction_applicable_income"][head_unit], - part["council_tax_reduction_applicable_income"][head_unit], - atol=0.01, - ) + if same_rooms: + assert with_them["housing_benefit"][0] <= without["housing_benefit"][0] + 0.01 diff --git a/policyengine_uk/tests/test_household_head_agreement_properties.py b/policyengine_uk/tests/test_household_head_agreement_properties.py index 4862578ad5..a59afeefe9 100644 --- a/policyengine_uk/tests/test_household_head_agreement_properties.py +++ b/policyengine_uk/tests/test_household_head_agreement_properties.py @@ -390,7 +390,24 @@ def test_every_programme_reads_one_head(population): # Non-dependants (pin). non_dependant = calc(sim, "is_non_dependant_of_household_head") rent_liable = calc(sim, "benunit_is_rent_liable") - assert np.array_equal(non_dependant, ~person_head_family & ~rent_liable[benunit_of]) + # Liability to the householder is person by person: the payer, and a + # claimant or partner whose family's claimant or partner pays. A family + # whose only rent is that payment is not liable for the household's rent, + # so its other members can be non-dependants. + pays = paid_to_householder > 0 + claimant_or_partner = calc(sim, "is_claimant_or_partner") + partner_pays = any_in_benunit(pays & claimant_or_partner, facts)[benunit_of] + liable_to_householder = ~person_head_family & ( + pays | (claimant_or_partner & partner_pays) + ) + family_pays_householder = any_in_benunit(liable_to_householder, facts) + liable_for_household_rent = rent_liable & ~family_pays_householder + assert np.array_equal( + non_dependant, + ~person_head_family + & ~liable_for_household_rent[benunit_of] + & ~liable_to_householder, + ) # Universal Credit non-dependant deductions fall on the one claim that # counts the household's non-dependants: the head's family's wherever it # is liable for the rent and claims. diff --git a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py index 0b68d8b3b1..2bf3482266 100644 --- a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -259,7 +259,24 @@ def test_non_dependants(population): payer = np.isin(roles, ["boarder", "lodger"]) liable_for_rent = calc(sim, "benunit_is_rent_liable") non_dependant = calc(sim, "is_non_dependant_of_household_head") - person_liable = sim.map_result(liable_for_rent, "benunit", "person") > 0 + # Liability to the householder is person by person: a boarder's or + # lodger's non-paying child or adult relative can be a non-dependant, + # while the payer and the payer's partner cannot. + pays = (calc(sim, "rent_paid_as_boarder") + calc(sim, "rent_paid_as_lodger")) > 0 + claimant_or_partner = calc(sim, "is_claimant_or_partner") + partner_pays = ( + sim.map_result( + sim.map_result(pays & claimant_or_partner, "person", "benunit") > 0, + "benunit", + "person", + ) + > 0 + ) + liable_to_householder = pays | (claimant_or_partner & partner_pays) + family_pays = np.isin(roles, ["boarder", "lodger"]) + person_liable = ( + sim.map_result(liable_for_rent & ~family_pays, "benunit", "person") > 0 + ) | liable_to_householder assert not np.any(non_dependant & person_liable) assert non_dependant.any() # Universal Credit: one claim per household counts the non-dependants. diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py index 00913bc2ac..c77c460ef1 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py @@ -9,10 +9,12 @@ class housing_benefit_individual_non_dep_deduction_eligible(Variable): reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/3" def formula(person, period, parameters): - rent_liable = person.benunit("benunit_is_rent_liable", period) + rent_liable = person.benunit("benunit_is_liable_for_household_rent", period) p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction age_eligible = person("age", period) >= p.age_threshold - # A boarder or lodger is liable to pay the claimant on a commercial - # basis for their occupation, so is not a non-dependant (reg 3(2)(e)(i)). + # A boarder or lodger, and their partner (reg 8(1)(b)), is liable to + # pay the claimant on a commercial basis for their occupation, so is + # not a non-dependant (reg 3(2)(e)(i)). A member of their family who + # is not liable stays a non-dependant. commercial = person("pays_rent_to_householder", period) return age_eligible & ~rent_liable & ~commercial diff --git a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py index 109ede8fe2..861c4e5350 100644 --- a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py +++ b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py @@ -12,7 +12,9 @@ class is_non_dependant_of_household_head(Variable): "sharer of the rent, a boarder and a lodger are liable on a " "commercial basis for their occupation, so none is a non-dependant " "of anyone, and the household head's household is not a boarder's or " - "lodger's. Universal Credit counts a non-dependant in one claim only " + "lodger's. In a boarder's or lodger's family, only the payer and the " + "payer's partner are liable (pays_rent_to_householder); an adult son " + "or daughter who pays nothing is a non-dependant. Universal Credit counts a non-dependant in one claim only " "(uc_non_dependants_counted); Housing Benefit and Council Tax " "Reduction apportion a non-dependant of several joint occupiers " "between them. Foster children and carers engaged through a charity " @@ -29,5 +31,6 @@ def formula(person, period, parameters): # UC Regs 2013 Sch 4 para 9(2)(d)-(f); HB Regs 2006 reg 3(2)(d)-(e) # and 3(4); HB (SPC) Regs 2006 reg 3. head_family = person.benunit("benunit_contains_household_head", period) - liable_for_rent = person.benunit("benunit_is_rent_liable", period) - return ~head_family & ~liable_for_rent + liable_for_rent = person.benunit("benunit_is_liable_for_household_rent", period) + pays_householder = person("pays_rent_to_householder", period) + return ~head_family & ~liable_for_rent & ~pays_householder diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py index a42dbbe2fb..49242222e8 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py @@ -9,7 +9,9 @@ class uc_individual_non_dep_deduction_eligible(Variable): reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9" def formula(person, period, parameters): - not_rent_liable = ~person.benunit("benunit_is_rent_liable", period) + not_rent_liable = ~person.benunit( + "benunit_is_liable_for_household_rent", period + ) p = parameters( period ).gov.dwp.universal_credit.elements.housing.non_dep_deduction @@ -17,6 +19,8 @@ def formula(person, period, parameters): age_eligible = age >= p.age_threshold exempt = person("uc_non_dep_deduction_exempt", period) # A boarder or lodger is liable to pay on a commercial basis for their - # occupation, so is not a non-dependant (Sch 4 para 9(2)(d)). + # occupation, so is not a non-dependant (Sch 4 para 9(2)(d)). The + # model treats their partner as liable too (pays_rent_to_householder); + # anyone else in their family who pays nothing stays a non-dependant. commercial = person("pays_rent_to_householder", period) return ~exempt & age_eligible & not_rent_liable & ~commercial 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..37a47aab42 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 @@ -15,8 +15,17 @@ class uc_non_dep_receives_pension_credit(Variable): "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." + "their Universal Credit housing element. Results for families liable " + "for the household's rent (benunit_is_liable_for_household_rent) are " + "disregarded because none of their members is a non-dependant. A " + "boarder's or lodger's family is not ruled out: its non-paying adult " + "members can be non-dependants, and keep the exemption when the " + "family receives Pension Credit. For that family the branch fixes " + "the family's Universal Credit housing element, which may cover the " + "rent it pays the householder, at nil, and computes its Pension " + "Credit there. A family whose members are all over the qualifying " + "age claims no Universal " + "Credit, so the branch gives its actual Pension Credit." ) definition_period = YEAR reference = ( @@ -49,4 +58,9 @@ def formula(benunit, period, parameters): finally: # A later period or changed input must start from current inputs. del simulation.branches[name] - return (pension_credit > 0) & ~benunit("benunit_is_rent_liable", period) + # A boarder's or lodger's family still has a non-paying member who can + # be a non-dependant, so only liability for the household's rent + # rules the family out here. + return (pension_credit > 0) & ~benunit( + "benunit_is_liable_for_household_rent", period + ) diff --git a/policyengine_uk/variables/household/consumption/rent/benunit_is_liable_for_household_rent.py b/policyengine_uk/variables/household/consumption/rent/benunit_is_liable_for_household_rent.py new file mode 100644 index 0000000000..165196a5ec --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/benunit_is_liable_for_household_rent.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class benunit_is_liable_for_household_rent(Variable): + value_type = bool + entity = BenUnit + label = "Liable for the household's rent" + documentation = ( + "Whether this family is liable for rent (benunit_is_rent_liable) " + "other than as boarders or lodgers paying the householder. " + "benunit_rent includes what members pay the householder, so a family " + "whose only liability is that payment reads as rent-liable there; " + "here it does not, and each member's own liability is " + "pays_rent_to_householder. The non-dependant rules read this flag: " + "a member of a liable family is not a non-dependant, while in a " + "boarder's or lodger's family only those liable to pay the " + "householder leave the count." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + ) + + def formula(benunit, period, parameters): + pays_householder = benunit.any( + benunit.members("pays_rent_to_householder", period) + ) + return benunit("benunit_is_rent_liable", period) & ~pays_householder diff --git a/policyengine_uk/variables/household/income/pays_rent_to_householder.py b/policyengine_uk/variables/household/income/pays_rent_to_householder.py index 55282b7ff0..a616a4f759 100644 --- a/policyengine_uk/variables/household/income/pays_rent_to_householder.py +++ b/policyengine_uk/variables/household/income/pays_rent_to_householder.py @@ -4,26 +4,42 @@ class pays_rent_to_householder(Variable): value_type = bool entity = Person - label = "in a family that pays the householder rent as boarders or lodgers" + label = "liable to pay the householder rent as a boarder or lodger" documentation = ( - "Whether this person's benefit unit, which does not contain the " - "household head, pays the household head rent for board and lodging " - "or for lodging. Such a person is liable to make payments on a " - "commercial basis for their occupation, so is not a non-dependant of " - "the householder. The model takes every such payment to be commercial " - "and the payer not to be a close relative living there, whom the " - "rules keep as a non-dependant (HB reg 3(3) with reg 9(1); CTR reg " - "9(3)). The Family Resources Survey asks about this rent only for " - "people not related to the household reference person." + "Whether this person, outside the household head's benefit unit, is " + "liable to pay the household head on a commercial basis for their " + "occupation as a boarder or lodger, so is not the householder's " + "non-dependant. That covers a person who pays rent for board and " + "lodging or for lodging (rent_paid_as_boarder or rent_paid_as_lodger " + "above nil), and the claimant or partner of a benefit unit where a " + "claimant or partner pays. HB Regs 2006 and HB (SPC) Regs 2006 reg " + "8(1)(b) treat the partner of a liable person as liable. The Universal " + "Credit and council tax reduction rules have no such words; the data " + "hold no agreement, and the model assumes, for every scheme, that a " + "couple's room binds both members. Nobody else in the payer's benefit " + "unit is liable: a payer who is not a claimant or partner, such as a " + "qualifying young person, makes nobody else liable, and a payer's " + "adult son or daughter who pays nothing stays a non-dependant. The " + "model takes every such payment to be commercial and the payer not to " + "be a close relative living there, whom the rules keep as a " + "non-dependant (HB reg 3(3) with reg 9(1); CTR reg 9(3)). The Family " + "Resources Survey asks about this rent only for people not related to " + "the household reference person." ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/8", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/8", "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", ] def formula(person, period, parameters): paid = add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) + pays = paid > 0 + claimant_or_partner = person("is_claimant_or_partner", period) + # HB Regs 2006 reg 8(1)(b): the partner of a liable person is liable. + partner_pays = person.benunit.any(pays & claimant_or_partner) in_head_benunit = person.benunit("benunit_contains_household_head", period) - return person.benunit.any(paid > 0) & ~in_head_benunit + return ~in_head_benunit & (pays | (claimant_or_partner & partner_pays)) From e7fa06ebdc4914385554546d73b8c78d4a6566e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:14:00 +0000 Subject: [PATCH 169/250] Follow the model's legacy closure in the UC qualifying young person test The model ends a person's own Income Support, income-based JSA and income-related ESA when person_legacy_benefits_closed holds, and the family's Housing Benefit and tax credits when legacy_benefits_closed holds outside the Pension Credit age conditions. Neither closure reads a Universal Credit variable, so the predicate follows both: nobody is entitled under SI 2014/1230 reg. 28 to an award the model has ended. Under disable_simulated_benefits the model pays each benefit as reported after its year gate closes, so the year gates also read that switch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KY1QwmCKh9DpfvfPk --- changelog.d/2804.fixed.md | 2 +- ...ifying_young_person_existing_benefits.yaml | 8 +- ...ualifying_young_person_legacy_closure.yaml | 292 ++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 8 +- ...nefit_cap_specified_benefits_properties.py | 5 +- .../tests/test_disable_simulated_benefits.py | 64 ++++ .../test_legacy_award_readers_properties.py | 6 +- ...ung_person_existing_benefits_properties.py | 165 ++++++++-- ..._qualifying_young_person_legacy_closure.py | 99 ++++++ ...fying_young_person_for_universal_credit.py | 97 ++++-- ...son_under_universal_credit_regulation_5.py | 5 +- 11 files changed, 684 insertions(+), 67 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml create mode 100644 policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index 1585e60f48..fd64aee48a 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays it and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit while the model pays it on that report (its year gate and the model's legacy closure) and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 0d608f2111..1b10314ae3 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -4,10 +4,14 @@ # ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax # Credit. The predicate reads the person's own reports and, for a claimant or # partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). -# It reads a report only in a year the model pays that benefit, and only if +# It reads a report only while the model pays that benefit on it, and only if # no reform neutralises it. The model pays Income Support and income-based # JSA until 31 March 2026 and tax credits until 5 April 2025, and has no -# closure date for Housing Benefit or income-related ESA. The first six cases, +# closure date for Housing Benefit or income-related ESA. It also ends a +# person's legacy awards when its annual migration proxy closes them, and +# the predicate follows that closure +# (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases +# no report closes the young person's legacy awards. The first six cases, # and the four after the seventh, share one household: a parent aged 45 and # a 17-year-old on a non-advanced course, North East, owned outright, no # income. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml new file mode 100644 index 0000000000..c7c2809447 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -0,0 +1,292 @@ +# The model ends a person's legacy awards once its annual migration proxy +# closes them, and the UC qualifying young person test (SI 2014/1230 reg. 28) +# follows that closure: nobody is entitled to an award the model has ended. +# The model ends a person's own Income Support, income-based JSA and +# income-related ESA when person_legacy_benefits_closed holds +# (income_support, jsa_income, esa_income_report_closed), and the family's +# Housing Benefit and tax credits when legacy_benefits_closed holds +# (housing_benefit_eligible, is_CTC_eligible, is_WTC_eligible). A +# dependant's own Income Support or income-based JSA report from 2026, or own +# tax credit report from 2025, closes the dependant's legacy awards +# (gov.dwp.income_support.active, gov.dwp.JSA.income.active and +# gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and +# 6 April 2025). The closure reports exclude nobody, as the benefit has closed. +# +# Every case uses one household: a parent aged 45 and a 17-year-old on a +# non-advanced course in the parent's benefit unit, North East, owned +# outright, no income. The parent claims Universal Credit as a single +# claimant over 25 (universal_credit/standard_allowance/amount.yaml +# SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the +# first child higher amount for a qualifying young person +# (elements/child/first/higher_amount.yaml: £339.00 in 2025-26, £351.88 in +# 2026-27). So: +# 2025 with the child element: 12 x (400.14 + 339.00) = 8,869.68 +# 2026 with the child element: 12 x (424.90 + 351.88) = 9,321.36 +# 2026 without the child element: 12 x 424.90 = 5,098.80 + +- name: A young person's own income-related ESA report alone removes the child element in 2026. + # No closure applies: the young person reports no closed benefit, so the + # model pays the £3,000 of income-related ESA (esa_income) and the young + # person is entitled to it. UC: 12 x 424.90 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, false] + esa_income: 3_000 + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A young person's own income-related ESA report with their own Income Support report in 2026 leaves the child element. + # Income Support closed on 1 April 2026, so the young person's own report + # closes their legacy awards (person_legacy_benefits_closed) and the model + # pays no income-related ESA on their report (esa_income 0). Neither report + # excludes them. UC: 12 x (424.90 + 351.88) = 9,321.36; child element + # 12 x 351.88 = 4,222.56. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-related ESA report with their own income-based JSA report in 2026 leaves the child element. + # Income-based JSA closed on 1 April 2026, so the young person's own report + # closes their legacy awards and the model pays no income-related ESA on + # their report. UC: 12 x (424.90 + 351.88) = 9,321.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-related ESA report with their own Child Tax Credit report in 2025 leaves the child element. + # Tax credits closed on 6 April 2025, so the young person's own Child Tax + # Credit report closes their legacy awards and the model pays no + # income-related ESA on their report. UC: 12 x (400.14 + 339.00) = 8,869.68; + # child element 12 x 339.00 = 4,068.00. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Income Support report with their own Child Tax Credit report in 2025 leaves the child element. + # Income Support still pays in 2025, but the young person's own Child Tax + # Credit report closes their legacy awards, which ends their Income Support + # too. UC: 12 x (400.14 + 339.00) = 8,869.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own income-based JSA report with their own Child Tax Credit report in 2025 leaves the child element. + # Income-based JSA still pays in 2025, but the young person's own Child Tax + # Credit report closes their legacy awards, so the model pays no + # income-based JSA on their report (jsa_income 0). UC: 8,869.68 as above. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + jsa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Housing Benefit report after the parent's legacy awards close leaves the child element. + # The parent's own Income Support report from 2026 closes the family's + # legacy awards (legacy_benefits_closed), and the family doesn't meet the + # Pension Credit age conditions, so the model pays no Housing Benefit on + # the young person's report (housing_benefit_eligible). UC: 12 x (424.90 + + # 351.88) = 9,321.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + housing_benefit_eligible: false + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own Housing Benefit report after the parent's tax credits close leaves the child element. + # The parent's own Child Tax Credit report from 2025 closes the family's + # legacy awards, which ends the family's Housing Benefit. UC: 12 x (400.14 + # + 339.00) = 8,869.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + housing_benefit_eligible: false + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 859fab063e..38d7067893 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -3,9 +3,11 @@ # and JSA without their income-related and income-based forms (UC Regs reg. # 2). DWP doesn't count a person entitled to income-related ESA, income-based # JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. -# 28). The UC predicate reads a report only in a year the model pays that -# benefit (income-based JSA and Income Support until 31 March 2026), and only -# if no reform neutralises it. The Secretary of State excludes own awards +# 28). The UC predicate reads a report only while the model pays that +# benefit on it (income-based JSA and Income Support until 31 March 2026, and +# income-related ESA, income-based JSA and Income Support only until the +# person's legacy awards close, person_legacy_benefits_closed), and only if +# no reform neutralises it. The Secretary of State excludes own awards # from the Pension Credit routes, including income support (SPC reg. 4A(5)): # is_on_income_support reads it, from the calculated award for a claimant or # partner and from their own report for anyone else. The reports identify the diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 1c328e968b..058700ec0c 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -283,8 +283,9 @@ def own_benefits(m, year=YEAR): # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it # pays, as in 2025): a personal report identifies an award in the - # person's own right. Reg. 28 turns on entitlement, so an income-based - # JSA report counts only in a year the model pays it. + # person's own right. The Secretary of State ties reg. 28's exclusion to + # entitlement, so the oracle counts an income-based JSA report only in a + # year the model pays it. dwp = baseline_system.parameters(f"{year}-01-01").gov.dwp reports = [ report diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 46ae03d233..a53be29791 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -818,3 +818,67 @@ def test_no_person_is_moved_off_their_legacy_benefits(reformed, year): # reported ESA in household income while incorrectly ending their status. closed = by_person(reformed, "person_legacy_benefits_closed", year) assert closed == dict.fromkeys(closed, False) + + +# A parent aged 45 and a 17-year-old on a non-advanced course in each family; +# the 17-year-old reports £3,000 of one existing benefit (SI 2014/1230 reg. +# 2(1)) in the dataset year. +YOUNG_PERSON_REPORTS = { + "income_support_reported": "income_support", + "jsa_income_reported": "jsa_income", + "esa_income_reported": "esa_income", + "housing_benefit_reported": "housing_benefit", + "child_tax_credit_reported": "child_tax_credit", + "working_tax_credit_reported": "working_tax_credit", +} + + +def young_person_situation(): + people, benunits, households = {}, {}, {} + for report in YOUNG_PERSON_REPORTS: + parent, young = f"{report}_parent", f"{report}_young" + people[parent] = dict(adult(45, True), is_parent=every_year(True)) + people[young] = { + "age": every_year(17), + "is_claimant_or_partner": every_year(False), + "current_education": every_year("POST_SECONDARY"), + report: {DATASET_YEAR: 3_000}, + } + benunits[report] = {"members": [parent, young]} + households[report] = {"members": [parent, young]} + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.mark.parametrize("year", YEARS) +def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_person( + year, +): + # The reform pays each benefit at its dataset-year report after the + # model's closure dates (income-based JSA from 2026, tax credits from + # 2025), so the young person stays entitled to it and reg. 28 excludes + # them from the UC qualifying young person test in every year. Income + # Support is the claimant's family's award, so the model pays none on a + # dependant's own report, with the reform or without it; the predicate + # reads that report in the years the model pays Income Support to anyone. + reformed = Simulation(situation=young_person_situation(), scenario=REFORM) + unreformed = Simulation(situation=young_person_situation()) + predicate = "is_qualifying_young_person_for_universal_credit" + paid = [ + float(reformed.calculate(benefit, year)[i]) + for i, benefit in enumerate(YOUNG_PERSON_REPORTS.values()) + ] + assert paid == [0, 3_000, 3_000, 3_000, 3_000, 3_000] + assert reformed.calculate(predicate, year)[1::2].tolist() == [False] * 6 + # Without the reform, the model stops paying tax credits from 2025 and + # Income Support and income-based JSA from 2026, and those reports + # exclude nobody. + tax_credits_closed = year >= 2025 + legacy_closed = year >= 2026 + assert unreformed.calculate(predicate, year)[1::2].tolist() == [ + legacy_closed, + legacy_closed, + False, + False, + tax_credits_closed, + tax_credits_closed, + ] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index f884cec685..734ed3c133 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -687,9 +687,9 @@ def test_other_members_awards_never_change_the_readers_for_young_singles(drawn): @pytest.mark.parametrize("report", ["esa_income_reported", "jsa_income_reported"]) def test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged(report): # A natural route-(a) case: no route or own-right Boolean is supplied. - # SI 2014/1230 reg. 28 excludes the member's own income-related ESA or - # income-based JSA (paying in 2025) from the UC test, and SPC reg. 4A(5) - # from the Pension Credit test. + # The Secretary of State excludes the member's own income-related ESA or + # income-based JSA (paying in 2025) from the UC test (SI 2014/1230 reg. + # 28) and from the Pension Credit test (SPC reg. 4A(5)). family = ( [ { diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 452f61f63b..534a714213 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -5,16 +5,26 @@ person's own reports of income-based JSA, income-related ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant or partner, the couple's joint tax credit award (Tax Credits Act 2002 -s.3(3)). It reads a report only in a year the model pays that benefit, and -only if no reform neutralises it; these tests run without a reform, and -test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. -These tests check that: - -- any positive own report of an existing benefit the model pays that year - makes the predicate false; +s.3(3)). It reads a report only while the model pays that benefit on it, +and only if no reform neutralises it; these tests run without a reform, and +test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. The +model stops paying a benefit when its year gate closes, and ends a person's +legacy awards when its annual migration proxy closes them: their own Income +Support, income-based JSA and income-related ESA when +person_legacy_benefits_closed holds, and the family's Housing Benefit and +tax credits when legacy_benefits_closed holds and the family doesn't meet +the Pension Credit age conditions. The oracle reads those three closure +variables from the model. These tests check that: + +- any positive own report of an existing benefit the model pays on it that + year makes the predicate false; - with every such report at zero, the predicate equals the UC Regs 2013 reg. 5 test less the person's reported UC, ESA and JSA awards; -- a claimant's or partner's reports never change a dependant's predicate; +- a claimant's or partner's reports change a dependant's predicate only by + closing the family's legacy awards; +- a person whose legacy awards have closed is never excluded through their + own income-related ESA report, nor through a Housing Benefit report once + the family's awards close outside the Pension Credit age conditions; - a parent's Universal Credit never rises when their young person's own report turns positive; - the predicate equals the reg. 5 test less reg. 28's exclusions, exactly. @@ -174,9 +184,8 @@ def qualifying_families(draw): """Families where reg. 28 alone decides most members' status. Each slot is drawn from qualifying, which meets reg. 5, or from the - general strategies. Over 10,000 derandomised draws for 2025, the - claimant met reg. 5 in 59% of draws, the partner or dependant in 62%, - and the dependants in 65% and 69%. A partner is otherwise an older adult whose + general strategies. Each slot meets reg. 5 in about three draws in + five. A partner is otherwise an older adult whose own reports reg. 28 must not pass to the claimant, except for the couple's tax credit award. """ @@ -196,7 +205,36 @@ def qualifying_families(draw): return [claimant, second] + dependants +# Someone who meets reg. 5 unless reg. 28 excludes them and reports each +# existing benefit one time in three, so a report that closes their legacy +# awards often sits beside an income-related ESA or Housing Benefit report. +reporting_qualifying = member( + st.integers(16, 18), + st.just(False), + NON_ADVANCED, + own_right=(False,), + reg_5_5_reports=False, +) + + +@st.composite +def closure_families(draw): + """A claimant and a second member who meet reg. 5 one time in two, and + two dependants who always do, each reporting any existing benefit.""" + couple = draw(st.booleans()) + claimant = draw(st.one_of(reporting_qualifying, adult)) + claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + second = draw(st.one_of(reporting_qualifying, adult if couple else young_member)) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict(draw(reporting_qualifying), is_claimant_or_partner=False, is_parent=False) + for _ in range(2) + ] + return [claimant, second] + dependants + + units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) +closure_units_strategy = st.lists(closure_families(), min_size=UNITS, max_size=UNITS) qualifying_units_strategy = st.lists( qualifying_families(), min_size=UNITS, max_size=UNITS ) @@ -277,24 +315,52 @@ def get(name): ) -def reg_28_exclusions(units, year): - """Who reg. 28 excludes: own reports of benefits the model pays that year, - and, for a claimant or partner, the couple's tax credit award.""" +def open_awards(sim, year): + """For each report, whether the model's legacy closure leaves the award on + it open, person by person.""" + own_open = ~sim.calculate("person_legacy_benefits_closed", year) + family_closed = sim.calculate("legacy_benefits_closed", year, map_to="person") + pension_age = sim.calculate( + "meets_pension_credit_age_conditions", year, map_to="person" + ) + return { + "income_support_reported": own_open, + "jsa_income_reported": own_open, + "esa_income_reported": own_open, + "housing_benefit_reported": ~(family_closed & ~pension_age), + "child_tax_credit_reported": ~family_closed, + "working_tax_credit_reported": ~family_closed, + } + + +def reg_28_exclusions(units, year, sim): + """Who reg. 28 excludes: own reports of benefits the model pays on them + that year, and, for a claimant or partner, the couple's tax credit + award.""" paying = PAYING[year] - own = np.array([any(p[n] > 0 for n in paying) for p in flatten(units)], dtype=bool) - couple_tax_credits = np.array( + award_open = open_awards(sim, year) + people = flatten(units) + own = np.array( [ - p["is_claimant_or_partner"] - and any( - q["is_claimant_or_partner"] and q[n] > 0 - for q in members - for n in paying.intersection(TAX_CREDITS) - ) - for members in units - for p in members + any(p[n] > 0 and award_open[n][i] for n in paying) + for i, p in enumerate(people) ], dtype=bool, ) + couple_tax_credits = np.zeros(len(people), dtype=bool) + start = 0 + for members in units: + indices = range(start, start + len(members)) + award = any( + people[j]["is_claimant_or_partner"] + and people[j][n] > 0 + and award_open[n][j] + for j in indices + for n in paying.intersection(TAX_CREDITS) + ) + for j in indices: + couple_tax_credits[j] = people[j]["is_claimant_or_partner"] and award + start += len(members) return own | couple_tax_credits @@ -302,7 +368,7 @@ def reg_28_exclusions(units, year): @given(units=units_strategy, year=st.sampled_from(YEARS)) def test_any_own_existing_benefit_report_excludes_the_person(units, year): sim = simulate(units, year) - assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year)) + assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year, sim)) @SETTINGS @@ -324,8 +390,12 @@ def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): @SETTINGS @given(units=units_strategy, year=st.sampled_from(YEARS)) -def test_a_claimants_reports_never_change_a_dependants_predicate(units, year): - reported = predicate(simulate(units, year), year) +def test_a_claimants_reports_change_a_dependant_only_by_closing_the_family(units, year): + sim = simulate(units, year) + reported = predicate(sim, year) + family_closed = sim.calculate( + "legacy_benefits_closed", year, map_to="person" + ).copy() cleared = predicate( simulate( with_zero(units, EXISTING_BENEFITS, lambda p: p["is_claimant_or_partner"]), @@ -334,7 +404,44 @@ def test_a_claimants_reports_never_change_a_dependants_predicate(units, year): year, ) dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) - assert reported[dependant].tolist() == cleared[dependant].tolist() + # Where the couple's reports leave the family's legacy awards open, they + # never change a dependant's predicate. + kept = dependant & ~family_closed + assert reported[kept].tolist() == cleared[kept].tolist() + # Where they close them, they can only lift a dependant's exclusion. + closed = dependant & family_closed + assert not np.any(~reported[closed] & cleared[closed]) + + +@SETTINGS +@given(units=closure_units_strategy, year=st.sampled_from((2025, 2026))) +def test_a_closed_legacy_award_never_excludes(units, year): + # The model ends a person's own income-related ESA once their legacy + # awards close (person_legacy_benefits_closed, read by + # esa_income_report_closed), and the family's Housing Benefit once the + # claimant's and partner's close outside the Pension Credit age + # conditions (housing_benefit_eligible). Clearing those reports leaves + # every predicate as it was. Neither report closes a legacy award, so + # clearing it leaves the closures as they were. Tax credits close from + # 2025 and Income Support and income-based JSA from 2026. + sim = simulate(units, year) + reported = predicate(sim, year) + award_open = open_awards(sim, year) + closed = { + name: ~award_open[name] + for name in ("esa_income_reported", "housing_benefit_reported") + } + cleared_units, index = [], 0 + for members in units: + cleared_members = [] + for p in members: + cleared_members.append( + dict(p, **{name: 0.0 for name in closed if closed[name][index]}) + ) + index += 1 + cleared_units.append(cleared_members) + cleared = predicate(simulate(cleared_units, year), year) + assert reported.tolist() == cleared.tolist() @SETTINGS @@ -381,5 +488,5 @@ def family(young_report): @given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) def test_the_predicate_is_reg_5_less_reg_28(units, year): sim = simulate(units, year) - expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year) + expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year, sim) assert predicate(sim, year).tolist() == expected.tolist() diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py new file mode 100644 index 0000000000..2806880775 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py @@ -0,0 +1,99 @@ +"""The UC qualifying young person test follows the family's tax credit closure. + +The model pays tax credits to the family, on every member's reports, and +ends them when the claimant's and partner's legacy awards close +(legacy_benefits_closed, read by is_CTC_eligible and is_WTC_eligible). +Nobody is entitled to an award the model has ended, so the predicate reads a +young person's own tax credit report only while the family's legacy awards +stay open (SI 2014/1230 reg. 28). + +In the baseline the family's legacy awards close only once a benefit has +closed, and tax credits close first (gov.dwp.tax_credits.active turns false +on 6 April 2025, Income Support on 1 April 2026), so the year gate already +covers every closed tax credit report. These tests keep tax credits paying +in 2026 by a parameter reform, so the family's closure decides. + +The household: a parent aged 45 and a 17-year-old on a non-advanced course, +North East, owned outright, no income, in 2026. The parent claims Universal +Credit as a single claimant over 25: £424.90 a month (standard_allowance +SINGLE_OLD) and £351.88 a month for a qualifying young person (child element +first higher amount), so 12 x (424.90 + 351.88) = £9,321.36 with the child +element and 12 x 424.90 = £5,098.80 without it. +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 +TAX_CREDITS_PAYING = {"gov.dwp.tax_credits.active": {"2020-01-01.2100-12-31": True}} + + +def household(young_report, parent_report=None): + parent = { + "age": {YEAR: 45}, + "is_parent": {YEAR: True}, + "is_claimant_or_partner": {YEAR: True}, + } + if parent_report: + parent[parent_report] = {YEAR: 3_000} + return { + "people": { + "parent": parent, + "young_person": { + "age": {YEAR: 17}, + "current_education": {YEAR: "POST_SECONDARY"}, + "is_claimant_or_partner": {YEAR: False}, + young_report: {YEAR: 1_000}, + }, + }, + "benunits": {"benunit": {"members": ["parent", "young_person"]}}, + "households": { + "household": { + "members": ["parent", "young_person"], + "region": {YEAR: "NORTH_EAST"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + } + }, + } + + +def outcome(situation, reform): + sim = Simulation(situation=situation, reform=reform) + return ( + sim.calculate("legacy_benefits_closed", YEAR).tolist(), + sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR).tolist(), + round(float(sim.calculate("universal_credit", YEAR)[0]), 2), + ) + + +@pytest.mark.parametrize( + "report", ["child_tax_credit_reported", "working_tax_credit_reported"] +) +def test_a_young_persons_tax_credit_report_excludes_while_the_family_stays_open( + report, +): + # Tax credits pay in 2026 under the reform, and nothing closes the + # family's legacy awards, so the young person's own report excludes them. + assert outcome(household(report), TAX_CREDITS_PAYING) == ( + [False], + [False, False], + 5_098.80, + ) + + +@pytest.mark.parametrize( + "report", ["child_tax_credit_reported", "working_tax_credit_reported"] +) +def test_the_parents_closed_legacy_award_ends_the_young_persons_tax_credits( + report, +): + # The parent's own Income Support report in 2026, after Income Support + # closed, closes the family's legacy awards, which ends the family's tax + # credits, the young person's report included. + situation = household(report, "income_support_reported") + assert outcome(situation, TAX_CREDITS_PAYING) == ( + [True], + [False, True], + 9_321.36, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 94ddd31351..e0426e32ea 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -13,18 +13,32 @@ class is_qualifying_young_person_for_universal_credit(Variable): "5(5)) and no existing benefit: income-based JSA, income-related ESA, " "Income Support, Housing Benefit, Child Tax Credit or Working Tax " "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " - "29). Reg. 28 turns on entitlement, so the formula reads a report only " - "in a year the model pays that benefit, and only if no reform " - "neutralises it. The model pays Income Support, income-based JSA and " - "tax credits only while gov.dwp.income_support.active, " - "gov.dwp.JSA.income.active and gov.dwp.tax_credits.active hold, and " - "has no closure date for Housing Benefit or income-related ESA. The " - "formula checks each report against its own variable: income_support, " - "jsa_income, esa_income, housing_benefit, child_tax_credit or " - "tax_credits for Child Tax Credit, and working_tax_credit or " - "tax_credits for Working Tax Credit. For a claimant or partner it also " - "reads the couple's joint tax credit award (Tax Credits Act 2002 " - "s.3(3)), each credit by its own check." + "29). The Secretary of State ties the exclusion to entitlement (reg. " + "28), so the formula reads a report only while the model pays that " + "benefit on it, and only if no reform neutralises it. The model pays " + "Income Support, income-based JSA and tax credits only while " + "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " + "gov.dwp.tax_credits.active hold, or while " + "gov.contrib.policyengine.disable_simulated_benefits pays them as " + "reported, and has no closure date for Housing Benefit or " + "income-related ESA. The model also ends a person's legacy awards " + "once its annual migration proxy closes them: it ends their Income " + "Support, income-based JSA and income-related ESA when " + "person_legacy_benefits_closed holds, and the family's Housing " + "Benefit and tax credits when legacy_benefits_closed holds and the " + "family doesn't meet the Pension Credit age conditions. The formula " + "follows both closures. It doesn't follow the closures that read " + "claims_universal_credit (esa_income_report_closed for a claimant or " + "partner, housing_benefit_eligible and is_CTC_eligible), because " + "claims_universal_credit reads this variable. The formula checks each " + "report against its own variable: income_support, jsa_income, " + "esa_income, housing_benefit, child_tax_credit or tax_credits for " + "Child Tax Credit, and working_tax_credit or tax_credits for Working " + "Tax Credit. A reform that changes or zeroes a benefit's formula " + "without neutralising its variable leaves the exclusion on that " + "benefit's reports in place. For a claimant or partner it also reads " + "the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)), " + "each credit by its own check." ) definition_period = YEAR reference = [ @@ -39,27 +53,62 @@ class is_qualifying_young_person_for_universal_credit(Variable): def formula(person, period, parameters): # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level - # reports, so it calculates no family benefit that depends on QYP - # status. It reads a dependant's own reports, never a parent's. - dwp = parameters(period).gov.dwp + # reports and the model's legacy closure proxy, so it calculates no + # family benefit that depends on QYP status. + p = parameters(period).gov + dwp = p.dwp + # The reform pays each benefit at its reported amount after the + # model's closure dates, and holds both legacy closures false. + paid_as_reported = p.contrib.policyengine.disable_simulated_benefits system = person.simulation.tax_benefit_system def paying(active, *names): # Nobody is entitled to a benefit the model no longer pays, or one # a reform neutralises. - return bool(active) and not any( + return bool(active or paid_as_reported) and not any( system.get_variable(name).is_neutralized for name in names ) - income_support_paying = paying(dwp.income_support.active, "income_support") - jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") - esa_income_paying = paying(True, "esa_income") - housing_benefit_paying = paying(True, "housing_benefit") - child_tax_credit_paying = paying( - dwp.tax_credits.active, "child_tax_credit", "tax_credits" + # The model ends a person's Income Support, income-based JSA and + # income-related ESA when their legacy awards close: the claimant's + # and partner's together (legacy_benefits_closed, read through + # income_support), and anyone else's on their own reports + # (jsa_income, esa_income). esa_income_report_closed also ends a + # claimant's or partner's income-related ESA when the family claims + # Universal Credit, but the formula can't read it: its + # claimant-or-partner limb reads claims_universal_credit, which reads + # this variable. person_legacy_benefits_closed and + # legacy_benefits_closed read reports, is_claimant_or_partner, the + # Pension Credit age conditions and parameters, and no Universal + # Credit variable. + own_award_open = ~person("person_legacy_benefits_closed", period) + # The model pays Housing Benefit and tax credits to the family, on + # every member's reports, and ends them when the claimant's and + # partner's legacy awards close (housing_benefit_eligible, + # is_CTC_eligible, is_WTC_eligible). A family that meets the Pension + # Credit age conditions keeps Housing Benefit unless it leaves that + # route by claiming Universal Credit, which the formula can't read, + # so for such a family the formula keeps the Housing Benefit report. + family_closed = person.benunit("legacy_benefits_closed", period) + family_award_open = ~family_closed + housing_benefit_open = ~( + family_closed + & ~person.benunit("meets_pension_credit_age_conditions", period) ) - working_tax_credit_paying = paying( - dwp.tax_credits.active, "working_tax_credit", "tax_credits" + + income_support_paying = ( + paying(dwp.income_support.active, "income_support") & own_award_open + ) + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & own_award_open + housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open + child_tax_credit_paying = ( + paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") + & family_award_open + ) + working_tax_credit_paying = ( + paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") + & family_award_open ) tax_credits_reported = child_tax_credit_paying * person( "child_tax_credit_reported", period diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py index 034e6772fe..882f7cf52a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -50,9 +50,8 @@ def formula(person, period, parameters): ) # The Secretary of State excludes a person receiving Universal Credit, # contributory ESA or contribution-based JSA (reg. 5(5)), on both - # routes. The formula reads raw - # person-level reports, so it calculates no family benefit that - # depends on qualifying young person status. + # routes. The formula reads raw person-level reports, so it calculates + # no family benefit that depends on qualifying young person status. receives_reg_5_5_benefit = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) From f379377f91b39251ace94b1a541555fe64065473 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:09:10 +0000 Subject: [PATCH 170/250] Pin the Pension Credit age exception to the Housing Benefit closure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KY1QwmCKh9DpfvfPk --- ...ualifying_young_person_legacy_closure.yaml | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index c7c2809447..442d3d0683 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -12,7 +12,7 @@ # gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and # 6 April 2025). The closure reports exclude nobody, as the benefit has closed. # -# Every case uses one household: a parent aged 45 and a 17-year-old on a +# Every case but the last uses one household: a parent aged 45 and a 17-year-old on a # non-advanced course in the parent's benefit unit, North East, owned # outright, no income. The parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml @@ -290,3 +290,40 @@ is_qualifying_young_person_for_universal_credit: [false, true] uc_child_element: 4_068 universal_credit: 8_869.68 + +- name: A young person's own Housing Benefit report still excludes them where the family meets the Pension Credit age conditions. + # A parent aged 70 reports their own Working Tax Credit in 2025, after tax + # credits closed, which closes the family's legacy awards. The family meets + # the Pension Credit age conditions, so the model keeps paying Housing + # Benefit unless the family leaves that route by claiming Universal Credit + # (left_pension_route_at_legacy_closure). The predicate can't read that + # claim, because claims_universal_credit reads the predicate, so it keeps + # the Housing Benefit report. UC: 12 x 400.14 = 4,801.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + is_parent: true + is_claimant_or_partner: true + working_tax_credit_reported: 1_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + meets_pension_credit_age_conditions: true + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_801.68 From f7788dc97a8d1ad4dc7aa136281ec0abadcaaf3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:28:04 +0000 Subject: [PATCH 171/250] Pin the tax uncounted rent bears without earnings on the UC route A lodger paying 30,000 leaves 22,500 taxable, above the personal allowance, so council tax reduction on the Universal Credit route leaves its 1,986 without earnings out of the deducted tax. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KubCAd9d7yLvRkRTg --- .../boarders_and_lodgers.yaml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml index b273991838..de27030513 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml @@ -610,3 +610,43 @@ output: rent_a_room_income_tax_without_earnings: [0, 0] council_tax_reduction_applicable_income: [0, 0] + +- name: CTR on the Universal Credit route leaves out the tax uncounted rent bears without earnings + # 2026-27, England, working age, with a Universal Credit award (set as an + # input). Pay 30,000 and a lodger paying 30,000: taxable rent-a-room income + # 30,000 - 7,500 = 22,500. With the pay, taxable income 52,500 - 12,570 = + # 39,930: 37,700 x 20% + 2,230 x 40% = 7,540 + 892 = 8,432, of which the + # pay alone bears (30,000 - 12,570) x 20% = 3,486 and the rent 4,946. + # Without the pay, the rent bears (22,500 - 12,570) x 20% = 1,986, which + # the route leaves out of the deducted tax; the other 2,960 is the extra + # tax earnings cause on the rent, and stays deducted. NI 17,430 x 8% = + # 1,394.40. Income: 30,000 - (8,432 - 1,986) - 1,394.40 = 22,159.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 45 + is_household_head: true + employment_income: 30_000 + lodger: + age: 30 + is_household_head: false + rent_paid_as_lodger: 30_000 + benunits: + householder_unit: + members: [householder] + council_tax_reduction_universal_credit_award: true + lodger_unit: + members: [lodger] + would_claim_uc: false + households: + household: + members: [householder, lodger] + region: NORTH_WEST + output: + income_tax_before_winter_fuel_payment_charge: [8_432, 0] + rent_a_room_income_tax: [4_946, 0] + rent_a_room_income_tax_without_earnings: [1_986, 0] + legacy_means_test_deducted_income_tax: [8_432, 0] + council_tax_reduction_applicable_income: [22_159.60, 0] From 3c16aea5ae07c928b222408aeef1a58d62f8ab64 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:42:20 +0000 Subject: [PATCH 172/250] 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 173/250] 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 174/250] 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 175/250] 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 176/250] 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 177/250] 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 178/250] 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 179/250] 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 180/250] 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 181/250] 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 182/250] 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 183/250] 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 184/250] 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 185/250] 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 ad19029f6bb216058927d9813c313702fe75c59b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:45:08 +0000 Subject: [PATCH 186/250] Follow the Universal Credit claim limbs for a claimant or partner in the UC qualifying young person test A claim for Universal Credit ends the claimant's and partner's own awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), not a dependant's. The predicate now reads esa_income_report_closed for income-related ESA, and for a claimant or partner applies housing_benefit_eligible's and is_CTC_eligible's claims_universal_credit terms to Housing Benefit and Child Tax Credit reports. Under disable_simulated_benefits it leaves the claim limbs out, as that reform pays each report claim or none. Moves closure cases 7 and 8 to a renting family that claims no Universal Credit, adds the claim-limb cases, the couple's joint tax credit closure test, the dependant Housing Benefit closure case, two claim invariants and the reform tests, and corrects the predicate's stated scope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JkWi1uaASqaobKahB --- changelog.d/2804.fixed.md | 2 +- ...ifying_young_person_existing_benefits.yaml | 24 +- ...ualifying_young_person_legacy_closure.yaml | 107 ++++-- ...g_young_person_universal_credit_claim.yaml | 339 ++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 21 +- .../tests/test_disable_simulated_benefits.py | 65 +++- ...ung_person_existing_benefits_properties.py | 136 +++++-- ..._qualifying_young_person_legacy_closure.py | 45 ++- ...fying_young_person_neutralised_benefits.py | 8 +- ...fying_young_person_for_universal_credit.py | 133 ++++--- 10 files changed, 767 insertions(+), 113 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index fd64aee48a..25484768fe 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit while the model pays it on that report (its year gate and the model's legacy closure) and no reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 1b10314ae3..03665bd930 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -4,14 +4,21 @@ # ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax # Credit. The predicate reads the person's own reports and, for a claimant or # partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). -# It reads a report only while the model pays that benefit on it, and only if -# no reform neutralises it. The model pays Income Support and income-based +# It reads a report only in a year the model pays that benefit, only until +# the model's legacy closure, or for a claimant or partner the family's +# Universal Credit claim, ends the award on it, and only if no reform +# neutralises it. The model pays Income Support and income-based # JSA until 31 March 2026 and tax credits until 5 April 2025, and has no # closure date for Housing Benefit or income-related ESA. It also ends a # person's legacy awards when its annual migration proxy closes them, and # the predicate follows that closure # (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases -# no report closes the young person's legacy awards. The first six cases, +# a young person's own report closes their legacy awards only where that +# benefit's year gate has already closed, so the closure decides no case. +# The claim limbs end only a claimant's or partner's own award +# (uc_qualifying_young_person_universal_credit_claim.yaml covers them), so +# the cases that test a claimant's own Child Tax Credit or Housing Benefit +# report claim no Universal Credit (would_claim_uc). The first six cases, # and the four after the seventh, share one household: a parent aged 45 and # a 17-year-old on a non-advanced course, North East, owned outright, no # income. @@ -178,6 +185,9 @@ universal_credit: 5_098.80 - name: An 18-year-old claimant on a non-advanced course who reports Housing Benefit is not a qualifying young person. + # The claimant doesn't claim Universal Credit, so no claim ends their own + # Housing Benefit (uc_qualifying_young_person_universal_credit_claim.yaml + # covers a claim). They get no Universal Credit. period: 2026 input: people: @@ -189,6 +199,7 @@ benunits: benunit: members: [claimant] + would_claim_uc: false households: household: members: [claimant] @@ -198,7 +209,7 @@ is_qualifying_young_person_for_universal_credit: [false] is_child_or_qualifying_young_person_for_universal_credit: [false] uc_child_element: 0 - universal_credit: 4_062.96 + universal_credit: 0 - name: A young person's own Income Support report after Income Support stops paying leaves the child element. # Income Support stops paying on 1 April 2026 (gov.dwp.income_support.active), so nobody is entitled to it in 2026. @@ -312,12 +323,15 @@ universal_credit: 8_869.68 - name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. + # Each person forms their own benefit unit as its claimant, and none + # claims Universal Credit, so no claim ends their Child Tax Credit. period: 2024 input: age: [17, 17, 17] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] child_tax_credit_reported: [0.01, 0, 0] working_tax_credit_reported: [0, 0.01, 0] + would_claim_uc: [false, false, false] output: is_qualifying_young_person_for_universal_credit: [false, false, true] @@ -366,6 +380,7 @@ is_qualifying_young_person_for_universal_credit: [false, false] - name: A couple's Child Tax Credit award does not exclude a dependant who reports none. + # The couple doesn't claim Universal Credit, so no claim ends its award. period: 2024 input: people: @@ -385,6 +400,7 @@ benunits: benunit: members: [claimant, partner, young_person] + would_claim_uc: false output: is_qualifying_young_person_for_universal_credit: [false, false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index 442d3d0683..2762b03988 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -10,11 +10,14 @@ # tax credit report from 2025, closes the dependant's legacy awards # (gov.dwp.income_support.active, gov.dwp.JSA.income.active and # gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and -# 6 April 2025). The closure reports exclude nobody, as the benefit has closed. +# 6 April 2025). The predicate excludes nobody on those reports, as each +# benefit's year gate has closed. # -# Every case but the last uses one household: a parent aged 45 and a 17-year-old on a -# non-advanced course in the parent's benefit unit, North East, owned -# outright, no income. The parent claims Universal Credit as a single +# Cases 1 to 6 and the last use one household: a parent aged 45 and a +# 17-year-old on a non-advanced course in the parent's benefit unit, North +# East, owned outright, no income. Cases 7 and 8 use the same family renting +# from the council at £6,000 a year and claiming no Universal Credit, so only +# the closure can end its Housing Benefit. Case 9 has a parent aged 70. The parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml # SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the # first child higher amount for a qualifying young person @@ -159,9 +162,11 @@ universal_credit: 8_869.68 - name: A young person's own Income Support report with their own Child Tax Credit report in 2025 leaves the child element. - # Income Support still pays in 2025, but the young person's own Child Tax - # Credit report closes their legacy awards, which ends their Income Support - # too. UC: 12 x (400.14 + 339.00) = 8,869.68. + # The model pays Income Support in 2025, though never on a dependant's own + # report. The young person's own Child Tax Credit report closes their + # legacy awards (person_legacy_benefits_closed), and the predicate then + # drops their Income Support report too. UC: 12 x (400.14 + 339.00) = + # 8,869.68. period: 2025 absolute_error_margin: 0.01 input: @@ -224,11 +229,15 @@ universal_credit: 8_869.68 - name: A young person's own Housing Benefit report after the parent's legacy awards close leaves the child element. - # The parent's own Income Support report from 2026 closes the family's - # legacy awards (legacy_benefits_closed), and the family doesn't meet the - # Pension Credit age conditions, so the model pays no Housing Benefit on - # the young person's report (housing_benefit_eligible). UC: 12 x (424.90 + - # 351.88) = 9,321.36. + # The family rents from the council at £6,000 a year and claims no + # Universal Credit, so without the parent's report the model continues its + # Housing Benefit award at the full rent (6,000, no income). The parent's + # own Income Support report from 2026 closes the family's legacy awards + # (legacy_benefits_closed), and the family doesn't meet the Pension Credit + # age conditions, so the model ends that award (housing_benefit_eligible), + # the young person's report included, and the predicate drops the report. + # The young person meets reg. 5, so the family's UC child element comes to + # 12 x 351.88 = 4,222.56, though the family claims no Universal Credit. period: 2026 absolute_error_margin: 0.01 input: @@ -246,22 +255,29 @@ benunits: benunit: members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [parent, young_person] region: NORTH_EAST - tenure_type: OWNED_OUTRIGHT + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 output: legacy_benefits_closed: true + claims_universal_credit: false housing_benefit_eligible: false + housing_benefit: 0 is_qualifying_young_person_for_universal_credit: [false, true] uc_child_element: 4_222.56 - universal_credit: 9_321.36 + universal_credit: 0 - name: A young person's own Housing Benefit report after the parent's tax credits close leaves the child element. - # The parent's own Child Tax Credit report from 2025 closes the family's - # legacy awards, which ends the family's Housing Benefit. UC: 12 x (400.14 - # + 339.00) = 8,869.68. + # The same renting family in 2025, which claims no Universal Credit. The + # parent's own Child Tax Credit report from 2025, after tax credits closed, + # closes the family's legacy awards, so the model ends its Housing Benefit + # award (6,000 without the report) and the predicate drops the young + # person's report. UC child element: 12 x 339.00 = 4,068. period: 2025 absolute_error_margin: 0.01 input: @@ -279,26 +295,34 @@ benunits: benunit: members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [parent, young_person] region: NORTH_EAST - tenure_type: OWNED_OUTRIGHT + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 output: legacy_benefits_closed: true + claims_universal_credit: false housing_benefit_eligible: false + housing_benefit: 0 is_qualifying_young_person_for_universal_credit: [false, true] uc_child_element: 4_068 - universal_credit: 8_869.68 + universal_credit: 0 - name: A young person's own Housing Benefit report still excludes them where the family meets the Pension Credit age conditions. # A parent aged 70 reports their own Working Tax Credit in 2025, after tax # credits closed, which closes the family's legacy awards. The family meets - # the Pension Credit age conditions, so the model keeps paying Housing - # Benefit unless the family leaves that route by claiming Universal Credit - # (left_pension_route_at_legacy_closure). The predicate can't read that - # claim, because claims_universal_credit reads the predicate, so it keeps - # the Housing Benefit report. UC: 12 x 400.14 = 4,801.68. + # the Pension Credit age conditions, so the model keeps its Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to it + # (left_pension_route_at_legacy_closure). This family does, so the model + # pays it no Housing Benefit. The predicate can't read + # left_pension_route_at_legacy_closure, whose is_uc_entitled reads the + # predicate through the child element, so it keeps the Housing Benefit + # report. The young person is a dependant, so the family's claim doesn't + # end their own award either. UC: 12 x 400.14 = 4,801.68. period: 2025 absolute_error_margin: 0.01 input: @@ -327,3 +351,38 @@ is_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 universal_credit: 4_801.68 + +- name: A young person's own Income Support report from 2026 doesn't lift their Housing Benefit exclusion. + # The young person's own Income Support report from 2026, after Income + # Support closed, closes their own legacy awards (person_legacy_benefits_closed), + # but not the family's: legacy_benefits_closed reads only the claimant's + # and partner's reports. The model ends Housing Benefit on the family's + # closure, so the young person's Housing Benefit report still excludes + # them. UC: 12 x 424.90 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + legacy_benefits_closed: false + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml new file mode 100644 index 0000000000..54057dbb77 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -0,0 +1,339 @@ +# A claim for Universal Credit ends the claimant's own tax credits, Income +# Support and Housing Benefit, and either joint claimant's tax credits (SI +# 2014/1230 reg. 8(2) and (2A)), and the claimant's and partner's +# income-related ESA (SI 2013/983 art. 4). It ends nobody else's award. The +# model ends the family's continuing Housing Benefit award +# (housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible), and +# the claimant's and partner's income-related ESA (esa_income_report_closed), +# when the family claims (claims_universal_credit). Nobody is entitled to an +# award the claim has ended, so for a claimant or partner the UC qualifying +# young person test (SI 2014/1230 reg. 28) follows those three limbs. For a +# dependant it doesn't: a parent's claim leaves the dependant's own award. +# +# would_claim_uc decides the claim here: no case closes the family's legacy +# awards, so claims_uc_at_legacy_closure stays false. The reg. 5 variable +# reads universal_credit_reported and receives_benefits_in_own_right for reg. +# 5(5), not a calculated Universal Credit award, and these cases leave both +# at their defaults, so a claimant who claims Universal Credit still meets +# reg. 5. +# +# Rates (universal_credit/standard_allowance/amount.yaml, elements/child/): +# SINGLE_YOUNG £311.68 a month in 2024-25 and £338.58 in 2026-27; +# COUPLE_OLD £617.60 in 2024-25; the child amount for a child born after +# 5 April 2017 £287.92 in 2024-25 (elements/child/amount.yaml). +# Child Tax Credit child element £3,455 a year in 2024-25 +# (tax_credits/child_tax_credit/elements/child_element.yaml). + +- name: An 18-year-old claimant's own income-related ESA report leaves them a qualifying young person once they claim Universal Credit. + # The claimant claims Universal Credit, which ends their own income-related + # ESA (SI 2013/983 art. 4; esa_income_report_closed), so the model pays no + # income-related ESA on their report and they aren't entitled to it. UC: 12 + # x 338.58 = 4,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_report_closed: [true] + esa_income: 0 + is_qualifying_young_person_under_universal_credit_regulation_5: [true] + is_qualifying_young_person_for_universal_credit: [true] + universal_credit: 4_062.96 + +- name: An 18-year-old claimant's own income-related ESA report excludes them while they don't claim Universal Credit. + # Nothing ends the award: the claimant doesn't claim Universal Credit and + # reports no closed benefit, so the model pays the £3,000 of income-related + # ESA on their report and they're entitled to it. They get no Universal + # Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_report_closed: [false] + esa_income: 3_000 + is_qualifying_young_person_for_universal_credit: [false] + universal_credit: 0 + +- name: An 18-year-old claimant's own Housing Benefit report leaves them a qualifying young person once they claim Universal Credit. + # The claimant rents from the council at £6,000 a year and claims Universal + # Credit, which ends their own Housing Benefit (SI 2014/1230 reg. 8(2A); + # housing_benefit_eligible), so the model pays no Housing Benefit on their + # report. UC: 12 x 338.58 + 6,000 housing element = 10,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_eligible: false + housing_benefit: 0 + is_qualifying_young_person_for_universal_credit: [true] + universal_credit: 10_062.96 + +- name: An 18-year-old claimant's own Housing Benefit report excludes them while they don't claim Universal Credit. + # The claimant rents from the council at £6,000 a year, has no income and + # doesn't claim Universal Credit, so the model continues their Housing + # Benefit award at the full rent (6,000) and they're entitled to it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_eligible: true + housing_benefit: 6_000 + is_qualifying_young_person_for_universal_credit: [false] + universal_credit: 0 + +- name: An 18-year-old lone parent's own Child Tax Credit report leaves them a qualifying young person once they claim Universal Credit. + # The claimant claims Universal Credit, which ends their tax credits (SI + # 2014/1230 reg. 8(2); is_CTC_eligible), so the model pays no Child Tax + # Credit. UC: 12 x (311.68 + 287.92) = 7,195.20. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: true + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: false + child_tax_credit: 0 + is_qualifying_young_person_for_universal_credit: [true, false] + universal_credit: 7_195.20 + +- name: An 18-year-old lone parent's own Child Tax Credit report excludes them while they don't claim Universal Credit. + # Tax credits pay in 2024 and the claimant doesn't claim Universal Credit, + # so the model pays Child Tax Credit for the child born after 5 April 2017: + # the child element of 3,455, with no family element and no income to taper + # it. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: false + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: true + child_tax_credit: 3_455 + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 0 + +- name: An 18-year-old claimant whose partner reports the couple's Child Tax Credit stays a qualifying young person once the couple claims Universal Credit. + # The members of a couple claim tax credits jointly (Tax Credits Act 2002 + # s.3(3)), so the partner's report gives the claimant the couple's award. + # The couple's claim for Universal Credit ends a joint claimant's tax + # credits (SI 2014/1230 reg. 8(2); is_CTC_eligible), so nobody is entitled + # to the award. UC: 12 x (617.60 + 287.92) = 10,866.24. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + partner: + age: 45 + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: true + households: + household: + members: [claimant, partner, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: false + child_tax_credit: 0 + is_qualifying_young_person_for_universal_credit: [true, false, false] + universal_credit: 10_866.24 + +- name: An 18-year-old claimant whose partner reports the couple's Child Tax Credit isn't a qualifying young person while the couple doesn't claim Universal Credit. + # Tax credits pay in 2024 and the couple doesn't claim Universal Credit, so + # the model pays the couple's Child Tax Credit (child element 3,455) and the + # claimant shares the joint award. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + partner: + age: 45 + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: false + households: + household: + members: [claimant, partner, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: true + child_tax_credit: 3_455 + is_qualifying_young_person_for_universal_credit: [false, false, false] + universal_credit: 0 + +- name: A parent's claim for Universal Credit leaves their young person's own Housing Benefit report excluding them. + # A parent aged 45 and a 17-year-old on a non-advanced course, owned + # outright. The parent's claim ends the parent's own awards, not the young + # person's (SI 2014/1230 reg. 8(2A) names the claimant's award), so the + # young person's own report still excludes them. UC: 12 x 424.90 = 5,098.80 + # (SINGLE_OLD, 2026-27). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_claimant_or_partner: true + is_parent: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: true + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: true + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own Housing Benefit report excludes them while their parent doesn't claim Universal Credit. + # The same household without a claim: the young person's own report excludes + # them as with the claim, and the parent gets no Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_claimant_or_partner: true + is_parent: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: false + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 38d7067893..78cb68fd0c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -3,11 +3,14 @@ # and JSA without their income-related and income-based forms (UC Regs reg. # 2). DWP doesn't count a person entitled to income-related ESA, income-based # JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. -# 28). The UC predicate reads a report only while the model pays that -# benefit on it (income-based JSA and Income Support until 31 March 2026, and -# income-related ESA, income-based JSA and Income Support only until the -# person's legacy awards close, person_legacy_benefits_closed), and only if -# no reform neutralises it. The Secretary of State excludes own awards +# 28). The UC predicate reads a report only in a year the model pays that +# benefit (income-based JSA and Income Support until 31 March 2026), reads an +# income-related ESA, income-based JSA or Income Support report only until +# the person's legacy awards close (person_legacy_benefits_closed), and +# reads none that a reform neutralises. A claim for Universal Credit also +# ends a claimant's or partner's own income-related ESA +# (esa_income_report_closed), so the cases that test a claimant's own +# income-related ESA report claim no Universal Credit (would_claim_uc). The Secretary of State excludes own awards # from the Pension Credit routes, including income support (SPC reg. 4A(5)): # is_on_income_support reads it, from the calculated award for a claimant or # partner and from their own report for anyone else. The reports identify the @@ -368,8 +371,11 @@ - name: A reported award also excludes the education route. # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + # Each person forms their own benefit unit as its claimant, and none claims + # Universal Credit, so no claim ends their income-related ESA. period: 2025 input: + would_claim_uc: [false, false, false, false, false] age: [18, 18, 18, 18, 18] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] universal_credit_reported: [5_000, 0, 0, 0, 0] @@ -382,8 +388,10 @@ is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] - name: An explicit false own-right flag does not erase a reported own ESA award. + # The person claims no Universal Credit, so no claim ends their award. period: 2026 input: + would_claim_uc: false age: 16 current_education: NOT_IN_EDUCATION receives_benefits_in_own_right: false @@ -583,8 +591,11 @@ is_qualifying_young_person_for_pension_credit: [false, true] - name: A report of one penny of a listed award other than Income Support excludes the young person. + # Each person forms their own benefit unit as its claimant, and none claims + # Universal Credit, so no claim ends their income-related ESA. period: 2025 input: + would_claim_uc: [false, false, false, false, false] age: [16, 16, 16, 16, 16] current_education: [NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION] universal_credit_reported: [0.01, 0, 0, 0, 0] diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index a53be29791..3cc555c204 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -855,8 +855,9 @@ def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_pers ): # The reform pays each benefit at its dataset-year report after the # model's closure dates (income-based JSA from 2026, tax credits from - # 2025), so the young person stays entitled to it and reg. 28 excludes - # them from the UC qualifying young person test in every year. Income + # 2025), so the young person stays entitled to it, and the Secretary of + # State excludes them from the UC qualifying young person test (reg. 28), + # in each of the reform's years. Income # Support is the claimant's family's award, so the model pays none on a # dependant's own report, with the reform or without it; the predicate # reads that report in the years the model pays Income Support to anyone. @@ -882,3 +883,63 @@ def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_pers tax_credits_closed, tax_credits_closed, ] + + +# An 18-year-old claimant on a non-advanced course, a lone parent of a child +# aged 1, renting from the council at £6,000 a year, who reports £3,000 of +# their own award in the dataset year. The family claims Universal Credit +# (would_claim_uc defaults to true). +CLAIMANT_REPORTS = { + "esa_income_reported": "esa_income", + "housing_benefit_reported": "housing_benefit", + "child_tax_credit_reported": "child_tax_credit", +} + + +def claimant_situation(): + people, benunits, households = {}, {}, {} + for report in CLAIMANT_REPORTS: + claimant, child = f"{report}_claimant", f"{report}_child" + people[claimant] = { + "age": every_year(18), + "is_claimant_or_partner": every_year(True), + "current_education": every_year("POST_SECONDARY"), + report: {DATASET_YEAR: 3_000}, + } + people[child] = { + "age": every_year(1), + "is_claimant_or_partner": every_year(False), + } + benunits[report] = {"members": [claimant, child]} + households[report] = { + "members": [claimant, child], + "region": every_year("NORTH_EAST"), + "tenure_type": every_year("RENT_FROM_COUNCIL"), + "rent": every_year(6_000), + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("index, report", list(enumerate(CLAIMANT_REPORTS))) +def test_a_claimant_on_a_benefit_the_reform_pays_beside_universal_credit_is_no_qualifying_young_person( + year, index, report +): + # A claim for Universal Credit ends the claimant's own income-related + # ESA, Housing Benefit and Child Tax Credit (SI 2014/1230 reg. 8(2) and + # (2A); SI 2013/983 art. 4), and without the reform the model ends each + # award on the claim, so the predicate doesn't read the report. The + # reform pays each report as reported, claim or none, so the claimant + # stays entitled to it and the predicate excludes them. + reformed = Simulation(situation=claimant_situation(), scenario=REFORM) + unreformed = Simulation(situation=claimant_situation()) + predicate = "is_qualifying_young_person_for_universal_credit" + benefit = CLAIMANT_REPORTS[report] + person = 2 * index + paid = reformed.calculate(benefit, year) + paid = paid[person] if benefit == "esa_income" else paid[index] + assert reformed.calculate("claims_universal_credit", year)[index] + assert float(paid) == 3_000 + assert not reformed.calculate(predicate, year)[person] + assert unreformed.calculate("claims_universal_credit", year)[index] + assert unreformed.calculate(predicate, year)[person] diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py index 534a714213..1561c2270d 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -5,29 +5,45 @@ person's own reports of income-based JSA, income-related ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant or partner, the couple's joint tax credit award (Tax Credits Act 2002 -s.3(3)). It reads a report only while the model pays that benefit on it, -and only if no reform neutralises it; these tests run without a reform, and +s.3(3)). It reads a report only in a year the model pays that benefit and +until the model's legacy closure, or for a claimant or partner the family's +Universal Credit claim, ends the award on it, and only if no reform +neutralises it; these tests run without a reform, and test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. The model stops paying a benefit when its year gate closes, and ends a person's legacy awards when its annual migration proxy closes them: their own Income Support, income-based JSA and income-related ESA when -person_legacy_benefits_closed holds, and the family's Housing Benefit and -tax credits when legacy_benefits_closed holds and the family doesn't meet -the Pension Credit age conditions. The oracle reads those three closure -variables from the model. These tests check that: - -- any positive own report of an existing benefit the model pays on it that - year makes the predicate false; +person_legacy_benefits_closed holds, the family's tax credits when +legacy_benefits_closed holds, and its Housing Benefit when +legacy_benefits_closed holds and the family doesn't meet the Pension Credit +age conditions. A claim for Universal Credit ends the claimant's and +partner's own awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), +and the model ends the family's continuing Housing Benefit award and its +Child Tax Credit, and the claimant's and partner's income-related ESA, when +the family claims (claims_universal_credit). The oracle reads those closure +variables from the model, and each family draws would_claim_uc. These tests +check that: + +- any positive own report of an existing benefit whose year gate, legacy + closure and, for a claimant or partner, claim limb leave it open makes the + predicate false; - with every such report at zero, the predicate equals the UC Regs 2013 reg. 5 test less the person's reported UC, ESA and JSA awards; -- a claimant's or partner's reports change a dependant's predicate only by - closing the family's legacy awards; +- compared with the same family without the claimant's and partner's + reports, those reports change a dependant's predicate only where the + proxy closes the family's legacy awards on them, and then only to lift an + exclusion; - a person whose legacy awards have closed is never excluded through their own income-related ESA report, nor through a Housing Benefit report once the family's awards close outside the Pension Credit age conditions; -- a parent's Universal Credit never rises when their young person's own - report turns positive; -- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly. +- a parent's Universal Credit never rises when one existing benefit report + of their young person turns positive and the young person reports no + other; +- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly; +- turning would_claim_uc on never changes the predicate of anyone who isn't + the claimant or partner; +- turning would_claim_uc on never turns a claimant's or partner's predicate + from true to false. """ from unittest.mock import patch @@ -169,7 +185,12 @@ def existing_benefit_reports(draw, one_existing_benefit): def families(draw): """A claimant, then a partner or a dependant, then two dependants.""" couple = draw(st.booleans()) - claimant = dict(draw(adult), is_claimant_or_partner=True, is_parent=True) + claimant = dict( + draw(adult), + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) second = draw(adult if couple else young_member) second = dict(second, is_claimant_or_partner=couple, is_parent=couple) dependants = [ @@ -191,7 +212,12 @@ def qualifying_families(draw): """ couple = draw(st.booleans()) claimant = draw(st.one_of(qualifying, qualifying, adult)) - claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + claimant = dict( + claimant, + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) second = draw(st.one_of(qualifying, qualifying, adult if couple else young_member)) second = dict(second, is_claimant_or_partner=couple, is_parent=couple) dependants = [ @@ -223,7 +249,12 @@ def closure_families(draw): two dependants who always do, each reporting any existing benefit.""" couple = draw(st.booleans()) claimant = draw(st.one_of(reporting_qualifying, adult)) - claimant = dict(claimant, is_claimant_or_partner=True, is_parent=True) + claimant = dict( + claimant, + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) second = draw(st.one_of(reporting_qualifying, adult if couple else young_member)) second = dict(second, is_claimant_or_partner=couple, is_parent=couple) dependants = [ @@ -249,7 +280,9 @@ def _release_simulations(): def simulate(units, year): - """Set the inputs on one cached simulation per shape and year.""" + """Set the inputs on one cached simulation per shape and year. Each + family claims Universal Credit (would_claim_uc) as its first member's + draw says, or by default.""" shape = (tuple(len(members) for members in units), year) if shape not in _CACHE: situation = {"people": {}, "benunits": {}, "households": {}} @@ -270,7 +303,12 @@ def simulate(units, year): people = flatten(units) for variable in INPUTS: sim.set_input(variable, year, [p[variable] for p in people]) - sim.input_variables = list(INPUTS) + sim.set_input( + "would_claim_uc", + year, + [members[0].get("would_claim_uc", True) for members in units], + ) + sim.input_variables = list(INPUTS) + ["would_claim_uc"] sim.reset_calculations() return sim @@ -279,6 +317,13 @@ def flatten(units): return [p for members in units for p in members] +def claiming(units, claims): + """The same families, each with would_claim_uc set to claims.""" + return [ + [dict(members[0], would_claim_uc=claims)] + members[1:] for members in units + ] + + def with_zero(units, names, who): return [ [dict(p, **{n: 0.0 for n in names}) if who(p) else p for p in members] @@ -315,20 +360,28 @@ def get(name): ) -def open_awards(sim, year): - """For each report, whether the model's legacy closure leaves the award on - it open, person by person.""" +def open_awards(sim, year, claim_limbs=True): + """For each report, whether the model's legacy closure and, with + claim_limbs, the family's Universal Credit claim leave the award on it + open, person by person. The claim ends the claimant's and partner's own + income-related ESA, Housing Benefit outside the Pension Credit age + conditions and Child Tax Credit, and nobody else's.""" own_open = ~sim.calculate("person_legacy_benefits_closed", year) family_closed = sim.calculate("legacy_benefits_closed", year, map_to="person") pension_age = sim.calculate( "meets_pension_credit_age_conditions", year, map_to="person" ) + claim_ends = np.zeros_like(own_open) + if claim_limbs: + claim_ends = sim.calculate( + "claims_universal_credit", year, map_to="person" + ) & sim.calculate("is_claimant_or_partner", year) return { "income_support_reported": own_open, "jsa_income_reported": own_open, - "esa_income_reported": own_open, - "housing_benefit_reported": ~(family_closed & ~pension_age), - "child_tax_credit_reported": ~family_closed, + "esa_income_reported": own_open & ~claim_ends, + "housing_benefit_reported": pension_age | (~family_closed & ~claim_ends), + "child_tax_credit_reported": ~family_closed & ~claim_ends, "working_tax_credit_reported": ~family_closed, } @@ -421,12 +474,15 @@ def test_a_closed_legacy_award_never_excludes(units, year): # esa_income_report_closed), and the family's Housing Benefit once the # claimant's and partner's close outside the Pension Credit age # conditions (housing_benefit_eligible). Clearing those reports leaves - # every predicate as it was. Neither report closes a legacy award, so - # clearing it leaves the closures as they were. Tax credits close from - # 2025 and Income Support and income-based JSA from 2026. + # every predicate as it was. Neither report enters legacy_award_is_closed. + # They reach the closures only through a mixed-age couple's Pension + # Credit saving, which clearing both reports of a closed family can't + # create. Tax credits close from 2025 and Income Support and income-based + # JSA from 2026. The claim limbs stay out: the claim can end an award in + # a family whose legacy awards stay open. sim = simulate(units, year) reported = predicate(sim, year) - award_open = open_awards(sim, year) + award_open = open_awards(sim, year, claim_limbs=False) closed = { name: ~award_open[name] for name in ("esa_income_reported", "housing_benefit_reported") @@ -490,3 +546,25 @@ def test_the_predicate_is_reg_5_less_reg_28(units, year): sim = simulate(units, year) expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year, sim) assert predicate(sim, year).tolist() == expected.tolist() + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_a_claim_changes_no_dependants_predicate(units, year): + # A claim for Universal Credit ends the claimant's and partner's own + # awards, not a dependant's (SI 2014/1230 reg. 8(2) and (2A)). + without_claim = predicate(simulate(claiming(units, False), year), year) + with_claim = predicate(simulate(claiming(units, True), year), year) + dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) + assert with_claim[dependant].tolist() == without_claim[dependant].tolist() + + +@SETTINGS +@given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) +def test_a_claim_never_excludes_a_claimant_or_partner(units, year): + # A claim only ends awards, so it can only lift a claimant's or partner's + # exclusion. + without_claim = predicate(simulate(claiming(units, False), year), year) + with_claim = predicate(simulate(claiming(units, True), year), year) + member_of_couple = np.array([p["is_claimant_or_partner"] for p in flatten(units)]) + assert not np.any(without_claim[member_of_couple] & ~with_claim[member_of_couple]) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py index 2806880775..5a8c425f47 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py @@ -10,8 +10,9 @@ In the baseline the family's legacy awards close only once a benefit has closed, and tax credits close first (gov.dwp.tax_credits.active turns false on 6 April 2025, Income Support on 1 April 2026), so the year gate already -covers every closed tax credit report. These tests keep tax credits paying -in 2026 by a parameter reform, so the family's closure decides. +covers every closed tax credit report. These tests keep the tax credit +scheme open in 2026 by a parameter reform (gov.dwp.tax_credits.active), so +the family's closure decides. The household: a parent aged 45 and a 17-year-old on a non-advanced course, North East, owned outright, no income, in 2026. The parent claims Universal @@ -97,3 +98,43 @@ def test_the_parents_closed_legacy_award_ends_the_young_persons_tax_credits( [False, True], 9_321.36, ) + + +def test_the_familys_closure_ends_the_couples_joint_tax_credit_award(): + # An 18-year-old claimant on a non-advanced course and a partner aged 45 + # with a child aged 1. The partner reports the couple's Working Tax Credit + # (Tax Credits Act 2002 s.3(3)) and their own Income Support in 2026, + # after Income Support closed, which closes the family's legacy awards + # and ends the couple's tax credits. So the couple's award doesn't + # exclude the claimant. Universal Credit can't show this: the claimant's + # status doesn't enter the award, so the test reads the predicate. + members = ["claimant", "partner", "child"] + situation = { + "people": { + "claimant": { + "age": {YEAR: 18}, + "current_education": {YEAR: "POST_SECONDARY"}, + "is_claimant_or_partner": {YEAR: True}, + }, + "partner": { + "age": {YEAR: 45}, + "is_claimant_or_partner": {YEAR: True}, + "income_support_reported": {YEAR: 3_000}, + "working_tax_credit_reported": {YEAR: 1_000}, + }, + "child": {"age": {YEAR: 1}, "is_claimant_or_partner": {YEAR: False}}, + }, + "benunits": {"benunit": {"members": members}}, + "households": { + "household": { + "members": members, + "region": {YEAR: "NORTH_EAST"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + } + }, + } + sim = Simulation(situation=situation, reform=TAX_CREDITS_PAYING) + assert sim.calculate("legacy_benefits_closed", YEAR).tolist() == [True] + assert sim.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [True, False, False] diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py index c1d64e1807..041d6d3af3 100644 --- a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py @@ -101,7 +101,8 @@ def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( ): """Two 18-year-olds on non-advanced courses form a couple, and the partner reports the couple's Child Tax Credit or Working Tax Credit - award (Tax Credits Act 2002 s.3(3)(a)).""" + award (Tax Credits Act 2002 s.3(3)(a)). The couple doesn't claim + Universal Credit, so no claim ends its Child Tax Credit.""" year = 2024 people, benunits, households = {}, {}, {} credits = ("child_tax_credit_reported", "working_tax_credit_reported") @@ -114,7 +115,10 @@ def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( "is_claimant_or_partner": {year: True}, } people[partner][report] = {year: 1_000} - benunits[f"benunit_{index}"] = {"members": [claimant, partner]} + benunits[f"benunit_{index}"] = { + "members": [claimant, partner], + "would_claim_uc": {year: False}, + } households[f"household_{index}"] = {"members": [claimant, partner]} sim = Simulation( situation={"people": people, "benunits": benunits, "households": households}, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index e0426e32ea..e2a319ccfa 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -14,31 +14,53 @@ class is_qualifying_young_person_for_universal_credit(Variable): "Income Support, Housing Benefit, Child Tax Credit or Working Tax " "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " "29). The Secretary of State ties the exclusion to entitlement (reg. " - "28), so the formula reads a report only while the model pays that " - "benefit on it, and only if no reform neutralises it. The model pays " - "Income Support, income-based JSA and tax credits only while " + "28), so the formula reads a report only in a year the model pays that " + "benefit, only until the model's legacy closure, or for a claimant or " + "partner the family's Universal Credit claim, ends the award on it, and" + " only if no reform neutralises it. It doesn't apply the model's other " + "conditions of payment, so a report can exclude a person even where the" + " model pays nothing on that report. For example, the model pays Income" + " Support only on the claimant's and partner's reports, Housing Benefit" + " only to a family that rents, and income-based JSA and income-related " + "ESA only after their capital and work screens. The model pays Income " + "Support, income-based JSA and tax credits only while " "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " - "gov.dwp.tax_credits.active hold, or while " - "gov.contrib.policyengine.disable_simulated_benefits pays them as " - "reported, and has no closure date for Housing Benefit or " - "income-related ESA. The model also ends a person's legacy awards " - "once its annual migration proxy closes them: it ends their Income " - "Support, income-based JSA and income-related ESA when " - "person_legacy_benefits_closed holds, and the family's Housing " - "Benefit and tax credits when legacy_benefits_closed holds and the " - "family doesn't meet the Pension Credit age conditions. The formula " - "follows both closures. It doesn't follow the closures that read " - "claims_universal_credit (esa_income_report_closed for a claimant or " - "partner, housing_benefit_eligible and is_CTC_eligible), because " - "claims_universal_credit reads this variable. The formula checks each " - "report against its own variable: income_support, jsa_income, " - "esa_income, housing_benefit, child_tax_credit or tax_credits for " - "Child Tax Credit, and working_tax_credit or tax_credits for Working " - "Tax Credit. A reform that changes or zeroes a benefit's formula " - "without neutralising its variable leaves the exclusion on that " - "benefit's reports in place. For a claimant or partner it also reads " - "the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)), " - "each credit by its own check." + "gov.dwp.tax_credits.active hold, and has no closure date for Housing " + "Benefit or income-related ESA. The formula also reads those three " + "benefits' reports while " + "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " + "that switch turns on pays them at their reported amounts in the " + "dataset year and the nine years after it, and the formula still reads " + "them in later years. That reform pays each report whether or not the " + "family claims Universal Credit, so while the switch is on the formula " + "leaves out the Universal Credit claim limbs below. The model also ends" + " a person's legacy awards once its annual migration proxy closes them:" + " it ends their Income Support, income-based JSA and income-related ESA" + " when person_legacy_benefits_closed holds, the family's tax credits " + "when legacy_benefits_closed holds, and its Housing Benefit when " + "legacy_benefits_closed holds and the family doesn't meet the Pension " + "Credit age conditions. The formula follows both closures. A claim for " + "Universal Credit ends the claimant's and partner's own awards (SI " + "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and the model ends " + "the family's continuing Housing Benefit award " + "(housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible)," + " and the claimant's and partner's income-related ESA " + "(esa_income_report_closed), when the family claims " + "(claims_universal_credit). For a claimant or partner, the formula " + "follows those limbs too. The family's claim doesn't end a dependant's " + "own award, so for a dependant the formula doesn't. It can't read " + "housing_benefit_eligible: that variable's Pension Credit route reads " + "left_pension_route_at_legacy_closure, which reads this variable " + "through is_uc_entitled and the child element. So the formula keeps a " + "Housing Benefit report while the family meets the Pension Credit age " + "conditions. The formula checks each report against its own variable: " + "income_support, jsa_income, esa_income, housing_benefit, " + "child_tax_credit or tax_credits for Child Tax Credit, and " + "working_tax_credit or tax_credits for Working Tax Credit. A reform " + "that changes or zeroes a benefit's formula without neutralising its " + "variable leaves the exclusion on that benefit's reports in place. For " + "a claimant or partner it also reads the couple's joint tax credit " + "award (Tax Credits Act 2002 s.3(3)), each credit by its own check." ) definition_period = YEAR reference = [ @@ -53,13 +75,17 @@ class is_qualifying_young_person_for_universal_credit(Variable): def formula(person, period, parameters): # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level - # reports and the model's legacy closure proxy, so it calculates no - # family benefit that depends on QYP status. + # reports, the model's legacy closure proxy and the family's Universal + # Credit claim, so it calculates no family benefit that depends on QYP + # status. p = parameters(period).gov dwp = p.dwp - # The reform pays each benefit at its reported amount after the - # model's closure dates, and holds both legacy closures false. - paid_as_reported = p.contrib.policyengine.disable_simulated_benefits + # The reform pays each listed benefit at its dataset-year report in + # that year and the nine after it, closure dates included, and holds + # both legacy closures false in those years. The formula reads the + # switch, not those years, so in a later year it still reads a tax + # credit report that the model no longer pays. + paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) system = person.simulation.tax_benefit_system def paying(active, *names): @@ -73,38 +99,58 @@ def paying(active, *names): # income-related ESA when their legacy awards close: the claimant's # and partner's together (legacy_benefits_closed, read through # income_support), and anyone else's on their own reports - # (jsa_income, esa_income). esa_income_report_closed also ends a - # claimant's or partner's income-related ESA when the family claims - # Universal Credit, but the formula can't read it: its - # claimant-or-partner limb reads claims_universal_credit, which reads - # this variable. person_legacy_benefits_closed and + # (jsa_income, esa_income). person_legacy_benefits_closed and # legacy_benefits_closed read reports, is_claimant_or_partner, the # Pension Credit age conditions and parameters, and no Universal # Credit variable. own_award_open = ~person("person_legacy_benefits_closed", period) + # A claim for Universal Credit ends the claimant's and partner's own + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and + # nobody else's. The model ends the claimant's and partner's + # income-related ESA when the family claims (esa_income_report_closed), + # and the formula follows it. claims_universal_credit reads + # would_claim_uc, would_claim_uc_at_legacy_closure and + # legacy_benefits_closed, not this variable. The reform behind + # disable_simulated_benefits pays each report as reported whether or + # not the family claims, and holds the legacy closures false in its + # years, so under it the formula keeps the person's own closure alone. + claimant_or_partner = person("is_claimant_or_partner", period) + esa_income_open = ~person("esa_income_report_closed", period) | ( + own_award_open & paid_as_reported + ) + claim_ends_own_award = ( + claimant_or_partner + & person.benunit("claims_universal_credit", period) + & (not paid_as_reported) + ) # The model pays Housing Benefit and tax credits to the family, on # every member's reports, and ends them when the claimant's and # partner's legacy awards close (housing_benefit_eligible, - # is_CTC_eligible, is_WTC_eligible). A family that meets the Pension - # Credit age conditions keeps Housing Benefit unless it leaves that - # route by claiming Universal Credit, which the formula can't read, - # so for such a family the formula keeps the Housing Benefit report. + # is_CTC_eligible, is_WTC_eligible). It also ends the family's + # continuing Housing Benefit award and its Child Tax Credit when the + # family claims Universal Credit, and the formula follows that limb + # for a claimant or partner, whose own award the claim ends. A family + # that meets the Pension Credit age conditions keeps Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to + # it (left_pension_route_at_legacy_closure). That variable reads this + # one through is_uc_entitled and the child element, so the formula + # keeps the Housing Benefit report for such a family. family_closed = person.benunit("legacy_benefits_closed", period) family_award_open = ~family_closed - housing_benefit_open = ~( - family_closed - & ~person.benunit("meets_pension_credit_age_conditions", period) - ) + housing_benefit_open = person.benunit( + "meets_pension_credit_age_conditions", period + ) | (~family_closed & ~claim_ends_own_award) income_support_paying = ( paying(dwp.income_support.active, "income_support") & own_award_open ) jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open - esa_income_paying = paying(True, "esa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & esa_income_open housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open child_tax_credit_paying = ( paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") & family_award_open + & ~claim_ends_own_award ) working_tax_credit_paying = ( paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") @@ -116,7 +162,6 @@ def paying(active, *names): # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, # so the couple's award belongs to the claimant and the partner. A # dependant claims as a single person, so their award stays theirs. - claimant_or_partner = person("is_claimant_or_partner", period) couple_tax_credits_reported = person.benunit.sum( where(claimant_or_partner, tax_credits_reported, 0) ) From cdd497084d0ea9891bce62f7d7fadc464a3a2315 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:48:36 +0000 Subject: [PATCH 187/250] Keep the no-claim cases free of a claim at the legacy closure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JkWi1uaASqaobKahB --- ...qualifying_young_person_universal_credit_claim.yaml | 10 ++++++++-- .../qualifying_young_person_reported_own_awards.yaml | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index 54057dbb77..e31d332463 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -10,8 +10,9 @@ # young person test (SI 2014/1230 reg. 28) follows those three limbs. For a # dependant it doesn't: a parent's claim leaves the dependant's own award. # -# would_claim_uc decides the claim here: no case closes the family's legacy -# awards, so claims_uc_at_legacy_closure stays false. The reg. 5 variable +# would_claim_uc decides the claim here. A case without a claim also sets +# would_claim_uc_at_legacy_closure false, so the family claims nothing when +# DWP moves it off its legacy benefits either. The reg. 5 variable # reads universal_credit_reported and receives_benefits_in_own_right for reg. # 5(5), not a calculated Universal Credit award, and these cases leave both # at their defaults, so a claimant who claims Universal Credit still meets @@ -72,6 +73,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -130,6 +132,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -194,6 +197,7 @@ benunit: members: [claimant, child] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant, child] @@ -264,6 +268,7 @@ benunit: members: [claimant, partner, child] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant, partner, child] @@ -328,6 +333,7 @@ benunit: members: [parent, young_person] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [parent, young_person] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 78cb68fd0c..39f05dbc38 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -388,10 +388,12 @@ is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] - name: An explicit false own-right flag does not erase a reported own ESA award. - # The person claims no Universal Credit, so no claim ends their award. + # The person claims no Universal Credit, now or when DWP moves them off + # their legacy benefits, so no claim ends their award. period: 2026 input: would_claim_uc: false + would_claim_uc_at_legacy_closure: false age: 16 current_education: NOT_IN_EDUCATION receives_benefits_in_own_right: false From 7b9e556954bf6762707e6cb5def410d6ba2138e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 14:29:06 +0000 Subject: [PATCH 188/250] Pin the Pension Credit route's Housing Benefit for a claimant or partner who claims Universal Credit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JkWi1uaASqaobKahB --- ...g_young_person_universal_credit_claim.yaml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index e31d332463..546774f357 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -280,6 +280,49 @@ is_qualifying_young_person_for_universal_credit: [false, false, false] universal_credit: 0 +- name: An 18-year-old partner's own Housing Benefit report excludes them while their mixed-age couple keeps the Pension Credit route. + # A claimant aged 75 who reports Pension Credit and an 18-year-old partner + # on a non-advanced course rent from the council at £6,000 a year and claim + # Universal Credit. The couple keeps the mixed-age Pension Credit saving + # (has_mixed_age_couple_pension_credit_saving), so it meets the Pension + # Credit age conditions. The model keeps such a family's Housing Benefit + # while it claims, unless it claims at a legacy closure and is entitled to + # Universal Credit (left_pension_route_at_legacy_closure), and nothing + # closes this family's legacy awards. So the model pays the full rent + # (6,000, no income) as Housing Benefit, the partner stays entitled to it, + # and their own report excludes them. The predicate can't read + # left_pension_route_at_legacy_closure, so it keeps any Housing Benefit + # report while the family meets those conditions. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_claimant_or_partner: true + pension_credit_reported: 1_000 + partner: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: true + households: + household: + members: [claimant, partner] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + meets_pension_credit_age_conditions: true + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 6_000 + is_qualifying_young_person_for_universal_credit: [false, false] + - name: A parent's claim for Universal Credit leaves their young person's own Housing Benefit report excluding them. # A parent aged 45 and a 17-year-old on a non-advanced course, owned # outright. The parent's claim ends the parent's own awards, not the young From 892911c810cc99c40759c850e27027f7405eb03b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:05:35 +0000 Subject: [PATCH 189/250] 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 190/250] 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 191/250] 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 192/250] 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 193/250] 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 8236c3a1cdfc09cc3220703106fb499b215d68ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:13:32 +0000 Subject: [PATCH 194/250] Leave a commercial lodger's child out of the host's UC extended benefit unit UC Regs 2013 Sch 4 para 9(2)(g) leaves out of the renter's non-dependants a child or qualifying young person for whom no one in the renter's extended benefit unit is responsible. A boarder or lodger and, in the model, their partner leave that unit under para 9(2)(d), so their children and qualifying young people leave it too. is_uc_non_dependant_of_household_head applies that rule on top of is_non_dependant_of_household_head, taking the claimant and partner of a child's own benefit unit as responsible for them. LHA_allowed_bedrooms, lha_renter_has_non_dependant and uc_non_dep_deductions read it; Housing Benefit and council tax reduction keep the shared flag. lha_renter_has_non_dependant now counts every Universal Credit non-dependant of another family, not only its claimants and partners, so a lodger's non-paying adult son takes a householder under 35 off the shared accommodation rate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJW5Q229j9RMjeKuoC2 --- changelog.d/boarder-lodger-rent.added.md | 2 +- .../boarders_and_lodgers_non_dependants.yaml | 283 ++++++++++++++++++ ..._child_extended_benefit_unit_properties.py | 178 +++++++++++ .../variables/gov/dwp/LHA_allowed_bedrooms.py | 9 +- .../dwp/is_non_dependant_of_household_head.py | 5 +- .../gov/dwp/lha_renter_has_non_dependant.py | 23 +- .../is_uc_non_dependant_of_household_head.py | 59 ++++ .../uc_non_dep_deductions.py | 6 +- 8 files changed, 551 insertions(+), 14 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py diff --git a/changelog.d/boarder-lodger-rent.added.md b/changelog.d/boarder-lodger-rent.added.md index 7546d6b98a..3f15c855b9 100644 --- a/changelog.d/boarder-lodger-rent.added.md +++ b/changelog.d/boarder-lodger-rent.added.md @@ -1 +1 @@ -Model rent from boarders, lodgers and sub-tenants for the householder: rent-a-room relief in income tax, the £20 and board-and-lodging disregards in the legacy means tests (not Universal Credit), and no non-dependant deductions for a boarder or lodger and their partner, while a non-paying adult in their family stays a non-dependant. +Model rent from boarders, lodgers and sub-tenants for the householder: rent-a-room relief in income tax, the £20 and board-and-lodging disregards in the legacy means tests (not Universal Credit), and no non-dependant deductions for a boarder or lodger and their partner, while a non-paying adult in their family stays a non-dependant. For Universal Credit, the model also leaves a boarder's or lodger's child or qualifying young person out of the householder's extended benefit unit, so they add no bedroom. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml index 54a449768d..6c772f8cd2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml @@ -380,3 +380,286 @@ uc_non_dep_receives_pension_credit: [false, true] uc_individual_non_dep_deduction_eligible: [false, false, false] uc_non_dep_deductions: [0, 0] + +# Universal Credit's extended benefit unit (UC Regs 2013 Sch 4 paras 8 to +# 10). Under para 9(2)(g), DWP leaves out of the renter's non-dependants "a +# child or qualifying young person for whom no-one in the renter's extended +# benefit unit is responsible". DWP leaves a boarder or lodger out of that +# unit (para 9(2)(d)), and the model leaves out their partner too, so nobody +# in it is responsible for their child or qualifying young person, who +# supplies no bedroom under para 10. A non-paying parent belongs to the unit, +# so their child stays a non-dependant. Housing Benefit and council tax +# reduction keep their own definitions (is_non_dependant_of_household_head). + +- name: A commercial lodger's qualifying young person gives the householder no Universal Credit bedroom + # London (Inner South East London), 2026-27. The household head (40) + # rents privately for 18,000 a year, earns 15,000 and claims Universal + # Credit. An unrelated lodger (40) earns 20,000 and pays the head 5,200 a + # year on a commercial basis. The lodger's son (18), in the lodger's + # benefit unit, studies for A levels, so the model counts him as a + # qualifying young person. The lodger has main responsibility for him, and + # nobody in the head's extended benefit unit has any, so he is not the + # head's non-dependant for Universal Credit. The head gets one bedroom + # (para 10(1)(a)), and the one-bedroom rate of 15,546 a year caps the + # rent: housing element 15,546, with no housing cost contribution. The + # maximum amount adds the standard allowance, 424.90 x 12 = 5,098.80: + # 20,644.80. Earned income: 15,000 less income tax of + # (15,000 - 12,570) x 20% = 486 and National Insurance of 8% above the + # primary threshold of 241.73 a week, 194.40, comes to 14,319.60, and the + # 55% taper takes 7,875.78. Universal Credit: 20,644.80 - 7,875.78 = + # 12,769.02. With the son as a non-dependant aged 16 or over, the head + # would get two bedrooms, whose rate of 18,600 exceeds the rent, a housing + # element of 18,000 and Universal Credit of 15,223.02. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + is_claimant_or_partner: true + employment_income: 15_000 + lodger: + age: 40 + is_household_head: false + is_claimant_or_partner: true + employment_income: 20_000 + rent_paid_as_lodger: 5_200 + son: + age: 18 + is_household_head: false + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + lodger_unit: + members: [lodger, son] + households: + household: + members: [householder, lodger, son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + region: LONDON + output: + pays_rent_to_householder: [false, true, false] + is_qualifying_young_person_for_universal_credit: [false, false, true] + # Housing Benefit and council tax reduction read their own flag. + is_non_dependant_of_household_head: [false, false, true] + is_uc_non_dependant_of_household_head: [false, false, false] + benunits: + householder_unit: + LHA_allowed_bedrooms: 1 + uc_non_dep_deductions: 0 + uc_housing_costs_element: 15_546 + universal_credit: 12_769.02 + +- name: A lodger's non-paying adult son stays the householder's non-dependant for Universal Credit and Housing Benefit + # The household of the first Housing Benefit case above: Scotland, + # 2026-27, a household head (60) with an eligible rent of 10,400, an + # unrelated lodger (50) paying 5,200 a year, and the lodger's son (30), + # who pays nothing and earns 15,600 over 20 hours a week. The formula + # applies para 9(2)(g) only to a child or qualifying young person. The + # son, at 30, is neither, so he stays the head's non-dependant for + # Universal Credit too. Housing Benefit keeps his deduction of + # 64.35 x 52 = 3,346.20 and pays 10,400 - 3,346.20 = 7,053.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + is_claimant_or_partner: true + housing_benefit_reported: 1 + lodger: + age: 50 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + son: + age: 30 + is_household_head: false + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 20 + benunits: + householder_unit: + members: [householder] + would_claim_uc: false + lodger_unit: + members: [lodger, son] + households: + household: + members: [householder, lodger, son] + rent: 10_400 + tenure_type: RENT_FROM_COUNCIL + region: SCOTLAND + output: + is_non_dependant_of_household_head: [false, false, true] + is_uc_non_dependant_of_household_head: [false, false, true] + housing_benefit_non_dep_deductions: [3_346.20, 0] + housing_benefit: [7_053.80, 0] + +- name: A non-paying daughter's qualifying young person stays the householder's non-dependant for Universal Credit + # London (Inner South East London), 2026-27. The household head (62) + # rents privately for 20,000 a year, earns 15,000 and claims Universal + # Credit. The head's daughter (36), who pays nothing, lives there with her + # son (17), who studies for A levels, in her own benefit unit. The + # daughter is the head's non-dependant, so she belongs to the head's + # extended benefit unit (para 9(1)(c)), and she has responsibility for + # her son, so he stays a non-dependant too. Each is a non-dependant who is + # not a child, so the head gets three bedrooms (para 10(1)(a) and (c)), + # whose rate of 23,400 exceeds the rent. The daughter, 21 or over and not + # exempt, brings a housing cost contribution of 96.55 x 12 = 1,158.60, so + # the housing element comes to 20,000 - 1,158.60 = 18,841.40. Universal + # Credit: 5,098.80 + 18,841.40 - 7,875.78 = 16,064.42, with the earned + # income of the first Universal Credit case in this group. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 62 + is_household_head: true + is_claimant_or_partner: true + employment_income: 15_000 + daughter: + age: 36 + is_household_head: false + is_claimant_or_partner: true + grandson: + age: 17 + is_household_head: false + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + daughter_unit: + members: [daughter, grandson] + households: + household: + members: [householder, daughter, grandson] + rent: 20_000 + tenure_type: RENT_PRIVATELY + region: LONDON + output: + pays_rent_to_householder: [false, false, false] + is_qualifying_young_person_for_universal_credit: [false, false, true] + is_uc_non_dependant_of_household_head: [false, true, true] + benunits: + householder_unit: + LHA_allowed_bedrooms: 3 + uc_non_dep_deductions: 1_158.60 + uc_housing_costs_element: 18_841.40 + universal_credit: 16_064.42 + +- name: A lodger's non-paying adult son takes a householder under 35 off the Universal Credit shared accommodation rate + # London (Inner South East London), 2026-27. The household head (25) + # rents privately for 18,000 a year, earns 15,000 and claims Universal + # Credit. An unrelated lodger (50) pays 5,200 a year; the lodger's son + # (30), in the lodger's benefit unit, pays nothing and earns 15,600 over + # 20 hours a week. The son is the head's non-dependant, so the model does + # not treat the head, single and under 35, as a specified renter: the + # head gets two bedrooms, whose rate of 18,600 exceeds the rent. The son + # brings a housing cost contribution of 96.55 x 12 = 1,158.60, so the + # housing element comes to 18,000 - 1,158.60 = 16,841.40. Universal + # Credit: 5,098.80 + 16,841.40 - 7,875.78 = 14,064.42, with the earned + # income of the first Universal Credit case in this group. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 25 + is_household_head: true + is_claimant_or_partner: true + employment_income: 15_000 + lodger: + age: 50 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + son: + age: 30 + is_household_head: false + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 20 + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + lodger_unit: + members: [lodger, son] + households: + household: + members: [householder, lodger, son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + region: LONDON + output: + is_uc_non_dependant_of_household_head: [false, false, true] + benunits: + householder_unit: + lha_renter_has_non_dependant: true + is_lha_shared_accommodation_rate_specified_renter: false + LHA_allowed_bedrooms: 2 + uc_non_dep_deductions: 1_158.60 + uc_housing_costs_element: 16_841.40 + universal_credit: 14_064.42 + +- name: A commercial lodger's child leaves a householder under 35 on the Universal Credit shared accommodation rate + # London (Inner South East London), 2026-27. The household head (25) + # rents privately for 18,000 a year, earns 15,000 and claims Universal + # Credit. An unrelated lodger (40) pays 5,200 a year and lives there with + # a child (10) in the lodger's benefit unit. Nobody in the head's extended + # benefit unit has responsibility for the child, so the child is not the + # head's non-dependant for Universal Credit. The head, single and under + # 35, has no non-dependant, so the shared accommodation rate of 7,800 a + # year caps the rent. Universal Credit: 5,098.80 + 7,800 - 7,875.78 = + # 5,023.02, with the earned income of the first Universal Credit case in + # this group. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 25 + is_household_head: true + is_claimant_or_partner: true + employment_income: 15_000 + lodger: + age: 40 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + child: + age: 10 + is_household_head: false + is_claimant_or_partner: false + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + lodger_unit: + members: [lodger, child] + households: + household: + members: [householder, lodger, child] + rent: 18_000 + tenure_type: RENT_PRIVATELY + region: LONDON + output: + is_non_dependant_of_household_head: [false, false, true] + is_uc_non_dependant_of_household_head: [false, false, false] + benunits: + householder_unit: + lha_renter_has_non_dependant: false + is_lha_shared_accommodation_rate_specified_renter: true + LHA_allowed_bedrooms: 1 + uc_housing_costs_element: 7_800 + universal_credit: 5_023.02 diff --git a/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py b/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py new file mode 100644 index 0000000000..009f508dd2 --- /dev/null +++ b/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py @@ -0,0 +1,178 @@ +"""Property-based test of a commercial lodger's children and the household +head's Universal Credit extended benefit unit. + +Under UC Regs 2013 Sch 4 para 9(2)(g), DWP leaves out of the renter's +non-dependants "a child or qualifying young person for whom no-one in the +renter's extended benefit unit is responsible". DWP also leaves out a person +liable to pay on a commercial basis for their occupation (para 9(2)(d)), and +the model treats that payer's partner as liable too. So when a claimant or +partner of a benefit unit pays the household head as a boarder or lodger, +nobody in the head's extended benefit unit has responsibility for the unit's +children and qualifying young people. + +Invariant, for any generated household whose head claims Universal Credit, +with one or two commercial lodger units (each with possibly a partner, an +adult who pays nothing and a child of their own), possibly a non-paying +family with a child, and the head's own partner and children: + +1. Adding a child under 16, or a qualifying young person aged 16 to 18 in + non-advanced education, to the first lodger unit leaves the head's + Universal Credit bedrooms (LHA_allowed_bedrooms), housing element + (uc_housing_costs_element) and non-dependant deductions + (uc_non_dep_deductions) unchanged, and the model never counts the added + person as the head's Universal Credit non-dependant. + +The generator keeps everyone in the lodger units under State Pension age, so +no lodger family receives Pension Credit, which would exempt the unit's +non-paying adult from the housing cost contribution. +""" + +import numpy as np +from hypothesis import HealthCheck, 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 = [2025, 2026] +HOST_OUTPUTS = [ + "LHA_allowed_bedrooms", + "uc_housing_costs_element", + "uc_non_dep_deductions", +] +money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def young_person(draw, min_age=0, max_age=18): + """A child under 16, or a qualifying young person aged 16 to 18 in + non-advanced education.""" + age = draw(st.integers(min_age, max_age)) + person = dict(age=age, is_claimant_or_partner=False) + if age >= 16: + person["current_education"] = "UPPER_SECONDARY" + return person + + +@st.composite +def adult(draw, claimant_or_partner, pays=False): + person = dict( + age=draw(st.integers(21, 60)), + is_claimant_or_partner=claimant_or_partner, + employment_income=draw(money), + ) + if pays: + kind = draw(st.sampled_from(["rent_paid_as_boarder", "rent_paid_as_lodger"])) + person[kind] = draw(st.floats(1_000, 12_000)) + return person + + +@st.composite +def lodger_unit(draw): + """A benefit unit whose claimant pays the household head, with possibly a + partner (paying or not), an adult who is neither claimant nor partner and + pays nothing, and a child of their own.""" + unit = [draw(adult(True, pays=True))] + if draw(st.booleans()): + unit.append(draw(adult(True, pays=draw(st.booleans())))) + if draw(st.booleans()): + unit.append(draw(adult(False))) + if draw(st.booleans()): + unit.append(draw(young_person())) + return unit + + +@st.composite +def households(draw): + head_age = draw(st.one_of(st.integers(18, 34), st.integers(35, 60))) + head_unit = [dict(age=head_age, employment_income=draw(money))] + if draw(st.booleans()): + head_unit.append(dict(age=draw(st.integers(18, 60)), employment_income=0.0)) + head_unit += [draw(young_person()) for _ in range(draw(st.integers(0, 2)))] + others = [draw(lodger_unit()) for _ in range(draw(st.integers(1, 2)))] + if draw(st.booleans()): + family = [draw(adult(True))] + if draw(st.booleans()): + family.append(draw(young_person())) + others.append(family) + return dict( + head_unit=head_unit, + others=others, + added=draw(st.one_of(young_person(0, 15), young_person(16, 18))), + region=draw(st.sampled_from(["LONDON", "NORTH_WEST", "SCOTLAND", "WALES"])), + tenure=draw(st.sampled_from(["RENT_PRIVATELY", "RENT_FROM_COUNCIL"])), + rent=draw(st.floats(6_000, 30_000)), + ) + + +def situation(h, year, with_added): + people, benunits, names = {}, {}, [] + + def add(name, attrs, head=False): + person = { + "age": {year: attrs["age"]}, + "is_household_head": {year: head}, + "is_claimant_or_partner": {year: attrs.get("is_claimant_or_partner", True)}, + } + for key, value in attrs.items(): + if key not in ("age", "is_claimant_or_partner"): + person[key] = {year: value} + people[name] = person + names.append(name) + return name + + head_members = [] + for i, attrs in enumerate(h["head_unit"]): + head_members.append(add(f"head_{i}", attrs, head=i == 0)) + benunits["head_unit"] = { + "members": head_members, + "would_claim_uc": {year: True}, + } + for j, unit in enumerate(h["others"]): + members = [add(f"other_{j}_{k}", attrs) for k, attrs in enumerate(unit)] + if with_added and j == 0: + members.append(add("added", h["added"])) + benunits[f"other_{j}"] = {"members": members} + return { + "people": people, + "benunits": benunits, + "households": { + "household": { + "members": names, + "rent": {year: h["rent"]}, + "tenure_type": {year: h["tenure"]}, + "region": {year: h["region"]}, + } + }, + } + + +def calculate(h, year, with_added): + sim = Simulation(situation=situation(h, year, with_added)) + values = {v: np.asarray(sim.calculate(v, year), dtype=float) for v in HOST_OUTPUTS} + values["is_uc_non_dependant_of_household_head"] = np.asarray( + sim.calculate("is_uc_non_dependant_of_household_head", year) + ) + return values + + +@PROPERTY_SETTINGS +@given(households(), st.sampled_from(YEARS)) +def test_a_commercial_lodgers_child_changes_nothing_for_the_heads_universal_credit( + h, year +): + without = calculate(h, year, with_added=False) + with_child = calculate(h, year, with_added=True) + # The head's benefit unit comes first. + for v in HOST_OUTPUTS: + np.testing.assert_allclose( + with_child[v][0], without[v][0], atol=0.01, err_msg=v + ) + # The added person comes after the members of the first lodger unit. + added = len(h["head_unit"]) + len(h["others"][0]) + assert not with_child["is_uc_non_dependant_of_household_head"][added] diff --git a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py index 4c87a0df20..c446c4c6ac 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py @@ -47,7 +47,10 @@ class LHA_allowed_bedrooms(Variable): "children's bedrooms. Joint tenants and other sharers of the rent, " "boarders and lodgers are not non-dependants, so they add no bedroom " "to anyone's entitlement; the household's non-dependants count in one " - "Universal Credit claim only (see uc_non_dependants_counted)." + "Universal Credit claim only (see uc_non_dependants_counted). A " + "boarder's or lodger's child or qualifying young person adds none " + "either: no one in the renter's extended benefit unit is responsible " + "for them (see is_uc_non_dependant_of_household_head)." ) definition_period = YEAR reference = ( @@ -59,8 +62,10 @@ def formula(benunit, period, parameters): person = benunit.members aged_16_or_over = person("age", period) >= 16 # UC Regs 2013 Sch 4 para 10(1)(c): a non-dependant who is not a child. + # The flag applies para 9(2)(g), so a boarder's or lodger's + # qualifying young person gets no bedroom. non_dependant = aged_16_or_over & person( - "is_non_dependant_of_household_head", period + "is_uc_non_dependant_of_household_head", period ) counted = benunit("uc_non_dependants_counted", period) non_dependants = counted * benunit.max(person.household.sum(non_dependant)) diff --git a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py index 861c4e5350..83bb7684ea 100644 --- a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py +++ b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py @@ -14,7 +14,10 @@ class is_non_dependant_of_household_head(Variable): "of anyone, and the household head's household is not a boarder's or " "lodger's. In a boarder's or lodger's family, only the payer and the " "payer's partner are liable (pays_rent_to_householder); an adult son " - "or daughter who pays nothing is a non-dependant. Universal Credit counts a non-dependant in one claim only " + "or daughter who pays nothing is a non-dependant. The Universal " + "Credit formulas read is_uc_non_dependant_of_household_head, which " + "also leaves out a boarder's or lodger's child or qualifying young " + "person (UC Regs 2013 Sch 4 para 9(2)(g)). Universal Credit counts a non-dependant in one claim only " "(uc_non_dependants_counted); Housing Benefit and Council Tax " "Reduction apportion a non-dependant of several joint occupiers " "between them. Foster children and carers engaged through a charity " diff --git a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py index b832db4ac4..a842584034 100644 --- a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py +++ b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py @@ -9,10 +9,14 @@ class lha_renter_has_non_dependant(Variable): "Universal Credit: someone in this benefit unit who is neither a " "claimant or partner nor a child or young person, or, for the claim " "that counts the household's non-dependants (see " - "uc_non_dependants_counted), a claimant or partner of a family not " - "liable for rent. Joint tenants and other sharers of the rent, " - "boarders and lodgers are not non-dependants. Foster children are not " - "identified. Housing Benefit uses housing_benefit_has_non_dependant." + "uc_non_dependants_counted), anyone in another family who is a " + "Universal Credit non-dependant of the renter " + "(is_uc_non_dependant_of_household_head), such as a lodger's adult " + "son who pays nothing. Joint tenants and other sharers of the rent, " + "boarders and lodgers are not non-dependants, and the flag leaves " + "out a boarder's or lodger's child or qualifying young person (UC " + "Regs 2013 Sch 4 para 9(2)(g)). The model does not identify foster " + "children. Housing Benefit uses housing_benefit_has_non_dependant." ) definition_period = YEAR reference = ( @@ -32,12 +36,15 @@ def formula(benunit, period, parameters): | ((age >= 16) & (age < 17)) ) within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_qyp) - # UC Regs 2013 Sch 4 para 9(2)(d)-(f); HB Regs 2006 reg 3(2)(d)-(e). - non_dependant_claimants = claimant_or_partner & person( - "is_non_dependant_of_household_head", period + # UC Regs 2013 Sch 4 para 9(1)(c) and 9(2): the non-dependants of + # other families, whoever in the family they are. A boarder's or + # lodger's non-paying adult son counts; their child or qualifying + # young person does not (para 9(2)(g)). + other_family_non_dependant = person( + "is_uc_non_dependant_of_household_head", period ) counted = benunit("uc_non_dependants_counted", period) other_family_non_dependants = counted * benunit.max( - person.household.sum(non_dependant_claimants) + person.household.sum(other_family_non_dependant) ) return within_benefit_unit | (other_family_non_dependants > 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py new file mode 100644 index 0000000000..db7dd33230 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * + + +class is_uc_non_dependant_of_household_head(Variable): + value_type = bool + entity = Person + label = "Universal Credit non-dependant of the household's renter" + documentation = ( + "Whether this person is a non-dependant in the Universal Credit " + "extended benefit unit of the household's renter. The formula starts " + "from is_non_dependant_of_household_head and leaves out, under UC " + "Regs 2013 Sch 4 para 9(2)(g), a child or qualifying young person " + "for whom no one in the renter's extended benefit unit is " + "responsible. The model takes the claimant and partner of a child's " + "own benefit unit as the people responsible for them. Under para " + "9(2)(d), DWP leaves a boarder or lodger out of the extended benefit " + "unit, and the model leaves out their partner too " + "(pays_rent_to_householder). The formula therefore leaves out their " + "children and qualifying young people, who supply no bedroom under " + "paras 8 and 10. A child whose parent pays nothing stays a " + "non-dependant, because the parent belongs to the extended benefit " + "unit. Where a child's benefit unit has no claimant or partner, the " + "model cannot tell who is responsible for them and keeps them a " + "non-dependant. The Housing Benefit and Council Tax Reduction " + "formulas read is_non_dependant_of_household_head, which follows " + "their own definitions." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/8", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/10", + ) + + def formula(person, period, parameters): + non_dependant = person("is_non_dependant_of_household_head", period) + claimant_or_partner = person("is_claimant_or_partner", period) + # UC Regs 2013 Sch 4 para 9(2)(g): "a child or qualifying young + # person for whom no-one in the renter's extended benefit unit is + # responsible". The model forms each benefit unit around its + # claimant and partner, so neither counts as anyone's child. + child_or_qualifying_young_person = ( + person("is_child_for_universal_credit", period) + | person("is_qualifying_young_person_for_universal_credit", period) + ) & ~claimant_or_partner + # The model takes the claimant or partner of the child's own benefit + # unit as responsible for them. They belong to the renter's extended + # benefit unit only as non-dependants (para 9(1)(c)); a boarder or + # lodger liable on a commercial basis does not (para 9(2)(d)). + responsible = person.benunit.any(claimant_or_partner) + responsible_in_extended_unit = person.benunit.any( + claimant_or_partner & non_dependant + ) + no_one_responsible = ( + child_or_qualifying_young_person + & responsible + & ~responsible_in_extended_unit + ) + return non_dependant & ~no_one_responsible diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py index c09e43cc46..e21ca84e66 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py @@ -17,10 +17,12 @@ def formula(benunit, period, parameters): # the renter's extended benefit unit (UC Regs 2013 Sch 4 para 13). # Non-dependants are not liable for rent (para 9(2)(d)), and each # counts in one Universal Credit claim only (para 9(2)(f)): see - # uc_non_dependants_counted. + # uc_non_dependants_counted. The Universal Credit flag also leaves + # out a boarder's or lodger's child or qualifying young person (para + # 9(2)(g)). person = benunit.members deductions = person("uc_individual_non_dep_deduction", period) * person( - "is_non_dependant_of_household_head", period + "is_uc_non_dependant_of_household_head", period ) counted = benunit("uc_non_dependants_counted", period) return counted * benunit.max(person.household.sum(deductions)) From bec8d91bb71090aed45676678e99fe10f958420f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:16:09 +0000 Subject: [PATCH 195/250] 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 196/250] 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 197/250] 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 198/250] 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 199/250] 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 685544ff22f18f8c01979e14db31a52ffa746f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:42:40 +0000 Subject: [PATCH 200/250] Pin SSI 2012/319 reg 3 for a lodger family in Scottish pension-age CTR In Scottish council tax reduction for people over the qualifying age for State Pension Credit, a lodger liable on a commercial basis falls within reg 3(2)(e); the model treats the lodger's partner as liable too. The lodger's son and child, who pay nothing and belong to no family of the head's, count as non-dependants under reg 3(1), and the model flags the son, aged 18 or over, for a deduction. Also name the actors in the new YAML comments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJW5Q229j9RMjeKuoC2 --- .../boarders_and_lodgers.yaml | 20 ++--- .../boarders_and_lodgers_non_dependants.yaml | 85 +++++++++++++++++-- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml index 117d39b68b..d4e3fcbe07 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers.yaml @@ -651,13 +651,13 @@ legacy_means_test_deducted_income_tax: [8_432, 0] council_tax_reduction_applicable_income: [22_159.60, 0] -# Council tax reduction counts rent from letting part of the home for its own -# applicant and partner (council_tax_reduction_home_letting_income), under its -# own pension-age test (council_tax_reduction_pensioner). Where the household -# head applies alone, the head is the applicant, so the rent boarders and -# lodgers pay the head counts in council tax reduction, though the benefit -# unit's own legacy measure, which follows its claimant and partner, holds -# none of it. +# Councils count rent from letting part of the home for the council tax +# reduction applicant and partner (council_tax_reduction_home_letting_income), +# under the scheme's own pension-age test (council_tax_reduction_pensioner). +# Where the household head applies alone, the head is the applicant, so the +# rent boarders and lodgers pay the head counts in council tax reduction, +# though the benefit unit's own legacy measure, which follows its claimant and +# partner, holds none of it. - name: A household head applying alone counts a lodger couple's rent in council tax reduction # England (Maidstone), 2026-27. The household head (70) has a private @@ -665,9 +665,9 @@ # 16, its claimant and partner) and their baby, so she applies alone. An # unrelated lodger couple (40 and 40), in their own benefit unit, each pay # her 3,640 a year (70 a week): 7,280 in all, within the 7,500 rent-a-room - # limit, so no tax. She falls under the pension-age scheme, which - # disregards 20 of "the amount paid by that person" (SI 2012/2885 Sch 5 - # para 10): (70 - 20) x 2 x 52 = 5,200. Income 12,000 + 5,200 = 17,200. + # limit, so no tax. She falls under the pension-age scheme, so the council + # disregards 20 a week of "the amount paid by that person" (SI 2012/2885 + # Sch 5 para 10): (70 - 20) x 2 x 52 = 5,200. Income 12,000 + 5,200 = 17,200. # CTR 2,000 - 20% x (17,200 - 12,376) = 2,000 - 964.80 = 1,035.20. The # couple's legacy measure holds none of the rent, which they do not # receive. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml index 6c772f8cd2..dec219dab3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml @@ -388,8 +388,9 @@ # unit (para 9(2)(d)), and the model leaves out their partner too, so nobody # in it is responsible for their child or qualifying young person, who # supplies no bedroom under para 10. A non-paying parent belongs to the unit, -# so their child stays a non-dependant. Housing Benefit and council tax -# reduction keep their own definitions (is_non_dependant_of_household_head). +# so their child stays a non-dependant. The Housing Benefit and council tax +# reduction formulas keep their own definitions +# (is_non_dependant_of_household_head). - name: A commercial lodger's qualifying young person gives the householder no Universal Credit bedroom # London (Inner South East London), 2026-27. The household head (40) @@ -402,11 +403,11 @@ # head's non-dependant for Universal Credit. The head gets one bedroom # (para 10(1)(a)), and the one-bedroom rate of 15,546 a year caps the # rent: housing element 15,546, with no housing cost contribution. The - # maximum amount adds the standard allowance, 424.90 x 12 = 5,098.80: - # 20,644.80. Earned income: 15,000 less income tax of + # formula adds the standard allowance, 424.90 x 12 = 5,098.80, for a + # maximum amount of 20,644.80. Earned income: 15,000 less income tax of # (15,000 - 12,570) x 20% = 486 and National Insurance of 8% above the - # primary threshold of 241.73 a week, 194.40, comes to 14,319.60, and the - # 55% taper takes 7,875.78. Universal Credit: 20,644.80 - 7,875.78 = + # primary threshold of 241.73 a week, 194.40, comes to 14,319.60, of which + # DWP takes 55%, 7,875.78. Universal Credit: 20,644.80 - 7,875.78 = # 12,769.02. With the son as a non-dependant aged 16 or over, the head # would get two bedrooms, whose rate of 18,600 exceeds the rent, a housing # element of 18,000 and Universal Credit of 15,223.02. @@ -462,8 +463,8 @@ # who pays nothing and earns 15,600 over 20 hours a week. The formula # applies para 9(2)(g) only to a child or qualifying young person. The # son, at 30, is neither, so he stays the head's non-dependant for - # Universal Credit too. Housing Benefit keeps his deduction of - # 64.35 x 52 = 3,346.20 and pays 10,400 - 3,346.20 = 7,053.80. + # Universal Credit too. The council keeps his Housing Benefit deduction + # of 64.35 x 52 = 3,346.20 and pays 10,400 - 3,346.20 = 7,053.80. period: 2026 absolute_error_margin: 0.01 input: @@ -663,3 +664,71 @@ LHA_allowed_bedrooms: 1 uc_housing_costs_element: 7_800 universal_credit: 5_023.02 + +# Council tax reduction for people over the qualifying age for State Pension +# Credit in Scotland: SSI 2012/319 reg 3. In reg 3(1) the Scottish Ministers +# define a non-dependant as "any person, except someone to whom paragraph (2) +# applies, who normally resides with the applicant", and in reg 3(2)(e) they +# except "any person who is liable to make payments on a commercial basis to +# the applicant or any partner of the applicant in respect of the person's +# occupation of the dwelling". The model treats the payer's partner as liable +# too (pays_rent_to_householder); whether the partner is liable turns on the +# agreement, which the data do not record. + +- name: Scottish pension-age council tax reduction leaves out a lodger and partner but not their son or child + # Edinburgh, 2026-27. The household head (70) has a State Pension of + # 10,000 and owns the home. An unrelated lodger (50) pays the head 5,200 a + # year; the lodger's partner (50) pays nothing; their son (30), who is + # neither claimant nor partner, pays nothing and earns 5,200 over 20 hours + # a week; their child (10) pays nothing. The head falls under the + # pension-age scheme. The lodger falls within reg 3(2)(e), and the model + # treats the partner as liable as well, so neither is a non-dependant. The + # son and the child belong to no family of the head's (reg 3(2)(a)) and + # owe the head nothing, so both count as non-dependants under reg 3(1); + # the model flags the son, at 18 or over, for a deduction, and the child, + # under 18, for none. + period: 2026 + input: + people: + householder: + age: 70 + is_household_head: true + is_claimant_or_partner: true + state_pension: 10_000 + lodger: + age: 50 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + partner: + age: 50 + is_household_head: false + is_claimant_or_partner: true + son: + age: 30 + is_household_head: false + is_claimant_or_partner: false + employment_income: 5_200 + weekly_hours: 20 + child: + age: 10 + is_household_head: false + is_claimant_or_partner: false + benunits: + householder_unit: + members: [householder] + claims_all_entitled_benefits: true + lodger_unit: + members: [lodger, partner, son, child] + households: + household: + members: [householder, lodger, partner, son, child] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + tenure_type: OWNED_OUTRIGHT + output: + council_tax_reduction_pensioner: [true, false] + pays_rent_to_householder: [false, true, true, false, false] + is_non_dependant_of_household_head: [false, false, false, true, true] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, true, false] From c21e35b266503874201caa5c0db3b26a2a19a1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:44:59 +0000 Subject: [PATCH 201/250] 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 202/250] 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 203/250] 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 a107b225c23f305bb14a11547f55b9fb270d1dac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:56:04 +0000 Subject: [PATCH 204/250] Say where the lodger's son and child sit in the reg 3 case Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJW5Q229j9RMjeKuoC2 --- .../boarders_and_lodgers_non_dependants.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml index dec219dab3..a7109607b0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml @@ -683,8 +683,9 @@ # a week; their child (10) pays nothing. The head falls under the # pension-age scheme. The lodger falls within reg 3(2)(e), and the model # treats the partner as liable as well, so neither is a non-dependant. The - # son and the child belong to no family of the head's (reg 3(2)(a)) and - # owe the head nothing, so both count as non-dependants under reg 3(1); + # son and the child sit in the lodger's benefit unit, not the head's (reg + # 3(2)(a)), and owe the head nothing, so both count as non-dependants under + # reg 3(1); # the model flags the son, at 18 or over, for a deduction, and the child, # under 18, for none. period: 2026 From b6b1ce7cb3b179870ee6f6b840a1f75aef35b63e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:58:21 +0000 Subject: [PATCH 205/250] 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 206/250] 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 f20faa47664bf2ca276a0003da1603e6e464a490 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:01:19 +0000 Subject: [PATCH 207/250] Give the Marriage Allowance as a Step 6 tax reduction at the basic rate Parliament gives the recipient a tax reduction of the appropriate percentage of the transferable amount (ITA 2007 s. 55B(1) and (3)) at Step 6 (s. 55A(2)), limited by s. 29(2) and (3). The model took the allowance off earned_taxable_income instead, so it saved the recipient tax at their top non-savings rate (21% for a Scottish intermediate-rate taxpayer) and widened the starting rate band for savings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6J1pBzUscCNHPteLKz --- changelog.d/2350.fixed.md | 2 +- docs/engineering/gift-aid.md | 5 +- .../allowances/marriage_allowance/max.yaml | 2 +- .../earned_taxable_income_exclusions.yaml | 1 - .../income_tax/step_6_tax_reductions.yaml | 10 + .../income/uc_earnings_deductions.yaml | 1 - .../marriage_allowance_tax_reduction.yaml | 372 ++++++++++++++++++ .../income_tax/property_income_band_base.yaml | 12 +- ...iage_allowance_tax_reduction_properties.py | 194 +++++++++ ...st_property_income_band_base_properties.py | 7 +- ...nsion_credit_self_employment_deductions.py | 11 +- .../allowances/marriage_allowance.py | 6 + ...rriage_allowance_appropriate_percentage.py | 46 +++ .../marriage_allowance_tax_reduction.py | 45 +++ ...ts_marriage_allowance_income_conditions.py | 16 +- .../non_savings_taxable_income.py | 8 +- 16 files changed, 709 insertions(+), 29 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml create mode 100644 policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md index dbcee5972f..3942a7effa 100644 --- a/changelog.d/2350.fixed.md +++ b/changelog.d/2350.fixed.md @@ -1 +1 @@ -The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends. It tests the Marriage Allowance income conditions directly under ITA 2007 s. 55B(2)(b) and (ba), allowing income at a rate limit and savings at a permitted nil rate. +The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends, and tests the Marriage Allowance income conditions directly under ITA 2007 s. 55B(2)(b) and (ba), allowing income at a rate limit and savings at a permitted nil rate. It gives the Marriage Allowance recipient a Step 6 tax reduction of the basic rate, or the Scottish basic rate, on the transferable amount (s. 55B(1) and (3)), in place of a deduction from taxable income. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 6303e0ba16..feb5968b63 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -84,8 +84,9 @@ order, each against the extended limits. Property income is taxed on top of and savings and dividends on top of `non_savings_taxable_income` (`earned_taxable_income` plus property income after allowances). Both bases derive from `net_income`, so the gift raises the limits without also shrinking -the income beneath them. They differ only when Marriage Allowance applies, -which `earned_taxable_income` subtracts (#1947). The Personal Savings Allowance +the income beneath them. The two bases come to the same amount; the Marriage +Allowance reduces neither, since the model gives it as a tax reduction at Step 6 +(`marriage_allowance_tax_reduction`). The Personal Savings Allowance compares net income less allowances with the UK basic and higher rate limits, both extended by the gross gift ([s. 12B](https://www.legislation.gov.uk/ukpga/2007/3/section/12B), including s. 12B(8) for Scottish taxpayers, with s. 414(2)(b)). diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml index bbde24b1b1..a63a25a863 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml @@ -1,4 +1,4 @@ -description: Maximum Marriage Allowance taxable income reduction, as a percentage of the full Personal Allowance. +description: Maximum Marriage Allowance transferable amount, as a percentage of the full Personal Allowance. The recipient gets a tax reduction of the appropriate percentage of that amount. values: 2016-04-01: 0.1 metadata: diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml index d40254f49b..f4e83a3d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml @@ -5,7 +5,6 @@ values: - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance metadata: unit: list diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml index 8e27c45473..f3fa7fad4f 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml @@ -10,6 +10,10 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 - title: Income Tax Act 2007 s. 29(2) and (3) href: https://www.legislation.gov.uk/ukpga/2007/3/section/29 + - title: Income Tax Act 2007 s. 27(2) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/27 + - title: Income Tax Act 2007 s. 55A(2) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55A - title: Budget 2026, paragraph 6.182 (Pension Tax Allowance) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135 values: @@ -19,3 +23,9 @@ values: - property_finance_cost_relief # Budget 2026, 2027-28 to 2029-30; zero in other years. - pension_tax_allowance + # ITA 2007 s. 55A(2) and s. 55B(1). Section 27(5) does not list Chapter 3A, + # so s. 27(2) sets its place: the order that gives the greatest reduction. + # Each reduction above has an amount fixed before Step 6, so every order + # deducts the same total. Listing this one last leaves the amounts of the + # reductions above as they were. + - marriage_allowance_tax_reduction diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index e753affdcf..5ba8c49947 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -411,7 +411,6 @@ - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance - taxable_pension_income age: 40 employment_income: 20_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml new file mode 100644 index 0000000000..3edd770b1f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml @@ -0,0 +1,372 @@ +# The Marriage Allowance as a tax reduction at Step 6 (ITA 2007 s. 55A(2)): +# the appropriate percentage of the transferable amount (s. 55B(1)), the basic +# rate or, for a Scottish taxpayer, the Scottish basic rate (s. 55B(3)), +# deducted only so far as Step 5 tax remains after earlier reductions +# (s. 29(2) and (3)). +# +# Worked by hand from the parameter files. 2025-26: personal allowance +# £12,570, transferable amount 10% x 12,570 = 1,257 rounded up to £1,260 +# (s. 55B(4)(b) and (5)), basic rate 20% on the first £37,700, dividend +# allowance £500 and dividend ordinary rate 8.75%, Personal Savings Allowance +# £1,000 for a basic-rate taxpayer, starting rate limit for savings £5,000. +# Scottish rates for 2025-26 (S6M-16531): 19% up to £2,827, 20% to £14,921, +# 21% to £31,092, 42% above. Employee NI: 8% above £12,570. Each household +# also pays the £174.50 colour TV licence (gov.dcms.bbc.tv_licence.colour). + +- name: A Scottish intermediate-rate recipient with dividends gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + # The spouse's £5,000 leaves 12,570 - 5,000 = 7,570 of allowance unused, + # so the earner gets the full £1,260. + marriage_allowance: [1_260, 0] + marriage_allowance_appropriate_percentage: [0.2, 0.2] + # 20% x 1,260 = 252. + marriage_allowance_tax_reduction: [252, 0] + # Non-savings taxable income 40,000 - 12,570 = 27,430: + # 2,827 x 19% + (14,921 - 2,827) x 20% + (27,430 - 14,921) x 21% + # = 537.13 + 2,418.80 + 2,626.89 = 5,582.82. + # Dividends: 27,430 + 5,000 = 32,430 stays within £37,700 (ss. 11D(6) and + # 13(5)), so (5,000 - 500) x 8.75% = 393.75. Step 5: 5,976.57. + # Step 6: 5,976.57 - 252 = 5,724.57. + income_tax: [5_724.57, 0] + # 50,000 - 5,724.57 - (40,000 - 12,570) x 8% - 174.50 + # = 50,000 - 5,724.57 - 2,194.40 - 174.50 = 41,906.53. + household_net_income: 41_906.53 + +- name: A Scottish intermediate-rate recipient with savings gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + # Non-savings tax 5,582.82 as above. Taxable income 32,430 stays within the + # rest-of-UK basic rate limit, so the Personal Savings Allowance is £1,000 + # (s. 12B); non-savings income of 27,430 leaves no starting-rate band. + # Savings: (5,000 - 1,000) x 20% = 800. Step 5: 6,382.82. + # Step 6: 6,382.82 - 252 = 6,130.82. + income_tax: [6_130.82, 0] + # 50,000 - 6,130.82 - 2,194.40 - 174.50 = 41,500.28. + household_net_income: 41_500.28 + +- name: A Scottish recipient with pay only gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 40_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + marriage_allowance_tax_reduction: [252, 0] + # 5,582.82 - 252 = 5,330.82. + income_tax: [5_330.82, 0] + +- name: A Scottish starter-rate recipient gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 14_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + marriage_allowance_tax_reduction: [252, 0] + # (14,000 - 12,570) x 19% = 271.70; 271.70 - 252 = 19.70. + income_tax: [19.70, 0] + +- name: A recipient's starting rate for savings does not grow with the Marriage Allowance + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 17_570 + savings_interest_income: 3_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance_tax_reduction: [252, 0] + # Non-savings taxable income 17,570 - 12,570 = 5,000 uses up the £5,000 + # starting rate limit for savings (s. 12), so no savings bear the starting + # rate. Pay: 5,000 x 20% = 1,000. Savings: (3,000 - 1,000) x 20% = 400. + # Step 5: 1,400. Step 6: 1,400 - 252 = 1,148. + income_tax: [1_148, 0] + +- name: A recipient whose tax comes only from dividends gets the full reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 12_570 + dividend_income: 5_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + # 2026-27: the personal allowance of £12,570 covers the pay. Dividends: + # (5,000 - 500) x 10.75% = 483.75. Step 6: 483.75 - 252 = 231.75. + income_tax: [231.75, 0] + +- name: A basic-rate recipient in England keeps the same tax under the reduction + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 30_000 + spouse: + age: 40 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance_tax_reduction: [252, 0] + # (30,000 - 12,570) x 20% - 1,260 x 20% = 3,486 - 252 = 3,234, as when the + # model took 1,260 off the 20% band. + income_tax: [3_234, 0] + +- name: The reduction stops at the recipient's Step 5 tax + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 13_500 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance: [1_260, 0] + # Step 5: (13,500 - 12,570) x 20% = 186, less than 252 (s. 29(2)). + marriage_allowance_tax_reduction: [186, 0] + income_tax: [0, 0] + +- name: The reduction takes only the tax that earlier reductions leave + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 14_000 + other_tax_credits: 100 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + # Step 5: (14,000 - 12,570) x 20% = 286. The earlier reductions take 100, + # leaving 186 (s. 29(3)), less than 252. + marriage_allowance_tax_reduction: [186, 0] + income_tax_reductions: [286, 0] + income_tax: [0, 0] + +# The appropriate percentage (s. 55B(3)) for a Scottish and an English +# taxpayer. The model's Scottish basic rate is 20% in each year; the Scottish +# rates of s. 11A start in 2016-17, so in 2015-16 both take the basic rate. + +- name: Appropriate percentage in 2015-16 + period: 2015 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: Appropriate percentage in 2017-18 + period: 2017 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: Appropriate percentage in 2018-19 + period: 2018 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: Appropriate percentage in 2025-26 + period: 2025 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: A reformed Scottish basic rate sets a Scottish taxpayer's appropriate percentage + period: 2025 + input: + gov.hmrc.income_tax.rates.scotland.rates[1].rate: 0.22 + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.22, 0.2] + +- name: Before 2016-17 a Scottish taxpayer's appropriate percentage stays the basic rate + period: 2015 + input: + gov.hmrc.income_tax.rates.scotland.rates[1].rate: 0.22 + people: + scot: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + output: + marriage_allowance_appropriate_percentage: 0.2 + +- name: A reformed basic rate sets the reduction outside Scotland + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.rates.uk[0].rate: 0.21 + people: + earner: + age: 40 + employment_income: 30_000 + spouse: + age: 40 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance_appropriate_percentage: [0.21, 0.21] + # 21% x 1,260 = 264.60. + marriage_allowance_tax_reduction: [264.60, 0] 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..40f4a75fde 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 @@ -119,7 +119,7 @@ taxed_income: 47_430 personal_pension_contributions_tax: 4_000 -- name: Case 7, Marriage Allowance still comes off the band base (pinned; #1947). +- name: Case 7, Marriage Allowance leaves the band base unchanged. period: 2026 absolute_error_margin: 0.01 input: @@ -134,8 +134,10 @@ members: [person] country: ENGLAND output: - # earned_taxable_income subtracts Marriage Allowance, which the law gives - # as a tax reduction (ITA 2007 s. 55B), so the base is 30,000 - 12,570 - - # 1,260 = 16,170 rather than 17,430. Pinned here until #1947 is fixed. - non_savings_taxable_income: 16_170 + # Parliament gives the Marriage Allowance as a tax reduction at Step 6 + # (ITA 2007 ss. 55A(2) and 55B(1)), so it leaves the base at + # 30,000 - 12,570 = 17,430. + non_savings_taxable_income: 17_430 + # Step 5: 17,430 x 20% = 3,486. Step 6: 3,486 - 20% x 1,260 = 3,234. + income_tax: 3_234 non_savings_income_after_allowances: 17_430 diff --git a/policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py b/policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py new file mode 100644 index 0000000000..045ef63b27 --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py @@ -0,0 +1,194 @@ +"""The Marriage Allowance gives its recipient a tax reduction at Step 6. + +Parliament gives the recipient a tax reduction of the appropriate percentage +of the transferable amount (ITA 2007 s. 55B(1)) at Step 6 (s. 55A(2)): the +basic rate, or for a Scottish taxpayer the Scottish basic rate (s. 55B(3)). +The reduction comes off only so far as Step 5 tax remains after the +reductions already deducted (s. 29(2) and (3)). + +Invariants, for generated married couples in England, Scotland, Wales and +Northern Ireland, with pay, pensions, savings and dividends drawn freely: + +1. For every person, income_tax equals the income tax with + would_claim_marriage_allowance false, less the smaller of the appropriate + percentage of marriage_allowance and the Step 5 tax the earlier reductions + leave. +2. The reduction does not depend on the recipient's marginal rate: under the + CPS expanded reform, which gives the allowance to recipients in every band, + a recipient with enough tax gets exactly the appropriate percentage of the + allowance, and income tax falls by that amount. +3. The election does not move the rate test: + meets_marriage_allowance_income_conditions gives the same result with + would_claim_marriage_allowance true and false. +4. The reduction lies between zero and the appropriate percentage of the + allowance, and the Step 6 reductions together never exceed the Step 5 tax. +""" + +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.reforms.cps.marriage_tax_reforms import expanded_ma_reform + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +TOLERANCE = 0.01 +COUPLES = 8 +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +YEARS = [2016, 2017, 2018, 2021, 2025, 2026] + +small = st.integers(min_value=0, max_value=20_000) +person_strategy = st.tuples( + st.integers(min_value=0, max_value=160_000), # employment income + st.integers(min_value=0, max_value=30_000), # private pension income + small, # savings interest + small, # dividends +) +couple_strategy = st.tuples(person_strategy, person_strategy, st.sampled_from(REGIONS)) +couples_strategy = st.lists(couple_strategy, min_size=COUPLES, max_size=COUPLES) + +VARIABLES = [ + "income_tax", + "income_tax_pre_charges", + "income_tax_reductions", + "marriage_allowance", + "marriage_allowance_tax_reduction", + "marriage_allowance_appropriate_percentage", + "meets_marriage_allowance_income_conditions", +] + + +def situation(couples, year, would_claim): + people, benunits, households = {}, {}, {} + for i, (first, second, region) in enumerate(couples): + members = [f"first_{i}", f"second_{i}"] + for name, (pay, pension, savings, dividends) in zip(members, (first, second)): + people[name] = { + "age": {year: 45}, + "marital_status": {year: "MARRIED"}, + "employment_income": {year: pay}, + "private_pension_income": {year: pension}, + "savings_interest_income": {year: savings}, + "dividend_income": {year: dividends}, + "would_claim_marriage_allowance": {year: would_claim}, + } + benunits[f"benunit_{i}"] = {"members": members, "is_married": {year: True}} + households[f"household_{i}"] = { + "members": members, + "region": {year: region}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(couples, year, would_claim, reform=None): + simulation = Simulation( + situation=situation(couples, year, would_claim), reform=reform + ) + values = {name: np.asarray(simulation.calculate(name, year)) for name in VARIABLES} + step_6 = simulation.tax_benefit_system.parameters( + year + ).gov.hmrc.income_tax.step_6_tax_reductions + earlier = list(step_6)[: list(step_6).index("marriage_allowance_tax_reduction")] + values["earlier_reductions"] = sum( + np.asarray(simulation.calculate(name, year)) for name in earlier + ) + values["step_6_reductions"] = sum( + np.asarray(simulation.calculate(name, year)) for name in step_6 + ) + return values + + +SETTINGS = settings( + max_examples=12, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@SETTINGS +@given(couples=couples_strategy, year=st.sampled_from(YEARS)) +def test_reduction_takes_the_appropriate_percentage_off_step_5_tax(couples, year): + claimed = calculate(couples, year, would_claim=True) + unclaimed = calculate(couples, year, would_claim=False) + rate = claimed["marriage_allowance_appropriate_percentage"] + allowance = claimed["marriage_allowance"] + reduction = claimed["marriage_allowance_tax_reduction"] + tax_left = np.maximum( + 0, unclaimed["income_tax_pre_charges"] - unclaimed["earlier_reductions"] + ) + expected = np.minimum(rate * allowance, tax_left) + # Invariant 1. + np.testing.assert_allclose( + claimed["income_tax"], unclaimed["income_tax"] - expected, atol=TOLERANCE + ) + np.testing.assert_allclose(reduction, expected, atol=TOLERANCE) + # Invariant 3. + np.testing.assert_array_equal( + claimed["meets_marriage_allowance_income_conditions"], + unclaimed["meets_marriage_allowance_income_conditions"], + ) + # Invariant 4. + assert (reduction >= -TOLERANCE).all() + assert (reduction <= rate * allowance + TOLERANCE).all() + step_5 = np.maximum(0, claimed["income_tax_pre_charges"]) + assert (claimed["step_6_reductions"] <= step_5 + TOLERANCE).all() + assert (claimed["income_tax_reductions"] <= step_5 + TOLERANCE).all() + # The allowance never changes the Step 5 tax. + np.testing.assert_allclose( + claimed["income_tax_pre_charges"], + unclaimed["income_tax_pre_charges"], + atol=TOLERANCE, + ) + + +@SETTINGS +@given(couples=couples_strategy, year=st.sampled_from([2018, 2021, 2025, 2026])) +def test_reduction_does_not_depend_on_the_marginal_rate(couples, year): + # Invariant 2. The CPS expanded reform, with no child condition, gives the + # allowance to married recipients in every band. + claimed = calculate(couples, year, would_claim=True, reform=expanded_ma_reform) + unclaimed = calculate(couples, year, would_claim=False, reform=expanded_ma_reform) + rate = claimed["marriage_allowance_appropriate_percentage"] + allowance = claimed["marriage_allowance"] + full = rate * allowance + enough_tax = ( + unclaimed["income_tax_pre_charges"] - unclaimed["earlier_reductions"] + >= full + TOLERANCE + ) + np.testing.assert_allclose( + claimed["marriage_allowance_tax_reduction"][enough_tax], + full[enough_tax], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + unclaimed["income_tax"][enough_tax] - claimed["income_tax"][enough_tax], + full[enough_tax], + atol=TOLERANCE, + ) + # The appropriate percentage is the basic rate or the Scottish basic rate, + # 20% in each of these years, whatever the recipient's income. + np.testing.assert_allclose(rate, 0.2) + + +def test_higher_rate_recipients_under_the_expanded_reform_get_20_percent(): + # 2025-26, England: pay of 60,000 and 150,000 put the recipients in the + # higher and additional rate bands. The CPS expanded reform gives each the + # 1,260 allowance, and the reduction is 20% x 1,260 = 252, not 40% or 45% + # of it. + couples = [ + ((60_000, 0, 0, 0), (0, 0, 0, 0), "LONDON"), + ((150_000, 0, 0, 0), (0, 0, 0, 0), "LONDON"), + ((60_000, 0, 0, 0), (0, 0, 0, 0), "SCOTLAND"), + ] + claimed = calculate(couples, 2025, would_claim=True, reform=expanded_ma_reform) + unclaimed = calculate(couples, 2025, would_claim=False, reform=expanded_ma_reform) + np.testing.assert_allclose( + claimed["marriage_allowance"], [1_260, 0, 1_260, 0, 1_260, 0] + ) + np.testing.assert_allclose( + unclaimed["income_tax"] - claimed["income_tax"], + [252, 0, 252, 0, 252, 0], + atol=TOLERANCE, + ) 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..98abf4cfd7 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -10,9 +10,10 @@ amount leaves savings and dividend tax unchanged, in England and in 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). +2. Accounting: non_savings_taxable_income equals adjusted net income less + savings, dividends and allowances, floored at nil. The Marriage Allowance + does not enter it: the model gives the allowance as a tax reduction at + Step 6 (ITA 2007 s. 55B(1)). 3. Monotonic: more property income never lowers savings or dividend tax. (Main passes this too; the differential and the higher-rate check below are what catch the bug.) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py index 8ac7b852c4..44d239318a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -42,9 +42,14 @@ def formula(person, period, parameters): scottish_basic_rate, rates.uk.rates[0], ) - # Chapter 2 (personal and blind person's allowances) and Chapter 3A - # (marriage allowance, which the model deducts from income) relieve - # income; Chapter 3 (married couple's allowance) reduces the tax. + # Chapter 2 (personal and blind person's allowances) relieves income. + # Chapter 3 (married couple's allowance) reduces the tax, and so does + # Chapter 3A: the Marriage Allowance gives a reduction of the basic + # rate, or the Scottish basic rate, on the transferable amount + # (ITA 2007 s. 55B(1) and (3)). At this single notional rate, with + # both floored at zero, deducting the amount from the profit gives the + # same tax as that reduction: rate * max(0, profit - reliefs - amount) + # equals max(0, rate * max(0, profit - reliefs) - rate * amount). reliefs = add( person, period, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index d54f76158a..75ea9edc77 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -20,6 +20,12 @@ class marriage_allowance(Variable): value_type = float entity = Person label = "Marriage Allowance" + documentation = ( + "The amount of personal allowance transferred to this person. It does " + "not reduce their taxable income: marriage_allowance_tax_reduction " + "gives them a tax reduction of the appropriate percentage of it at " + "Step 6 (ITA 2007 s. 55B(1))." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py new file mode 100644 index 0000000000..d4ade32ba5 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_appropriate_percentage(Variable): + value_type = float + entity = Person + label = "Marriage Allowance appropriate percentage" + documentation = ( + "The rate of the Marriage Allowance tax reduction: the basic rate, or " + "for a Scottish taxpayer the Scottish basic rate, whatever rates the " + "person's income bears (ITA 2007 s. 55B(3)). The Scottish rates of " + "s. 11A start in 2016-17, so before then the formula gives a Scottish " + "taxpayer the basic rate. Section 55B(3) gives a Welsh taxpayer the " + "Welsh basic rate, but the model has no Welsh rates, so a Welsh " + "taxpayer takes the basic rate." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Scottish Parliament motion S6M-16531 (2025-26 Scottish rates)", + href="https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531", + ), + ] + unit = "/1" + + def formula(person, period, parameters): + rates = parameters(period).gov.hmrc.income_tax.rates + basic_rate = rates.uk.rates[0] + if period.start.year < 2016: + return basic_rate + # The Scottish basic rate band is the second band of the Scottish + # scale in every year: before 2018-19 the first band (the starter + # rate) has no values. Read that band itself rather than an index into + # the rates in force at the instant, which would move if a reform + # removed the starter rate. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + return where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + basic_rate, + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py new file mode 100644 index 0000000000..6fc83049ee --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py @@ -0,0 +1,45 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions + + +class marriage_allowance_tax_reduction(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction" + documentation = ( + "The Marriage Allowance recipient's tax reduction at Step 6 of the " + "Income Tax calculation: the appropriate percentage of the " + "transferable amount (ITA 2007 s. 55A(2) and s. 55B(1)). It does not " + "depend on the rates the recipient's income bears. The formula " + "deducts it after the reductions listed before it in " + "step_6_tax_reductions, and only so far as there is Step 5 tax left " + "(s. 29(2) and (3))." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55A(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55A", + ), + dict( + title="Income Tax Act 2007 s. 55B(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 29(2) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + unit = GBP + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + reduction = person("marriage_allowance_appropriate_percentage", period) * ( + person("marriage_allowance", period) + ) + earlier = earlier_tax_reductions(income_tax, "marriage_allowance_tax_reduction") + tax_left = max_( + 0, + person("income_tax_pre_charges", period) - add(person, period, earlier), + ) + return min_(max_(0, reduction), tax_left) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 34e837bbcc..3089892bd2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -20,19 +20,19 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): s. 4(17)), and places the nil-rate slice above the starting rate limit for savings (s. 12A). - The test reads income before the Marriage Allowance. Parliament charges - tax at each rate at Step 4 and takes tax reductions off at Step 6 - (s. 23), and it gives the allowance as a tax reduction (s. 55B(1)). The - model still takes the allowance off earned_taxable_income (#1947), so - non_savings_taxable_income and the savings rate bands built on it would - read this test back. + Parliament charges tax at each rate at Step 4 and takes tax reductions + off at Step 6 (s. 23), and it gives the allowance as a tax reduction at + Step 6 (ss. 55A(2) and 55B(1)). So the allowance never changes the income + each rate applies to, and this test never reads it: + marriage_allowance_tax_reduction takes the allowance off the tax, and + the test reads income after allowances and the savings, dividend and + Gift Aid amounts directly. """ rates = parameters(period).gov.hmrc.income_tax.rates gross_gift = person("gift_aid_grossed_up", period) # Earnings, pensions, property and other non-savings income after # allowances, which go to it first: the income below savings and - # dividends (s. 16). This reads non-savings taxable income before the - # Marriage Allowance. + # dividends (s. 16). non_savings = person("non_savings_income_after_allowances", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; in 2017-18 the Scottish scale had only the basic rate below it. diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py index 8e1d2253a4..db6335c2c7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py @@ -12,10 +12,10 @@ class non_savings_taxable_income(Variable): "earned_taxable_income leaves property income out because " "property_income_tax taxes it separately, but property income still " "fills the bands before savings and dividends do (from 2027-28, " - "s. 16A places it immediately below savings income). With no Marriage " - "Allowance it equals non_savings_income_after_allowances; " - "earned_taxable_income also subtracts Marriage Allowance, which the " - "law gives as a tax reduction (#1947)." + "s. 16A places it immediately below savings income). It equals " + "non_savings_income_after_allowances. The Marriage Allowance does not " + "reduce it: marriage_allowance_tax_reduction gives the allowance as a " + "tax reduction at Step 6 (s. 55B(1))." ) definition_period = YEAR reference = [ From 25535f380f4d893ad5e28c8ace24bb372d963a94 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:09:03 +0000 Subject: [PATCH 208/250] Hold the head-alone letting property where her rent-a-room tax stays put MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1995's head-alone property asserted that the couple's rent never moves the household head's council tax reduction income. With this branch's rent-a-room relief, the couple's first receipts halve the head's limit, so part of her own letting rent becomes taxable and the tax the scheme deducts rises: Hypothesis found a head of 65 in the North East with £5,254.85 of rent whose income fell by £300.97 when the couple's rent rose from nil to £1. The equalities now hold where her taxable rent-a-room income stays the same, the couple's rent never raises her income, and an example pins that household. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QmsZJW5Q229j9RMjeKuoC2 --- ...ans_test_income_from_capital_properties.py | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 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..0981de6e52 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 @@ -706,9 +706,14 @@ def head_alone_situation(households, year, head_extra=0.0, parent_extra=0.0): def head_alone_values(households, year, **kwargs): sim = Simulation(situation=head_alone_situation(households, year, **kwargs)) - return { + values = { v: np.asarray(sim.calculate(v, year), dtype=float) for v in HEAD_ALONE_VARIABLES } + # The head comes first of each household's four members. + values["head_taxable_rent_a_room_income"] = np.asarray( + sim.calculate("taxable_rent_a_room_income", year), dtype=float + )[::4] + return values @PROPERTY_SETTINGS @@ -733,6 +738,23 @@ def head_alone_values(households, year, **kwargs): 2026, 1_000.0, ) +# The couple's first receipts halve the head's rent-a-room limit, so part of +# her own rent becomes taxable and the tax the scheme deducts rises. +@example( + [ + dict( + head_age=65, + parent_ages=(16, 16), + head_pension=13_819.52, + head_wages=0.0, + head_rent=5_254.85, + parent_rent=0.0, + region="NORTH_EAST", + ) + ], + 2025, + 1.0, +) def test_a_head_applying_alone_counts_only_her_own_letting_income( households, year, extra ): @@ -740,7 +762,12 @@ def test_a_head_applying_alone_counts_only_her_own_letting_income( council tax reduction applicant (SI 2012/2885 reg 2(1), Sch 1 para 11): her income moves with her own rent from letting part of the home, by the change in council_tax_reduction_home_letting_income wherever the scheme - counts it, and not with the benefit unit's claimant's or partner's.""" + counts it, and not with the benefit unit's claimant's or partner's. Her + rent-a-room relief can move the tax the scheme deducts: rent above her + limit bears tax, and the limit halves once someone else in the household + also has receipts (rent_a_room_limit). So the equalities hold where her + taxable rent-a-room income stays the same, and the couple's rent never + raises her income.""" base = head_alone_values(households, year) assert np.all(base["council_tax_reduction_head_applies_alone"] == 1) more_own = head_alone_values(households, year, head_extra=extra) @@ -751,9 +778,12 @@ def test_a_head_applying_alone_counts_only_her_own_letting_income( counts = ~((region == "SCOTLAND") & (base["council_tax_reduction_pensioner"] == 0)) income = "council_tax_reduction_applicable_income" letting = "council_tax_reduction_home_letting_income" + taxable = "head_taxable_rent_a_room_income" + same_tax_own = np.isclose(more_own[taxable], base[taxable], atol=0.01) + same_tax_others = np.isclose(more_others[taxable], base[taxable], atol=0.01) np.testing.assert_allclose( - more_own[income] - base[income], - np.where(counts, more_own[letting] - base[letting], 0), + (more_own[income] - base[income])[same_tax_own], + np.where(counts, more_own[letting] - base[letting], 0)[same_tax_own], atol=0.02, ) assert np.all(more_own[letting] >= base[letting] - 0.01) @@ -761,7 +791,12 @@ def test_a_head_applying_alone_counts_only_her_own_letting_income( # a margin for float32 rounding, her letting income is positive. above = np.array([(h["head_rent"] + extra) / 52 > 20.01 for h in households]) assert np.all(more_own[letting][above] > 0) - np.testing.assert_allclose(more_others[income], base[income], atol=0.01) + np.testing.assert_allclose( + more_others[income][same_tax_others], + base[income][same_tax_others], + atol=0.01, + ) + assert np.all(more_others[income] <= base[income] + 0.01) np.testing.assert_allclose(more_others[letting], base[letting], atol=0.01) # The couple's own legacy measure does count it, less the £20 weekly # disregard. From 988bd914dc7026707c805240814725e860fd4901 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:12:54 +0000 Subject: [PATCH 209/250] Pin Universal Credit's tax on earnings in the Marriage Allowance reduction cases Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6J1pBzUscCNHPteLKz --- .../allowances/marriage_allowance_tax_reduction.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml index 3edd770b1f..fc82157caa 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml @@ -106,6 +106,9 @@ marriage_allowance_tax_reduction: [252, 0] # 5,582.82 - 252 = 5,330.82. income_tax: [5_330.82, 0] + # Universal Credit deducts the tax on earnings after the Step 6 + # reductions: 5,582.82 - 252 = 5,330.82. + uc_income_tax_on_earnings: [5_330.82, 0] - name: A Scottish starter-rate recipient gets 20% of the transferable amount period: 2025 @@ -197,6 +200,7 @@ # (30,000 - 12,570) x 20% - 1,260 x 20% = 3,486 - 252 = 3,234, as when the # model took 1,260 off the 20% band. income_tax: [3_234, 0] + uc_income_tax_on_earnings: [3_234, 0] - name: The reduction stops at the recipient's Step 5 tax period: 2025 From 4c9c8b177532798c93ebe523764cca94560f1ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:14:22 +0000 Subject: [PATCH 210/250] 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 89f2f9f34d3a39ea5909e1f8e3b8feed8047392e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:41:58 +0000 Subject: [PATCH 211/250] Give the CPS reform's higher-rate recipient the basic rate of the allowance The switch test pinned the reform's values from when the model took the allowance off taxable income, worth 40% to a higher-rate earner. The recipient's reduction is 20% of the amount (ITA 2007 s. 55B(1) and (3)). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6J1pBzUscCNHPteLKz --- .../test_marriage_allowance_cps_switch.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index 3825f11522..6c305411ee 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -29,8 +29,8 @@ everyone else meets the conditions, and no one has more than the statutory share of the personal allowance, rounded up to the next £10. 4. The allowance never falls when the statutory rate rises. -5. Switched on by its own parameters, the reform behaves as it did before - this change (regression values computed before the fix). +5. Switched on by its own parameters, the reform gives the allowance to the + higher-rate earner too, worth the basic rate of the allowance. """ import functools @@ -168,19 +168,19 @@ def test_a_higher_statutory_rate_raises_only_the_statutory_allowance(route): assert outcome(simulation) == ([2_520, 0, 0, 0], [2_982, 11_432]) -# Invariant 5. Each expectation was computed on main before this change -# (ecd531776); the reform's own parameters switched it on there too. The -# expanded allowance reaches the higher-rate earner. The model takes the -# allowance off taxable income, so it is worth 40% to them: 11,432 - 1,260 x -# 40% = 10,928 at 10%, and 11,432 - 2,520 x 40% = 10,424 at 20%. +# Invariant 5, by hand. The expanded allowance reaches the higher-rate earner. +# Parliament gives the allowance as a tax reduction of the basic rate on the +# amount (ITA 2007 s. 55B(1) and (3)), whatever the recipient's top rate: +# 11,432 - 1,260 x 20% = 11,180 at 10%, and 11,432 - 2,520 x 20% = 10,928 at +# 20%. OWN_SWITCHES = { "remove_income_condition": ( change((REMOVE_INCOME_CONDITION, True)), - ([1_260, 0, 1_260, 0], [3_234, 10_928]), + ([1_260, 0, 1_260, 0], [3_234, 11_180]), ), "cps_rate": ( change((CPS_RATE, 0.2)), - ([2_520, 0, 2_520, 0], [2_982, 10_424]), + ([2_520, 0, 2_520, 0], [2_982, 10_928]), ), } @@ -199,7 +199,7 @@ def test_a_cps_rate_switches_the_reform_on_whatever_the_statutory_rate(): reform = change((STATUTORY_RATE, 0.2), (CPS_RATE, 0.2)) simulation = Simulation(situation=ISSUE_COUPLES, reform=reform) assert REFORM_VARIABLE in simulation.tax_benefit_system.variables - assert outcome(simulation) == ([2_520, 0, 2_520, 0], [2_982, 10_424]) + assert outcome(simulation) == ([2_520, 0, 2_520, 0], [2_982, 10_928]) CHILD_CONDITION_COUPLES = couples( From 3feb308eea20890984f51d3be7864f34a546c843 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:28:30 +0000 Subject: [PATCH 212/250] 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 213/250] 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 2ca7c5ff77ed88c8a00580d0a659f54637049133 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 13:00:47 +0000 Subject: [PATCH 214/250] Update the Scottish Pension Credit cases to the Step 6 Marriage Allowance reduction Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GW4VyFJ2Wtu8apY8j --- .../pension_credit_earnings_disregard.yaml | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 2131ed247c..975138b2e0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -337,12 +337,25 @@ members: [claimant, partner] country: SCOTLAND output: - # The 1,040 of earnings add 210.89 of income tax (they cross into the - # Scottish intermediate band), so net earnings are 829.11 and the 20 pound - # disregard of 1,040 is capped there; income is as with no earnings (next). - income_tax: [3_016.62, 0] - pension_credit_earnings_disregard: 829.11 - pension_credit_income: 25_194.27 + # 2025-26 Scottish schedule on taxable income: 19% up to 2,827, 20% to + # 14,921, 21% to 31,092. The partner's unused personal allowance gives the + # claimant a Marriage Allowance of 1,260 (10% of 12,570, rounded up to + # 10), which comes off as a tax reduction of 20% x 1,260 = 252, the + # Scottish basic rate (ITA 2007 s. 55B(1) and (3)). + # With earnings: 12,000 + 16,000 + 1,040 - 12,570 = 16,470 taxable; + # 2,827 x 19% + 12,094 x 20% + 1,549 x 21% = 537.13 + 2,418.80 + 325.29 + # = 3,281.22, less 252 = 3,029.22. + # Without earnings (next case): 15,430 taxable; 537.13 + 2,418.80 + + # 509 x 21% = 3,062.82, less 252 = 2,810.82. + # The taxable income already exceeds 14,921 without the earnings, so the + # earnings do not cross into the intermediate band: all 1,040 bear 21%, + # adding 3,029.22 - 2,810.82 = 218.40 of tax. No Class 1 above State + # Pension age, so net earnings are 1,040 - 218.40 = 821.60, and the 20 + # pound disregard (attendance allowance) of 1,040 is capped there. + # Income: 29,040 - 3,029.22 - 821.60 = 25,189.18, as with no earnings. + income_tax: [3_029.22, 0] + pension_credit_earnings_disregard: 821.60 + pension_credit_income: 25_189.18 - name: The same Scottish couple with no earnings has the same Pension Credit income. period: 2025 @@ -365,9 +378,13 @@ members: [claimant, partner] country: SCOTLAND output: - income_tax: [2_805.73, 0] + # 28,000 - 12,570 = 15,430 taxable: 2,827 x 19% + 12,094 x 20% + 509 x + # 21% = 3,062.82, less the 252 Marriage Allowance reduction (20% x 1,260) + # = 2,810.82. Income: 12,000 + 16,000 - 2,810.82 = 25,189.18; attendance + # allowance does not count. + income_tax: [2_810.82, 0] pension_credit_earnings_disregard: 0 - pension_credit_income: 25_194.27 + pension_credit_income: 25_189.18 - name: Caring hours without entitlement to carer's allowance do not qualify for the 20 pound disregard (Sch VI para 3 needs Sch I para 4). period: 2025 From be8c090a5e8c3ef184169becbd24976ba54ebd51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 08:26:27 -0500 Subject: [PATCH 215/250] 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 216/250] 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 e511964cb5d3408713bf16910857c0ad00b595b7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 09:02:51 -0500 Subject: [PATCH 217/250] Clarify the Scottish Pension Credit case comment The taxable income exceeds 14,921 even without the earnings, so all 1,040 of them bear 21%. The claimant pays no Class 1 contributions above State Pension age, and the 20 pound disregard covers all 821.60 of net earnings. No expected value changes. Co-Authored-By: Claude Opus 5.5 --- .../pension_credit_earnings_disregard.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 975138b2e0..8450543a61 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -347,11 +347,11 @@ # = 3,281.22, less 252 = 3,029.22. # Without earnings (next case): 15,430 taxable; 537.13 + 2,418.80 + # 509 x 21% = 3,062.82, less 252 = 2,810.82. - # The taxable income already exceeds 14,921 without the earnings, so the - # earnings do not cross into the intermediate band: all 1,040 bear 21%, - # adding 3,029.22 - 2,810.82 = 218.40 of tax. No Class 1 above State - # Pension age, so net earnings are 1,040 - 218.40 = 821.60, and the 20 - # pound disregard (attendance allowance) of 1,040 is capped there. + # The taxable income exceeds 14,921 even without the earnings, so all + # 1,040 of the earnings bear 21%, adding 3,029.22 - 2,810.82 = 218.40 of + # tax. The claimant pays no Class 1 contributions above State Pension age, + # so net earnings come to 1,040 - 218.40 = 821.60, and the 20 pound + # disregard (attendance allowance), up to 1,040 a year, covers all 821.60. # Income: 29,040 - 3,029.22 - 821.60 = 25,189.18, as with no earnings. income_tax: [3_029.22, 0] pension_credit_earnings_disregard: 821.60 From 7f2ac120bba1175a5457a1739709387ce8d52cfc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 09:31:29 -0500 Subject: [PATCH 218/250] 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 219/250] 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 220/250] 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 221/250] 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 222/250] 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 223/250] 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 224/250] 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 225/250] 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 226/250] 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 227/250] 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 228/250] 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 229/250] 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 a25cc40d08633077448e6d9dd07ad7de42d4b027 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:52:23 +0000 Subject: [PATCH 230/250] Say what the reform pays on a claim, and pin the Working Tax Credit report The reform behind disable_simulated_benefits pays a claimant's or partner's own income-related ESA, Housing Benefit and Child Tax Credit report as reported only in its ten years. From then on the model ends those awards on the family's Universal Credit claim again, while the formula, which reads the switch, still leaves the claim limbs out. The docstring and comments now say so, and cite SI 2014/1230 reg. 8 and SI 2013/983 art. 4. A new case pins that the predicate follows no claim limb for Working Tax Credit: the model keeps a lone parent's award beside a Universal Credit claim in 2024, and their own report still excludes them. The reform test now also checks that the model pays nothing on each claimant's report without the reform. Three YAML headers name the model, not the claim, as the actor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- ...ifying_young_person_existing_benefits.yaml | 5 ++- ...ualifying_young_person_legacy_closure.yaml | 3 +- ...g_young_person_universal_credit_claim.yaml | 38 +++++++++++++++++++ ...ying_young_person_reported_own_awards.yaml | 7 ++-- .../tests/test_disable_simulated_benefits.py | 18 ++++++--- ...fying_young_person_for_universal_credit.py | 27 ++++++++----- 6 files changed, 77 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index 03665bd930..cc0a03a6f2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -15,8 +15,9 @@ # (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases # a young person's own report closes their legacy awards only where that # benefit's year gate has already closed, so the closure decides no case. -# The claim limbs end only a claimant's or partner's own award -# (uc_qualifying_young_person_universal_credit_claim.yaml covers them), so +# The predicate follows the family's Universal Credit claim only for a +# claimant's or partner's own award +# (uc_qualifying_young_person_universal_credit_claim.yaml covers it), so # the cases that test a claimant's own Child Tax Credit or Housing Benefit # report claim no Universal Credit (would_claim_uc). The first six cases, # and the four after the seventh, share one household: a parent aged 45 and diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index 2762b03988..e5c5d5cd07 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -17,7 +17,8 @@ # 17-year-old on a non-advanced course in the parent's benefit unit, North # East, owned outright, no income. Cases 7 and 8 use the same family renting # from the council at £6,000 a year and claiming no Universal Credit, so only -# the closure can end its Housing Benefit. Case 9 has a parent aged 70. The parent claims Universal Credit as a single +# the closure can end its Housing Benefit. Case 9 has a parent aged 70. In +# every case but 7 and 8 the parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml # SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the # first child higher amount for a qualifying young person diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index 546774f357..545524e659 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -9,6 +9,8 @@ # award the claim has ended, so for a claimant or partner the UC qualifying # young person test (SI 2014/1230 reg. 28) follows those three limbs. For a # dependant it doesn't: a parent's claim leaves the dependant's own award. +# The model ends no Working Tax Credit, Income Support or income-based JSA +# award on the claim, so the predicate follows no claim limb for them. # # would_claim_uc decides the claim here. A case without a claim also sets # would_claim_uc_at_legacy_closure false, so the family claims nothing when @@ -280,6 +282,42 @@ is_qualifying_young_person_for_universal_credit: [false, false, false] universal_credit: 0 +- name: An 18-year-old lone parent's own Working Tax Credit report excludes them though they claim Universal Credit. + # Tax credits pay in 2024. The model ends Working Tax Credit when the + # family's legacy awards close, but not on its Universal Credit claim + # (is_WTC_eligible reads legacy_benefits_closed, not + # claims_universal_credit). The claimant works 16 hours a week and looks + # after a child, so they meet the model's lone parent hours test (16 hours + # from age 16), and the model keeps their award beside the claim. The + # predicate follows no claim limb for Working Tax Credit, so the + # claimant's own report still excludes them. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + weekly_hours: 16 + working_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: true + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: true + is_WTC_eligible: true + is_qualifying_young_person_under_universal_credit_regulation_5: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + - name: An 18-year-old partner's own Housing Benefit report excludes them while their mixed-age couple keeps the Pension Credit route. # A claimant aged 75 who reports Pension Credit and an 18-year-old partner # on a non-advanced course rent from the council at £6,000 a year and claim diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 39f05dbc38..2ca48da78e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -7,10 +7,11 @@ # benefit (income-based JSA and Income Support until 31 March 2026), reads an # income-related ESA, income-based JSA or Income Support report only until # the person's legacy awards close (person_legacy_benefits_closed), and -# reads none that a reform neutralises. A claim for Universal Credit also -# ends a claimant's or partner's own income-related ESA +# reads none that a reform neutralises. The model also ends a claimant's or +# partner's own income-related ESA when the family claims Universal Credit # (esa_income_report_closed), so the cases that test a claimant's own -# income-related ESA report claim no Universal Credit (would_claim_uc). The Secretary of State excludes own awards +# income-related ESA report claim no Universal Credit (would_claim_uc). The +# Secretary of State excludes own awards # from the Pension Credit routes, including income support (SPC reg. 4A(5)): # is_on_income_support reads it, from the calculated award for a claimant or # partner and from their own report for anyone else. The reports identify the diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 3cc555c204..316eaa8596 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -928,18 +928,24 @@ def test_a_claimant_on_a_benefit_the_reform_pays_beside_universal_credit_is_no_q # A claim for Universal Credit ends the claimant's own income-related # ESA, Housing Benefit and Child Tax Credit (SI 2014/1230 reg. 8(2) and # (2A); SI 2013/983 art. 4), and without the reform the model ends each - # award on the claim, so the predicate doesn't read the report. The - # reform pays each report as reported, claim or none, so the claimant - # stays entitled to it and the predicate excludes them. + # award on the claim, so the predicate doesn't read the report (the + # model's year gate closes the Child Tax Credit report from 2025 too). + # In its years, the dataset year and the nine after it, the reform pays + # each report as reported, claim or none, so the claimant stays entitled + # to it and the predicate excludes them. reformed = Simulation(situation=claimant_situation(), scenario=REFORM) unreformed = Simulation(situation=claimant_situation()) predicate = "is_qualifying_young_person_for_universal_credit" benefit = CLAIMANT_REPORTS[report] person = 2 * index - paid = reformed.calculate(benefit, year) - paid = paid[person] if benefit == "esa_income" else paid[index] + + def paid(simulation): + amounts = simulation.calculate(benefit, year) + return float(amounts[person] if benefit == "esa_income" else amounts[index]) + assert reformed.calculate("claims_universal_credit", year)[index] - assert float(paid) == 3_000 + assert paid(reformed) == 3_000 assert not reformed.calculate(predicate, year)[person] assert unreformed.calculate("claims_universal_credit", year)[index] + assert paid(unreformed) == 0 assert unreformed.calculate(predicate, year)[person] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index e2a319ccfa..b4b3d3474b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -31,9 +31,12 @@ class is_qualifying_young_person_for_universal_credit(Variable): "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " "that switch turns on pays them at their reported amounts in the " "dataset year and the nine years after it, and the formula still reads " - "them in later years. That reform pays each report whether or not the " - "family claims Universal Credit, so while the switch is on the formula " - "leaves out the Universal Credit claim limbs below. The model also ends" + "them in later years. In those years the reform also pays a claimant's " + "or partner's own income-related ESA, Housing Benefit and Child Tax " + "Credit report whether or not the family claims Universal Credit, so " + "while the switch is on the formula leaves out the Universal Credit " + "claim limbs below. It leaves them out in later years too, when the " + "model again ends those awards on the family's claim. The model also ends" " a person's legacy awards once its annual migration proxy closes them:" " it ends their Income Support, income-based JSA and income-related ESA" " when person_legacy_benefits_closed holds, the family's tax credits " @@ -48,7 +51,9 @@ class is_qualifying_young_person_for_universal_credit(Variable): "(esa_income_report_closed), when the family claims " "(claims_universal_credit). For a claimant or partner, the formula " "follows those limbs too. The family's claim doesn't end a dependant's " - "own award, so for a dependant the formula doesn't. It can't read " + "own award, so for a dependant the formula doesn't. The model ends no " + "Working Tax Credit, Income Support or income-based JSA award on the " + "claim, so the formula follows no claim limb for them. It can't read " "housing_benefit_eligible: that variable's Pension Credit route reads " "left_pension_route_at_legacy_closure, which reads this variable " "through is_uc_entitled and the child element. So the formula keeps a " @@ -67,6 +72,8 @@ class is_qualifying_young_person_for_universal_credit(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2013/983/article/4", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", @@ -84,7 +91,8 @@ def formula(person, period, parameters): # that year and the nine after it, closure dates included, and holds # both legacy closures false in those years. The formula reads the # switch, not those years, so in a later year it still reads a tax - # credit report that the model no longer pays. + # credit report that the model no longer pays, and still leaves out + # the claim limbs below. paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) system = person.simulation.tax_benefit_system @@ -110,10 +118,11 @@ def paying(active, *names): # income-related ESA when the family claims (esa_income_report_closed), # and the formula follows it. claims_universal_credit reads # would_claim_uc, would_claim_uc_at_legacy_closure and - # legacy_benefits_closed, not this variable. The reform behind - # disable_simulated_benefits pays each report as reported whether or - # not the family claims, and holds the legacy closures false in its - # years, so under it the formula keeps the person's own closure alone. + # legacy_benefits_closed, not this variable. In its years the reform + # behind disable_simulated_benefits pays each report as reported + # whether or not the family claims, and holds the legacy closures + # false, so while the switch is on the formula keeps the person's own + # closure alone. claimant_or_partner = person("is_claimant_or_partner", period) esa_income_open = ~person("esa_income_report_closed", period) | ( own_award_open & paid_as_reported From c8e31269af11a02a111cdd9fb049c81aff2594ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:01:40 +0000 Subject: [PATCH 231/250] Carry the composition with #2806: read reg. 28 on the migration day for reg. 60(1) #2806 merged first, so this PR carries the change the composition note gives (comment 6605782269 on #2806), applied unchanged. is_qualifying_young_person_for_uc_education returns is_qualifying_young_person_for_universal_credit, and a new is_qualifying_young_person_on_uc_migration_day applies reg. 28 to every reported existing benefit, the award a closure claim ended, for the reg. 60(1) protection in uc_is_excepted_from_education_condition. The note's YAML and property-test changes come with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- .../uc_receiving_education.yaml | 9 ++- .../test_uc_receiving_education_properties.py | 74 ++++++++++++++++--- ...ualifying_young_person_for_uc_education.py | 64 +++++----------- ...ifying_young_person_on_uc_migration_day.py | 68 +++++++++++++++++ ...uc_is_excepted_from_education_condition.py | 16 ++-- 5 files changed, 162 insertions(+), 69 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index bc1ed54e69..dda84a456c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -831,7 +831,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_universal_credit: [false] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [false] is_uc_eligible: true @@ -886,7 +886,7 @@ members: [person1, person2] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true, true] + is_qualifying_young_person_for_universal_credit: [false, false] is_qualifying_young_person_for_uc_education: [false, false] uc_receiving_education: [false, false] @@ -915,7 +915,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_universal_credit: [false] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] @@ -1256,7 +1256,8 @@ output: legacy_benefits_closed: true claims_uc_at_legacy_closure: true - is_qualifying_young_person_for_uc_education: [false] + is_qualifying_young_person_for_uc_education: [true] + is_qualifying_young_person_on_uc_migration_day: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] meets_uc_education_condition: [true] diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 44c304d9d7..1d010d4de2 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -101,16 +101,34 @@ YEARS = [2019, 2021, 2024, 2026] REGIONS = ["NORTH_EAST", "LONDON", "SCOTLAND", "NORTHERN_IRELAND"] EDUCATION = ["TERTIARY", "POST_SECONDARY", "NOT_IN_EDUCATION"] -# SI 2014/1230 reg. 2(1), as reported receipt. +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# SI 2014/1230 reg. 2(1), as reported receipt, for the migration day: the +# report stands for the award the closure claim ended. EXISTING_BENEFITS = [ "jsa_income_reported", "esa_income_reported", "income_support_reported", "housing_benefit_reported", - "child_tax_credit_reported", - "working_tax_credit_reported", + *TAX_CREDITS, ] -TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# Reg. 28 turns on entitlement, +# so a report counts only in a year the model pays that benefit (the +# parameter, or None where the model has no closure date) and only if no +# reform neutralises one of its variables. +REPORT_BENEFITS = { + "jsa_income_reported": ("JSA.income.active", ["jsa_income"]), + "esa_income_reported": (None, ["esa_income"]), + "income_support_reported": ("income_support.active", ["income_support"]), + "housing_benefit_reported": (None, ["housing_benefit"]), + "child_tax_credit_reported": ( + "tax_credits.active", + ["child_tax_credit", "tax_credits"], + ), + "working_tax_credit_reported": ( + "tax_credits.active", + ["working_tax_credit", "tax_credits"], + ), +} # Compare eligibility, maximum entitlement, income reduction and Universal # Credit awards. OUTPUTS = [ @@ -302,6 +320,23 @@ def all_flags(case): BEFORE = (None, True) +def paying_reports(simulation, year): + """The existing benefit reports reg. 28 reads in the year: those of + benefits the model pays then and no reform neutralises.""" + system = simulation.tax_benefit_system + dwp = system.parameters(f"{year}-01-01").gov.dwp + paying = [] + for report, (active, variables) in REPORT_BENEFITS.items(): + node = dwp + for key in active.split(".") if active else []: + node = getattr(node, key) + if (active is None or bool(node)) and not any( + system.get_variable(v).is_neutralized for v in variables + ): + paying.append(report) + return paying + + def run(case, variants=(DRAWN,)): """One simulation of every variant; returns one result per variant.""" simulation = Simulation(situation=situation(case, variants)) @@ -314,6 +349,7 @@ def run(case, variants=(DRAWN,)): np.array([uses_main for _, uses_main in variants for _ in case["families"]]), ) values = {name: simulation.calculate(name, year) for name in VARIABLES} + paying = paying_reports(simulation, year) limit = simulation.tax_benefit_system.parameters( f"{year}-01-01" ).gov.dwp.universal_credit.means_test.capital.limit @@ -324,7 +360,7 @@ def run(case, variants=(DRAWN,)): } results = [] for v in range(len(variants)): - result = {"capital_limit": limit} + result = {"capital_limit": limit, "paying": paying} for name, array in values.items(): entity = simulation.tax_benefit_system.variables[name].entity.key size = sizes[entity] @@ -353,18 +389,31 @@ def reference(case, result): members = range(start, start + len(fam["people"])) person_of = dict(zip(members, fam["people"])) couple = [i for i in members if result["is_uc_assessed_claimant"][i]] + tax_credits = [name for name in TAX_CREDITS if name in result["paying"]] couple_tax_credits = any( + person_of[j].get(name, 0) > 0 for j in couple for name in tax_credits + ) + couple_tax_credits_on_migration_day = any( person_of[j].get(name, 0) > 0 for j in couple for name in TAX_CREDITS ) - qyp, recv = {}, {} + qyp, qyp_on_migration_day, recv = {}, {}, {} for i, person in person_of.items(): existing_benefit = any( - person.get(name, 0) > 0 for name in EXISTING_BENEFITS + person.get(name, 0) > 0 for name in result["paying"] ) or (i in couple and couple_tax_credits) qyp[i] = ( bool(result["is_qualifying_young_person_for_universal_credit"][i]) and not existing_benefit ) + # Reg. 28 on the migration day, when the award the closure claim + # ended still existed. + existing_benefit_on_migration_day = any( + person.get(name, 0) > 0 for name in EXISTING_BENEFITS + ) or (i in couple and couple_tax_credits_on_migration_day) + qyp_on_migration_day[i] = ( + bool(result["is_qualifying_young_person_for_universal_credit"][i]) + and not existing_benefit_on_migration_day + ) advanced = person["current_education"] == "TERTIARY" or person.get( "in_HE", False ) @@ -410,7 +459,7 @@ def reference(case, result): reg_60 = ( in_couple_role and fam["benunit"]["claims_uc_at_legacy_closure"] - and not qyp[i] + and not qyp_on_migration_day[i] ) exc[i] = limb_b or limb_c or limb_d or limb_e or limb_f or reg_60 claimants = [i for i in members if result["is_uc_single_or_joint_claimant"][i]] @@ -663,11 +712,12 @@ def no_loans_or_grants(i, values): atol=0.005, err_msg=f"{name}: {context}", ) - # 8. Reg. 28: no one who reports an existing benefit counts as a - # qualifying young person for the condition. + # 8. Reg. 28: no one who reports an existing benefit the model pays that + # year, and no reform neutralises, counts as a qualifying young person + # for the condition. reports = np.array( [ - any(p.get(name, 0) > 0 for name in EXISTING_BENEFITS) + any(p.get(name, 0) > 0 for name in drawn["paying"]) for fam in case["families"] for p in fam["people"] ] @@ -711,7 +761,7 @@ def test_examples_reach_the_cases(): # People 9 and 10: the young Housing Benefit claimants. young = slice(9, 11) np.testing.assert_array_equal( - drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] + drawn["is_qualifying_young_person_for_universal_credit"][young], [False, False] ) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_uc_education"][young], [False, False] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 39307c22a0..88d8dc9931 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -1,16 +1,5 @@ from policyengine_uk.model_api import * -# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). -EXISTING_BENEFITS = [ - "jsa_income_reported", - "esa_income_reported", - "income_support_reported", - "housing_benefit_reported", - "child_tax_credit_reported", - "working_tax_credit_reported", -] -TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] - class is_qualifying_young_person_for_uc_education(Variable): value_type = bool @@ -19,25 +8,23 @@ class is_qualifying_young_person_for_uc_education(Variable): documentation = ( "Whether this person counts as a qualifying young person for the " "Universal Credit condition that a claimant is not receiving " - "education: they meet is_qualifying_young_person_for_universal_credit " - "and have no entitlement to an existing benefit. Under the Universal " - "Credit (Transitional Provisions) Regulations 2014 reg. 28, a person " - "entitled to an existing benefit doesn't count as a qualifying young " - "person, and the Secretary of State lists the existing benefits in " - "reg. 2(1): income-based Jobseeker's Allowance, income-related " - "Employment and Support Allowance, Income Support, Housing Benefit, " - "Child Tax Credit and Working Tax Credit. The model reads entitlement " - "from the year's reported receipt: the person's own report of any of " - "them, or, for a claimant or partner, either member's report of tax " - "credits, which a couple claims jointly. A person outside the " - "definition under reg. 28 takes the ordinary route under the Universal " - "Credit Regulations 2013 reg. 12(2). The model's other rules read " - "is_qualifying_young_person_for_universal_credit, which leaves reg. 28 " - "out. In Northern Ireland the decision maker applies the same rule " - "under reg. 29 of the Universal Credit (Transitional Provisions) " - "Regulations (Northern Ireland) 2016, with reg. 6 of the Universal " - "Credit Regulations (Northern Ireland) 2016 in place of the Universal " - "Credit Regulations 2013 reg. 5." + "education. Under the Universal Credit (Transitional Provisions) " + "Regulations 2014 reg. 28, a person entitled to an existing benefit " + "doesn't count as a qualifying young person, and the Secretary of " + "State lists the existing benefits in reg. 2(1): income-based " + "Jobseeker's Allowance, income-related Employment and Support " + "Allowance, Income Support, Housing Benefit, Child Tax Credit and " + "Working Tax Credit. It equals " + "is_qualifying_young_person_for_universal_credit, which applies reg. " + "28. A person outside the definition under reg. 28 takes the ordinary " + "route under the Universal Credit Regulations 2013 reg. 12(2). For " + "the reg. 60(1) protection the model reads the status on the " + "migration day instead (is_qualifying_young_person_on_uc_migration_day). " + "In Northern Ireland the decision maker applies the same rule under " + "reg. 29 of the Universal Credit (Transitional Provisions) Regulations " + "(Northern Ireland) 2016, with reg. 6 of the Universal Credit " + "Regulations (Northern Ireland) 2016 in place of the Universal Credit " + "Regulations 2013 reg. 5." ) definition_period = YEAR reference = ( @@ -50,19 +37,4 @@ class is_qualifying_young_person_for_uc_education(Variable): ) def formula(person, period, parameters): - qualifying_young_person = person( - "is_qualifying_young_person_for_universal_credit", period - ) - # SI 2014/1230 reg. 28: where a person who would be a qualifying - # young person "is entitled to an existing benefit", "that person is - # not a qualifying young person for the purposes of the Universal - # Credit Regulations". - own_award = add(person, period, EXISTING_BENEFITS) > 0 - # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a - # tax credit jointly, so the award belongs to both. - claimant_or_partner = person("is_claimant_or_partner", period) - joint_tax_credits = claimant_or_partner & ( - person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) - > 0 - ) - return qualifying_young_person & ~(own_award | joint_tax_credits) + return person("is_qualifying_young_person_for_universal_credit", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py new file mode 100644 index 0000000000..2ef8cb2e31 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py @@ -0,0 +1,68 @@ +from policyengine_uk.model_api import * + +# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). +EXISTING_BENEFITS = [ + "jsa_income_reported", + "esa_income_reported", + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +] +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] + + +class is_qualifying_young_person_on_uc_migration_day(Variable): + value_type = bool + entity = Person + label = "Qualifying young person on the Universal Credit migration day" + documentation = ( + "Whether this person counted as a qualifying young person for the " + "Universal Credit condition that a claimant is not receiving " + "education on the migration day of a closure claim, the day before " + "the Universal Credit award starts (Universal Credit (Transitional " + "Provisions) Regulations 2014 reg. 49). Under reg. 28, a person " + "entitled to an existing benefit doesn't count as a qualifying young " + "person, and the Secretary of State lists the existing benefits in " + "reg. 2(1). Under reg. 8(2)(a), a claim for Universal Credit ends a " + 'tax credit award "on the day before the first date on which the ' + 'claimant is entitled to universal credit", so on the migration day ' + "reg. 28 still applied to that award. The model reads the year's " + "reported receipt of an existing benefit as that award, whether or " + "not it still pays the benefit that year: the person's own report, " + "or, for a claimant or partner, either member's report of tax " + "credits, which a couple claims jointly. " + "uc_is_excepted_from_education_condition reads this status for the " + "reg. 60(1) protection. In Northern Ireland the decision maker applies " + "the same rules under regs. 29 and 50 of the Universal Credit " + "(Transitional Provisions) Regulations (Northern Ireland) 2016." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/50", + ) + + def formula(person, period, parameters): + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + # SI 2014/1230 reg. 28: where a person who would be a qualifying + # young person "is entitled to an existing benefit", "that person is + # not a qualifying young person for the purposes of the Universal + # Credit Regulations". The report stands for the award the closure + # claim ended, so the formula reads it in every year. + own_award = add(person, period, EXISTING_BENEFITS) > 0 + # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a + # tax credit jointly, so the award belongs to both. + claimant_or_partner = person("is_claimant_or_partner", period) + joint_tax_credits = claimant_or_partner & ( + person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) + > 0 + ) + return qualifying_young_person & ~(own_award | joint_tax_credits) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index 209eddad27..5b42c4658f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -22,10 +22,10 @@ class uc_is_excepted_from_education_condition(Variable): "a family that claims Universal Credit when DWP moves it off its " "legacy benefits (claims_uc_at_legacy_closure), which the model takes " "as the qualifying claim under reg. 48 of the 2014 Regulations, and who " - "doesn't count " - "as a qualifying young person for the condition " - "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " - "of the 2014 Regulations). The model needs no rule of its own for limb " + "didn't count as a qualifying young person for the condition on the " + "migration day (is_qualifying_young_person_on_uc_migration_day, which " + "applies reg. 28 of the 2014 Regulations to the award the claim " + "ended). The model needs no rule of its own for limb " "(e)(i): it places responsibility for a child on the benefit unit, so " "both members of the couple meet (c). Under (b), the decision maker " "must determine limited capability for work before the course starts. " @@ -63,6 +63,7 @@ class uc_is_excepted_from_education_condition(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/48", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60", "https://www.legislation.gov.uk/nisr/2016/216/regulation/14", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", @@ -127,12 +128,13 @@ def formula(person, period, parameters): # that condition is not to apply ... while they are continuing to # undertake that course". A qualifying young person receives # education under reg. 12(1A), not reg. 12(2); reg. 28 applies - # first, so a person entitled to an existing benefit takes reg. - # 12(2) and keeps the protection. + # first, so a person entitled to an existing benefit on the + # migration day took reg. 12(2) and keeps the protection while they + # continue the course, after that award has ended. migrated_student = ( claimant_or_partner & benunit("claims_uc_at_legacy_closure", period) - & ~person("is_qualifying_young_person_for_uc_education", period) + & ~person("is_qualifying_young_person_on_uc_migration_day", period) ) return ( disabled_with_limited_capability_for_work From 45450629f2a7552f1d3421448408bd14bb8e11d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:08:19 +0000 Subject: [PATCH 232/250] Read reg. 28 at the claim for the education condition The composition makes is_qualifying_young_person_for_uc_education return the UC predicate. With the claim limbs, that predicate gives true for an 18-year-old who claims Universal Credit while reporting their own Housing Benefit, so the model would treat them as a qualifying young person receiving education (UC Regs 2013 reg. 12(1A)) and deny them Universal Credit (#2806's case 31). The Secretary of State excludes a person entitled to an existing benefit (SI 2014/1230 reg. 28) and a person receiving Universal Credit (UC Regs 2013 reg. 5(5)), and the model's reg. 5 test reads only reported Universal Credit. So the education status now reads reg. 28 against the awards a claimant or partner held when they claimed: the predicate's formula moves into qualifying_young_person, whose claim_limbs switch the education status turns off. #2806's own assertions for cases 31 and 34 and for people 9 and 10 of test_examples_reach_the_cases come back: those claimants claim Universal Credit, which ends their own Housing Benefit, so the predicate gives true as #2806 asserted. The changelog names the education condition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- changelog.d/2804.fixed.md | 2 +- .../uc_receiving_education.yaml | 8 +- .../test_uc_receiving_education_properties.py | 7 +- ...fying_young_person_for_universal_credit.py | 225 +++++++++--------- ...ualifying_young_person_for_uc_education.py | 24 +- 5 files changed, 148 insertions(+), 118 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index 25484768fe..f0b3fdc0a6 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test against the awards a claimant held when they claimed, and against every reported existing benefit on the migration day for the reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index dda84a456c..6f5067b868 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -815,6 +815,10 @@ # Housing Benefit (reg. 2(1)), doesn't count as a qualifying young person # for the Universal Credit Regulations. With no maintenance funding, reg. # 12(2) doesn't apply either, so the claimant doesn't receive education. + # The claimant claims Universal Credit, which ends their own Housing + # Benefit (reg. 8(2A)), so is_qualifying_young_person_for_universal_credit + # follows that claim limb. The condition reads reg. 28 against the award + # they held when they claimed (is_qualifying_young_person_for_uc_education). period: 2026 input: people: @@ -831,7 +835,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_universal_credit: [true] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [false] is_uc_eligible: true @@ -915,7 +919,7 @@ members: [person1] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_universal_credit: [true] is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 1d010d4de2..ce0ac3a773 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -758,10 +758,13 @@ def test_examples_reach_the_cases(): drawn["uc_is_excepted_from_education_condition"][1:5], [True, False, True, False], ) - # People 9 and 10: the young Housing Benefit claimants. + # People 9 and 10: the young Housing Benefit claimants. Each claims + # Universal Credit, which ends their own Housing Benefit (SI 2014/1230 + # reg. 8(2A)), so the UC predicate follows that claim limb; the condition + # reads reg. 28 against the award they held when they claimed. young = slice(9, 11) np.testing.assert_array_equal( - drawn["is_qualifying_young_person_for_universal_credit"][young], [False, False] + drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] ) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_uc_education"][young], [False, False] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index b4b3d3474b..cfc5952581 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -80,117 +80,124 @@ class is_qualifying_young_person_for_universal_credit(Variable): ] def formula(person, period, parameters): - # The Secretary of State excludes a person entitled to an existing - # benefit (TP regs. 2(1) and 28). The formula reads raw person-level - # reports, the model's legacy closure proxy and the family's Universal - # Credit claim, so it calculates no family benefit that depends on QYP - # status. - p = parameters(period).gov - dwp = p.dwp - # The reform pays each listed benefit at its dataset-year report in - # that year and the nine after it, closure dates included, and holds - # both legacy closures false in those years. The formula reads the - # switch, not those years, so in a later year it still reads a tax - # credit report that the model no longer pays, and still leaves out - # the claim limbs below. - paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) - system = person.simulation.tax_benefit_system + return qualifying_young_person(person, period, parameters) - def paying(active, *names): - # Nobody is entitled to a benefit the model no longer pays, or one - # a reform neutralises. - return bool(active or paid_as_reported) and not any( - system.get_variable(name).is_neutralized for name in names - ) - # The model ends a person's Income Support, income-based JSA and - # income-related ESA when their legacy awards close: the claimant's - # and partner's together (legacy_benefits_closed, read through - # income_support), and anyone else's on their own reports - # (jsa_income, esa_income). person_legacy_benefits_closed and - # legacy_benefits_closed read reports, is_claimant_or_partner, the - # Pension Credit age conditions and parameters, and no Universal - # Credit variable. - own_award_open = ~person("person_legacy_benefits_closed", period) - # A claim for Universal Credit ends the claimant's and partner's own - # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and - # nobody else's. The model ends the claimant's and partner's - # income-related ESA when the family claims (esa_income_report_closed), - # and the formula follows it. claims_universal_credit reads - # would_claim_uc, would_claim_uc_at_legacy_closure and - # legacy_benefits_closed, not this variable. In its years the reform - # behind disable_simulated_benefits pays each report as reported - # whether or not the family claims, and holds the legacy closures - # false, so while the switch is on the formula keeps the person's own - # closure alone. - claimant_or_partner = person("is_claimant_or_partner", period) - esa_income_open = ~person("esa_income_report_closed", period) | ( - own_award_open & paid_as_reported - ) - claim_ends_own_award = ( - claimant_or_partner - & person.benunit("claims_universal_credit", period) - & (not paid_as_reported) - ) - # The model pays Housing Benefit and tax credits to the family, on - # every member's reports, and ends them when the claimant's and - # partner's legacy awards close (housing_benefit_eligible, - # is_CTC_eligible, is_WTC_eligible). It also ends the family's - # continuing Housing Benefit award and its Child Tax Credit when the - # family claims Universal Credit, and the formula follows that limb - # for a claimant or partner, whose own award the claim ends. A family - # that meets the Pension Credit age conditions keeps Housing Benefit - # unless it claims Universal Credit at the closure and is entitled to - # it (left_pension_route_at_legacy_closure). That variable reads this - # one through is_uc_entitled and the child element, so the formula - # keeps the Housing Benefit report for such a family. - family_closed = person.benunit("legacy_benefits_closed", period) - family_award_open = ~family_closed - housing_benefit_open = person.benunit( - "meets_pension_credit_age_conditions", period - ) | (~family_closed & ~claim_ends_own_award) +def qualifying_young_person(person, period, parameters, claim_limbs=True): + """The UC qualifying young person test with SI 2014/1230 reg. 28 + applied, as is_qualifying_young_person_for_universal_credit documents + it. With claim_limbs false it leaves out the limbs that end a + claimant's or partner's own award on the family's Universal Credit + claim, so it reads reg. 28 against the awards they held when they + claimed (is_qualifying_young_person_for_uc_education reads that).""" + # The Secretary of State excludes a person entitled to an existing + # benefit (TP regs. 2(1) and 28). The formula reads raw person-level + # reports, the model's legacy closure proxy and the family's Universal + # Credit claim, so it calculates no family benefit that depends on QYP + # status. + p = parameters(period).gov + dwp = p.dwp + # The reform pays each listed benefit at its dataset-year report in + # that year and the nine after it, closure dates included, and holds + # both legacy closures false in those years. The formula reads the + # switch, not those years, so in a later year it still reads a tax + # credit report that the model no longer pays, and still leaves out + # the claim limbs below. + paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) + system = person.simulation.tax_benefit_system - income_support_paying = ( - paying(dwp.income_support.active, "income_support") & own_award_open - ) - jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open - esa_income_paying = paying(True, "esa_income") & esa_income_open - housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open - child_tax_credit_paying = ( - paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") - & family_award_open - & ~claim_ends_own_award - ) - working_tax_credit_paying = ( - paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") - & family_award_open - ) - tax_credits_reported = child_tax_credit_paying * person( - "child_tax_credit_reported", period - ) + working_tax_credit_paying * person("working_tax_credit_reported", period) - # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, - # so the couple's award belongs to the claimant and the partner. A - # dependant claims as a single person, so their award stays theirs. - couple_tax_credits_reported = person.benunit.sum( - where(claimant_or_partner, tax_credits_reported, 0) + def paying(active, *names): + # Nobody is entitled to a benefit the model no longer pays, or one + # a reform neutralises. + return bool(active or paid_as_reported) and not any( + system.get_variable(name).is_neutralized for name in names ) - receives_tax_credits = (tax_credits_reported > 0) | ( - claimant_or_partner & (couple_tax_credits_reported > 0) - ) - entitled_to_existing_benefit = ( - (esa_income_paying & (person("esa_income_reported", period) > 0)) - | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) - | (income_support_paying & (person("income_support_reported", period) > 0)) - | ( - housing_benefit_paying - & (person("housing_benefit_reported", period) > 0) - ) - | receives_tax_credits - ) - return ( - person( - "is_qualifying_young_person_under_universal_credit_regulation_5", - period, - ) - & ~entitled_to_existing_benefit + + # The model ends a person's Income Support, income-based JSA and + # income-related ESA when their legacy awards close: the claimant's + # and partner's together (legacy_benefits_closed, read through + # income_support), and anyone else's on their own reports + # (jsa_income, esa_income). person_legacy_benefits_closed and + # legacy_benefits_closed read reports, is_claimant_or_partner, the + # Pension Credit age conditions and parameters, and no Universal + # Credit variable. + own_award_open = ~person("person_legacy_benefits_closed", period) + # A claim for Universal Credit ends the claimant's and partner's own + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and + # nobody else's. The model ends the claimant's and partner's + # income-related ESA when the family claims (esa_income_report_closed), + # and the formula follows it. claims_universal_credit reads + # would_claim_uc, would_claim_uc_at_legacy_closure and + # legacy_benefits_closed, not this variable. In its years the reform + # behind disable_simulated_benefits pays each report as reported + # whether or not the family claims, and holds the legacy closures + # false, so while the switch is on the formula keeps the person's own + # closure alone. + claimant_or_partner = person("is_claimant_or_partner", period) + esa_income_open = ~person("esa_income_report_closed", period) | ( + own_award_open & (paid_as_reported or not claim_limbs) + ) + claim_ends_own_award = ( + claimant_or_partner + & person.benunit("claims_universal_credit", period) + & (claim_limbs and not paid_as_reported) + ) + # The model pays Housing Benefit and tax credits to the family, on + # every member's reports, and ends them when the claimant's and + # partner's legacy awards close (housing_benefit_eligible, + # is_CTC_eligible, is_WTC_eligible). It also ends the family's + # continuing Housing Benefit award and its Child Tax Credit when the + # family claims Universal Credit, and the formula follows that limb + # for a claimant or partner, whose own award the claim ends. A family + # that meets the Pension Credit age conditions keeps Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to + # it (left_pension_route_at_legacy_closure). That variable reads this + # one through is_uc_entitled and the child element, so the formula + # keeps the Housing Benefit report for such a family. + family_closed = person.benunit("legacy_benefits_closed", period) + family_award_open = ~family_closed + housing_benefit_open = person.benunit( + "meets_pension_credit_age_conditions", period + ) | (~family_closed & ~claim_ends_own_award) + + income_support_paying = ( + paying(dwp.income_support.active, "income_support") & own_award_open + ) + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & esa_income_open + housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open + child_tax_credit_paying = ( + paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") + & family_award_open + & ~claim_ends_own_award + ) + working_tax_credit_paying = ( + paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") + & family_award_open + ) + tax_credits_reported = child_tax_credit_paying * person( + "child_tax_credit_reported", period + ) + working_tax_credit_paying * person("working_tax_credit_reported", period) + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, + # so the couple's award belongs to the claimant and the partner. A + # dependant claims as a single person, so their award stays theirs. + couple_tax_credits_reported = person.benunit.sum( + where(claimant_or_partner, tax_credits_reported, 0) + ) + receives_tax_credits = (tax_credits_reported > 0) | ( + claimant_or_partner & (couple_tax_credits_reported > 0) + ) + entitled_to_existing_benefit = ( + (esa_income_paying & (person("esa_income_reported", period) > 0)) + | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) + | (income_support_paying & (person("income_support_reported", period) > 0)) + | (housing_benefit_paying & (person("housing_benefit_reported", period) > 0)) + | receives_tax_credits + ) + return ( + person( + "is_qualifying_young_person_under_universal_credit_regulation_5", + period, ) + & ~entitled_to_existing_benefit + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 88d8dc9931..3e30ee590a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.child_element.is_qualifying_young_person_for_universal_credit import ( + qualifying_young_person, +) class is_qualifying_young_person_for_uc_education(Variable): @@ -14,9 +17,18 @@ class is_qualifying_young_person_for_uc_education(Variable): "State lists the existing benefits in reg. 2(1): income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance, Income Support, Housing Benefit, Child Tax Credit and " - "Working Tax Credit. It equals " - "is_qualifying_young_person_for_universal_credit, which applies reg. " - "28. A person outside the definition under reg. 28 takes the ordinary " + "Working Tax Credit. It applies reg. 28 as " + "is_qualifying_young_person_for_universal_credit does, except for a " + "claimant or partner whose own income-related ESA, Housing Benefit or " + "Child Tax Credit award the family's Universal Credit claim ends. " + "The Secretary of State excludes a claimant while they hold an " + "existing award (reg. 28), and one who receives Universal Credit " + "(Universal Credit Regulations 2013 reg. 5(5)), which the model's " + "reg. 5 test reads only from reported Universal Credit. So for such a " + "claimant the formula reads reg. 28 against the awards they held when " + "they claimed, and leaves out the claim limbs that " + "is_qualifying_young_person_for_universal_credit follows. A person " + "outside the definition under reg. 28 takes the ordinary " "route under the Universal Credit Regulations 2013 reg. 12(2). For " "the reg. 60(1) protection the model reads the status on the " "migration day instead (is_qualifying_young_person_on_uc_migration_day). " @@ -30,6 +42,7 @@ class is_qualifying_young_person_for_uc_education(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", @@ -37,4 +50,7 @@ class is_qualifying_young_person_for_uc_education(Variable): ) def formula(person, period, parameters): - return person("is_qualifying_young_person_for_universal_credit", period) + # The Secretary of State keeps such a claimant outside the + # definition: through reg. 28 while the award their claim ends + # runs, and through reg. 5(5) once they receive Universal Credit. + return qualifying_young_person(person, period, parameters, claim_limbs=False) From 0835944512f8632f878168bbe1d2eceba951500d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:39:29 +0000 Subject: [PATCH 233/250] 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 234/250] 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 cc191bb524ad29e260729817b6c37b7e8ca65628 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:59:41 +0000 Subject: [PATCH 235/250] Read the education status on the first day of the award, and test it against the closures An independent review of 45450629 found three things, which this commit acts on. - A claim ends a tax credit award the day before the Universal Credit award starts (SI 2014/1230 reg. 8(2)(a)), but Housing Benefit only two weeks after (reg. 8(2A)), and esa_income_report_closed describes the same run-on for income-related ESA. So qualifying_young_person's switch is now at_claim_start: it leaves out the Housing Benefit and income-related ESA claim limbs and keeps the Child Tax Credit one, as #2806's case 13 reads reg. 5(5) for a claimant with no existing award. - is_qualifying_young_person_for_uc_education reads the predicate variable again and removes only those the two run-on limbs readmit, so an input or reform that sets the predicate reaches the education condition too. - The composition's property oracle read every paying report, without the model's legacy closures, and no Child Tax Credit claim limb. It now reads the reg. 5 variable, the closures and the family's claim (excluded_on_first_day), with two more examples: a claimant whose family's legacy awards closed before the claim, and a Child Tax Credit claimant with no child. The comments on cases 31 and 34 and on people 9 and 10 say that the predicate reads reg. 5(5) only from reported Universal Credit. The documentation names the nil-award limit, and the changelog the first day. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3DAwvqayXkSWRJ17kcEaE --- changelog.d/2804.fixed.md | 2 +- .../uc_receiving_education.yaml | 17 +- .../test_uc_receiving_education_properties.py | 145 +++++++++++++++--- ...fying_young_person_for_universal_credit.py | 23 ++- ...ualifying_young_person_for_uc_education.py | 47 ++++-- 5 files changed, 180 insertions(+), 54 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index f0b3fdc0a6..b95c3b9b00 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test against the awards a claimant held when they claimed, and against every reported existing benefit on the migration day for the reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit or income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 6f5067b868..31b6796266 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -816,9 +816,12 @@ # for the Universal Credit Regulations. With no maintenance funding, reg. # 12(2) doesn't apply either, so the claimant doesn't receive education. # The claimant claims Universal Credit, which ends their own Housing - # Benefit (reg. 8(2A)), so is_qualifying_young_person_for_universal_credit - # follows that claim limb. The condition reads reg. 28 against the award - # they held when they claimed (is_qualifying_young_person_for_uc_education). + # Benefit two weeks after the award starts (reg. 8(2A)). + # is_qualifying_young_person_for_universal_credit follows that claim limb + # and reads reg. 5(5) only from reported Universal Credit, so it gives true + # for this claimant. The condition reads the status on the first day of the + # award, when the Housing Benefit still runs + # (is_qualifying_young_person_for_uc_education). period: 2026 input: people: @@ -896,11 +899,15 @@ - name: Case 34, the 18-year-old of case 32 claims when DWP moves them off legacy benefits and keeps the award. # The claimant reports Housing Benefit, so under SI 2014/1230 reg. 28 they - # don't count as a qualifying young person, and they receive education + # don't count as a qualifying young person for the condition + # (is_qualifying_young_person_for_uc_education), and they receive education # under reg. 12(2)(b), not reg. 12(1A). They fail the condition on the # migration day "because the person is undertaking a full-time course (see # regulation 12(2) and 13 ...)", so under reg. 60(1) it doesn't apply to - # them while they continue the course. + # them while they continue the course. Their claim ends their own Housing + # Benefit, so is_qualifying_young_person_for_universal_credit, which + # follows that claim limb and reads reg. 5(5) only from reported + # Universal Credit, gives true. period: 2026 input: people: diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index ce0ac3a773..be3bf5aa66 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -302,7 +302,12 @@ def all_flags(case): "meets_pension_credit_age_conditions", "uc_assessable_capital", "is_qualifying_young_person_for_universal_credit", + "is_qualifying_young_person_under_universal_credit_regulation_5", "is_qualifying_young_person_for_uc_education", + "is_claimant_or_partner", + "person_legacy_benefits_closed", + "legacy_benefits_closed", + "claims_universal_credit", "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", "has_attained_state_pension_credit_qualifying_age", "is_mixed_age_couple", @@ -337,6 +342,60 @@ def paying_reports(simulation, year): return paying +# Reports whose award the model ends on the person's own legacy closure +# (person_legacy_benefits_closed); it ends the family's Housing Benefit, +# outside the Pension Credit age conditions, and tax credits on the family's +# (legacy_benefits_closed). +OWN_CLOSURE = ["jsa_income_reported", "esa_income_reported", "income_support_reported"] + + +def excluded_on_first_day(case, result): + """Per person, whether reg. 28 keeps them out of the definition for the + condition: an own report, or for a claimant or partner the couple's tax + credit report, that the model pays that year, no reform neutralises and + the model's legacy closure leaves open, on the first day of the + Universal Credit award. That day a Housing Benefit or income-related ESA + award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim + has already ended a claimant's or partner's Child Tax Credit (reg. + 8(2)(a)).""" + flags = [] + index = 0 + for f, fam in enumerate(case["families"]): + members = range(index, index + len(fam["people"])) + family_closed = bool(result["legacy_benefits_closed"][f]) + pension_age = bool(result["meets_pension_credit_age_conditions"][f]) + claims = bool(result["claims_universal_credit"][f]) + + def is_open(i, name): + if fam["people"][i - index].get(name, 0) <= 0: + return False + if name not in result["paying"]: + return False + if name in OWN_CLOSURE: + return not result["person_legacy_benefits_closed"][i] + if name == "housing_benefit_reported": + return pension_age or not family_closed + if family_closed: + return False + return not ( + name == "child_tax_credit_reported" + and claims + and result["is_claimant_or_partner"][i] + ) + + couple = [i for i in members if result["is_uc_assessed_claimant"][i]] + couple_tax_credits = any( + is_open(j, name) for j in couple for name in TAX_CREDITS + ) + for i in members: + flags.append( + any(is_open(i, name) for name in EXISTING_BENEFITS) + or (i in couple and couple_tax_credits) + ) + index += len(fam["people"]) + return np.array(flags, dtype=bool) + + def run(case, variants=(DRAWN,)): """One simulation of every variant; returns one result per variant.""" simulation = Simulation(situation=situation(case, variants)) @@ -377,10 +436,12 @@ def reference(case, result): person for the condition, and per family, eligibility with the education condition and without it. Reads from the model only the roles (who is a claimant), the age conditions, qualifying young person status before reg. - 28, the Pension Credit route, capital, responsibility for a child, limited + 28 (the reg. 5 variable), the legacy closures, the family's claim, the + Pension Credit route, capital, responsibility for a child, limited capability for work and the disability benefit amounts, which other rules settle. """ + excluded = excluded_on_first_day(case, result) receiving, excepted, qyp_for_education = [], [], [] eligible, eligible_before = [], [] index = 0 @@ -389,21 +450,18 @@ def reference(case, result): members = range(start, start + len(fam["people"])) person_of = dict(zip(members, fam["people"])) couple = [i for i in members if result["is_uc_assessed_claimant"][i]] - tax_credits = [name for name in TAX_CREDITS if name in result["paying"]] - couple_tax_credits = any( - person_of[j].get(name, 0) > 0 for j in couple for name in tax_credits - ) couple_tax_credits_on_migration_day = any( person_of[j].get(name, 0) > 0 for j in couple for name in TAX_CREDITS ) qyp, qyp_on_migration_day, recv = {}, {}, {} for i, person in person_of.items(): - existing_benefit = any( - person.get(name, 0) > 0 for name in result["paying"] - ) or (i in couple and couple_tax_credits) qyp[i] = ( - bool(result["is_qualifying_young_person_for_universal_credit"][i]) - and not existing_benefit + bool( + result[ + "is_qualifying_young_person_under_universal_credit_regulation_5" + ][i] + ) + and not excluded[i] ) # Reg. 28 on the migration day, when the award the closure claim # ended still existed. @@ -712,17 +770,52 @@ def no_loans_or_grants(i, values): atol=0.005, err_msg=f"{name}: {context}", ) - # 8. Reg. 28: no one who reports an existing benefit the model pays that - # year, and no reform neutralises, counts as a qualifying young person - # for the condition. - reports = np.array( - [ - any(p.get(name, 0) > 0 for name in drawn["paying"]) - for fam in case["families"] - for p in fam["people"] - ] - ) - assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][reports]) + # 8. Reg. 28: no one whose existing benefit report the model pays that + # year, no reform neutralises and the legacy closure leaves open, on the + # first day of the Universal Credit award, counts as a qualifying young + # person for the condition. + excluded = excluded_on_first_day(case, drawn) + assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][excluded]) + + +# An 18-year-old on a non-advanced full-time course who claims Universal +# Credit: in 2026, after the family's legacy awards closed on their tax +# credit report, which ended their Housing Benefit; and in 2024, reporting +# their own Child Tax Credit, which the claim ends the day before the award, +# with no child to except them. +CLAIM_EXAMPLES = [ + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + working_tax_credit_reported=1_000, + ) + ] + ) + ], + ), + dict( + year=2024, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + child_tax_credit_reported=2_000, + ) + ] + ) + ], + ), +] @PROPERTY_SETTINGS @@ -730,6 +823,8 @@ def no_loans_or_grants(i, values): @example(EXAMPLES[1]) @example(EXAMPLES[2]) @example(EXAMPLES[3]) +@example(CLAIM_EXAMPLES[0]) +@example(CLAIM_EXAMPLES[1]) @given(cases()) def test_education_condition_invariants(case): check(case) @@ -759,9 +854,11 @@ def test_examples_reach_the_cases(): [True, False, True, False], ) # People 9 and 10: the young Housing Benefit claimants. Each claims - # Universal Credit, which ends their own Housing Benefit (SI 2014/1230 - # reg. 8(2A)), so the UC predicate follows that claim limb; the condition - # reads reg. 28 against the award they held when they claimed. + # Universal Credit, which ends their own Housing Benefit two weeks after + # the award starts (SI 2014/1230 reg. 8(2A)). The UC predicate follows + # that claim limb and reads reg. 5(5) only from reported Universal + # Credit, so it gives true; the condition reads the status on the first + # day of the award, when the Housing Benefit still runs. young = slice(9, 11) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index cfc5952581..458ec0a4a6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -83,13 +83,17 @@ def formula(person, period, parameters): return qualifying_young_person(person, period, parameters) -def qualifying_young_person(person, period, parameters, claim_limbs=True): +def qualifying_young_person(person, period, parameters, at_claim_start=False): """The UC qualifying young person test with SI 2014/1230 reg. 28 applied, as is_qualifying_young_person_for_universal_credit documents - it. With claim_limbs false it leaves out the limbs that end a - claimant's or partner's own award on the family's Universal Credit - claim, so it reads reg. 28 against the awards they held when they - claimed (is_qualifying_young_person_for_uc_education reads that).""" + it. With at_claim_start it gives a claimant's or partner's status on the + first day of their Universal Credit award + (is_qualifying_young_person_for_uc_education reads it). A Housing + Benefit or income-related ESA award the claim ends still runs that day + (reg. 8(2A); esa_income_report_closed's run-on), so the formula leaves + out those two claim limbs. The claim has already ended a tax credit + award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. + The year gates and the legacy closures apply either way.""" # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level # reports, the model's legacy closure proxy and the family's Universal @@ -135,13 +139,16 @@ def paying(active, *names): # closure alone. claimant_or_partner = person("is_claimant_or_partner", period) esa_income_open = ~person("esa_income_report_closed", period) | ( - own_award_open & (paid_as_reported or not claim_limbs) + own_award_open & (paid_as_reported or at_claim_start) ) claim_ends_own_award = ( claimant_or_partner & person.benunit("claims_universal_credit", period) - & (claim_limbs and not paid_as_reported) + & (not paid_as_reported) ) + # On the first day of the award a Housing Benefit award the claim ends + # still runs (reg. 8(2A)). + claim_has_ended_housing_benefit = claim_ends_own_award & (not at_claim_start) # The model pays Housing Benefit and tax credits to the family, on # every member's reports, and ends them when the claimant's and # partner's legacy awards close (housing_benefit_eligible, @@ -158,7 +165,7 @@ def paying(active, *names): family_award_open = ~family_closed housing_benefit_open = person.benunit( "meets_pension_credit_age_conditions", period - ) | (~family_closed & ~claim_ends_own_award) + ) | (~family_closed & ~claim_has_ended_housing_benefit) income_support_paying = ( paying(dwp.income_support.active, "income_support") & own_award_open diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 3e30ee590a..88f8ff849e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -17,18 +17,25 @@ class is_qualifying_young_person_for_uc_education(Variable): "State lists the existing benefits in reg. 2(1): income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance, Income Support, Housing Benefit, Child Tax Credit and " - "Working Tax Credit. It applies reg. 28 as " - "is_qualifying_young_person_for_universal_credit does, except for a " - "claimant or partner whose own income-related ESA, Housing Benefit or " - "Child Tax Credit award the family's Universal Credit claim ends. " - "The Secretary of State excludes a claimant while they hold an " - "existing award (reg. 28), and one who receives Universal Credit " - "(Universal Credit Regulations 2013 reg. 5(5)), which the model's " - "reg. 5 test reads only from reported Universal Credit. So for such a " - "claimant the formula reads reg. 28 against the awards they held when " - "they claimed, and leaves out the claim limbs that " - "is_qualifying_young_person_for_universal_credit follows. A person " - "outside the definition under reg. 28 takes the ordinary " + "Working Tax Credit. It reads " + "is_qualifying_young_person_for_universal_credit, which applies reg. " + "28, but for a claimant or partner it reads the status on the first " + "day of their Universal Credit award. A claim ends a tax credit award " + "the day before the award starts (reg. 8(2)(a)), but a Housing " + "Benefit award only two weeks after (reg. 8(2A)), and the model's " + "esa_income_report_closed describes the same run-on for " + "income-related ESA. So the Secretary of State still excludes a " + "claimant on such an award that day (reg. 28), and once they receive " + "Universal Credit, as a person receiving it (Universal Credit " + "Regulations 2013 reg. 5(5)), which the model's reg. 5 test reads " + "only from reported Universal Credit. The formula therefore leaves " + "out the Housing Benefit and income-related ESA claim limbs that " + "is_qualifying_young_person_for_universal_credit follows, and keeps " + "the Child Tax Credit one, the year gates and the legacy closures. A " + "claimant whose Universal Credit comes to nil receives none, so after " + "the run-on the law counts them as a qualifying young person again; " + "the annual model keeps them outside the definition all year. A " + "person outside the definition under reg. 28 takes the ordinary " "route under the Universal Credit Regulations 2013 reg. 12(2). For " "the reg. 60(1) protection the model reads the status on the " "migration day instead (is_qualifying_young_person_on_uc_migration_day). " @@ -50,7 +57,15 @@ class is_qualifying_young_person_for_uc_education(Variable): ) def formula(person, period, parameters): - # The Secretary of State keeps such a claimant outside the - # definition: through reg. 28 while the award their claim ends - # runs, and through reg. 5(5) once they receive Universal Credit. - return qualifying_young_person(person, period, parameters, claim_limbs=False) + # Those whom the Housing Benefit and income-related ESA claim limbs + # readmit: on the first day of their award the Secretary of State + # still excludes them through reg. 28, and once they receive + # Universal Credit through reg. 5(5). Removing only them keeps any + # input or reform that sets the predicate. + readmitted = qualifying_young_person( + person, period, parameters + ) & ~qualifying_young_person(person, period, parameters, at_claim_start=True) + return ( + person("is_qualifying_young_person_for_universal_credit", period) + & ~readmitted + ) From 426c72fceb77cc9b1b58a6e0d69ae643f1c26740 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:54:34 +0000 Subject: [PATCH 236/250] Say that the annual model applies both claim run-ons in earlier years SI 2018/65 inserted SI 2014/1230 reg. 8(2A) from 11 April 2018, and SI 2019/1152 reg. 5 reaches claims made from 22 July 2020. The education status reads the first day of the award in every year, so its documentation now says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HmavpLcsnXLwhKe5k --- .../is_qualifying_young_person_for_uc_education.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 88f8ff849e..aa3d74752f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -24,7 +24,11 @@ class is_qualifying_young_person_for_uc_education(Variable): "the day before the award starts (reg. 8(2)(a)), but a Housing " "Benefit award only two weeks after (reg. 8(2A)), and the model's " "esa_income_report_closed describes the same run-on for " - "income-related ESA. So the Secretary of State still excludes a " + "income-related ESA. The Secretary of State inserted reg. 8(2A) from " + "11 April 2018 (SI 2018/65), and SI 2019/1152 reg. 5 gives the " + "income-related ESA run-on only to claims made from 22 July 2020; " + "the annual model applies both run-ons in earlier years too. So the " + "Secretary of State still excludes a " "claimant on such an award that day (reg. 28), and once they receive " "Universal Credit, as a person receiving it (Universal Credit " "Regulations 2013 reg. 5(5)), which the model's reg. 5 test reads " From 62f5fcad240837189aa74505415e724217075059 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:18:33 +0000 Subject: [PATCH 237/250] 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 238/250] 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 239/250] 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 a1e07291c856f5b0c1b819d79c13dbab6def01a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:15:15 +0000 Subject: [PATCH 240/250] Count children with charges for the CTR childcare cap; deduct Scottish childcare after unearned income C1: council_tax_reduction_working_age_childcare_deduction now picks each of its three weekly caps (Wales, Scotland without UC, Scotland with UC) from the number of the family's children with positive childcare_expenses, the children "in respect of whom relevant childcare charges are paid" (WSI 2013/3029 Sch 6 para 20(3); SSI 2021/249 regs 38(4) and 42(4)), not from all its children. Charges entered on an adult still count, at the one-child cap. C2: council_tax_reduction_working_age_applicable_income deducts a non-UC Scottish applicant's childcare charges after adding unearned income (SSI 2021/249 reg 38(2)(a)-(c)), as it already did with UC (reg 42(2)). Wales keeps deducting them from earnings only (Sch 6 para 20(1)(c)). Income never falls below zero. Adds YAML regressions through final CTR for the Welsh and both Scottish caps, a Welsh case with the charges on the parent, and the Scottish pension case through final CTR and household net income, plus Hypothesis properties for both rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HGqHxxCpeUeyX9Vy8 --- changelog.d/1962.fixed.md | 2 +- ...il_tax_reduction_devolved_working_age.yaml | 209 ++++++++++++++++++ ...duction_devolved_working_age_properties.py | 138 ++++++++++++ ...reduction_working_age_applicable_income.py | 25 ++- ...duction_working_age_childcare_deduction.py | 41 ++-- 5 files changed, 392 insertions(+), 23 deletions(-) diff --git a/changelog.d/1962.fixed.md b/changelog.d/1962.fixed.md index 1f2cbe1405..43b3dbf905 100644 --- a/changelog.d/1962.fixed.md +++ b/changelog.d/1962.fixed.md @@ -1 +1 @@ -- Give Scotland's working-age council tax reduction scheme and the Welsh rules for persons who are not pensioners their own route: Universal Credit claimants are means-tested under each country's Universal Credit rules (Wales against the Universal Credit maximum amount; Scotland counting only the child and childcare elements), Income Support, income-based JSA and income-related ESA passport to the maximum reduction, applicable amounts include each country's personal allowances, child amounts, family premium and disabled child premiums (2013-14 to 2026-27), and income follows the law (Child Benefit disregarded, earnings disregards, childcare charges and tariff income from capital). +Give Scotland's working-age council tax reduction scheme and the Welsh rules for persons who are not pensioners their own route: Universal Credit claimants are means-tested under each country's Universal Credit rules (Wales against the Universal Credit maximum amount; Scotland counting only the child and childcare elements), Income Support, income-based JSA and income-related ESA passport to the maximum reduction, applicable amounts include each country's personal allowances, child amounts, family premium and disabled child premiums (2013-14 to 2026-27), and income follows the law (Child Benefit disregarded, earnings disregards, tariff income from capital, and childcare charges capped by the number of children with charges, which Scotland deducts after adding unearned income). diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index 414cedc5ca..22678523b9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -1299,3 +1299,212 @@ council_tax_reduction_working_age_unearned_income: 6_000 council_tax_reduction_working_age_applicable_income: 6_000 simulated_council_tax_reduction_benunit: 1_793.72 + +- name: Wales, the childcare cap counts only children with charges + # WSI 2013/3029 Sch 6 para 20(3): the £300 cap applies "where the + # applicant's family includes more than one child in respect of whom + # relevant child care charges are paid". Lone parent, children aged 5 and + # 10, charges of £12,000 on the five-year-old only, so the £175 cap applies: + # 175 x 52 = 9,100. Earnings 40,000 - 5,486 tax - 2,194.40 NI = 32,319.60; + # disregard (25 + 17.10) x 52 = 2,189.20; income 32,319.60 - 2,189.20 - + # 9,100 = 21,030.40. Applicable amount (101.85 + 2 x 87.88 + 20.22) x 52 = + # 15,487.16; award 3,000 - 0.2 x 5,543.24 = 1,891.35 (the £300 cap would + # deduct all 12,000 and pay 2,471.35). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 40_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + young_child: + age: 5 + childcare_expenses: 12_000 + older_child: + age: 10 + benunits: + benunit: + members: [parent, young_child, older_child] + would_claim_uc: false + households: + household: + members: [parent, young_child, older_child] + region: WALES + country: WALES + council_tax: 3_000 + savings: 0 + output: + council_tax_reduction_working_age_earned_income: 32_319.60 + council_tax_reduction_working_age_earnings_disregard: 2_189.20 + council_tax_reduction_working_age_childcare_deduction: 9_100 + council_tax_reduction_working_age_applicable_amount: 15_487.16 + council_tax_reduction_working_age_applicable_income: 21_030.40 + simulated_council_tax_reduction_benunit: 1_891.35 + council_tax_reduction: 1_891.35 + +- name: Wales, charges entered on the parent take the one-child cap + # The same family with the £12,000 of charges entered on the parent rather + # than on either child. The formula still deducts them, at the one-child cap + # of 175 x 52 = 9,100, so the award matches the case above: 1,891.35. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 40_000 + weekly_hours: 30 + childcare_expenses: 12_000 + council_tax_benefit_reported: 100 + young_child: + age: 5 + older_child: + age: 10 + benunits: + benunit: + members: [parent, young_child, older_child] + would_claim_uc: false + households: + household: + members: [parent, young_child, older_child] + region: WALES + country: WALES + council_tax: 3_000 + savings: 0 + output: + council_tax_reduction_working_age_childcare_deduction: 9_100 + council_tax_reduction: 1_891.35 + +- name: Scotland without UC, the childcare cap counts only children with charges + # SSI 2021/249 reg 38(4): £175 a week where only one child has relevant + # childcare charges. Lone parent, children aged 5 and 10, £12,000 of + # charges on the five-year-old only: cap 175 x 52 = 9,100. Earnings + # 32,254.53 after tax and NI; disregard (25 + 17.10) x 52 = 2,189.20; + # income 32,254.53 - 2,189.20 - 9,100 = 20,965.33. Applicable amount + # (95.55 + 2 x 109.85) x 52 = 16,393; award 3,000 - 0.2 x 4,572.33 = + # 2,085.53 (the £300 cap would deduct all 12,000 and pay 2,665.53). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 40_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + young_child: + age: 5 + childcare_expenses: 12_000 + older_child: + age: 10 + benunits: + benunit: + members: [parent, young_child, older_child] + would_claim_uc: false + would_claim_tfc: false + households: + household: + members: [parent, young_child, older_child] + region: SCOTLAND + country: SCOTLAND + council_tax: 3_000 + savings: 0 + output: + council_tax_reduction_working_age_earned_income: 32_254.53 + council_tax_reduction_working_age_earnings_disregard: 2_189.20 + council_tax_reduction_working_age_childcare_deduction: 9_100 + council_tax_reduction_working_age_applicable_amount: 16_393 + council_tax_reduction_working_age_applicable_income: 20_965.33 + council_tax_reduction: 2_085.53 + +- name: Scotland with UC, the childcare cap counts only children with charges + # SSI 2021/249 reg 42(4): £290 a week in 2026 where only one child has + # relevant childcare charges. Lone parent on UC, children aged 5 and 10, + # £24,000 of charges on the five-year-old only. UC pays a childcare element + # of 0.85 x 24,000 = 20,400, which reg 78(3) grosses up to 24,000; cap 290 x + # 52 = 15,080. Earnings 17,959.27 on the UC measure; disregard 25 x 52 = + # 1,300; relevant UC payments min(7,869.84 + 20,400, award 28,177.04) = + # 28,177.04; income 17,959.27 - 1,300 + 28,177.04 - 15,080 = 29,756.31. + # Applicable amount 16,393; award 3,000 - 0.2 x 13,363.31 = 327.34 (the + # £497 cap would deduct all 24,000 and pay 2,111.34). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + employment_income: 20_000 + weekly_hours: 30 + council_tax_benefit_reported: 100 + young_child: + age: 5 + childcare_expenses: 24_000 + older_child: + age: 10 + benunits: + benunit: + members: [parent, young_child, older_child] + would_claim_tfc: false + households: + household: + members: [parent, young_child, older_child] + region: SCOTLAND + country: SCOTLAND + council_tax: 3_000 + savings: 0 + output: + council_tax_reduction_working_age_has_universal_credit: true + uc_childcare_element: 20_400 + council_tax_reduction_working_age_earned_income: 17_959.27 + council_tax_reduction_working_age_earnings_disregard: 1_300 + council_tax_reduction_relevant_universal_credit_payments: 28_177.04 + council_tax_reduction_working_age_childcare_deduction: 15_080 + council_tax_reduction_working_age_applicable_income: 29_756.31 + council_tax_reduction: 327.34 + +- name: Scotland without UC, childcare comes off after unearned income + # SSI 2021/249 reg 38(2): estimate earned income, add unearned income, then + # deduct relevant childcare charges. Lone parent aged 60, child aged 5, + # £5,000 self-employed profit from 30 hours (no notional tax or Class 4 on + # it), £15,000 private pension, £9,000 of charges (under the 175 x 52 = + # 9,100 cap). Income 5,000 - 1,300 (£25 lone parent disregard; the £17.10 + # needs employed earnings from April 2022) + 15,000 - 9,000 = 9,700, below + # the applicable amount (95.55 + 109.85) x 52 = 10,680.80, so the award is + # the full 1,800. Deducting the charges from earnings alone would count + # 15,000 and pay 936.16, with household net income 18,916.43. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 60 + self_employment_income: 5_000 + weekly_hours: 30 + private_pension_income: 15_000 + council_tax_benefit_reported: 100 + child: + age: 5 + childcare_expenses: 9_000 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_tfc: false + households: + household: + members: [parent, child] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_earned_income: 5_000 + council_tax_reduction_working_age_earnings_disregard: 1_300 + council_tax_reduction_working_age_unearned_income: 15_000 + council_tax_reduction_working_age_childcare_deduction: 9_000 + council_tax_reduction_working_age_applicable_amount: 10_680.80 + council_tax_reduction_working_age_applicable_income: 9_700 + council_tax_reduction: 1_800 + household_net_income: 19_780.27 diff --git a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py index 1b2186ed95..d49efcb51c 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py @@ -20,6 +20,19 @@ 4. A claimant on income-related ESA and not on Universal Credit gets the whole liability (SSI 2021/249 reg 13(11); WSI 2013/3029 Sch 9 para 8), whatever their earnings. +5. The childcare cap depends on how many children have relevant childcare + charges paid in respect of them (WSI 2013/3029 Sch 6 para 20(3); SSI + 2021/249 regs 38(4) and 42(4)). So a family whose charges sit on one child + gets the same deduction however many other children it has. Without + Universal Credit the deduction stays the same; a Scottish family with + Universal Credit never deducts more than the one-child cap. +6. Scotland, without Universal Credit: the applicant's income falls by + exactly the childcare deduction while it stays above zero, so the + deduction reduces unearned income as well as earnings (SSI 2021/249 reg + 38(2)). The only other effect of the charges is through the earnings + disregard: the £17.10 additional disregard needs earnings that cover the + charges, and from April 2022 only employed earnings qualify, so the + disregard never changes for a family with no employed earnings. Each example puts every family and its variants in one simulation, so the formulas also run on arrays with several benefit units and households. @@ -302,3 +315,128 @@ def family(rent): assert work_allowance[0] > work_allowance[1] == work_allowance[2] assert abs(award[1] - award[2]) < 0.01 assert award[0] < award[1] + + +@st.composite +def childcare_families(draw, region=None, universal_credit=None): + region = region or draw(st.sampled_from(REGIONS)) + couple = draw(st.booleans()) + hours = st.sampled_from([0, 16, 30, 40]) + people = [ + { + "age": draw(st.integers(25, 60)), + "employment_income": draw(st.sampled_from([0, 4_000, 15_000, 35_000])), + "self_employment_income": draw(st.floats(0, 25_000)), + "private_pension_income": draw(st.floats(0, 20_000)), + "weekly_hours": draw(hours), + "council_tax_benefit_reported": 100, + } + ] + if couple: + people.append( + { + "age": draw(st.integers(25, 60)), + "employment_income": draw(st.floats(0, 20_000)), + "weekly_hours": draw(hours), + } + ) + people.append( + { + "age": draw(st.integers(0, 12)), + "childcare_expenses": draw(st.floats(0, 30_000)), + } + ) + return { + "region": region, + "people": people, + "would_claim_uc": ( + draw(st.booleans()) if universal_credit is None else universal_credit + ), + "rent": 0, + "council_tax": draw(st.floats(500, 3_500)), + "savings": draw(st.floats(0, 15_000)), + "esa_income": 0, + } + + +def with_other_children(unit, ages): + return {**unit, "people": unit["people"] + [{"age": age} for age in ages]} + + +def without_charges(unit): + people = [dict(p) for p in unit["people"]] + people[-1]["childcare_expenses"] = 0 + return {**unit, "people": people} + + +@SETTINGS +@given( + st.lists(childcare_families(), min_size=1, max_size=5), + st.lists(st.integers(0, 15), min_size=1, max_size=3), +) +def test_childcare_cap_ignores_children_without_charges(units, other_ages): + n = len(units) + variants = units + [with_other_children(u, other_ages) for u in units] + sim = Simulation(situation=situation(variants)) + deduction = sim.calculate( + "council_tax_reduction_working_age_childcare_deduction", YEAR + ) + has_uc = sim.calculate( + "council_tax_reduction_working_age_has_universal_credit", YEAR + ) + p = sim.tax_benefit_system.parameters(YEAR).gov.local_authorities + scottish_uc_one_child_cap = ( + p.scotland.council_tax_reduction.working_age.childcare.universal_credit_maximum_one_child + * 52 + ) + for i in range(n): + j = n + i + if has_uc[i] != has_uc[j]: + continue + if not has_uc[i]: + # 5. Without UC, the other children leave the deduction as it is. + assert abs(deduction[j] - deduction[i]) < 0.01, (units[i], other_ages) + elif units[i]["region"] == "SCOTLAND": + # 5. With UC, the one-child cap holds whatever the family size. + assert deduction[j] <= scottish_uc_one_child_cap + 0.01, ( + units[i], + other_ages, + ) + + +@SETTINGS +@given( + st.lists( + childcare_families(region="SCOTLAND", universal_credit=False), + min_size=1, + max_size=5, + ) +) +def test_scottish_childcare_comes_off_earned_and_unearned_income(units): + n = len(units) + variants = units + [without_charges(u) for u in units] + sim = Simulation(situation=situation(variants)) + income = sim.calculate("council_tax_reduction_working_age_applicable_income", YEAR) + deduction = sim.calculate( + "council_tax_reduction_working_age_childcare_deduction", YEAR + ) + disregard = sim.calculate( + "council_tax_reduction_working_age_earnings_disregard", YEAR + ) + has_uc = sim.calculate( + "council_tax_reduction_working_age_has_universal_credit", YEAR + ) + passported = sim.calculate("council_tax_reduction_working_age_passported", YEAR) + for i in range(n): + j = n + i + if has_uc[i] or passported[i]: + continue + employed = sum(p.get("employment_income", 0) for p in units[i]["people"]) + if employed == 0: + assert abs(disregard[i] - disregard[j]) < 0.01, units[i] + # 6. Income falls by the deduction, net of any change in the + # disregard, while it stays above zero; it never falls below zero. + assert income[i] >= 0, units[i] + if income[i] > 0.01: + expected = deduction[i] + disregard[i] - disregard[j] + assert abs((income[j] - income[i]) - expected) < 0.02, units[i] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py index de1e520908..374a299787 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py @@ -17,9 +17,14 @@ class council_tax_reduction_working_age_applicable_income(Variable): "Universal Credit counts the Secretary of State's Universal Credit " "income figure (earnings before the work allowance, plus Universal " "Credit unearned income) plus the Universal Credit award. Everyone " - "else counts net earnings less disregards and childcare charges, " - "unearned income (in Wales, less tax on it), tariff income from " - "capital and, in Scotland, the relevant Universal Credit payments." + "else counts net earnings less disregards, unearned income (in " + "Wales, less tax on it), tariff income from capital and, in Scotland, " + "the relevant Universal Credit payments, less relevant childcare " + "charges. Wales deducts the charges from earnings, so they never take " + "earnings below zero or reduce other income (WSI 2013/3029 Sch 6 para " + "20(1)(c)). Scotland deducts them from the total of earned and " + "unearned income, with or without Universal Credit (SSI 2021/249 " + "regs 38(2) and 42(2)). Income never falls below zero." ) definition_period = YEAR unit = GBP @@ -50,12 +55,14 @@ def formula(benunit, period, parameters): childcare = benunit( "council_tax_reduction_working_age_childcare_deduction", period ) - # Without Universal Credit, childcare charges come off earnings after - # the disregards (WSI 2013/3029 Sch 6 para 20(1)(c); SSI 2021/249 reg - # 38(2)(c)). A Scottish applicant with Universal Credit deducts them - # from income (reg 42(2)(c)). + # Wales deducts childcare charges "from any earnings" (WSI 2013/3029 + # Sch 6 para 20(1)(c)), after the disregards. Scotland estimates + # earned income, adds unearned income and then deducts the charges + # (SSI 2021/249 reg 38(2) without Universal Credit, reg 42(2) with + # it), so they can reduce unearned income too. + from_earnings = wales & ~has_universal_credit earnings_after_deductions = max_( - 0, earnings - disregard - where(has_universal_credit, 0, childcare) + 0, earnings - disregard - where(from_earnings, childcare, 0) ) unearned = benunit("council_tax_reduction_working_age_unearned_income", period) # Wales disregards tax on unearned income (Sch 9 para 4); Scotland @@ -82,7 +89,7 @@ def formula(benunit, period, parameters): "council_tax_reduction_relevant_universal_credit_payments", ], ) - - where(has_universal_credit, childcare, 0) + - where(from_earnings, 0, childcare) ) income = where( wales & has_universal_credit, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py index 785906e49a..4086c1a814 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py @@ -15,16 +15,23 @@ class council_tax_reduction_working_age_childcare_deduction(Variable): documentation = ( "Annual relevant childcare charges deducted from a working-age " "applicant's income in Scotland or Wales. Without Universal Credit, " - "childcare spending is deducted from earnings after the earnings " - "disregards (never below zero), up to a weekly cap for " - "one child or for two or more, where a lone parent, or both members " - "of a couple, are in remunerative work. A Scottish applicant with " - "Universal Credit deducts the Universal Credit childcare costs element " - "grossed up to the full cost, up to higher caps. A Welsh applicant " - "with Universal Credit uses the Secretary of State's income figure, " - "which has no childcare deduction. The number of children in the " - "family stands in for the number with childcare charges, and the " - "incapacity, hospital and prison cases for couples are not modelled." + "the charges are capped at a weekly maximum where a lone parent, or " + "both members of a couple, are in remunerative work. Wales deducts " + "them from earnings after the earnings disregards (never below zero); " + "Scotland deducts them from earnings plus unearned income " + "(council_tax_reduction_working_age_applicable_income). A Scottish " + "applicant with Universal Credit deducts the Universal Credit " + "childcare costs element grossed up to the full cost, up to higher " + "caps. A Welsh applicant with Universal Credit uses the Secretary of " + "State's income figure, which has no childcare deduction. Each cap " + "depends on how many children in the family have relevant childcare " + "charges paid in respect of them: the formula counts the children " + "with positive childcare_expenses, and takes the two-or-more-children " + "cap only where at least two of them have charges. Where the family's " + "charges sit on an adult rather than on any child, the formula still " + "deducts them, at the one-child cap. The formula does not model the " + "incapacity, hospital and " + "prison cases for couples." ) definition_period = YEAR unit = GBP @@ -44,8 +51,16 @@ def formula(benunit, period, parameters): scotland = is_scotland_scheme(benunit.household("country", period)) person = benunit.members claimant_or_partner = working_age_applicant_or_partner(person, period) - children = benunit.sum(working_age_child(person, period)) - two_or_more = children >= 2 + child = working_age_child(person, period) + children = benunit.sum(child) + # Each cap depends on the children "in respect of whom relevant + # childcare charges are paid" (WSI 2013/3029 Sch 6 para 20(3); SSI + # 2021/249 regs 38(4) and 42(4)), not on all the family's children. + # Charges entered on an adult count towards the deduction at the + # one-child cap. + expenses = person("childcare_expenses", period) + children_with_charges = benunit.sum(child & (expenses > 0)) + two_or_more = children_with_charges >= 2 has_universal_credit = benunit( "council_tax_reduction_working_age_has_universal_credit", period ) @@ -58,7 +73,7 @@ def formula(benunit, period, parameters): ) in_work = person("weekly_hours", period) >= benunit.project(hours_threshold) all_claimants_in_work = benunit.sum(claimant_or_partner & ~in_work) == 0 - charges = benunit.sum(person("childcare_expenses", period)) + charges = benunit.sum(expenses) weekly_cap = where( scotland, where( From 09b52038d371f848861974b1aca9ff6c599578bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:21:52 +0000 Subject: [PATCH 241/250] Take working-age CTR capital, tariff income and letting income from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main now has #1995: council_tax_reduction_assessable_capital (property at 90% less secured debt, the UC route, and passported capital by scheme), council_tax_reduction_tariff_income (the Welsh and Scottish working-age tariffs and the UC assumed yield) and council_tax_reduction_universal_credit_award, plus council_tax_reduction_home_letting_income. The devolved working-age route now reads them: - the capital limit tests council_tax_reduction_assessable_capital for every national claim, so council_tax_reduction_working_age_capital goes; - the route's income adds council_tax_reduction_tariff_income, so council_tax_reduction_working_age_tariff_income and its two parameter files (the same £1 per £250 over £6,000 as main's) go; - the passport excludes an applicant with an award of UC as council_tax_reduction_universal_credit_award defines it; - without UC, the route counts rent from letting part of the home in Wales, and in Scotland only before SSI 2021/249. main's two let-property cases in income_from_capital.yaml pinned Welsh and Scottish working-age awards under the shared formula; they are re-derived under the devolved route. Two devolved cases cover sub-tenant rent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HGqHxxCpeUeyX9Vy8 --- .../working_age/tariff_income.yaml | 36 ----------- .../working_age/tariff_income.yaml | 47 --------------- .../income_from_capital.yaml | 39 +++++++----- ...il_tax_reduction_devolved_working_age.yaml | 60 ++++++++++++++++++- ...tax_reduction_pension_credit_properties.py | 6 +- ...simulated_council_tax_reduction_benunit.py | 6 +- ...reduction_working_age_applicable_income.py | 30 ++++++++-- ...uncil_tax_reduction_working_age_capital.py | 42 ------------- ...il_tax_reduction_working_age_passported.py | 9 +-- ...tax_reduction_working_age_tariff_income.py | 42 ------------- 10 files changed, 116 insertions(+), 201 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml delete mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml deleted file mode 100644 index 3f3c0b67cf..0000000000 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/tariff_income.yaml +++ /dev/null @@ -1,36 +0,0 @@ -description: Tariff income treated as arising from a working-age applicant's capital under the Scottish council tax reduction scheme. -threshold: - description: Capital above which a working-age applicant is treated as having tariff income. - values: - 2013-04-01: 6_000 - metadata: - label: Scotland working-age CTR tariff income threshold - unit: currency-GBP - period: year - reference: - - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 63(1)(b) - href: https://www.legislation.gov.uk/ssi/2021/249/regulation/63 -step: - description: Size of each capital step, or part of one, above the threshold that yields the weekly tariff income amount. - values: - 2013-04-01: 250 - metadata: - label: Scotland working-age CTR tariff income capital step - unit: currency-GBP - period: year - reference: - - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 63(1)(b) - href: https://www.legislation.gov.uk/ssi/2021/249/regulation/63 -amount: - description: Weekly tariff income for each capital step, or part of one, above the threshold. - values: - 2013-04-01: 1 - metadata: - label: Scotland working-age CTR weekly tariff income per step - unit: currency-GBP - period: week - reference: - - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 63(1)(b) - href: https://www.legislation.gov.uk/ssi/2021/249/regulation/63 -metadata: - label: Scotland working-age CTR tariff income diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml deleted file mode 100644 index 3fdd064d72..0000000000 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/working_age/tariff_income.yaml +++ /dev/null @@ -1,47 +0,0 @@ -description: Tariff income treated as arising from the capital of an applicant who is not a pensioner - under the Welsh council tax reduction schemes. -threshold: - description: Capital above which an applicant who is not a pensioner is treated as having tariff income. - values: - 2013-04-01: 6000 - metadata: - label: Wales working-age CTR tariff income threshold - unit: currency-GBP - period: year - reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. - 6 para. 33 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 - - &id001 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 - schemes) - href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made -step: - description: Size of each capital step, or part of one, above the threshold that yields the weekly tariff - income amount. - values: - 2013-04-01: 250 - metadata: - label: Wales working-age CTR tariff income capital step - unit: currency-GBP - period: year - reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. - 6 para. 33 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 - - *id001 -amount: - description: Weekly tariff income for each capital step, or part of one, above the threshold. - values: - 2013-04-01: 1 - metadata: - label: Wales working-age CTR weekly tariff income per step - unit: currency-GBP - period: week - reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. - 6 para. 33 - href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33 - - *id001 -metadata: - label: Wales working-age CTR tariff income diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml index e080bdba36..438c2c4391 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml @@ -737,12 +737,13 @@ # The rent no longer counts, but the property counts as capital at 90% of # its value (WSI 2013/3029 Sch 6 para 28(a)): 9,000. Sch 6 para 33 treats it # as a weekly income of 1 for each 250, or part of 250, over 6,000: 12 a - # week, 624 a year. Earnings 9,000 are below the personal allowance and the - # NI threshold, but they leave 3,570 of the allowance for the rent of - # 5,200, which bears (5,200 - 3,570) x 20% = 326. Without the earnings the - # rent bears no tax, so the 326 is deducted. Income 9,000 - 326 + 624 = - # 9,298. With the applicable amount supplied as 6,000, CTR = 1,500 - 20% x - # (9,298 - 6,000) = 840.40. + # week, 624 a year. The devolved working-age route assesses the claim. It + # deducts only the tax on the pay (Sch 6 para 15(3)(a)), taking earnings + # as the lowest slice of income, so the 9,000 of pay bears none; the 326 + # the shared formula would deduct falls on the rent. Earnings 9,000 less + # the £5 single disregard (260) + 624 = 9,364. With the working-age + # applicable amount supplied as 6,000, CTR = 1,500 - 20% x (9,364 - 6,000) + # = 827.20. period: 2026 absolute_error_margin: 0.01 input: @@ -756,7 +757,7 @@ members: [person] claims_all_entitled_benefits: true would_claim_uc: false - council_tax_reduction_applicable_amount: 6_000 + council_tax_reduction_working_age_applicable_amount: 6_000 households: household: members: [person] @@ -767,20 +768,24 @@ council_tax: 1_500 other_residential_property_value: 10_000 output: + council_tax_reduction_devolved_working_age: true council_tax_reduction_assessable_capital: 9_000 council_tax_reduction_tariff_income: 624 legacy_means_test_deducted_income_tax: 326 - council_tax_reduction_applicable_income: 9_298 - council_tax_reduction: 840.40 + council_tax_reduction_working_age_earned_income: 9_000 + council_tax_reduction_working_age_applicable_income: 9_364 + council_tax_reduction: 827.20 - name: Scottish working-age assumed yield on a let property below the capital limit # SSI 2021/249 reg 70(1)(a) values the property less 10%: 9,000. Reg # 63(1)(b): a weekly income of 1 for each 250 over 6,000 and 1 for any part # of 250: 12 a week, 624 a year; reg 63(3) treats the actual rent as - # capital. The earnings leave 3,570 of the personal allowance for the rent - # of 5,200, which bears (5,200 - 3,570) x 19% (the Scottish starter rate) = - # 309.70, all caused by the earnings and deducted. Income 9,000 - 309.70 + - # 624 = 9,314.30. CTR = 1,500 - 20% x (9,314.30 - 6,000) = 837.14. + # capital. The devolved working-age route assesses the claim. It deducts + # only the tax deducted from the pay (reg 50(3)(a)), taking earnings as the + # lowest slice of income, so the 9,000 of pay bears none; the 309.70 the + # shared formula would deduct falls on the rent. Earnings 9,000 less the £5 + # single disregard (260) + 624 = 9,364. With the working-age applicable + # amount supplied as 6,000, CTR = 1,500 - 20% x (9,364 - 6,000) = 827.20. period: 2026 absolute_error_margin: 0.01 input: @@ -794,7 +799,7 @@ members: [person] claims_all_entitled_benefits: true would_claim_uc: false - council_tax_reduction_applicable_amount: 6_000 + council_tax_reduction_working_age_applicable_amount: 6_000 households: household: members: [person] @@ -805,11 +810,13 @@ council_tax: 1_500 other_residential_property_value: 10_000 output: + council_tax_reduction_devolved_working_age: true council_tax_reduction_assessable_capital: 9_000 council_tax_reduction_tariff_income: 624 legacy_means_test_deducted_income_tax: 309.70 - council_tax_reduction_applicable_income: 9_314.30 - council_tax_reduction: 837.14 + council_tax_reduction_working_age_earned_income: 9_000 + council_tax_reduction_working_age_applicable_income: 9_364 + council_tax_reduction: 827.20 - name: An English pensioner's let property valued less sale expenses stays within the limit # A 17,000 property is valued at 15,300 (SI 2012/2885 Sch 1 para 32(a)), so diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index 22678523b9..9005d13a41 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -238,7 +238,7 @@ output: council_tax_reduction_working_age_passported: true council_tax_reduction_working_age_applicable_income: 0 - council_tax_reduction_working_age_capital: 0 + council_tax_reduction_assessable_capital: 0 simulated_council_tax_reduction_benunit: 1_800 - name: Scotland, income-related ESA passports to the maximum reduction @@ -314,7 +314,7 @@ council_tax: 1_800 savings: 10_100 output: - council_tax_reduction_working_age_tariff_income: 884 + council_tax_reduction_tariff_income: 884 - name: Wales, a mixed-age couple on Universal Credit claims under the working-age rules # WSI 2013/3029 reg 3(1)(b)(ii): a person over the qualifying age whose @@ -1508,3 +1508,59 @@ council_tax_reduction_working_age_applicable_income: 9_700 council_tax_reduction: 1_800 household_net_income: 19_780.27 + +- name: Wales, rent from a sub-tenant counts less £20 a week on the working-age route + # WSI 2013/3029 Sch 9 para 26 disregards £20 a week of the rent a + # sub-tenant pays (council_tax_reduction_home_letting_income): (200 - 20) x + # 52 = 9,360. No earnings. Applicable amount 101.85 x 52 = 5,296.20; award + # 1,800 - 0.2 x (9,360 - 5,296.20) = 987.24. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + sublet_income: 10_400 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_home_letting_income: 9_360 + council_tax_reduction_working_age_applicable_amount: 5_296.20 + council_tax_reduction_working_age_applicable_income: 9_360 + council_tax_reduction: 987.24 + +- name: Scotland, rent from a sub-tenant does not count on the working-age route from April 2022 + # SSI 2021/249 reg 57(1) has no head for it. Applicable amount 95.55 x 52 = + # 4,968.60 exceeds the nil income, so the award is the full 1,800. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + sublet_income: 10_400 + council_tax_benefit_reported: 100 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + country: SCOTLAND + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_working_age_applicable_income: 0 + council_tax_reduction: 1_800 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 e384a859eb..27d9d209f7 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 @@ -63,7 +63,7 @@ 10. No Pension Credit route: a working-age claim is never in receipt of a guarantee credit or of savings credit only. 11. Taper on the route's own measures: CTR is max(0, L - 0.2 x max(0, I_wa - - A_wa) - ND) when the working-age capital is within 16,000, and nothing + A_wa) - ND) when the assessable capital is within 16,000, and nothing otherwise, where I_wa and A_wa are the working-age income and applicable amount. 12. Passport: a claimant on Income Support, income-based JSA or @@ -300,7 +300,7 @@ def situation( "council_tax_reduction_devolved_working_age", "council_tax_reduction_working_age_applicable_amount", "council_tax_reduction_working_age_applicable_income", - "council_tax_reduction_working_age_capital", + "council_tax_reduction_assessable_capital", "council_tax_reduction_working_age_passported", "council_tax_reduction_working_age_has_universal_credit", "council_tax_reduction_working_age_earned_income", @@ -569,7 +569,7 @@ def check_devolved_route(unit, values, i): ), unit assert abs(amount - values["uc_maximum_amount"][i]) < 0.01, unit expected_ctr = tapered(liability, income, amount, non_dep) * ( - values["council_tax_reduction_working_age_capital"][i] <= CAPITAL_LIMIT + values["council_tax_reduction_assessable_capital"][i] <= CAPITAL_LIMIT ) assert abs(ctr - expected_ctr) < 0.01, (unit, ctr, expected_ctr) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index ed77ca551e..6aa0a0c700 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -97,11 +97,7 @@ def formula(benunit, period, parameters): - excess_income * withdrawal_rate - non_dep_deductions, ) - capital = where( - devolved_working_age, - benunit("council_tax_reduction_working_age_capital", period), - benunit("council_tax_reduction_assessable_capital", period), - ) + capital = benunit("council_tax_reduction_assessable_capital", period) capital_eligible = capital <= capital_limit national_ctr = ( national_scheme diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py index 374a299787..0d42fcac43 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_applicable_income.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_scotland_scheme, is_wales_scheme, ) @@ -18,8 +19,11 @@ class council_tax_reduction_working_age_applicable_income(Variable): "income figure (earnings before the work allowance, plus Universal " "Credit unearned income) plus the Universal Credit award. Everyone " "else counts net earnings less disregards, unearned income (in " - "Wales, less tax on it), tariff income from capital and, in Scotland, " - "the relevant Universal Credit payments, less relevant childcare " + "Wales, less tax on it), tariff income from capital " + "(council_tax_reduction_tariff_income), rent for letting part of the " + "home (council_tax_reduction_home_letting_income) where the scheme " + "counts it and, in Scotland, the relevant Universal Credit payments, " + "less relevant childcare " "charges. Wales deducts the charges from earnings, so they never take " "earnings below zero or reduce other income (WSI 2013/3029 Sch 6 para " "20(1)(c)). Scotland deducts them from the total of earned and " @@ -37,7 +41,9 @@ class council_tax_reduction_working_age_applicable_income(Variable): ) def formula(benunit, period, parameters): - wales = is_wales_scheme(benunit.household("country", period)) + country = benunit.household("country", period) + wales = is_wales_scheme(country) + scotland = is_scotland_scheme(country) has_universal_credit = benunit( "council_tax_reduction_working_age_has_universal_credit", period ) @@ -77,15 +83,31 @@ def formula(benunit, period, parameters): ), 0, ) + # Rent for letting part of the home counts without Universal Credit, + # in Wales and in Scotland before SSI 2021/249 (whose reg 57(1) list + # has no head for it); the Universal Credit routes take income from + # the Secretary of State's assessment, which does not count it. + p_scotland = parameters( + period + ).gov.local_authorities.scotland.council_tax_reduction.means_test + counts_home_letting = ~has_universal_credit & ( + wales | (scotland & p_scotland.working_age_counts_home_letting_income) + ) + home_letting = where( + counts_home_letting, + benunit("council_tax_reduction_home_letting_income", period), + 0, + ) own_rules_income = ( earnings_after_deductions + unearned - unearned_tax + + home_letting + add( benunit, period, [ - "council_tax_reduction_working_age_tariff_income", + "council_tax_reduction_tariff_income", "council_tax_reduction_relevant_universal_credit_payments", ], ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py deleted file mode 100644 index 88331d5454..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_capital.py +++ /dev/null @@ -1,42 +0,0 @@ -from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_wales_scheme, -) - - -class council_tax_reduction_working_age_capital(Variable): - value_type = float - entity = BenUnit - label = "Working-age council tax reduction capital" - documentation = ( - "Capital tested against the council tax reduction capital limit for a " - "working-age claim in Scotland or Wales. A Welsh applicant with " - "Universal Credit uses the Secretary of State's Universal Credit " - "capital figure, and a Welsh applicant on Income Support, " - "income-based Jobseeker's Allowance or income-related Employment and " - "Support Allowance has all capital disregarded. Otherwise, as for " - "other council tax reduction claims in the model, household savings " - "stand in for the applicant's and partner's capital." - ) - definition_period = YEAR - unit = GBP - quantity_type = STOCK - reference = ( - "https://www.legislation.gov.uk/ssi/2021/249/regulation/66", - "https://www.legislation.gov.uk/wsi/2013/3029/regulation/30", - "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", - "https://www.legislation.gov.uk/wsi/2013/3029/schedule/10/paragraph/8", - ) - - def formula(benunit, period, parameters): - wales = is_wales_scheme(benunit.household("country", period)) - has_universal_credit = benunit( - "council_tax_reduction_working_age_has_universal_credit", period - ) - passported = benunit("council_tax_reduction_working_age_passported", period) - savings = benunit.household("savings", period) - return select( - [wales & has_universal_credit, wales & passported], - [benunit("uc_assessable_capital", period), 0], - savings, - ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py index 3cd25dc4e6..305afee705 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py @@ -8,7 +8,8 @@ class council_tax_reduction_working_age_passported(Variable): documentation = ( "Whether the applicant or partner is on Income Support, income-based " "Jobseeker's Allowance or income-related Employment and Support " - "Allowance and not on Universal Credit. In Scotland such an applicant " + "Allowance and has no award of Universal Credit " + "(council_tax_reduction_universal_credit_award). In Scotland such an applicant " "gets the maximum reduction (SSI 2021/249 reg 13(11)); in Wales the " "whole of their income and capital is disregarded (WSI 2013/3029 " "Sch 9 para 8, Sch 10 para 8)." @@ -24,7 +25,7 @@ def formula(benunit, period, parameters): income_related_benefit = benunit( "council_tax_reduction_relevant_income_based_benefit", period ) - has_universal_credit = benunit( - "council_tax_reduction_working_age_has_universal_credit", period + universal_credit_award = benunit( + "council_tax_reduction_universal_credit_award", period ) - return income_related_benefit & ~has_universal_credit + return income_related_benefit & ~universal_credit_award diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py deleted file mode 100644 index 23b8bfc387..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_tariff_income.py +++ /dev/null @@ -1,42 +0,0 @@ -from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_scotland_scheme, - is_wales_scheme, -) - - -class council_tax_reduction_working_age_tariff_income(Variable): - value_type = float - entity = BenUnit - label = "Working-age council tax reduction tariff income" - documentation = ( - "Annual tariff income from capital in a working-age council tax " - "reduction claim in Scotland or Wales: £1 a week for each £250, or " - "part of £250, above £6,000. A Welsh applicant with Universal Credit " - "has none, because the Universal Credit income figure already " - "includes Universal Credit's own assumed yield from capital." - ) - definition_period = YEAR - unit = GBP - reference = ( - "https://www.legislation.gov.uk/ssi/2021/249/regulation/63", - "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/33", - ) - - def formula(benunit, period, parameters): - p = parameters(period).gov.local_authorities - country = benunit.household("country", period) - scotland = is_scotland_scheme(country) - wales = is_wales_scheme(country) - scot = p.scotland.council_tax_reduction.working_age.tariff_income - welsh = p.wales.council_tax_reduction.working_age.tariff_income - threshold = where(scotland, scot.threshold, welsh.threshold) - step = where(scotland, scot.step, welsh.step) - amount = where(scotland, scot.amount, welsh.amount) - capital = benunit("council_tax_reduction_working_age_capital", period) - steps = np.ceil(max_(0, capital - threshold) / step) - has_universal_credit = benunit( - "council_tax_reduction_working_age_has_universal_credit", period - ) - applies = ~(wales & has_universal_credit) - return applies * steps * amount * WEEKS_IN_YEAR From 5df5afef27d039e09fe40e3c6523904f3e449f2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:25:18 +0000 Subject: [PATCH 242/250] Name the actor in the childcare deduction and passport documentation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HGqHxxCpeUeyX9Vy8 --- ...ouncil_tax_reduction_working_age_childcare_deduction.py | 5 +++-- .../council_tax_reduction_working_age_passported.py | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py index 4086c1a814..5801e7904f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_childcare_deduction.py @@ -15,8 +15,9 @@ class council_tax_reduction_working_age_childcare_deduction(Variable): documentation = ( "Annual relevant childcare charges deducted from a working-age " "applicant's income in Scotland or Wales. Without Universal Credit, " - "the charges are capped at a weekly maximum where a lone parent, or " - "both members of a couple, are in remunerative work. Wales deducts " + "the formula deducts the charges, up to a weekly maximum, where a " + "lone parent, or both members of a couple, work 16 hours or more a " + "week (remunerative work). Wales deducts " "them from earnings after the earnings disregards (never below zero); " "Scotland deducts them from earnings plus unearned income " "(council_tax_reduction_working_age_applicable_income). A Scottish " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py index 305afee705..442f5dc178 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_passported.py @@ -9,9 +9,10 @@ class council_tax_reduction_working_age_passported(Variable): "Whether the applicant or partner is on Income Support, income-based " "Jobseeker's Allowance or income-related Employment and Support " "Allowance and has no award of Universal Credit " - "(council_tax_reduction_universal_credit_award). In Scotland such an applicant " - "gets the maximum reduction (SSI 2021/249 reg 13(11)); in Wales the " - "whole of their income and capital is disregarded (WSI 2013/3029 " + "(council_tax_reduction_universal_credit_award). In Scotland such an " + "applicant gets the maximum reduction (SSI 2021/249 reg 13(11)); in " + "Wales the scheme disregards the whole of their income and capital " + "(WSI 2013/3029 " "Sch 9 para 8, Sch 10 para 8)." ) definition_period = YEAR From b55ff4022f64f8950cddd8e1f78bab006f3e7d96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:57:32 +0000 Subject: [PATCH 243/250] Read Wales's 2013 working-age tariff from main's parameters in the fixed-amounts test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The working-age route now takes tariff income from main's gov.local_authorities.wales.council_tax_reduction.means_test.tariff_income.working_age, which also starts on 1 April 2013 at £1 per £250 over £6,000. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HGqHxxCpeUeyX9Vy8 --- ...duction_devolved_working_age_properties.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py index d49efcb51c..6a28465c01 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py @@ -263,21 +263,20 @@ def test_welsh_fixed_amounts_start_with_the_2013_scheme(): from policyengine_uk.system import system wales = system.parameters.gov.local_authorities.wales.council_tax_reduction - working_age = wales.working_age first = { - "earnings_disregard.single": 5, - "earnings_disregard.couple": 10, - "earnings_disregard.lone_parent": 25, - "earnings_disregard.disability_or_carer": 20, - "earnings_disregard.additional": 17.10, - "tariff_income.threshold": 6_000, - "tariff_income.step": 250, - "tariff_income.amount": 1, - "childcare.maximum_one_child": 175, - "childcare.maximum_two_or_more_children": 300, + "working_age.earnings_disregard.single": 5, + "working_age.earnings_disregard.couple": 10, + "working_age.earnings_disregard.lone_parent": 25, + "working_age.earnings_disregard.disability_or_carer": 20, + "working_age.earnings_disregard.additional": 17.10, + "means_test.tariff_income.working_age.threshold": 6_000, + "means_test.tariff_income.working_age.step": 250, + "means_test.tariff_income.working_age.amount": 1, + "working_age.childcare.maximum_one_child": 175, + "working_age.childcare.maximum_two_or_more_children": 300, } for name, value in first.items(): - parameter = working_age.get_child(name) + parameter = wales.get_child(name) earliest = parameter.values_list[-1] assert earliest.instant_str == "2013-04-01", name assert earliest.value == value, name From 8402aee706e41e0f2c807ea3a23aa07748b57733 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 06:54:11 -0500 Subject: [PATCH 244/250] Read an income-related ESA report only while the claim stays payable The predicate's income-related ESA gate now requires esa_income_payable_share above 0 unless disable_simulated_benefits pays the report as reported. The term covers the education status on the first day of a claim and the reform, which bypass esa_income_report_closed. Two 2026 cases now expect 706.85 of esa_income (86 of 365 days of 3,000). Co-Authored-By: Claude Opus 5.5 --- ...ing_young_person_esa_income_abolition.yaml | 175 ++++++++++++++++++ ...ifying_young_person_existing_benefits.yaml | 5 +- ...ualifying_young_person_legacy_closure.yaml | 10 +- ...g_young_person_universal_credit_claim.yaml | 9 +- .../tests/test_disable_simulated_benefits.py | 2 +- ...fying_young_person_for_universal_credit.py | 19 +- 6 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml new file mode 100644 index 0000000000..8ede79769f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml @@ -0,0 +1,175 @@ +# The Secretary of State appointed 1 July 2026 for the abolition of the +# remaining old style income-related ESA awards in Great Britain (SI +# 2025/1148 art. 3A(1)), apart from awards saved for an appointee (art. +# 3A(2)). The model pays each claim for the share of the fiscal year before +# that date (esa_income_payable_share, from gov.dwp.ESA.income.awards_payable): +# 1 up to 2025-26, 86/365 = 0.2356 in 2026-27 and 0 from 2027-28, or 1 for a +# saved claim. Nobody is entitled to a claim that pays nothing, so a young +# person's own income-related ESA report excludes them from the UC qualifying +# young person test (SI 2014/1230 reg. 28) only while the share exceeds 0. +# +# The first four cases use one household: a parent aged 45 and a 17-year-old on a +# non-advanced course in the parent's benefit unit, North East, owned +# outright, no income. The parent claims Universal Credit as a single +# claimant over 25 (standard_allowance SINGLE_OLD: £424.90 a month in +# 2026-27, £443.2099 projected for 2027-28) with the first child higher +# amount for a qualifying young person (£364.1952 projected for 2027-28): +# 2027 with the child element: 12 x (443.2099 + 364.1952) = 9,688.86 +# 2027 without the child element: 12 x 443.2099 = 5,318.52 +# 2026 without the child element: 12 x 424.90 = 5,098.80 + +- name: A young person's own income-related ESA report after the claim stops paying leaves the child element. + # The claim pays nothing in 2027-28, so nobody is entitled to it. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 0] + is_qualifying_young_person_for_universal_credit: [false, true] + universal_credit: 9_688.86 + +- name: A young person's own income-related ESA report saved for an appointee removes the child element. + # An award saved for an appointee stays payable (SI 2025/1148 art. 3A(2)). + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 1] + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_318.52 + +- name: A young person's own income-related ESA report in the year the claim stops paying removes the child element. + # The claim pays for 86 of the 365 days of 2026-27 in Great Britain. + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0.2356, 0.2356] + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own income-related ESA report after the claim stops paying leaves the child element while their legacy awards stay open. + # The young person also reports Pension Credit, so the model's migration + # proxy leaves their legacy awards open (person_legacy_benefits_closed is + # false), but the claim still pays nothing in 2027-28 + # (esa_income_payable_share 0) and nobody is entitled to it. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 0] + person_legacy_benefits_closed: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + universal_credit: 9_688.86 + +- name: An 18-year-old claimant's own income-related ESA report after the claim stops paying leaves them receiving education while their legacy awards stay open. + # A single 18-year-old on a full-time non-advanced course with no + # maintenance funding reports their own income-related ESA and Pension + # Credit, and claims Universal Credit. The Pension Credit report leaves + # their legacy awards open (person_legacy_benefits_closed false), but the + # ESA claim pays nothing in 2027-28 (esa_income_payable_share 0), so on the + # first day of their Universal Credit award they aren't entitled to it + # either. They count as a qualifying young person for the education + # condition and receive education (UC Regs 2013 reg. 12(1A)), with no + # exception, so they get no Universal Credit. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + is_claimant_or_partner: true + esa_income_reported: 3_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: NORTH_EAST + output: + esa_income_payable_share: [0] + person_legacy_benefits_closed: [false] + is_qualifying_young_person_for_uc_education: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml index cc0a03a6f2..8966b13925 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -8,8 +8,9 @@ # the model's legacy closure, or for a claimant or partner the family's # Universal Credit claim, ends the award on it, and only if no reform # neutralises it. The model pays Income Support and income-based -# JSA until 31 March 2026 and tax credits until 5 April 2025, and has no -# closure date for Housing Benefit or income-related ESA. It also ends a +# JSA until 31 March 2026 and tax credits until 5 April 2025, has no +# closure date for Housing Benefit, and pays an income-related ESA claim +# only while it stays payable (esa_income_payable_share). It also ends a # person's legacy awards when its annual migration proxy closes them, and # the predicate follows that closure # (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index e5c5d5cd07..694dd2eaf5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -29,9 +29,11 @@ # 2026 without the child element: 12 x 424.90 = 5,098.80 - name: A young person's own income-related ESA report alone removes the child element in 2026. - # No closure applies: the young person reports no closed benefit, so the - # model pays the £3,000 of income-related ESA (esa_income) and the young - # person is entitled to it. UC: 12 x 424.90 = 5,098.80. + # No closure applies: the young person reports no closed benefit, and the + # claim pays for 86 of the 365 days of 2026-27 (esa_income_payable_share + # 0.2356), so the model pays 3,000 x 86 / 365 = 706.85 of income-related + # ESA (esa_income) and the young person is entitled to it. UC: 12 x 424.90 + # = 5,098.80. period: 2026 absolute_error_margin: 0.01 input: @@ -55,7 +57,7 @@ tenure_type: OWNED_OUTRIGHT output: person_legacy_benefits_closed: [false, false] - esa_income: 3_000 + esa_income: 706.85 is_qualifying_young_person_for_universal_credit: [false, false] uc_child_element: 0 universal_credit: 5_098.80 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml index 545524e659..f348fa21c5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -59,9 +59,10 @@ - name: An 18-year-old claimant's own income-related ESA report excludes them while they don't claim Universal Credit. # Nothing ends the award: the claimant doesn't claim Universal Credit and - # reports no closed benefit, so the model pays the £3,000 of income-related - # ESA on their report and they're entitled to it. They get no Universal - # Credit. + # reports no closed benefit, and the claim pays for 86 of the 365 days of + # 2026-27 (esa_income_payable_share 0.2356), so the model pays 3,000 x 86 / + # 365 = 706.85 of income-related ESA on their report and they're entitled + # to it. They get no Universal Credit. period: 2026 absolute_error_margin: 0.01 input: @@ -83,7 +84,7 @@ tenure_type: OWNED_OUTRIGHT output: esa_income_report_closed: [false] - esa_income: 3_000 + esa_income: 706.85 is_qualifying_young_person_for_universal_credit: [false] universal_credit: 0 diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 773a5cf406..292a35cc03 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -898,7 +898,7 @@ def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_pers assert unreformed.calculate(predicate, year)[1::2].tolist() == [ legacy_closed, legacy_closed, - False, + year >= 2027, False, tax_credits_closed, tax_credits_closed, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 458ec0a4a6..32b04bd7ab 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -25,9 +25,11 @@ class is_qualifying_young_person_for_universal_credit(Variable): "ESA only after their capital and work screens. The model pays Income " "Support, income-based JSA and tax credits only while " "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " - "gov.dwp.tax_credits.active hold, and has no closure date for Housing " - "Benefit or income-related ESA. The formula also reads those three " - "benefits' reports while " + "gov.dwp.tax_credits.active hold, has no closure date for Housing " + "Benefit, and pays an income-related ESA claim only while it stays " + "payable (esa_income_payable_share), so the formula reads its report " + "only then. The formula also reads the Income Support, income-based " + "JSA and tax credit reports while " "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " "that switch turns on pays them at their reported amounts in the " "dataset year and the nine years after it, and the formula still reads " @@ -138,9 +140,14 @@ def paying(active, *names): # false, so while the switch is on the formula keeps the person's own # closure alone. claimant_or_partner = person("is_claimant_or_partner", period) - esa_income_open = ~person("esa_income_report_closed", period) | ( - own_award_open & (paid_as_reported or at_claim_start) - ) + # The Secretary of State (SI 2025/1148 art. 3A) and, in Northern + # Ireland, the Department for Communities (SR 2025/176 art. 3A) end old + # style income-related ESA claims on the appointed date unless an + # appointee saving keeps them (esa_income_payable_share). + esa_income_open = ( + ~person("esa_income_report_closed", period) + | (own_award_open & (paid_as_reported or at_claim_start)) + ) & ((person("esa_income_payable_share", period) > 0) | paid_as_reported) claim_ends_own_award = ( claimant_or_partner & person.benunit("claims_universal_credit", period) From 83e286b3e6c05f9bf45bef5e4ed64fd71ceb41e3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 07:03:15 -0500 Subject: [PATCH 245/250] 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", []), ] From 3e696c86d9764b9753976e8512dfd51fc7ea1219 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 08:22:38 -0500 Subject: [PATCH 246/250] Re-pin the Scottish lone parent's budget totals to the working-age route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline and uprating totals for scottish_lone_parent held no Council Tax Reduction, because the shared means test counted the whole Universal Credit award as income. The Scottish working-age route pays £806.12, £915.42, £966.70 and £1,065.44 in 2023, 2025, 2026 and 2028, and the uprating reform raises those by 15%. The cases that abolish council tax pay no reduction and don't move. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_budget_totals_pinned.py | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/test_budget_totals_pinned.py b/policyengine_uk/tests/test_budget_totals_pinned.py index 2a2d5286e4..04fe355727 100644 --- a/policyengine_uk/tests/test_budget_totals_pinned.py +++ b/policyengine_uk/tests/test_budget_totals_pinned.py @@ -4,6 +4,11 @@ EXPECTED holds the three totals for thirteen hand-built households in four years under four reforms, computed with the lists that the formula modules held as literals at commit 46a60678ff52d829253572393abdcc6a03ceb6a7. The +baseline and uprating totals for scottish_lone_parent instead include what +Scotland's working-age Council Tax Reduction route pays (SSI 2021/249 regs 13, +35, 42 and 57). In 2026 it pays £1,100 less 20% of £666.48, the income of +£11,347.28 (£7,700 of pay after the £25 a week disregard, plus the £3,647.28 +Universal Credit child element) above the £10,680.80 applicable amount. The reforms switch on gov.contrib.abolish_council_tax, the two gov.contrib.benefit_uprating rates, and a flat basic income, which the uprating leaves out. The test compares with exact equality, because the @@ -191,7 +196,7 @@ def situation(year): # (reform, year, household): (household_tax, household_benefits, -# household_net_income), from commit 46a60678. +# household_net_income), from commit 46a60678, except as the docstring says. EXPECTED = { ("baseline", 2023, "single_renter_london"): ( 5636.60498046875, @@ -206,8 +211,8 @@ def situation(year): ("baseline", 2023, "pensioner_couple"): (2259.0, 22177.880859375, 19918.880859375), ("baseline", 2023, "scottish_lone_parent"): ( 1259.0, - 14060.240234375, - 21801.240234375, + 14866.3603515625, + 22607.359375, ), ("baseline", 2023, "welsh_working_family"): ( 14074.208984375, @@ -251,8 +256,8 @@ def situation(year): ("baseline", 2025, "pensioner_couple"): (2274.5, 22106.400390625, 19831.900390625), ("baseline", 2025, "scottish_lone_parent"): ( 1274.5, - 14044.3994140625, - 21769.8984375, + 14959.81640625, + 22685.31640625, ), ("baseline", 2025, "welsh_working_family"): ( 12575.306640625, @@ -294,7 +299,7 @@ def situation(year): 35014.81640625, ), ("baseline", 2026, "pensioner_couple"): (2280.0, 22386.83984375, 20106.83984375), - ("baseline", 2026, "scottish_lone_parent"): (1280.0, 14687.2802734375, 22407.28125), + ("baseline", 2026, "scottish_lone_parent"): (1280.0, 15653.984375, 23373.984375), ("baseline", 2026, "welsh_working_family"): ( 12580.806640625, 8410.099609375, @@ -333,8 +338,8 @@ def situation(year): ("baseline", 2028, "pensioner_couple"): (2280.0, 22573.318359375, 20293.318359375), ("baseline", 2028, "scottish_lone_parent"): ( 1280.0, - 15792.3837890625, - 23512.3828125, + 16857.82421875, + 24577.82421875, ), ("baseline", 2028, "welsh_working_family"): ( 12580.806640625, @@ -626,8 +631,8 @@ def situation(year): ("uprating", 2023, "pensioner_couple"): (2259.0, 23504.5625, 21245.5625), ("uprating", 2023, "scottish_lone_parent"): ( 1259.0, - 16169.2763671875, - 23910.27734375, + 17096.314453125, + 24837.314453125, ), ("uprating", 2023, "welsh_working_family"): ( 14074.208984375, @@ -675,8 +680,8 @@ def situation(year): ("uprating", 2025, "pensioner_couple"): (2274.5, 23422.359375, 21147.859375), ("uprating", 2025, "scottish_lone_parent"): ( 1274.5, - 16151.0595703125, - 23876.55859375, + 17203.7890625, + 24929.2890625, ), ("uprating", 2025, "welsh_working_family"): ( 12575.306640625, @@ -716,8 +721,8 @@ def situation(year): ("uprating", 2026, "pensioner_couple"): (2280.0, 23744.865234375, 21464.865234375), ("uprating", 2026, "scottish_lone_parent"): ( 1280.0, - 16890.373046875, - 24610.373046875, + 18002.08203125, + 25722.08203125, ), ("uprating", 2026, "welsh_working_family"): ( 12580.806640625, @@ -761,8 +766,8 @@ def situation(year): ("uprating", 2028, "pensioner_couple"): (2280.0, 23959.31640625, 21679.31640625), ("uprating", 2028, "scottish_lone_parent"): ( 1280.0, - 18161.240234375, - 25881.240234375, + 19386.498046875, + 27106.498046875, ), ("uprating", 2028, "welsh_working_family"): ( 12580.806640625, From 98b56ca0802f552c2afe6ab8e94a34822f1261df Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 08:59:08 -0500 Subject: [PATCH 247/250] Bind the Northern Ireland claim termination rules to the first-day test SR 2016/226 reg. 6 is the twin of SI 2014/1230 reg. 8 with one difference: a claim ends a rate rebate the day before the award starts (reg. 6(2)), and only a rent rebate or allowance runs on for two weeks (reg. 6(2A)). The first-day test now keeps the Housing Benefit claim limb for a Northern Ireland family that doesn't rent, whose report the model reads as a rate rebate. The variables also cite SR 2017/190 art. 6, SR 2019/152 reg. 4 and the Housing Benefit Regulations (Northern Ireland) 2006 regs. 12 and 13. The predicate now keeps a Housing Benefit report for a family in specified or temporary accommodation, since neither a claim nor the closure ends that award (SI 2014/1230 regs. 8(3) and 46(2); SR 2016/226 regs. 6(3) and 47(2)). Co-Authored-By: Claude Opus 5.5 --- changelog.d/2804.fixed.md | 2 +- ...ualifying_young_person_legacy_closure.yaml | 47 +++++++- .../uc_receiving_education.yaml | 110 ++++++++++++++++++ .../test_uc_receiving_education_properties.py | 47 +++++++- .../gov/dwp/esa_income_report_closed.py | 4 +- ...fying_young_person_for_universal_credit.py | 63 ++++++++-- ...ualifying_young_person_for_uc_education.py | 23 +++- ...ifying_young_person_on_uc_migration_day.py | 3 +- 8 files changed, 277 insertions(+), 22 deletions(-) diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index b95c3b9b00..e741d4f257 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit or income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml index 694dd2eaf5..450b158b04 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -13,12 +13,12 @@ # 6 April 2025). The predicate excludes nobody on those reports, as each # benefit's year gate has closed. # -# Cases 1 to 6 and the last use one household: a parent aged 45 and a +# Cases 1 to 6 and 10 use one household: a parent aged 45 and a # 17-year-old on a non-advanced course in the parent's benefit unit, North -# East, owned outright, no income. Cases 7 and 8 use the same family renting +# East, owned outright, no income. Cases 7, 8 and 11 use the same family renting # from the council at £6,000 a year and claiming no Universal Credit, so only # the closure can end its Housing Benefit. Case 9 has a parent aged 70. In -# every case but 7 and 8 the parent claims Universal Credit as a single +# every case but 7, 8 and 11 the parent claims Universal Credit as a single # claimant over 25 (universal_credit/standard_allowance/amount.yaml # SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the # first child higher amount for a qualifying young person @@ -389,3 +389,44 @@ legacy_benefits_closed: false is_qualifying_young_person_for_universal_credit: [false, false] universal_credit: 5_098.80 + +- name: A young person's own Housing Benefit report in temporary accommodation still excludes them after the parent's legacy awards close. + # The family of case 7 in temporary accommodation. The parent's own Income + # Support report closes the family's legacy awards, but the move off legacy + # benefits doesn't end a Housing Benefit award for specified or temporary + # accommodation (SI 2014/1230 reg. 46(2)). The model keeps the award + # (housing_benefit_eligible) and the predicate keeps the young person's + # report, so the family gets no child element. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + legacy_benefits_closed: true + claims_universal_credit: false + housing_benefit_eligible: true + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 31b6796266..3368618e24 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1274,3 +1274,113 @@ meets_uc_education_condition: [true] is_uc_eligible: true universal_credit: 4_062.96 + +- name: Case 43, the 18-year-old of case 31 in Northern Ireland, whose family rents, keeps the ordinary route. + # SR 2016/226 reg. 6(2A): a claim ends a rent rebate or allowance two weeks + # after the award starts, so on the first day the claimant keeps an existing + # benefit and reg. 29 keeps them out of the definition, as in case 31. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: RENT_PRIVATELY + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 44, the same 18-year-old in Northern Ireland in a home the family owns receives education. + # A rent rebate or allowance needs rent or a similar payment (Housing Benefit + # Regulations (Northern Ireland) 2006 reg. 13(1)), so the model reads the report + # as a rate rebate (reg. 12). SR 2016/226 reg. 6(2): the claim ends it the day + # before the award starts. On the first day the claimant has no existing + # benefit, so they count as a qualifying young person and receive education + # (Universal Credit Regulations (Northern Ireland) 2016 reg. 12(1)). No + # exception applies, so they get no Universal Credit. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 45, the same 18-year-old in England in a home the family owns keeps the ordinary route. + # SI 2014/1230 reg. 8(2A) runs on an award of Housing Benefit whatever its + # form, so reg. 28 keeps the claimant out of the definition on the first day. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 46, the 18-year-old of case 31 in temporary accommodation stays outside the definition all year. + # SI 2014/1230 reg. 8(3): a claim doesn't end a Housing Benefit award for + # specified or temporary accommodation, so reg. 28 keeps the claimant out of + # the definition after the first day as well, and they take the ordinary + # route, as in case 31. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + in_specified_or_temporary_accommodation: true + households: + household: + members: [person1] + region: NORTH_EAST + tenure_type: RENT_PRIVATELY + output: + is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index be3bf5aa66..2342980b67 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -357,7 +357,9 @@ def excluded_on_first_day(case, result): Universal Credit award. That day a Housing Benefit or income-related ESA award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim has already ended a claimant's or partner's Child Tax Credit (reg. - 8(2)(a)).""" + 8(2)(a)) and, in Northern Ireland, their rate rebate (SR 2016/226 reg. + 6(2)), which the model reads from a report in a family that doesn't + rent.""" flags = [] index = 0 for f, fam in enumerate(case["families"]): @@ -374,7 +376,13 @@ def is_open(i, name): if name in OWN_CLOSURE: return not result["person_legacy_benefits_closed"][i] if name == "housing_benefit_reported": - return pension_age or not family_closed + rate_rebate_ended = ( + fam["household"]["region"] == "NORTHERN_IRELAND" + and fam["household"]["tenure_type"] == "OWNED_OUTRIGHT" + and claims + and result["is_claimant_or_partner"][i] + ) + return pension_age or not (family_closed or rate_rebate_ended) if family_closed: return False return not ( @@ -782,7 +790,10 @@ def no_loans_or_grants(i, values): # Credit: in 2026, after the family's legacy awards closed on their tax # credit report, which ended their Housing Benefit; and in 2024, reporting # their own Child Tax Credit, which the claim ends the day before the award, -# with no child to except them. +# with no child to except them. In Northern Ireland in 2026, an owner reports +# what the model reads as a rate rebate, which the claim ends the day before +# the award (SR 2016/226 reg. 6(2)), and a tenant reports a rent rebate or +# allowance, which runs on (reg. 6(2A)). CLAIM_EXAMPLES = [ dict( year=2026, @@ -815,6 +826,35 @@ def no_loans_or_grants(i, values): ) ], ), + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + region="NORTHERN_IRELAND", + ), + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + region="NORTHERN_IRELAND", + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, + ), + ], + ), ] @@ -825,6 +865,7 @@ def no_loans_or_grants(i, values): @example(EXAMPLES[3]) @example(CLAIM_EXAMPLES[0]) @example(CLAIM_EXAMPLES[1]) +@example(CLAIM_EXAMPLES[2]) @given(cases()) def test_education_condition_invariants(case): check(case) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py index 6c02b921d2..80cbc3dec7 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py @@ -21,7 +21,8 @@ class esa_income_report_closed(Variable): "in Northern Ireland, Welfare Reform (Northern Ireland) Order 2015 " "art. 39(1)(b) and SR 2017/190 art. 6(1) to (3) and (6)). For claims " "from 22 July 2020 the award runs on for two weeks after that day (SI " - "2019/1152 reg. 5), which this annual model does not represent. A " + "2019/1152 reg. 5; in Northern Ireland, SR 2019/152 reg. 4), which this " + "annual model does not represent. A " "contributory allowance in the award continues as new style ESA " "(esa_contrib). A member who is neither the claimant nor the partner, " "such as a non-dependent adult, claims in their own right and is not " @@ -48,6 +49,7 @@ class esa_income_report_closed(Variable): "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", "https://www.legislation.gov.uk/nisi/2015/2006/article/39", "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", "https://www.legislation.gov.uk/nisr/2025/176/article/3A", ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 32b04bd7ab..1f303f8ad6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -46,8 +46,9 @@ class is_qualifying_young_person_for_universal_credit(Variable): "legacy_benefits_closed holds and the family doesn't meet the Pension " "Credit age conditions. The formula follows both closures. A claim for " "Universal Credit ends the claimant's and partner's own awards (SI " - "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and the model ends " - "the family's continuing Housing Benefit award " + "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4; in Northern Ireland, " + "SR 2016/226 reg. 6(2) and (2A) and SR 2017/190 art. 6), and the model " + "ends the family's continuing Housing Benefit award " "(housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible)," " and the claimant's and partner's income-related ESA " "(esa_income_report_closed), when the family claims " @@ -60,7 +61,12 @@ class is_qualifying_young_person_for_universal_credit(Variable): "left_pension_route_at_legacy_closure, which reads this variable " "through is_uc_entitled and the child element. So the formula keeps a " "Housing Benefit report while the family meets the Pension Credit age " - "conditions. The formula checks each report against its own variable: " + "conditions. It also keeps the report while the family lives in " + "specified or temporary accommodation " + "(in_specified_or_temporary_accommodation), since neither the claim nor " + "the closure ends that award (SI 2014/1230 regs. 8(3) and 46(2); SR " + "2016/226 regs. 6(3) and 47(2)). The formula checks each report against " + "its own variable: " "income_support, jsa_income, esa_income, housing_benefit, " "child_tax_credit or tax_credits for Child Tax Credit, and " "working_tax_credit or tax_credits for Working Tax Credit. A reform " @@ -75,9 +81,19 @@ class is_qualifying_young_person_for_universal_credit(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", "https://www.legislation.gov.uk/uksi/2013/983/article/4", + "https://www.legislation.gov.uk/uksi/2019/1152/regulation/5", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/47", + "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", ] @@ -95,7 +111,11 @@ def qualifying_young_person(person, period, parameters, at_claim_start=False): (reg. 8(2A); esa_income_report_closed's run-on), so the formula leaves out those two claim limbs. The claim has already ended a tax credit award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. - The year gates and the legacy closures apply either way.""" + In Northern Ireland the claim has also already ended a rate rebate (SR + 2016/226 reg. 6(2)), and only a rent rebate or allowance runs on (reg. + 6(2A)), so there the formula keeps the Housing Benefit limb for a family + that doesn't rent. The year gates and the legacy closures apply either + way.""" # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level # reports, the model's legacy closure proxy and the family's Universal @@ -129,8 +149,9 @@ def paying(active, *names): # Credit variable. own_award_open = ~person("person_legacy_benefits_closed", period) # A claim for Universal Credit ends the claimant's and partner's own - # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), and - # nobody else's. The model ends the claimant's and partner's + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4; in + # Northern Ireland, SR 2016/226 reg. 6(2) and (2A) and SR 2017/190 art. + # 6), and nobody else's. The model ends the claimant's and partner's # income-related ESA when the family claims (esa_income_report_closed), # and the formula follows it. claims_universal_credit reads # would_claim_uc, would_claim_uc_at_legacy_closure and @@ -154,8 +175,21 @@ def paying(active, *names): & (not paid_as_reported) ) # On the first day of the award a Housing Benefit award the claim ends - # still runs (reg. 8(2A)). - claim_has_ended_housing_benefit = claim_ends_own_award & (not at_claim_start) + # still runs (reg. 8(2A)). In Northern Ireland only a rent rebate or + # allowance still runs (SR 2016/226 reg. 6(2A)): the claim has already + # ended a rate rebate (reg. 6(2)). The report doesn't show the form. A + # rent rebate or allowance needs rent or a similar payment (Housing + # Benefit Regulations (Northern Ireland) 2006 reg. 13(1)), so the formula + # reads a report in a family that doesn't rent as a rate rebate (reg. 12). + if at_claim_start: + country = person.household("country", period) + claim_has_ended_housing_benefit = ( + claim_ends_own_award + & (country == country.possible_values.NORTHERN_IRELAND) + & ~person.benunit("benunit_is_renting", period) + ) + else: + claim_has_ended_housing_benefit = claim_ends_own_award # The model pays Housing Benefit and tax credits to the family, on # every member's reports, and ends them when the claimant's and # partner's legacy awards close (housing_benefit_eligible, @@ -167,12 +201,17 @@ def paying(active, *names): # unless it claims Universal Credit at the closure and is entitled to # it (left_pension_route_at_legacy_closure). That variable reads this # one through is_uc_entitled and the child element, so the formula - # keeps the Housing Benefit report for such a family. + # keeps the Housing Benefit report for such a family. Neither the claim nor + # the closure ends an award for specified or temporary accommodation (SI + # 2014/1230 regs. 8(3) and 46(2); SR 2016/226 regs. 6(3) and 47(2)), so the + # formula keeps that report too, as housing_benefit_eligible keeps the award. family_closed = person.benunit("legacy_benefits_closed", period) family_award_open = ~family_closed - housing_benefit_open = person.benunit( - "meets_pension_credit_age_conditions", period - ) | (~family_closed & ~claim_has_ended_housing_benefit) + housing_benefit_open = ( + person.benunit("meets_pension_credit_age_conditions", period) + | person.benunit("in_specified_or_temporary_accommodation", period) + | (~family_closed & ~claim_has_ended_housing_benefit) + ) income_support_paying = ( paying(dwp.income_support.active, "income_support") & own_award_open diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index aa3d74752f..1a0314998e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -47,17 +47,38 @@ class is_qualifying_young_person_for_uc_education(Variable): "reg. 29 of the Universal Credit (Transitional Provisions) Regulations " "(Northern Ireland) 2016, with reg. 6 of the Universal Credit " "Regulations (Northern Ireland) 2016 in place of the Universal Credit " - "Regulations 2013 reg. 5." + "Regulations 2013 reg. 5. SR 2016/226 reg. 6 ends awards on the same " + "days as reg. 8, and SR 2019/152 reg. 4 gives the income-related ESA " + "run-on to claims made from 22 July 2020, with one difference: a claim " + "ends a rate rebate the day before the award starts (reg. 6(2)), and " + "only a rent rebate or allowance runs on (reg. 6(2A)). The report " + "doesn't show the form. A rent rebate or allowance needs rent or a " + "similar payment (Housing Benefit Regulations (Northern Ireland) 2006 " + "reg. 13(1)), so the formula reads a report in a family that doesn't " + "rent as a rate rebate (reg. 12), and a claimant with no other " + "existing benefit counts as a qualifying young person on the first " + "day. The model can't see someone who owns a caravan or houseboat and " + "pays site or mooring charges (reg. 13(1)(f) and (g)), so it reads " + "their rent allowance as a rate rebate." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2013/983/article/4", + "https://www.legislation.gov.uk/uksi/2019/1152/regulation/5", "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", + "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py index 2ef8cb2e31..076ac7d474 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py @@ -34,7 +34,7 @@ class is_qualifying_young_person_on_uc_migration_day(Variable): "credits, which a couple claims jointly. " "uc_is_excepted_from_education_condition reads this status for the " "reg. 60(1) protection. In Northern Ireland the decision maker applies " - "the same rules under regs. 29 and 50 of the Universal Credit " + "the same rules under regs. 6(2), 29 and 50 of the Universal Credit " "(Transitional Provisions) Regulations (Northern Ireland) 2016." ) definition_period = YEAR @@ -44,6 +44,7 @@ class is_qualifying_young_person_on_uc_migration_day(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", "https://www.legislation.gov.uk/nisr/2016/226/regulation/50", ) From 5ca81bbad5aafd4b099a425f1a33c7bc9a79f6ca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 09:49:59 -0500 Subject: [PATCH 248/250] Pin the Scottish and Welsh working-age awards in the pensioner child test The test's table holds main's awards, and this branch pays those six families through the working-age route. Hand arithmetic reproduces each award CI printed and gives the Welsh single person's in each year. Co-Authored-By: Claude Opus 5.5 --- ...on_pensioner_children_tariff_properties.py | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py index fc8e207c69..15c9896e4c 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py @@ -41,11 +41,13 @@ report no Child Tax Credit, so none has an award. 3. A pensioner with no child and capital up to £10,000 gets no child amount, family premium or tariff income, and a working-age family gets no child - amount or family premium, so their applicable amount, applicable income - and award match main. An English working-age family also gets no tariff - income here, because its local scheme sets its own. - test_matches_origin_main_on_unchanged_households checks that against - values computed on main. + amount or family premium, so their applicable amount and applicable income + match main. Their awards match main too, except that Scottish and Welsh + working-age families take theirs from the working-age route + (council_tax_reduction_devolved_working_age). An English working-age family + also gets no tariff income here, because its local scheme sets its own. + test_matches_origin_main_on_unchanged_households checks that against values + computed on main and, for those awards, by hand. """ import math @@ -342,7 +344,12 @@ def test_northern_ireland_gets_no_new_amounts(): # applicable amount, applicable income and council_tax_benefit. In Scotland # and Wales the working-age families with savings of 12,000, 16,000 and 8,000 # have working-age tariff income of 1 a week for each 250, or part, over -# 6,000: 24 * 52 = 1,248, 40 * 52 = 2,080 and 8 * 52 = 416 a year. +# 6,000: 24 * 52 = 1,248, 40 * 52 = 2,080 and 8 * 52 = 416 a year. Their awards +# come from the working-age route, which sets its own amount and income: 2,000 +# less 20% of the excess of net earnings after the earnings disregard, plus that +# tariff income, over the personal allowance and child amounts. For example, the +# Welsh family with savings of 8,000 in 2026 has 9,000 - 5 * 52 + 416 = 9,156 +# against 101.85 * 52 = 5,296.20, so an award of 1,228.04. ORIGIN_MAIN = { 2019: [ (9_412, 12_000, 1_482.4), @@ -354,15 +361,15 @@ def test_northern_ireland_gets_no_new_amounts(): (9_412, 12_000, 1_482.4), (14_071.2, 17_170, 1_380.24), (9_412, 9_592.34, 1_963.93), - (3_801.2, 16_080.24, 0), - (5_972.2, 24_674.64, 0), - (3_801.2, 9_371.84, 885.87), + (3_801.2, 16_080.24, 889.22), + (5_972.2, 24_674.64, 460.78), + (3_801.2, 9_371.84, 937.87), (9_412, 12_000, 1_482.4), (14_071.2, 17_150, 1_384.24), (9_412, 9_592.34, 1_963.93), - (3_801.2, 16_060.24, 0), - (5_972.2, 24_654.64, 0), - (3_801.2, 9_371.84, 885.87), + (3_801.2, 16_060.24, 950.63), + (5_972.2, 24_654.64, 376.27), + (3_801.2, 9_371.84, 987.79), (3_801.2, 12_672.24, 225.79), (3_801.2, 12_672.24, 0), (3_801.2, 12_672.24, 0), @@ -379,15 +386,15 @@ def test_northern_ireland_gets_no_new_amounts(): (12_230.4, 12_000, 2_000), (18_304, 17_189.06, 2_000), (12_230.4, 0, 2_000), - (4_706, 16_921.86, 0), - (7_397, 26_087.26, 0), - (4_706, 9_416, 1_058), + (4_706, 16_921.86, 1_165.19), + (7_397, 26_087.26, 972.71), + (4_706, 9_416, 1_110), (12_230.4, 12_000, 2_000), (18_304, 17_166, 2_000), (12_230.4, 0, 2_000), - (4_706, 16_898.8, 0), - (7_397, 26_064.2, 0), - (4_706, 9_416, 1_058), + (4_706, 16_898.8, 1_214.42), + (7_397, 26_064.2, 839.31), + (4_706, 9_416, 1_171.88), (4_706, 13_331.2, 274.96), (4_706, 13_331.2, 0), (4_706, 13_331.2, 0), @@ -404,15 +411,15 @@ def test_northern_ireland_gets_no_new_amounts(): (13_312, 12_000, 2_000), (19_934.2, 17_205.67, 2_000), (13_312, 0, 2_000), - (4_968.6, 16_998.5, 0), - (7_807.8, 26_228.67, 0), - (4_968.6, 9_416, 1_110.52), + (4_968.6, 16_998.5, 1_277.78), + (7_807.8, 26_228.67, 1_172.19), + (4_968.6, 9_416, 1_162.52), (13_312, 12_000, 2_000), (19_934.2, 17_166, 2_000), (13_312, 0, 2_000), - (4_968.6, 16_974.2, 0), - (7_807.8, 26_189, 0), - (4_968.6, 9_416, 1_110.52), + (4_968.6, 16_974.2, 1_329.96), + (7_807.8, 26_189, 1_034.31), + (4_968.6, 9_416, 1_228.04), (4_968.6, 13_406.6, 312.4), (4_968.6, 13_406.6, 0), (4_968.6, 13_406.6, 0), From 590a2cc39c218941496c01e6e3cb8f720a5e2833 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 10:36:24 -0500 Subject: [PATCH 249/250] Read the Northern Ireland rate rebate from a supplied input, not tenure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit housing_benefit_reported doesn't show whether a Northern Ireland award takes the form of a rate rebate or of a rent rebate or allowance, and tenure doesn't settle it: SR 2006/405 reg. 13(1)(f) and (g) pays a rent rebate or allowance on mooring charges and site payments, so an owner can get one, and SR 2016/226 reg. 6(2A) runs it on. The first-day test now reads housing_benefit_report_is_rate_rebate_only, false by default, so a report takes the run-on unless the input marks it as a rate rebate alone. Case 47 covers an owner with a rent allowance, who keeps Universal Credit of £4,062.96. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2804.fixed.md | 2 +- .../uc_receiving_education.yaml | 46 +++++++++++++++---- .../test_uc_receiving_education_properties.py | 31 +++++++++---- ...sing_benefit_report_is_rate_rebate_only.py | 35 ++++++++++++++ ...fying_young_person_for_universal_credit.py | 14 +++--- ...ualifying_young_person_for_uc_education.py | 15 +++--- 6 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md index e741d4f257..8a73e94f80 100644 --- a/changelog.d/2804.fixed.md +++ b/changelog.d/2804.fixed.md @@ -1 +1 @@ -Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. The supplied input `housing_benefit_report_is_rate_rebate_only` marks a Northern Ireland report that covers only a rate rebate, which the claim ends the day before the award (SR 2016/226 reg. 6(2)). diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index 3368618e24..35386bd3d5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -1301,14 +1301,12 @@ uc_receiving_education: [false] is_uc_eligible: true -- name: Case 44, the same 18-year-old in Northern Ireland in a home the family owns receives education. - # A rent rebate or allowance needs rent or a similar payment (Housing Benefit - # Regulations (Northern Ireland) 2006 reg. 13(1)), so the model reads the report - # as a rate rebate (reg. 12). SR 2016/226 reg. 6(2): the claim ends it the day - # before the award starts. On the first day the claimant has no existing - # benefit, so they count as a qualifying young person and receive education - # (Universal Credit Regulations (Northern Ireland) 2016 reg. 12(1)). No - # exception applies, so they get no Universal Credit. +- name: Case 44, the same 18-year-old in Northern Ireland, whose report covers only a rate rebate, receives education. + # SR 2016/226 reg. 6(2): the claim ends a rate rebate the day before the award + # starts. On the first day the claimant has no existing benefit, so they count + # as a qualifying young person and receive education (Universal Credit + # Regulations (Northern Ireland) 2016 reg. 12(1)). No exception applies, so they + # get no Universal Credit. period: 2026 input: people: @@ -1317,6 +1315,7 @@ current_education: POST_SECONDARY is_in_full_time_education: true housing_benefit_reported: 6_000 + housing_benefit_report_is_rate_rebate_only: true benunits: benunit: members: [person1] @@ -1384,3 +1383,34 @@ is_qualifying_young_person_for_uc_education: [false] uc_receiving_education: [false] is_uc_eligible: true + +- name: Case 47, the same 18-year-old in a caravan the family owns in Northern Ireland, with a rent allowance, keeps the ordinary route. + # Housing Benefit Regulations (Northern Ireland) 2006 reg. 13(1)(f) and (g): a + # rent rebate or allowance covers mooring charges for a houseboat and site + # payments for a caravan or mobile home, so an owner can get one. SR 2016/226 + # reg. 6(2A) runs it on for two weeks, so on the first day reg. 29 keeps the + # claimant out of the definition, as in case 43. They get the standard allowance + # for a single person under 25: 12 x 338.58 (SR 2026/54 reg. 3(3)(a)). + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + accommodation_type: MOBILE + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + universal_credit: 4_062.96 diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 2342980b67..a211977060 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -208,6 +208,7 @@ def adult(draw, ages): uc_is_responsible_foster_parent_of_child_under_one=draw(rarely), uc_is_foster_parent_or_new_friend_or_family_carer=draw(rarely), housing_benefit_reported=draw(st.sampled_from([0, 0, 0, 0, 6_000])), + housing_benefit_report_is_rate_rebate_only=draw(rarely), working_tax_credit_reported=draw(st.sampled_from([0, 0, 0, 0, 1_000])), employment_income=draw(st.sampled_from([0, 0, 4_000, 15_000])), is_claimant_or_partner=True, @@ -357,9 +358,8 @@ def excluded_on_first_day(case, result): Universal Credit award. That day a Housing Benefit or income-related ESA award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim has already ended a claimant's or partner's Child Tax Credit (reg. - 8(2)(a)) and, in Northern Ireland, their rate rebate (SR 2016/226 reg. - 6(2)), which the model reads from a report in a family that doesn't - rent.""" + 8(2)(a)) and, in Northern Ireland, a report of a rate rebate alone (SR + 2016/226 reg. 6(2); housing_benefit_report_is_rate_rebate_only).""" flags = [] index = 0 for f, fam in enumerate(case["families"]): @@ -378,7 +378,9 @@ def is_open(i, name): if name == "housing_benefit_reported": rate_rebate_ended = ( fam["household"]["region"] == "NORTHERN_IRELAND" - and fam["household"]["tenure_type"] == "OWNED_OUTRIGHT" + and fam["people"][i - index].get( + "housing_benefit_report_is_rate_rebate_only", False + ) and claims and result["is_claimant_or_partner"][i] ) @@ -790,10 +792,11 @@ def no_loans_or_grants(i, values): # Credit: in 2026, after the family's legacy awards closed on their tax # credit report, which ended their Housing Benefit; and in 2024, reporting # their own Child Tax Credit, which the claim ends the day before the award, -# with no child to except them. In Northern Ireland in 2026, an owner reports -# what the model reads as a rate rebate, which the claim ends the day before -# the award (SR 2016/226 reg. 6(2)), and a tenant reports a rent rebate or -# allowance, which runs on (reg. 6(2A)). +# with no child to except them. In Northern Ireland in 2026, one owner reports +# a rate rebate alone, which the claim ends the day before the award (SR +# 2016/226 reg. 6(2)). Another owner and a tenant report a rent rebate or +# allowance, which runs on (reg. 6(2A)): an owner can get one on site or +# mooring payments (SR 2006/405 reg. 13(1)(f) and (g)). CLAIM_EXAMPLES = [ dict( year=2026, @@ -829,6 +832,18 @@ def no_loans_or_grants(i, values): dict( year=2026, families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + housing_benefit_report_is_rate_rebate_only=True, + ) + ], + region="NORTHERN_IRELAND", + ), single_family( [ person( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py new file mode 100644 index 0000000000..ccb2eb3350 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_report_is_rate_rebate_only(Variable): + value_type = bool + entity = Person + label = "Housing Benefit report covers only a Northern Ireland rate rebate" + documentation = ( + "Whether this person's housing_benefit_reported covers only a rate " + "rebate on the home they occupy in Northern Ireland, with no rent " + "rebate or allowance. The Housing Benefit Regulations (Northern " + "Ireland) 2006 (SR 2006/405) and their twin for people at the " + "qualifying age for State Pension Credit (SR 2006/406) pay a rate " + "rebate on rates (reg. 12(1)) and a rent rebate or allowance on rent " + "and similar payments (reg. 13(1)), including mooring charges for a " + "houseboat and site payments for a caravan or mobile home (reg. " + "13(1)(f) and (g)). So tenure doesn't settle the form: an owner can " + "get a rent allowance. A Universal Credit claim ends a rate rebate the " + "day before the award starts (SR 2016/226 reg. 6(2)) and runs a rent " + "rebate or allowance on for two weeks (reg. 6(2A)). Only the first-day " + "test that is_qualifying_young_person_for_uc_education reads uses this " + "status, and only in Northern Ireland. A person with both forms leaves " + "it false, because their rent rebate or allowance runs on. It has no " + "formula and defaults to False, so the model reads a report as a rent " + "rebate or allowance unless this input says otherwise." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 1f303f8ad6..b770f9000e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -113,9 +113,9 @@ def qualifying_young_person(person, period, parameters, at_claim_start=False): award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. In Northern Ireland the claim has also already ended a rate rebate (SR 2016/226 reg. 6(2)), and only a rent rebate or allowance runs on (reg. - 6(2A)), so there the formula keeps the Housing Benefit limb for a family - that doesn't rent. The year gates and the legacy closures apply either - way.""" + 6(2A)), so there the formula keeps the Housing Benefit limb for a report + of a rate rebate alone (housing_benefit_report_is_rate_rebate_only). The + year gates and the legacy closures apply either way.""" # The Secretary of State excludes a person entitled to an existing # benefit (TP regs. 2(1) and 28). The formula reads raw person-level # reports, the model's legacy closure proxy and the family's Universal @@ -177,16 +177,14 @@ def paying(active, *names): # On the first day of the award a Housing Benefit award the claim ends # still runs (reg. 8(2A)). In Northern Ireland only a rent rebate or # allowance still runs (SR 2016/226 reg. 6(2A)): the claim has already - # ended a rate rebate (reg. 6(2)). The report doesn't show the form. A - # rent rebate or allowance needs rent or a similar payment (Housing - # Benefit Regulations (Northern Ireland) 2006 reg. 13(1)), so the formula - # reads a report in a family that doesn't rent as a rate rebate (reg. 12). + # ended a rate rebate (reg. 6(2)). The report doesn't show the form, so + # the formula reads housing_benefit_report_is_rate_rebate_only. if at_claim_start: country = person.household("country", period) claim_has_ended_housing_benefit = ( claim_ends_own_award & (country == country.possible_values.NORTHERN_IRELAND) - & ~person.benunit("benunit_is_renting", period) + & person("housing_benefit_report_is_rate_rebate_only", period) ) else: claim_has_ended_housing_benefit = claim_ends_own_award diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 1a0314998e..cee52f742f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -51,15 +51,12 @@ class is_qualifying_young_person_for_uc_education(Variable): "days as reg. 8, and SR 2019/152 reg. 4 gives the income-related ESA " "run-on to claims made from 22 July 2020, with one difference: a claim " "ends a rate rebate the day before the award starts (reg. 6(2)), and " - "only a rent rebate or allowance runs on (reg. 6(2A)). The report " - "doesn't show the form. A rent rebate or allowance needs rent or a " - "similar payment (Housing Benefit Regulations (Northern Ireland) 2006 " - "reg. 13(1)), so the formula reads a report in a family that doesn't " - "rent as a rate rebate (reg. 12), and a claimant with no other " - "existing benefit counts as a qualifying young person on the first " - "day. The model can't see someone who owns a caravan or houseboat and " - "pays site or mooring charges (reg. 13(1)(f) and (g)), so it reads " - "their rent allowance as a rate rebate." + "only a rent rebate or allowance runs on (reg. 6(2A)). " + "housing_benefit_reported doesn't show the form, so the formula reads " + "housing_benefit_report_is_rate_rebate_only: a claimant with that " + "status and no other existing benefit counts as a qualifying young " + "person on the first day. It defaults to False, so the formula reads " + "any other report as a rent rebate or allowance." ) definition_period = YEAR reference = ( From e71dc2ec7d11795c0587a25750728b743901c81f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 6 Nov 2026 05:24:11 -0500 Subject: [PATCH 250/250] Update the child benefit charge case for the least-tax share The same test update as the allowance order branch's latest commit. Co-Authored-By: Claude Opus 5.5 --- .../income_tax_caused_by_earnings.yaml | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml index 9a50ebbd02..6940037519 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml @@ -103,14 +103,17 @@ # A parent aged 35 with one child, pay 40,000 and interest 30,000. Child # Benefit 27.05 x 52 = 1,406.60 (1,406 in whole pounds). Adjusted net income # 70,000 is half-way through the 60,000 to 80,000 taper: charge 50% x 1,406 - # = 703 (ITEPA 2003 s.681C). Pay: (40,000 - 12,570) x 20% = 5,486. Interest, - # higher-rate (savings allowance 500): 500 at nil, 37,700 - 27,430 - 500 = - # 9,770 at 20% = 1,954, 30,000 - 10,270 = 19,730 at 40% = 7,892; 9,846. - # Income tax before the winter fuel payment charge: 5,486 + 9,846 + 703 = - # 16,035. Without the pay: adjusted net income 30,000, no charge; interest - # 17,430 taxable, 5,000 at the starting rate and 1,000 savings allowance at - # nil, 11,430 at 20% = 2,286. Tax deducted: 16,035 - 2,286 = 13,749, which - # includes the whole 703 charge. + # = 703 (ITEPA 2003 s.681C). The least tax sets 2,300 of the personal + # allowance against the pay and 10,270 against the interest (ITA 2007 + # s.25(2)). Pay: 37,700 at 20% = 7,540. Interest, higher-rate (savings + # allowance 500): 19,730 above the basic rate limit, 500 at nil and 19,230 + # at 40% = 7,692. Income tax before the winter fuel payment charge: 7,540 + + # 7,692 + 703 = 15,935. Setting the whole allowance against the pay leaves + # the 500 at nil in the basic rate band and charges 100 more. Without the + # pay: adjusted net income 30,000, no charge; interest 17,430 taxable, 5,000 + # at the starting rate and 1,000 savings allowance at nil, 11,430 at 20% = + # 2,286. Tax deducted: 15,935 - 2,286 = 13,649, which includes the whole 703 + # charge. period: 2026 absolute_error_margin: 0.01 input: @@ -130,10 +133,10 @@ region: NORTH_EAST output: CB_HITC: [703, 0] - income_tax_before_winter_fuel_payment_charge: [16_035, 0] - legacy_means_test_income_tax: [5_486, 0] + income_tax_before_winter_fuel_payment_charge: [15_935, 0] + legacy_means_test_income_tax: [7_540, 0] legacy_means_test_uncounted_income_tax_without_earnings: [2_286, 0] - legacy_means_test_deducted_income_tax: [13_749, 0] + legacy_means_test_deducted_income_tax: [13_649, 0] - name: Only the part of the High Income Child Benefit Charge that the pay adds is deducted # A parent aged 35 with one child, pay 10,000 and interest 65,000. With the