From efd8afbe988a5f6f7c940fdf6a8841d273703133 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Wed, 24 Jun 2026 13:17:18 +0100 Subject: [PATCH 01/75] Add cash ISA and stocks and shares ISA holdings variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two household-level wealth input variables, cash_isa and stocks_and_shares_isa, so the model can carry ISA balances separately. Previously the only ISA variable was the interest-income flow (individual_savings_account_interest_income); ISA *balances* had nowhere to land — investment ISAs were folded into corporate_wealth in the (now archived) policyengine-uk-data wealth imputation, and cash ISAs were not represented at all. These variables provide the model-side home for ISA balances imputed by the data pipeline (populace). Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog_entry.yaml | 4 ++++ .../variables/household/wealth/cash_isa.py | 12 ++++++++++++ .../household/wealth/stocks_and_shares_isa.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 changelog_entry.yaml create mode 100644 policyengine_uk/variables/household/wealth/cash_isa.py create mode 100644 policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py diff --git a/changelog_entry.yaml b/changelog_entry.yaml new file mode 100644 index 0000000000..7cb280ed04 --- /dev/null +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: minor + changes: + added: + - Cash ISA and stocks and shares ISA holdings as household wealth input variables. diff --git a/policyengine_uk/variables/household/wealth/cash_isa.py b/policyengine_uk/variables/household/wealth/cash_isa.py new file mode 100644 index 0000000000..69bcfca51f --- /dev/null +++ b/policyengine_uk/variables/household/wealth/cash_isa.py @@ -0,0 +1,12 @@ +from policyengine_uk.model_api import * + + +class cash_isa(Variable): + label = "cash ISA holdings" + documentation = "Amount held in cash Individual Savings Accounts" + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK diff --git a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py new file mode 100644 index 0000000000..ab71b89b9f --- /dev/null +++ b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py @@ -0,0 +1,15 @@ +from policyengine_uk.model_api import * + + +class stocks_and_shares_isa(Variable): + label = "stocks and shares ISA holdings" + documentation = ( + "Amount held in stocks and shares (investment) Individual Savings " + "Accounts" + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK From 8eaab780f2a085577038c55e35dd12248eabd13f Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Wed, 24 Jun 2026 14:41:20 +0100 Subject: [PATCH 02/75] Format ISA variables with ruff --- .../variables/household/wealth/stocks_and_shares_isa.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py index ab71b89b9f..5c9b4d6ccb 100644 --- a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py +++ b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py @@ -4,8 +4,7 @@ class stocks_and_shares_isa(Variable): label = "stocks and shares ISA holdings" documentation = ( - "Amount held in stocks and shares (investment) Individual Savings " - "Accounts" + "Amount held in stocks and shares (investment) Individual Savings Accounts" ) entity = Household definition_period = YEAR From abc47354a18376c259da621318aeca48e54adf82 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Jul 2026 09:24:19 -0400 Subject: [PATCH 03/75] Move changelog to a towncrier fragment so the minor bump lands The repo dropped root changelog_entry.yaml for changelog.d/ fragments; bump_version.py and the versioning workflow only read changelog.d/**, and the .added suffix is what infers the minor bump. Co-Authored-By: Claude Fable 5 --- changelog.d/add-isa-balance-variables.added.md | 1 + changelog_entry.yaml | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 changelog.d/add-isa-balance-variables.added.md delete mode 100644 changelog_entry.yaml diff --git a/changelog.d/add-isa-balance-variables.added.md b/changelog.d/add-isa-balance-variables.added.md new file mode 100644 index 0000000000..62222c5b28 --- /dev/null +++ b/changelog.d/add-isa-balance-variables.added.md @@ -0,0 +1 @@ +- Add `cash_isa` and `stocks_and_shares_isa` Household wealth input variables for ISA balances. diff --git a/changelog_entry.yaml b/changelog_entry.yaml deleted file mode 100644 index 7cb280ed04..0000000000 --- a/changelog_entry.yaml +++ /dev/null @@ -1,4 +0,0 @@ -- bump: minor - changes: - added: - - Cash ISA and stocks and shares ISA holdings as household wealth input variables. From 8983070615b0812cb1c5aeae16e5de9e1b273d8f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 09:49:30 -0400 Subject: [PATCH 04/75] Require mirroring policy changes in Axiom RuleSpec Adds the axiom: PR-description line (encoded-correct | encoded | queued | n/a) and the dispatch-ready standard for queued rulespec issues. Co-Authored-By: Claude Opus 5.5 --- .github/CONTRIBUTING.md | 8 ++++++++ .github/PULL_REQUEST_TEMPLATE.md | 12 ++++++++++++ changelog.d/axiom-parity-guidelines.changed.md | 1 + docs/engineering/skills/github-prs.md | 16 ++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 changelog.d/axiom-parity-guidelines.changed.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 24d8ad3289..cf17a663cc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -46,6 +46,14 @@ and add `verified_end_year` when verified coverage has a defined end. Change the verified year fields only after confirming that parameters and tests cover the stated years. +## Axiom parity + +Every policy change here must also be correct in [rulespec-uk](https://github.com/TheAxiomFoundation/rulespec-uk). That covers a new programme, a parameter or threshold update, an eligibility rule and a bug fix. The shared guide, [Mirror policy changes in Axiom](https://github.com/PolicyEngine/.github/blob/main/CONTRIBUTING.md#mirror-policy-changes-in-axiom), defines the `axiom:` line your PR description needs and what a `queued` issue must contain. UK specifics: + +- National modules live under `uk/`, and council schemes such as council tax reduction live under `uk-/`. Search `main` there before opening a new issue. +- An `encoded-correct` claim names the module and a companion case in its `.test.yaml` that exercises the same situation as your YAML test. +- Label `queued` issues `pe-parity`. Reuse your YAML test's externally sourced expected values (legislation.gov.uk, gov.uk guidance, official calculators) as the companion tests; don't copy values computed by policyengine-uk. + ## Repo-specific anti-patterns - **Don't change HF upload destinations** in the `policyengine-uk-data` upload pipeline without explicit authorisation — the private/public split exists to respect the UK Data Service licence. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e69de29bb2..453df24228 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +Fixes # + +## What and why + +## Test plan + +- [ ] YAML tests whose expected values come from an external source (legislation, official guidance, official calculator) +- [ ] `make format` +- [ ] Changelog fragment in `changelog.d/` + + +axiom: encoded-correct | encoded | queued | n/a: diff --git a/changelog.d/axiom-parity-guidelines.changed.md b/changelog.d/axiom-parity-guidelines.changed.md new file mode 100644 index 0000000000..519d89dc89 --- /dev/null +++ b/changelog.d/axiom-parity-guidelines.changed.md @@ -0,0 +1 @@ +- Documented that policy changes must be mirrored in Axiom RuleSpec (rulespec-uk), and added a pull request template with the required `axiom:` line. diff --git a/docs/engineering/skills/github-prs.md b/docs/engineering/skills/github-prs.md index 596438aeee..eadb157457 100644 --- a/docs/engineering/skills/github-prs.md +++ b/docs/engineering/skills/github-prs.md @@ -54,6 +54,22 @@ unless the user or maintainer explicitly asks. The type controls the inferred version bump: `breaking` is major, `added` and `removed` are minor, and other types are patch. +## Required Axiom Line + +Every PR that adds, updates or fixes policy (programmes, parameters, eligibility +rules, bug fixes) must also leave the same provision correct in +[rulespec-uk](https://github.com/TheAxiomFoundation/rulespec-uk). State which of +these applies in one line of the PR description: + +```text +axiom: encoded-correct | encoded | queued | n/a: +``` + +A `queued` rulespec-uk issue must be dispatch-ready and labelled `pe-parity`. +It needs the module path and corpus citation, the verbatim law, the required +outputs, and companion tests from the same external source as the PR's YAML +tests. Never hand-write RuleSpec. See `.github/CONTRIBUTING.md` ("Axiom parity"). + ## Required Lint And Format Run lint and formatting before committing. The default command is: From 787be8c85e59d4cd7a9a4e179a0f543b0bd049c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:19:29 -0400 Subject: [PATCH 05/75] Charge Class 4 NICs on Chapter 2 profits with Schedule 2 loss relief Class 4 was charged on raw self_employment_income. It now uses ni_class_4_profits: profit after capital allowances and the trading allowance (ITTOIA 2005 Part 2 Chapter 2), less trading losses as SSCBA 1992 Sch. 2 para. 3 allows, carried forward without limit. Co-Authored-By: Claude Opus 5.5 --- .../class_4/ni_class_4_profits.yaml | 224 +++++++++ .../tests/test_ni_class_4_maximum.py | 3 + .../test_ni_class_4_profits_properties.py | 457 ++++++++++++++++++ .../tests/test_ni_class_4_properties.py | 19 +- .../national_insurance/class_4/ni_class_4.py | 6 +- .../class_4/ni_class_4_loss_relief.py | 28 ++ .../ni_class_4_losses_brought_forward.py | 55 +++ .../ni_class_4_losses_carried_forward.py | 32 ++ .../class_4/ni_class_4_main.py | 2 +- .../class_4/ni_class_4_maximum.py | 2 +- .../class_4/ni_class_4_profits.py | 31 ++ .../ni_class_4_profits_before_losses.py | 41 ++ 12 files changed, 891 insertions(+), 9 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml create mode 100644 policyengine_uk/tests/test_ni_class_4_profits_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml new file mode 100644 index 0000000000..392d459075 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml @@ -0,0 +1,224 @@ +# Class 4 profits: SSCBA 1992 s. 15(1)(b) and Sch. 2 paras. 2-3. +# Class 4 is charged on the profits chargeable to income tax under ITTOIA 2005 +# Part 2 Chapter 2, which are after capital allowances (CAA 2001 s. 247) and +# the trading allowance (ITTOIA 2005 ss. 783AF and 783AI; HMRC BIM86052), less +# trading losses as Sch. 2 para. 3 allows. +# 2026-27: lower profits limit £12,570, upper profits limit £50,270, main rate +# 6%, additional rate 2%. + +- name: Capital allowances reduce Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + capital_allowances: 5_000 + output: + ni_class_4_profits_before_losses: 25_000 + ni_class_4_profits: 25_000 + # (25,000 - 12,570) * 6% + ni_class_4_main: 745.80 + ni_class_4: 745.80 + +- name: Partial trading allowance with receipts known (s. 783AI) + period: 2026 + absolute_error_margin: 0.01 + input: + # £300 of expenses: the £1,000 allowance is worth £700 more. + self_employment_gross_receipts: 20_300 + self_employment_income: 20_000 + output: + ni_class_4_profits: 19_300 + # (19,300 - 12,570) * 6% + ni_class_4: 403.80 + +- name: The trading allowance replaces expenses and capital allowances + period: 2026 + absolute_error_margin: 0.01 + input: + # Expenses £300 and capital allowances £400 are both replaced by the + # £1,000 allowance: 20,300 - 1,000 = 19,300. + self_employment_gross_receipts: 20_300 + self_employment_income: 20_000 + capital_allowances: 400 + output: + ni_class_4_profits_before_losses: 19_300 + ni_class_4_profits: 19_300 + ni_class_4: 403.80 + +- name: No trading allowance where expenses exceed it + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_gross_receipts: 22_000 + self_employment_income: 20_000 + output: + ni_class_4_profits: 20_000 + # (20,000 - 12,570) * 6% + ni_class_4: 445.80 + +- name: Receipts within the allowance give nil profits (s. 783AF) + period: 2026 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 950 + self_employment_income: 700 + output: + ni_class_4_profits_before_losses: 0 + ni_class_4_profits: 0 + ni_class_4: 0 + +- name: The current year's trading loss reduces Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + trading_loss: 4_000 + output: + ni_class_4_loss_relief: 4_000 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 26_000 + # (26,000 - 12,570) * 6% + ni_class_4: 805.80 + +- name: A loss is set only against trade profits, and the rest carried forward + period: 2026 + absolute_error_margin: 0.01 + input: + # Sch. 2 para. 3(4): even where income tax sets the loss against + # employment income, for Class 4 it reduces trade profits only. + employment_income: 40_000 + self_employment_income: 5_000 + trading_loss: 8_000 + output: + ni_class_4_loss_relief: 5_000 + ni_class_4_losses_carried_forward: 3_000 + ni_class_4_profits: 0 + ni_class_4: 0 + +- name: A loss from a loss-making year is brought forward in full + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2025: 0 + 2026: 30_000 + trading_loss: + 2025: 10_000 + 2026: 0 + output: + ni_class_4_losses_brought_forward: 10_000 + ni_class_4_loss_relief: 10_000 + ni_class_4_profits: 20_000 + # (20,000 - 12,570) * 6% + ni_class_4: 445.80 + +- name: In the loss year, the loss uses up that year's other trade profits first + period: 2025 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2025: 6_000 + 2026: 30_000 + trading_loss: + 2025: 10_000 + 2026: 0 + output: + ni_class_4_loss_relief: 6_000 + ni_class_4_profits: 0 + ni_class_4_losses_carried_forward: 4_000 + +- name: Only the unrelieved part of a loss is brought forward + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2025: 6_000 + 2026: 30_000 + trading_loss: + 2025: 10_000 + 2026: 0 + output: + # 10,000 loss less 6,000 relieved against 2025 profits. + ni_class_4_losses_brought_forward: 4_000 + ni_class_4_profits: 26_000 + # (26,000 - 12,570) * 6% + ni_class_4: 805.80 + +- name: Class 4 losses carry forward until used (Sch. 2 para. 3(4)(b)) + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2024: 0 + 2025: 20_000 + 2026: 60_000 + trading_loss: + 2024: 50_000 + 2025: 0 + 2026: 0 + output: + # 50,000 from 2024, less 20,000 used in 2025. + ni_class_4_losses_brought_forward: 30_000 + ni_class_4_loss_relief: 30_000 + ni_class_4_profits: 30_000 + # (30,000 - 12,570) * 6% + ni_class_4: 1_045.80 + +- name: A supplied balance of unrelieved losses is brought forward + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + ni_class_4_losses_brought_forward: 7_000 + output: + ni_class_4_loss_relief: 7_000 + ni_class_4_profits: 23_000 + # (23,000 - 12,570) * 6% + ni_class_4: 625.80 + +- name: A supplied balance carries into later years + period: 2027 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2026: 5_000 + 2027: 30_000 + ni_class_4_losses_brought_forward: + 2026: 7_000 + trading_loss: + 2026: 0 + 2027: 0 + output: + # 7,000 brought into 2026, less 5,000 used there. + ni_class_4_losses_brought_forward: 2_000 + ni_class_4_profits: 28_000 + +- name: Pension contributions are not deducted from Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + # Sch. 2 para. 3(2)(f)-(g): pension contributions relieve income tax only. + self_employment_income: 30_000 + personal_pension_contributions: 5_000 + output: + ni_class_4_profits: 30_000 + # (30,000 - 12,570) * 6% + ni_class_4: 1_045.80 + +- name: The annual maximum is worked out on Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + # Primary Class 1 on £60,000 of earnings exceeds Step Two (6% of the + # £37,700 main band, £2,262), so Case 3 applies: Step Four is nil. + employment_income: 60_000 + self_employment_income: 80_000 + capital_allowances: 20_000 + output: + ni_class_4_profits: 60_000 + # Main band before the maximum: 6% * 37,700. + ni_class_4_main: 2_262 + # Steps Six to Eight: 2% * (50,270 - 12,570) = 754. + # Step Nine: 2% * (60,000 - 50,270) = 194.60. + ni_class_4_maximum: 948.60 + ni_class_4: 948.60 diff --git a/policyengine_uk/tests/test_ni_class_4_maximum.py b/policyengine_uk/tests/test_ni_class_4_maximum.py index c32e9f69e9..f2d3780870 100644 --- a/policyengine_uk/tests/test_ni_class_4_maximum.py +++ b/policyengine_uk/tests/test_ni_class_4_maximum.py @@ -149,12 +149,15 @@ def test_class_2_counts_towards_annual_maximum_only_before_april_2024(year, expe # applies and Step Four is negative (Case 3), capping Class 4 at # 2% x 100 + 2% x 900 = £20. From then SI 2024/377 removes Class 2 # from regulation 100, so without Class 1 the maximum does not apply. + # Receipts of £3,000 put expenses above the £1,000 trading allowance, + # so the whole £1,000 profit is chargeable. sim = Simulation( situation={ "people": { "person": { "age": {year: 40}, "self_employment_income": {year: 1_000}, + "self_employment_gross_receipts": {year: 3_000}, "ni_class_2": {year: 182}, } }, diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py new file mode 100644 index 0000000000..bc28d5e7fa --- /dev/null +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -0,0 +1,457 @@ +"""Property tests for the profits Class 4 NICs are charged on. + +SSCBA 1992 s. 15(1)(b) and Sch. 2 charge Class 4 on the trade profits +chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2, which are after +capital allowances (CAA 2001 s. 247) and the trading allowance (ITTOIA 2005 +ss. 783AF and 783AI), less trading losses as Sch. 2 para. 3 allows. + +Invariants, for every generated case: + +1. Differential: ni_class_4_profits_before_losses, ni_class_4_loss_relief, + ni_class_4_losses_brought_forward, ni_class_4_losses_carried_forward and + ni_class_4_profits equal an independent exact-rational reference: the + better of actual deductions and the allowance where receipts are known, + and losses set against each year's profits, earliest first, with the + rest carried forward without limit. +2. Accounting: trading_loss + losses brought forward = loss relief + losses + carried forward; losses brought forward equal last year's losses carried + forward; 0 <= loss relief <= profits before losses. +3. Bound: 0 <= ni_class_4 <= the s. 15(3) amount on the reference Class 4 + profits <= the s. 15(3) amount on the unrelieved profit. Where regulation + 100 cannot apply (no Class 1 and, from April 2024, Class 2 irrelevant), + ni_class_4 equals the s. 15(3) amount. +4. Monotonicity: ni_class_4 is non-decreasing in profit, at fixed expenses + (or unknown receipts), capital allowances, losses and employment income, + wherever Class 2 does not change; and non-increasing in capital + allowances and trading losses. +5. Carry-forward over many years matches the reference fold, whatever order + the years are calculated in, including beyond the engine's ten-step + spiral limit. + +test_ni_class_4_properties.py checks s. 15(3) and regulation 100 given these +profits, for arbitrary thresholds and rates. Comparisons allow float32 +rounding: the model stores values as float32. +""" + +from fractions import Fraction + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +ALLOWANCE = 1_000 +CASES = 24 # Each case is simulated twice: as drawn, and varied. +PEOPLE = 2 * CASES +PROPERTY_SETTINGS = settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +amount = st.one_of( + st.floats(min_value=0, max_value=2_500), + st.floats(min_value=0, max_value=300_000), + st.sampled_from( + [0.0, 999.99, 1_000.0, 1_000.01, 6_725.0, 12_570.0, 50_270.0, 50_271.0] + ), +) +profit = st.one_of(amount, st.floats(min_value=-5_000, max_value=0)) +expenses = st.one_of( + st.sampled_from([0.0, 150.0, 999.0, 1_000.0, 1_001.0]), + st.floats(min_value=0, max_value=5_000), +) +capital_allowances = st.one_of( + st.just(0.0), st.sampled_from([150.0, 900.0, 5_000.0]), amount +) +loss = st.one_of(st.just(0.0), amount) +# Receipts: unknown, consistent (profit + expenses), or inconsistently below +# profit, which the model treats as unknown. +receipts_mode = st.sampled_from(["unknown", "consistent", "below_profit"]) +year_inputs = st.tuples( + profit, + expenses, + capital_allowances, + loss, + receipts_mode, + st.floats(min_value=0.01, max_value=0.99), # receipts share if below profit +) +case = st.tuples( + year_inputs, # previous year + year_inputs, # current year + st.sampled_from([0.0, 0.0, 8_000.0, 30_000.0, 60_000.0, 200_000.0]), + st.floats(min_value=0, max_value=30_000), # variation +) +cases = st.lists(case, min_size=CASES, max_size=CASES) + + +def exact(x): + return Fraction(float(x)) + + +def reference_profits_before_losses(profit, receipts, capital_allowances): + """ITTOIA 2005 Part 2 Chapter 2 profits, after capital allowances and + the trading allowance, before loss relief.""" + if receipts > 0 and receipts >= profit: + if receipts <= ALLOWANCE: + # s. 783AF: the trade's profits are nil. + return Fraction(0) + # The better of actual deductions and the allowance (s. 783AI); + # capital allowances are a deduction (CAA 2001 s. 247) that the + # allowance replaces, so the two never stack. + actual_deductions = receipts - profit + capital_allowances + return max(Fraction(0), receipts - max(actual_deductions, ALLOWANCE)) + # Receipts unknown: a profit within the allowance is taken to come from + # receipts within it; a larger one already reflects the better deduction. + if profit <= ALLOWANCE: + return Fraction(0) + return max(Fraction(0), profit - capital_allowances) + + +def reference_losses(years): + """Sch. 2 para. 3: each year's losses (its own and those brought + forward) are set against its profits; the rest is carried forward to the + following years without limit (para. 3(4)(b); ITA 2007 s. 84).""" + brought_forward = Fraction(0) + out = [] + for profits, trading_loss in years: + losses = max(trading_loss, 0) + brought_forward + relief = min(losses, profits) + out.append( + dict( + brought_forward=brought_forward, + relief=relief, + profits=profits - relief, + carried_forward=losses - relief, + ) + ) + brought_forward = losses - relief + return out + + +def statutory_class_4(profits, class_4): + """s. 15(3) SSCBA 1992 in exact arithmetic.""" + lpl = exact(class_4.thresholds.lower_profits_limit) + upl = exact(class_4.thresholds.upper_profits_limit) + return exact(class_4.rates.main) * min(max(profits - lpl, 0), upl - lpl) + exact( + class_4.rates.additional + ) * max(profits - upl, 0) + + +def tolerance(*amounts): + # float32 has a 24-bit significand: a few ulps of the largest amount + # involved, plus a penny. + return 0.01 + 4e-7 * max(abs(float(a)) for a in amounts) + + +def receipts_for(profit, expenses, mode, share): + return np.select( + [mode == "consistent", mode == "below_profit"], + [profit + expenses, profit * share], + 0, + ) + + +@pytest.fixture(scope="module", params=[2023, 2026]) +def sim(request): + year = request.param + people = { + f"person_{i}": { + "age": {year - 1: 40, year: 41}, + **{ + variable: {year - 1: 0, year: 0} + for variable in [ + "employment_income", + "self_employment_income", + "self_employment_gross_receipts", + "capital_allowances", + "trading_loss", + ] + }, + } + for i in range(PEOPLE) + } + sim = Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + } + ) + return sim, year + + +def run(simulation, drawn, vary): + """Simulate each drawn case twice: as drawn, and with `vary` applied to + the current year's inputs. Returns stored inputs and model outputs.""" + sim, year = simulation + previous, current, employment, variation = zip(*drawn) + previous = [np.array(x) for x in zip(*previous)] + current = [np.array(x) for x in zip(*current)] + employment, variation = np.array(employment), np.array(variation) + varied = vary(*current, variation) + + def both(x, y): + return np.concatenate([x, y]) + + inputs = { + year - 1: [both(x, x) for x in previous], + year: [both(x, y) for x, y in zip(current, varied)], + } + for period, (profit, expenses, allowances, loss, mode, share) in inputs.items(): + sim.set_input("self_employment_income", period, profit) + sim.set_input( + "self_employment_gross_receipts", + period, + receipts_for(profit, expenses, mode, share), + ) + sim.set_input("capital_allowances", period, allowances) + sim.set_input("trading_loss", period, loss) + sim.set_input("employment_income", period, both(employment, employment)) + sim.reset_calculations() + + def calculate(variable, period): + return sim.calculate(variable, period).astype(float) + + return { + period: dict( + # Stored (float32) inputs, not the float64 draws. + profit=calculate("self_employment_income", period), + receipts=calculate("self_employment_gross_receipts", period), + capital_allowances=calculate("capital_allowances", period), + trading_loss=calculate("trading_loss", period), + **{ + name: calculate(f"ni_class_4_{name}", period) + for name in [ + "profits_before_losses", + "loss_relief", + "losses_brought_forward", + "losses_carried_forward", + "profits", + ] + }, + ni_class_4=calculate("ni_class_4", period), + ni_class_2=calculate("ni_class_2", period), + ni_class_1_employee=calculate("ni_class_1_employee", period), + ) + for period in (year - 1, year) + } + + +def reference(values, i): + years = [values[period] for period in sorted(values)] + profits = [ + reference_profits_before_losses( + exact(v["profit"][i]), + exact(v["receipts"][i]), + exact(v["capital_allowances"][i]), + ) + for v in years + ] + losses = reference_losses( + [(p, exact(v["trading_loss"][i])) for p, v in zip(profits, years)] + ) + return [dict(profits_before_losses=p, **l) for p, l in zip(profits, losses)] + + +def unchanged(profit, expenses, allowances, loss, mode, share, variation): + return profit, expenses, allowances, loss, mode, share + + +@PROPERTY_SETTINGS +@given(cases) +def test_class_4_profits_match_statutory_reference(sim, drawn): + values = run(sim, drawn, unchanged) + for i in range(PEOPLE): + expected = reference(values, i) + for period, reference_year in zip(sorted(values), expected): + model = values[period] + for name, expected_value in reference_year.items(): + model_name = { + "brought_forward": "losses_brought_forward", + "relief": "loss_relief", + "carried_forward": "losses_carried_forward", + }.get(name, name) + actual = model[model_name][i] + tol = tolerance(actual, expected_value, model["profit"][i]) + assert abs(actual - float(expected_value)) <= tol, ( + period, + i, + model_name, + actual, + float(expected_value), + ) + # Accounting identities. + tol = tolerance( + model["trading_loss"][i], + model["losses_brought_forward"][i], + model["profits_before_losses"][i], + ) + assert ( + abs( + max(model["trading_loss"][i], 0) + + model["losses_brought_forward"][i] + - model["loss_relief"][i] + - model["losses_carried_forward"][i] + ) + <= tol + ) + assert -tol <= model["loss_relief"][i] + assert model["loss_relief"][i] <= model["profits_before_losses"][i] + tol + previous, current = (values[period] for period in sorted(values)) + assert current["losses_brought_forward"][i] == pytest.approx( + previous["losses_carried_forward"][i], abs=0.01, rel=1e-6 + ) + + +@PROPERTY_SETTINGS +@given(cases) +def test_class_4_never_exceeds_uncapped_statutory_amount(sim, drawn): + values = run(sim, drawn, unchanged) + simulation, year = sim + current = values[year] + class_4 = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.national_insurance.class_4 + for i in range(PEOPLE): + profits = reference(values, i)[-1]["profits"] + uncapped = statutory_class_4(profits, class_4) + unrelieved = statutory_class_4(max(exact(current["profit"][i]), 0), class_4) + model = current["ni_class_4"][i] + tol = tolerance(model, uncapped, current["profit"][i]) + assert uncapped <= unrelieved + assert -tol <= model <= float(uncapped) + tol, (i, model, float(uncapped)) + regulation_100_cannot_apply = current["ni_class_1_employee"][i] == 0 and ( + year >= 2024 or current["ni_class_2"][i] == 0 + ) + if regulation_100_cannot_apply: + assert abs(model - float(uncapped)) <= tol, (i, model, float(uncapped)) + + +def more_profit(profit, expenses, allowances, loss, mode, share, variation): + # Expenses stay fixed, so known receipts rise with profit. + return profit + variation, expenses, allowances, loss, mode, share + + +def more_capital_allowances(profit, expenses, allowances, loss, mode, share, variation): + return profit, expenses, allowances + variation, loss, mode, share + + +def more_trading_loss(profit, expenses, allowances, loss, mode, share, variation): + return profit, expenses, allowances, loss + variation, mode, share + + +@PROPERTY_SETTINGS +@given(cases) +def test_class_4_is_non_decreasing_in_profit(sim, drawn): + values = run(sim, drawn, more_profit) + current = values[sim[1]] + for i in range(CASES): + j = i + CASES + assert current["profit"][j] >= current["profit"][i] + assert current["profits"][j] >= current["profits"][i] - tolerance( + current["profits"][j] + ) + if current["ni_class_2"][j] == current["ni_class_2"][i]: + tol = tolerance(current["profit"][j], current["ni_class_4"][j]) + assert current["ni_class_4"][j] >= current["ni_class_4"][i] - tol, ( + i, + current["profit"][i], + current["profit"][j], + current["ni_class_4"][i], + current["ni_class_4"][j], + ) + + +@PROPERTY_SETTINGS +@given(cases, st.sampled_from([more_capital_allowances, more_trading_loss])) +def test_class_4_is_non_increasing_in_reliefs(sim, drawn, vary): + values = run(sim, drawn, vary) + current = values[sim[1]] + for i in range(CASES): + j = i + CASES + tol = tolerance(current["profit"][i], current["ni_class_4"][i]) + assert current["profits"][j] <= current["profits"][i] + tol + assert current["ni_class_4"][j] <= current["ni_class_4"][i] + tol, ( + vary.__name__, + i, + current["ni_class_4"][i], + current["ni_class_4"][j], + ) + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.tuples( + st.one_of(st.just(0.0), st.floats(0, 80_000)), # profit + st.one_of(st.just(0.0), st.just(0.0), st.floats(0, 150_000)), # loss + ), + min_size=2, + max_size=16, + ), + st.randoms(use_true_random=False), +) +def test_losses_carry_forward_over_many_years(years, random): + last_year = 2030 + first_year = last_year - len(years) + 1 + periods = list(range(first_year, last_year + 1)) + sim = Simulation( + situation={ + "people": { + "person": { + "age": {first_year: 40}, + "self_employment_income": dict( + zip(periods, (profit for profit, _ in years)) + ), + "trading_loss": dict(zip(periods, (loss for _, loss in years))), + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + expected = reference_losses( + [ + ( + reference_profits_before_losses( + exact(sim.calculate("self_employment_income", period)[0]), + Fraction(0), + Fraction(0), + ), + exact(sim.calculate("trading_loss", period)[0]), + ) + for period in periods + ] + ) + order = periods.copy() + random.shuffle(order) + for period in order: + reference_year = expected[period - first_year] + brought_forward = float( + sim.calculate("ni_class_4_losses_brought_forward", period)[0] + ) + profits = float(sim.calculate("ni_class_4_profits", period)[0]) + tol = tolerance(reference_year["brought_forward"], sum(l for _, l in years)) + assert abs(brought_forward - float(reference_year["brought_forward"])) <= tol, ( + period, + brought_forward, + float(reference_year["brought_forward"]), + ) + assert abs(profits - float(reference_year["profits"])) <= tol, ( + period, + profits, + float(reference_year["profits"]), + ) diff --git a/policyengine_uk/tests/test_ni_class_4_properties.py b/policyengine_uk/tests/test_ni_class_4_properties.py index 9f0e3fa512..da60adfcd8 100644 --- a/policyengine_uk/tests/test_ni_class_4_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_properties.py @@ -6,8 +6,10 @@ apply (no primary Class 1, nor Class 2 before April 2024) or its maximum is at least the s.15(3) amount, ni_class_4 equals main * clamp(p - LPL, 0, UPL - LPL) + additional * max(p - UPL, 0), - where p is the full self-employment profit: Class 1 contributions are not - deducted (SSCBA 1992 Sch 2 para 2). + where p is ni_class_4_profits. With no capital allowances, receipts or + losses, p is the full self-employment profit, or nil within the trading + allowance: Class 1 contributions are not deducted (SSCBA 1992 Sch 2 + para 2). test_ni_class_4_profits_properties.py covers the other reliefs. 2. Differential: for everyone, ni_class_4 equals an exact-rational implementation of s.15(3) SSCBA 1992 capped by the literal regulation 100 steps (with the Case 1 comparison done exactly). The one exception is a @@ -140,6 +142,7 @@ def simulate(policy, people): variable: sim.calculate(variable, year) for variable in [ "ni_class_4", + "ni_class_4_profits", "self_employment_income", "ni_class_1_employee", "ni_class_1_employee_primary", @@ -152,6 +155,9 @@ def simulate(policy, people): assert ni.class_4.rates.main == policy["main_rate"] assert ni.class_4.rates.additional == policy["additional_rate"] values["class_2_flat_rate"] = ni.class_2.flat_rate + values["trading_allowance"] = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.income_tax.allowances.trading_allowance values["year"] = year return values @@ -176,7 +182,7 @@ def reference_class_4(policy, values, i): lpl, upl = exact(policy["lpl"]), exact(policy["upl"]) main_rate = exact(policy["main_rate"]) additional_rate = exact(policy["additional_rate"]) - profits = exact(values["self_employment_income"][i]) + profits = exact(values["ni_class_4_profits"][i]) employee_ni = exact(values["ni_class_1_employee"][i]) class_1 = exact(values["ni_class_1_employee_primary"][i]) class_2 = exact(values["ni_class_2"][i]) @@ -217,9 +223,14 @@ def test_class_4_matches_statute_and_exact_regulation_100(population): lpl, upl = policy["lpl"], policy["upl"] for i in range(len(people)): model = float(values["ni_class_4"][i]) - profits = float(values["self_employment_income"][i]) + profits = float(values["ni_class_4_profits"][i]) + self_employment_income = float(values["self_employment_income"][i]) tol = tolerance(profits, upl, model) + # Receipts unknown: a profit within the trading allowance is nil. + within_allowance = self_employment_income <= values["trading_allowance"] + assert profits == (0 if within_allowance else self_employment_income) + reference, reference_pre_maximum, maximum = reference_class_4(policy, values, i) assert abs(model - float(reference)) <= tol, (i, model, float(reference)) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py index 6f43033d47..d1d81de624 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py @@ -12,9 +12,9 @@ class ni_class_4(Variable): def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 - # Schedule 2 para 2: Class 4 is charged on the full trading profits; - # Class 1 contributions are not a deduction. - profits = person("self_employment_income", period) + # Schedule 2: Class 4 is charged on the Chapter 2 trade profits less + # the para 3 loss reliefs; Class 1 contributions are not a deduction. + profits = person("ni_class_4_profits", period) add_rate_income = max_( profits - class_4.thresholds.upper_profits_limit, 0, diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py new file mode 100644 index 0000000000..d2afbd37bc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_loss_relief(Variable): + value_type = float + entity = Person + label = "Trading losses deducted from Class 4 profits" + documentation = ( + "Trading losses set against the year's Class 4 profits under SSCBA " + "1992 Sch. 2 para. 3: the current year's trading_loss and losses " + "brought forward, up to the profits. Unlike income tax, a loss only " + "ever reduces trade profits for Class 4: where it is set against " + "other income for income tax, it still reduces the year's Class 4 " + "profits, and any excess is carried forward (para. 3(4))." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ) + + def formula(person, period, parameters): + losses = max_(person("trading_loss", period), 0) + max_( + person("ni_class_4_losses_brought_forward", period), 0 + ) + profits = person("ni_class_4_profits_before_losses", period) + return min_(losses, profits) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py new file mode 100644 index 0000000000..66ad63e9b9 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_losses_brought_forward(Variable): + value_type = float + entity = Person + label = "Trading losses brought forward for Class 4" + documentation = ( + "Trading losses from earlier years not yet deducted from Class 4 " + "profits, equal to the previous year's " + "ni_class_4_losses_carried_forward. By default, losses are carried " + "forward from the first year a trading_loss is known for, however " + "many years back. Set this for a year to supply the unrelieved " + "losses from before it; later years then build on that balance." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3(1)(c) and (4)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + dict( + title="Income Tax Act 2007, ss. 83 and 84", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/84", + ), + ] + + def formula(person, period, parameters): + supplied = person.get_holder("ni_class_4_losses_brought_forward") + earlier_years = [ + known_period.start.year + for holder in (person.get_holder("trading_loss"), supplied) + for known_period in holder.get_known_periods() + if known_period.start < period.start + ] + brought_forward = person.empty_array() + if not earlier_years: + # No loss can be known for any earlier year. + return brought_forward + # Run the carry-forward year by year from the first year a loss is + # known for: each year's losses are deducted from that year's + # profits and the rest carried to the next (ITA 2007 s. 84). A loop + # rather than a formula on the previous year, which the engine would + # cut off as a spiral after ten years. + branch_name = person.simulation.branch_name + for years_back in range(period.start.year - min(earlier_years), 0, -1): + year = period.offset(-years_back, "year") + balance = supplied.get_array(year, branch_name) + if balance is not None: + brought_forward = balance + losses = max_(person("trading_loss", year), 0) + max_(brought_forward, 0) + profits = person("ni_class_4_profits_before_losses", year) + brought_forward = losses - min_(losses, profits) + return brought_forward diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py new file mode 100644 index 0000000000..75e23fc50f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_losses_carried_forward(Variable): + value_type = float + entity = Person + label = "Trading losses carried forward for Class 4" + documentation = ( + "Trading losses not yet deducted from Class 4 profits at the end of " + "the year: the year's trading_loss and losses brought forward, less " + "those deducted this year. They reduce the Class 4 profits of the " + "following years, earliest first (SSCBA 1992 Sch. 2 para. 3(4)(b); " + "ITA 2007 ss. 83 and 84)." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3(4)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + dict( + title="Income Tax Act 2007, s. 84", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/84", + ), + ] + + def formula(person, period, parameters): + losses = max_(person("trading_loss", period), 0) + max_( + person("ni_class_4_losses_brought_forward", period), 0 + ) + return losses - person("ni_class_4_loss_relief", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py index 6f738716ba..74b467363e 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py @@ -12,7 +12,7 @@ class ni_class_4_main(Variable): def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 - profits = person("self_employment_income", period) + profits = person("ni_class_4_profits", period) add_rate_income = max_( profits - class_4.thresholds.upper_profits_limit, 0, diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py index 1d9cd4e3ba..b9e61c8aad 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py @@ -35,7 +35,7 @@ def formula(person, period, parameters): # a wrong Case 1 dropped the additional-rate band (#1878). The unused # band is exactly zero at or above the UPL, so that equality is now # decided exactly. - profits = person("self_employment_income", period) + profits = person("ni_class_4_profits", period) unused_main_band = clip(upl - profits.astype(np.float64), 0, step_1) case_1 = (step_4_raw >= 0) & ( main_rate * unused_main_band + class_2_weeks_addition diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py new file mode 100644 index 0000000000..4266eaba7e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_profits(Variable): + value_type = float + entity = Person + label = "Profits chargeable to Class 4 NICs" + documentation = ( + "Trade profits chargeable to income tax under ITTOIA 2005 Part 2 " + "Chapter 2 (after capital allowances and the trading allowance), less " + "trading losses as SSCBA 1992 Sch. 2 para. 3 allows. Personal " + "reliefs, interest relief and pension contributions are not deducted " + "(para. 3(2)), and neither are Class 1 contributions." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, s. 15(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/15", + ), + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 paras. 2 and 3", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2", + ), + ] + + def formula(person, period, parameters): + profits = person("ni_class_4_profits_before_losses", period) + loss_relief = person("ni_class_4_loss_relief", period) + return max_(profits - loss_relief, 0) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py new file mode 100644 index 0000000000..28ea1dcb2d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_profits_before_losses(Variable): + value_type = float + entity = Person + label = "Trade profits chargeable under ITTOIA 2005 Part 2 Chapter 2" + documentation = ( + "Self-employment profit after capital allowances and the trading " + "allowance, before loss relief: the profits chargeable to income tax " + "under Chapter 2 of Part 2 of ITTOIA 2005 on which Class 4 NICs are " + "charged. Capital allowances are a trade expense (CAA 2001 s. 247). " + "The trading allowance recomputes the trade's profits (ITTOIA 2005 " + "ss. 783AF and 783AI), so no Class 4 NICs are due on income it covers " + "(HMRC BIM86052). Allowances in excess of profit do not create a loss " + "here; supply any such loss through trading_loss." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, s. 15(1)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/15", + ), + dict( + title="Capital Allowances Act 2001, s. 247", + href="https://www.legislation.gov.uk/ukpga/2001/2/section/247", + ), + dict( + title="HMRC Business Income Manual BIM86052", + href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86052", + ), + ] + + def formula(person, period, parameters): + profit = person("self_employment_income", period) + capital_allowances = person("capital_allowances", period) + # trading_allowance_deduction is the allowance's relief beyond actual + # expenses and capital allowances, so the two do not stack. + trading_allowance = person("trading_allowance_deduction", period) + return max_(profit - capital_allowances - trading_allowance, 0) From bb44635d9c7a3a8df564c2fb66124cc762c2eb8c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:34:43 -0400 Subject: [PATCH 06/75] Property-test a supplied brought-forward balance over many years Co-Authored-By: Claude Opus 5.5 --- .../test_ni_class_4_profits_properties.py | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index bc28d5e7fa..49cee6e1aa 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -24,9 +24,9 @@ (or unknown receipts), capital allowances, losses and employment income, wherever Class 2 does not change; and non-increasing in capital allowances and trading losses. -5. Carry-forward over many years matches the reference fold, whatever order - the years are calculated in, including beyond the engine's ten-step - spiral limit. +5. Carry-forward over many years matches the reference fold, with or + without a supplied brought-forward balance, whatever order the years are + calculated in, including beyond the engine's ten-step spiral limit. test_ni_class_4_properties.py checks s. 15(3) and regulation 100 given these profits, for arbitrary thresholds and rates. Comparisons allow float32 @@ -111,13 +111,16 @@ def reference_profits_before_losses(profit, receipts, capital_allowances): return max(Fraction(0), profit - capital_allowances) -def reference_losses(years): +def reference_losses(years, supplied=None): """Sch. 2 para. 3: each year's losses (its own and those brought forward) are set against its profits; the rest is carried forward to the - following years without limit (para. 3(4)(b); ITA 2007 s. 84).""" + following years without limit (para. 3(4)(b); ITA 2007 s. 84). + `supplied` maps a year's index to a supplied brought-forward balance.""" brought_forward = Fraction(0) out = [] - for profits, trading_loss in years: + for index, (profits, trading_loss) in enumerate(years): + if supplied and index in supplied: + brought_forward = supplied[index] losses = max(trading_loss, 0) + brought_forward relief = min(losses, profits) out.append( @@ -402,23 +405,33 @@ def test_class_4_is_non_increasing_in_reliefs(sim, drawn, vary): min_size=2, max_size=16, ), + # Optionally, a brought-forward balance supplied for one year. + st.one_of( + st.none(), + st.tuples(st.integers(0, 15), st.floats(0, 100_000)), + ), st.randoms(use_true_random=False), ) -def test_losses_carry_forward_over_many_years(years, random): +def test_losses_carry_forward_over_many_years(years, opening_balance, random): last_year = 2030 first_year = last_year - len(years) + 1 periods = list(range(first_year, last_year + 1)) + person = { + "age": {first_year: 40}, + "self_employment_income": dict(zip(periods, (profit for profit, _ in years))), + "trading_loss": dict(zip(periods, (loss for _, loss in years))), + } + supplied = None + if opening_balance is not None: + index = opening_balance[0] % len(years) + person["ni_class_4_losses_brought_forward"] = { + periods[index]: opening_balance[1] + } + # Compare against the stored (float32) input. + supplied = {index: exact(np.float32(opening_balance[1]))} sim = Simulation( situation={ - "people": { - "person": { - "age": {first_year: 40}, - "self_employment_income": dict( - zip(periods, (profit for profit, _ in years)) - ), - "trading_loss": dict(zip(periods, (loss for _, loss in years))), - } - }, + "people": {"person": person}, "benunits": {"benunit": {"members": ["person"]}}, "households": {"household": {"members": ["person"]}}, } @@ -434,8 +447,10 @@ def test_losses_carry_forward_over_many_years(years, random): exact(sim.calculate("trading_loss", period)[0]), ) for period in periods - ] + ], + supplied, ) + total_losses = sum(loss for _, loss in years) + sum((supplied or {}).values()) order = periods.copy() random.shuffle(order) for period in order: @@ -444,7 +459,7 @@ def test_losses_carry_forward_over_many_years(years, random): sim.calculate("ni_class_4_losses_brought_forward", period)[0] ) profits = float(sim.calculate("ni_class_4_profits", period)[0]) - tol = tolerance(reference_year["brought_forward"], sum(l for _, l in years)) + tol = tolerance(total_losses) assert abs(brought_forward - float(reference_year["brought_forward"])) <= tol, ( period, brought_forward, From cb1b77f287221cee0376f847a6c4ed2261f2c31f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:35:47 -0400 Subject: [PATCH 07/75] Add changelog fragment and document the Class 4 profits measure Co-Authored-By: Claude Opus 5.5 --- changelog.d/1916.fixed.md | 1 + docs/book/programs/gov/hmrc/national-insurance.ipynb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1916.fixed.md diff --git a/changelog.d/1916.fixed.md b/changelog.d/1916.fixed.md new file mode 100644 index 0000000000..d75e06f315 --- /dev/null +++ b/changelog.d/1916.fixed.md @@ -0,0 +1 @@ +- Charged Class 4 NICs on the profits chargeable under ITTOIA 2005 Part 2 Chapter 2, after capital allowances and the trading allowance, less trading losses under SSCBA 1992 Sch. 2 para. 3 (new `ni_class_4_profits`). Losses reduce only trade profits for Class 4 and carry forward until used. diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index 8c475d72b8..c7cde1248e 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: the current year's `trading_loss` first, with the rest carried forward to later years until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", From 70cbd58eb7add067d9688697bd501e6a9142bc70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:57:46 -0400 Subject: [PATCH 08/75] Document Class 4 loss limits and add a fixed spiral-limit regression test Addresses the independent review: documents trading_loss carry-over, that negative profit creates no loss (as for income tax), unmodelled carry-backs and para. 3(5) deductions, pooling across trades, and the s. 83(4) order. Co-Authored-By: Claude Opus 5.5 --- .../gov/hmrc/national-insurance.ipynb | 4 +-- .../test_ni_class_4_profits_properties.py | 26 +++++++++++++++++++ .../gov/hmrc/income_tax/bases/trading_loss.py | 7 +++++ .../class_4/ni_class_4_loss_relief.py | 10 ++++--- .../ni_class_4_losses_brought_forward.py | 4 ++- .../class_4/ni_class_4_profits.py | 4 ++- .../ni_class_4_profits_before_losses.py | 5 ++-- 7 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index c7cde1248e..055826394e 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -22,7 +22,7 @@ "- **Class 1**: Paid by employees and their employers. This is based on the employee's earnings and is deducted directly from wages.\n", "- **Class 2**: Paid by self-employed people with profits above a certain threshold.\n", "- **Class 3**: Voluntary contributions, often made by people who want to fill gaps in their National Insurance record.\n", - "- **Class 4**: Paid by self-employed people with profits above a certain threshold, in addition to Class 2 NICs." + "- **Class 4**: Paid by self-employed people with profits above a certain threshold (in addition to Class 2 NICs before April 2024)." ] }, { @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: the current year's `trading_loss` first, with the rest carried forward to later years until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own `trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index 49cee6e1aa..e15ae10c6d 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -470,3 +470,29 @@ def test_losses_carry_forward_over_many_years(years, opening_balance, random): profits, float(reference_year["profits"]), ) + + +def test_losses_carry_forward_beyond_the_spiral_limit(): + # A £720,000 loss in 2016 against £50,000 of profit a year: 2016-2029 + # use £700,000, so £20,000 is brought into 2030 and 2030's Class 4 + # profits are £30,000. A formula recursing on the previous year would + # be cut off by the engine's spiral guard after ten years, dropping the + # loss and leaving £50,000. + years = range(2016, 2031) + sim = Simulation( + situation={ + "people": { + "person": { + "age": {2016: 40}, + "self_employment_income": {year: 50_000 for year in years}, + "trading_loss": { + year: 720_000 if year == 2016 else 0 for year in years + }, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + assert sim.calculate("ni_class_4_losses_brought_forward", 2030)[0] == 20_000 + assert sim.calculate("ni_class_4_profits", 2030)[0] == 30_000 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py index 0277b37f86..75757eb01c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py @@ -5,6 +5,13 @@ class trading_loss(Variable): value_type = float entity = Person label = "Loss from trading in the current year." + documentation = ( + "Trading losses made in the year. self_employment_income holds the " + "profits of trades that made one, so a loss here alongside a profit " + "comes from a different trade. Like other inputs, a value carries " + "into later years that are not set, so set them to zero for a " + "one-off loss." + ) reference = dict( title="Income Tax Act 2007 s. 64", href="https://www.legislation.gov.uk/ukpga/2007/3/section/64", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py index d2afbd37bc..6e13485329 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py @@ -7,11 +7,15 @@ class ni_class_4_loss_relief(Variable): label = "Trading losses deducted from Class 4 profits" documentation = ( "Trading losses set against the year's Class 4 profits under SSCBA " - "1992 Sch. 2 para. 3: the current year's trading_loss and losses " - "brought forward, up to the profits. Unlike income tax, a loss only " + "1992 Sch. 2 para. 3: losses brought forward and the year's " + "trading_loss, up to the profits. Unlike income tax, a loss only " "ever reduces trade profits for Class 4: where it is set against " "other income for income tax, it still reduces the year's Class 4 " - "profits, and any excess is carried forward (para. 3(4))." + "profits, and any excess is carried forward (para. 3(4)). Assumes " + "the year's loss is claimed in that year: carry-back claims (ITA " + "2007 ss. 64(2)(b), 72 and 89) are not modelled. Losses are pooled " + "across trades, though s. 83 carries a loss forward only against " + "the same trade." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py index 66ad63e9b9..b642ae4003 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -11,7 +11,9 @@ class ni_class_4_losses_brought_forward(Variable): "ni_class_4_losses_carried_forward. By default, losses are carried " "forward from the first year a trading_loss is known for, however " "many years back. Set this for a year to supply the unrelieved " - "losses from before it; later years then build on that balance." + "losses from before it; later years then build on that balance. " + "trading_loss carries into later years that are not set, so set " + "them to zero for a one-off loss." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py index 4266eaba7e..953bb0b2eb 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py @@ -10,7 +10,9 @@ class ni_class_4_profits(Variable): "Chapter 2 (after capital allowances and the trading allowance), less " "trading losses as SSCBA 1992 Sch. 2 para. 3 allows. Personal " "reliefs, interest relief and pension contributions are not deducted " - "(para. 3(2)), and neither are Class 1 contributions." + "(para. 3(2)), and neither are Class 1 contributions. The para. 3(5) " + "deductions for trade interest and certain royalties are not " + "modelled." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py index 28ea1dcb2d..e076d6b5d1 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits_before_losses.py @@ -12,8 +12,9 @@ class ni_class_4_profits_before_losses(Variable): "charged. Capital allowances are a trade expense (CAA 2001 s. 247). " "The trading allowance recomputes the trade's profits (ITTOIA 2005 " "ss. 783AF and 783AI), so no Class 4 NICs are due on income it covers " - "(HMRC BIM86052). Allowances in excess of profit do not create a loss " - "here; supply any such loss through trading_loss." + "(HMRC BIM86052). As for income tax in this model, a negative " + "self_employment_income, or capital allowances in excess of profit, " + "create no loss here; supply losses through trading_loss." ) definition_period = YEAR unit = GBP From 0ca9becda6554c6d4b7c1fda0eaae80c0a3a6974 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 09:05:16 -0400 Subject: [PATCH 09/75] Split additional State Pension by the period's State Pension type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit basic_state_pension and new_state_pension split the data year's reported State Pension by state_pension_type in the period, but additional_state_pension used the data year's type. Survey ages are held fixed across projected years, so a record's cohort, and its type, can change: for records on the basic State Pension in the data year and the new State Pension later, the band between the two flat rates was paid twice (£0.84bn in 2025-26 rising to £6.77bn in 2030-31 on the enhanced FRS 2024-25). All three components now use the period's type and add up to the reported amount uprated by that type's flat rate. Adds property tests (Hypothesis) for that identity on simulations built from data, including datasets that carry other ages in later years, and examples at hand-worked cohorts. Updates the State Pension docs with the split across years and a sourced comparison with the OBR's March 2026 forecast. Fixes #1921 Co-Authored-By: Claude Opus 5.5 --- ...itional-state-pension-period-type.fixed.md | 1 + docs/book/programs/gov/dwp/state-pension.md | 84 +++++- .../tests/test_state_pension_components.py | 266 ++++++++++++++++++ .../gov/dwp/additional_state_pension.py | 15 +- 4 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 changelog.d/additional-state-pension-period-type.fixed.md create mode 100644 policyengine_uk/tests/test_state_pension_components.py diff --git a/changelog.d/additional-state-pension-period-type.fixed.md b/changelog.d/additional-state-pension-period-type.fixed.md new file mode 100644 index 0000000000..8d6871c631 --- /dev/null +++ b/changelog.d/additional-state-pension-period-type.fixed.md @@ -0,0 +1 @@ +Split additional State Pension by each person's State Pension type in the year simulated, as basic and new State Pension already are, instead of their type in the data year. Survey ages are held fixed across projected years, so records that were on the basic State Pension in the data year stand for new State Pension cohorts later, and the part of their reported State Pension between the two flat rates was paid twice. Basic, new and additional State Pension now add up to the reported amount uprated by the flat rate. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index db743051f2..5cd763c2b5 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -28,6 +28,24 @@ Three variables under `gov/dwp/`: - Both are computed as `max(reported - flat_max_for_type, 0)` per week, multiplied by `WEEKS_IN_YEAR`. +All three split the amount a person reported in the data year (the +dataset's first year) by the person's `state_pension_type` in the year +simulated, and uprate it by that type's flat rate: the year's full rate +over the data year's. Together they pay the reported amount uprated by the +flat rate, for anyone over State Pension age. + +Survey ages are held fixed in the years a dataset is projected to, so a +record's birth cohort moves one year later for each year projected, and its +type can change: a man aged 75 in the 2024-25 data reached State Pension age +in 2014, on the basic State Pension, but a man aged 75 in 2030-31 reached it +in 2021, on the new State Pension. Until the fix for +[#1921](https://github.com/PolicyEngine/policyengine-uk/issues/1921), +`additional_state_pension` used the data year's type while the other two +used the year's, so for records like his the part of the reported amount +between the basic and new flat rates was paid twice. The records keep their +reported amounts: a record that moves to a new State Pension cohort is not +given a new State Pension award. + The flag-up split (`state_pension_type`) is settled by [PR #1618](https://github.com/PolicyEngine/policyengine-uk/pull/1618): classification is based on whether the person reaches State Pension age before or on/after 6 April 2016. No component is paid before State @@ -98,10 +116,53 @@ These flags exist so that policy reforms can disable individual limbs ## Known aggregate gap (#1632) -After the BASIC/NEW classification fix in PR #1618 and the pro-rating -+ Protected Payment fixes in [PR #1634](https://github.com/PolicyEngine/policyengine-uk/pull/1634), -the model's State Pension aggregate is **~£127.5 bn** against the OBR -2025 target of **~£140 bn** — a **-£12 bn gap**. +The model's State Pension covers UK private households: the Family +Resources Survey covers private households only, not nursing homes and +other communal establishments. The OBR's State Pension line matches DWP's +forecast of State Pension spending, which covers Great Britain and UK State +Pensions paid to people living abroad, but not Northern Ireland, whose +State Pension the Department for Communities pays. The table compares full +microsimulation runs on the enhanced FRS 2024-25 +(policyengine-uk-data-private 1.57.4) with that line less DWP's payments +abroad. + +| £bn | 2024-25 | 2025-26 | 2026-27 | 2027-28 | 2028-29 | 2029-30 | 2030-31 | +|---|---:|---:|---:|---:|---:|---:|---:| +| Model State Pension | 119.0 | 124.8 | 129.6 | 130.9 | 132.9 | 136.9 | 140.9 | +| OBR March 2026 EFO, State Pension | 138.0 | 146.2 | 154.2 | 158.9 | 164.0 | 172.2 | 180.7 | +| of which paid abroad (DWP) | 5.3 | 5.6 | 5.9 | 6.1 | 6.2 | 6.4 | 6.6 | +| Model minus OBR less paid abroad | -13.6 | -15.8 | -18.6 | -21.9 | -24.8 | -28.9 | -33.2 | + +The model also includes Northern Ireland, which the OBR line does not, so +the like-for-like gap is larger by Northern Ireland's State Pension. In +2024-25, the data year, DWP's outturn for residents of Great Britain is +£131.2bn, £12.2bn above the model. The FRS's own grossed estimate for +the UK that year, with benefit amounts linked to DWP records, is £130.6bn, +against £135.1bn from administrative data, so most of the model's shortfall +arises in building the enhanced FRS rather than in the survey's coverage. + +The gap widens in later years for two reasons: + +- **Caseload.** Survey ages are held fixed in projected years and household + weights grow with total population + (`policyengine_uk/data/uprating_indices.yaml`), so the + pension-age population keeps the data year's age structure. From 2025-26 + to 2030-31 the model's State Pension recipients fall 3.0%, as State + Pension age rises to 67, while DWP's caseload less those paid abroad rises + 4.8%. +- **Awards.** Each record keeps its reported amount, uprated by the flat + rate, including records whose cohort moves from basic to new State + Pension. The model's State Pension per recipient grows 16.4% over those + years. DWP's spending per recipient, less those paid abroad, grows 18.3%, + as its new State Pension spending grows from £56.1bn to £102.4bn and its + basic State Pension spending falls from £66.7bn to £57.8bn. + +Until the fix for [#1921](https://github.com/PolicyEngine/policyengine-uk/issues/1921), +`additional_state_pension` paid the band between the flat rates twice for +records moving from basic to new State Pension cohorts (see Components +above): £0.8bn in 2025-26, rising to £6.8bn in 2030-31. +That made the model's State Pension per recipient grow 21.2%, faster than +DWP's, and hid part of the gap. ### What's been fixed @@ -110,11 +171,12 @@ the model's State Pension aggregate is **~£127.5 bn** against the OBR | BASIC vs NEW classification used `is_SP_age` heuristic | Fixed | #1618 | | `new_state_pension` returned flat max for every NEW retiree, ignoring partial NI records | Fixed | #1634 | | Protected Payment only computed for BASIC-type recipients | Fixed | #1634 (ASP now extends to NEW) | +| `additional_state_pension` split the reported amount by the data year's State Pension type, paying part of it twice for records moving from basic to new cohorts | Fixed | #1921 | ### What's still open -The remaining ~£12 bn gap appears to come from the **data side** rather -than the formula. The FRS records State Pension as a single weekly +The data year's gap appears to come from the **data side** rather than +the formula. The FRS records State Pension as a single weekly benefit value (`state_pension_reported`), which is derived from the DWP-administered single weekly figure (SRP). For BASIC-type retirees who reported exactly the maximum basic rate, the formula assigns ASP = @@ -129,6 +191,10 @@ The proposed data-side fix lives in `policyengine-uk-data` and would: - Source the ASP-by-band distribution from ONS *National Pensioners Survey* breakdowns or DWP administrative caseload by pension type. +Projected years also need the pension-age population to follow its +cohorts, and records that move to a new State Pension cohort to get that +cohort's awards; neither is modelled. + This is tracked under [#1632](https://github.com/PolicyEngine/policyengine-uk/issues/1632) and the broader UK pipeline-alignment tracker [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). @@ -139,5 +205,9 @@ and the broader UK pipeline-alignment tracker - HMRC, [State Pension forecast](https://www.gov.uk/check-state-pension) (the underlying SP1 figure that lands in the FRS). - [Pensions Act 2014](https://www.legislation.gov.uk/ukpga/2014/19/contents) — introduces the New State Pension and the BASIC / NEW boundary. - [Social Security Contributions and Benefits Act 1992, Part 2](https://www.legislation.gov.uk/ukpga/1992/4/part/II) — primary statute for the basic scheme. -- OBR March 2026 EFO — State Pension expenditure target. +- OBR, [Economic and fiscal outlook, March 2026: detailed forecast tables, expenditure](https://obr.uk/download/march-2026-economic-and-fiscal-outlook-detailed-forecast-tables-expenditure/), table 4.9, "State pension". +- DWP, [Benefit expenditure and caseload tables 2026: outturn and forecast, Spring Forecast 2026](https://assets.publishing.service.gov.uk/media/69dcdc8c6b695d635c34dcc4/outturn-and-forecast-tables-spring-forecast-2026.xlsx), "State Pension" sheet: total, paid outside the UK, components and caseload. +- DWP, [State Pension expenditure by country of residence, 2024-25](https://assets.publishing.service.gov.uk/media/693ffff9cfacd5e888491fb6/State-Pension-by-country-2024-25.ods), for the 2024-25 outturn for residents of Great Britain. +- DWP, [Family Resources Survey, integrating administrative data for benefits: tables](https://assets.publishing.service.gov.uk/media/69c416acb66ff902f45441f0/family-resources-survey-transformation-benefits.xlsx), sheet 16, State Pension: admin-linked survey and administrative estimates, 2024-25. +- DWP, [Family Resources Survey 2024-25: background information and methodology](https://www.gov.uk/government/statistics/family-resources-survey-financial-year-2024-to-2025/family-resources-survey-background-information-and-methodology), section 4.1, for the survey's coverage of private households. - House of Commons Library, [State Pension triple lock](https://commonslibrary.parliament.uk/research-briefings/cbp-7812/) — context for the triple-lock parameters. diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py new file mode 100644 index 0000000000..1a84da2336 --- /dev/null +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -0,0 +1,266 @@ +"""State Pension components when the model year is not the data year. + +basic_state_pension, new_state_pension and additional_state_pension split the +State Pension a person reported in the data year. Survey ages are held fixed +across the years a dataset is projected to, so a record's birth cohort moves +one year later for each year projected, and its State Pension type in the +period can differ from its type in the data year. All three components split +by the period's type, so together they pay the reported amount uprated by that +type's flat rate. + +The YAML runner builds simulations without a dataset, where the data year is +the period by construction, so these cases build simulations from data. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Microsimulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset + +DATA_YEAR = 2024 +YEARS = range(DATA_YEAR, 2031) +WEEKS_IN_YEAR = 52 +COMPONENTS = ["basic_state_pension", "new_state_pension", "additional_state_pension"] + + +def person_table(people: list, ages=None) -> pd.DataFrame: + ids = np.arange(len(people)) + return pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [p["age"] for p in people] if ages is None else ages, + "gender": ["MALE" if p["male"] else "FEMALE" for p in people], + "months_since_last_birthday": [p["months"] for p in people], + "state_pension_reported": [p["weekly"] * WEEKS_IN_YEAR for p in people], + } + ) + + +def year_dataset(people: list, year: int, ages=None) -> UKSingleYearDataset: + """One person per household.""" + ids = np.arange(len(people)) + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": np.ones(len(people)), + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ) + return UKSingleYearDataset( + person=person_table(people, ages), + benunit=pd.DataFrame({"benunit_id": ids}), + household=household, + fiscal_year=year, + ) + + +def projected_simulation(people: list) -> Microsimulation: + """Data for 2024-25, projected forward as survey data is: ages held fixed.""" + return Microsimulation(dataset=year_dataset(people, DATA_YEAR)) + + +def flat_rates(sim, year: int) -> dict: + p = sim.tax_benefit_system.parameters.gov.dwp.state_pension + return { + "BASIC": p.basic_state_pension.amount(year), + "NEW": p.new_state_pension.amount(year), + } + + +def uprated_reported(sim, year: int, pension_type: str, weekly: float) -> float: + """The data-year amount uprated by the flat rate of the period's type.""" + now, then = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) + return weekly * WEEKS_IN_YEAR * now[pension_type] / then[pension_type] + + +def calculate(sim, variable: str, year: int) -> np.ndarray: + return np.asarray(sim.calculate(variable, year)) + + +def test_basic_in_data_year_and_new_in_period_is_not_paid_twice(): + """A man aged 75 on 6 October 2024, born 6 July 1949, reached State Pension + age at 65 in 2014: basic State Pension. Held at 75, the record in 2030-31 + stands for a man born 6 July 1955, who reached it at 66 in 2021: new State + Pension. His £200 a week, below the new State Pension's full rate, is all + new State Pension; before the fix, the £30.50 above the basic State + Pension's full rate was paid again as additional State Pension.""" + person = {"age": 75, "male": True, "months": 3, "weekly": 200} + sim = projected_simulation([person]) + assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "BASIC" + for year in (2027, 2030): + assert calculate(sim, "state_pension_type", year)[0] == "NEW" + expected = uprated_reported(sim, year, "NEW", 200) + assert calculate(sim, "new_state_pension", year)[0] == pytest.approx( + expected, abs=0.01 + ) + assert calculate(sim, "basic_state_pension", year)[0] == 0 + assert calculate(sim, "additional_state_pension", year)[0] == 0 + assert calculate(sim, "state_pension", year)[0] == pytest.approx( + expected, abs=0.01 + ) + + +def test_protected_payment_above_new_state_pension_follows_period_type(): + """A woman aged 72 in 2024-25 (born 6 July 1952) reached State Pension age in + 2014 on the basic State Pension; held at 72 she stands, by 2030-31, for a + woman born in 1958, on the new State Pension. Her £240 a week exceeds the + new State Pension's full rate, so the excess is additional State Pension + (a protected payment) at the new State Pension's uprating.""" + person = {"age": 72, "male": False, "months": 3, "weekly": 240} + sim = projected_simulation([person]) + assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "BASIC" + assert calculate(sim, "state_pension_type", 2030)[0] == "NEW" + rates, data_rates = flat_rates(sim, 2030), flat_rates(sim, DATA_YEAR) + uprating = rates["NEW"] / data_rates["NEW"] + assert calculate(sim, "new_state_pension", 2030)[0] == pytest.approx( + rates["NEW"] * WEEKS_IN_YEAR, abs=0.01 + ) + assert calculate(sim, "additional_state_pension", 2030)[0] == pytest.approx( + (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * uprating, abs=0.01 + ) + + +@pytest.mark.parametrize( + "person, pension_type", + [ + # Aged 90: born 1934 in the data year, 1940 by 2030-31. + ({"age": 90, "male": True, "months": 3, "weekly": 250}, "BASIC"), + # Aged 70: born 1954 in the data year, 1960 by 2030-31; both reached + # State Pension age after 5 April 2016. + ({"age": 70, "male": True, "months": 3, "weekly": 240}, "NEW"), + ], +) +def test_unchanged_type_splits_at_its_own_flat_rate(person, pension_type): + sim = projected_simulation([person]) + for year in YEARS: + assert calculate(sim, "state_pension_type", year)[0] == pension_type + total = sum(calculate(sim, v, year)[0] for v in COMPONENTS) + assert total == pytest.approx( + uprated_reported(sim, year, pension_type, person["weekly"]), abs=0.01 + ) + + +def test_nothing_is_paid_once_the_record_falls_below_state_pension_age(): + """Aged 66 in 2024-25 (born 6 July 1958) is over State Pension age on the new + State Pension; aged 66 in 2027-28 (born 6 July 1961) attains it at 67.""" + person = {"age": 66, "male": True, "months": 3, "weekly": 230} + sim = projected_simulation([person]) + assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "NEW" + assert calculate(sim, "state_pension_type", 2027)[0] == "NONE" + for variable in COMPONENTS + ["state_pension"]: + assert calculate(sim, variable, 2027)[0] == 0 + + +def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): + """Held-fixed ages only move cohorts later, so a record never goes from new + to basic State Pension when projected. A dataset may still carry other ages + in later years; the band between the two flat rates must still be paid.""" + people = [ + # New State Pension at 70 in 2024-25; 85 in 2030-31 is basic. + {"age": 70, "male": True, "months": 3, "weekly": 240}, + # Below State Pension age at 60 in 2024-25; 70 in 2030-31 is new. + {"age": 60, "male": True, "months": 3, "weekly": 230}, + ] + dataset = UKMultiYearDataset( + datasets=[ + year_dataset(people, DATA_YEAR), + year_dataset(people, 2030, ages=[85, 70]), + ] + ) + sim = Microsimulation(dataset=dataset) + assert list(calculate(sim, "state_pension_type", DATA_YEAR)) == ["NEW", "NONE"] + assert list(calculate(sim, "state_pension_type", 2030)) == ["BASIC", "NEW"] + total = sum(calculate(sim, v, 2030) for v in COMPONENTS) + assert total[0] == pytest.approx( + uprated_reported(sim, 2030, "BASIC", 240), abs=0.01 + ) + assert total[1] == pytest.approx(uprated_reported(sim, 2030, "NEW", 230), abs=0.01) + + +PEOPLE = st.lists( + st.fixed_dictionaries( + { + "age": st.integers(min_value=55, max_value=100), + "male": st.booleans(), + "months": st.floats(min_value=0, max_value=12, exclude_max=True), + # Include each data-year flat rate, where the split changes. + "weekly": st.one_of( + st.floats(min_value=0, max_value=600), + st.sampled_from([0, 169.5, 221.2]), + ), + } + ), + min_size=1, + max_size=30, +) + + +def assert_components_add_up(sim, people: list, years) -> None: + """For each person and year: below State Pension age, no component and type + NONE; over it, only the period type's flat-rate component, and the three + components sum to the reported amount uprated by that type's flat rate.""" + weekly = np.array([p["weekly"] for p in people]) + for year in years: + pension_type = calculate(sim, "state_pension_type", year).astype(str) + over_spa = calculate(sim, "is_SP_age", year).astype(bool) + basic, new, additional = (calculate(sim, v, year) for v in COMPONENTS) + rates, data_rates = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) + uprating = np.select( + [pension_type == "BASIC", pension_type == "NEW"], + [ + rates["BASIC"] / data_rates["BASIC"], + rates["NEW"] / data_rates["NEW"], + ], + 0, + ) + expected = over_spa * weekly * WEEKS_IN_YEAR * uprating + assert np.array_equal(pension_type == "NONE", ~over_spa), year + assert np.allclose(basic + new + additional, expected, rtol=1e-5, atol=0.01) + assert np.all(basic[pension_type != "BASIC"] == 0) + assert np.all(new[pension_type != "NEW"] == 0) + assert np.all(additional >= 0) + assert np.all(basic <= rates["BASIC"] * WEEKS_IN_YEAR * (1 + 1e-6)) + assert np.all(new <= rates["NEW"] * WEEKS_IN_YEAR * (1 + 1e-6)) + + +@settings(max_examples=25, deadline=None) +@given(people=PEOPLE) +def test_components_add_up_to_uprated_reported_amount(people): + """Data projected with ages held fixed, every year from 2024-25 to 2030-31.""" + sim = projected_simulation(people) + assert_components_add_up(sim, people, YEARS) + + +@settings(max_examples=25, deadline=None) +@given( + people=PEOPLE, + period=st.integers(min_value=DATA_YEAR + 1, max_value=2030), + data=st.data(), +) +def test_components_add_up_whatever_ages_the_period_carries(people, period, data): + """Any age in the period, so every pair of data-year and period types occurs, + including new to basic and none to either.""" + ages = data.draw( + st.lists( + st.integers(min_value=55, max_value=100), + min_size=len(people), + max_size=len(people), + ) + ) + dataset = UKMultiYearDataset( + datasets=[ + year_dataset(people, DATA_YEAR), + year_dataset(people, period, ages=ages), + ] + ) + sim = Microsimulation(dataset=dataset) + assert_components_add_up(sim, people, (DATA_YEAR, period)) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index 0467ec7d3e..3a8c5204f2 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -19,7 +19,14 @@ def formula(person, period, parameters): else: data_year = period.start.year reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR - pension_type = person("state_pension_type", data_year) + # Split by the period's type, as basic_state_pension and + # new_state_pension do, so the three components add up to the + # reported amount. Survey ages are held fixed across years, so a + # record's birth cohort, and with it its type, can differ from the + # data year's. The data year's type would pay the band between the + # two flat-rate ceilings twice for a record that is BASIC in the data + # year and NEW in the period, and leave it unpaid the other way round. + pension_type = person("state_pension_type", period) types = pension_type.possible_values bsp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount @@ -51,7 +58,5 @@ def formula(person, period, parameters): max_for_type_period / max_for_type_data, 1, ) - # No State Pension is paid before State Pension age; the data-year - # type above only splits the reported amount into its components. - is_sp_age = person("is_SP_age", period) - return is_sp_age * amount_in_data_year * uprating * WEEKS_IN_YEAR + # The type is NONE below State Pension age, so nothing is paid there. + return amount_in_data_year * uprating * WEEKS_IN_YEAR From 1e66b422ab11515923b2f4bdbdaf905f8204cc82 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:22:28 -0400 Subject: [PATCH 10/75] Give pension-age HB and CTR the severe disability premium Pension Credit uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pension Credit minimum guarantee included the severe disability addition for an Attendance Allowance recipient, but the Housing Benefit and Council Tax Reduction applicable amounts took the legacy severe disability premium, which tests a narrower tax-credit disability flag (DLA care highest rate, PIP daily living enhanced rate). Guarantee Credit passports HB to its maximum, so the gap only showed when GC ended: a single pensioner on AA lost £2,040.52 of net income when private pension rose from £11,900 to £12,000. HB(SPC) Regs 2006 Sch 3 paras 6 and 12(1) and the pensioner CTR schedules give the severe disability premium the same qualifying benefits, conditions and amounts as SPC Regs Sch I para 1 and reg 6(5), and have no adult disability or enhanced disability premium. At pension age benefits_premiums is now the Pension Credit severe disability addition plus the carer premium. Co-Authored-By: Claude Opus 5.5 --- ...ion-age-severe-disability-premium.fixed.md | 1 + .../housing_benefit/pension_age_premiums.yaml | 182 ++++++++++++++ ...ge_severe_disability_premium_properties.py | 222 ++++++++++++++++++ .../pension_age_severe_disability_premium.py | 36 +++ .../household/income/benefits_premiums.py | 46 +++- 5 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 changelog.d/hb-pension-age-severe-disability-premium.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml create mode 100644 policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py create mode 100644 policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py diff --git a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md new file mode 100644 index 0000000000..25e9cbde97 --- /dev/null +++ b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md @@ -0,0 +1 @@ +- Include the severe disability premium in the pension-age Housing Benefit and Council Tax Reduction applicable amounts on the same qualifying benefits as the Pension Credit severe disability addition (Attendance Allowance, DLA care middle or highest rate, PIP daily living at either rate, Armed Forces Independence Payment), and drop the adult disability and enhanced disability premiums, which the pension-age schedules do not have (HB(SPC) Regs 2006 Sch 3; CTR schemes for pensioners in England, Wales and Scotland). Previously an Attendance Allowance recipient lost up to about £2,000 a year of Housing Benefit when their Guarantee Credit ended. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml new file mode 100644 index 0000000000..046ca31c8f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml @@ -0,0 +1,182 @@ +# Pension-age Housing Benefit and Council Tax Reduction premiums. +# HB(SPC) Regs 2006 Sch 3 paras 4-12 and CTR (Prescribed Requirements) +# (England) Regs 2012 Sch 2 paras 4-12: the severe disability premium has the +# same conditions and amounts as the Pension Credit severe disability +# additional amount (SPC Regs 2002 Sch I para 1, reg 6(5)); there is no adult +# disability or enhanced disability premium. 2026-27 weekly rates: pension-age +# personal allowance (attained pensionable age before 1 April 2021) 256.00, +# couple 383.35; severe disability premium 86.05 (172.10 for a couple who +# both qualify); carer premium 48.15. + +- name: AA claimant just above the Guarantee Credit boundary keeps the severe disability premium in HB and CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 70} + state_pension: {2026: 6_000} + private_pension_income: {2026: 12_000} + aa_category: {2026: HIGHER} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + council_tax: {2026: 2_000} + output: + guarantee_credit: 0 + severe_disability_minimum_guarantee_addition: 86.05 * 52 + pension_age_severe_disability_premium: 86.05 * 52 + benefits_premiums: 86.05 * 52 + # (256.00 + 86.05) * 52 = 17,786.60 + housing_benefit_applicable_amount: 17_786.60 + council_tax_reduction_applicable_amount: 17_786.60 + # HB income 16,548.80 is below the applicable amount: maximum HB. + housing_benefit: 6_240 + council_tax_benefit: 2_000 + +- name: Same claimant just below the boundary is passported to maximum HB + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 70} + state_pension: {2026: 6_000} + private_pension_income: {2026: 11_900} + aa_category: {2026: HIGHER} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + council_tax: {2026: 2_000} + output: + guarantee_credit: 16.60 + housing_benefit: 6_240 + council_tax_benefit: 2_000 + +- name: Pension-age applicable amount has no adult disability or enhanced disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 70} + dla_sc_category: {2026: HIGHER} + is_disabled_for_benefits: {2026: true} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + # Only the severe disability premium applies: 86.05 * 52 = 4,474.60. + benefits_premiums: 4_474.60 + housing_benefit_applicable_amount: 13_312 + 4_474.60 + +- name: Pensioner couple who both receive AA get two severe disability rates + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 72} + aa_category: {2026: HIGHER} + partner: + age: {2026: 70} + aa_category: {2026: LOWER} + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + # 172.10 * 52 = 8,949.20; couple allowance 383.35 * 52 = 19,934.20. + benefits_premiums: 8_949.20 + housing_benefit_applicable_amount: 19_934.20 + 8_949.20 + +- name: Pension-age carer premium is kept + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 70} + care_hours: {2026: 40} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + carer_premium: 48.15 * 52 + benefits_premiums: 48.15 * 52 + +- name: Working-age continuing HB award keeps the legacy premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 50} + is_disabled_for_benefits: {2026: true} + housing_benefit_reported: {2026: 1} + benunits: + benunit: + members: [claimant] + would_claim_uc: {2026: false} + households: + household: + members: [claimant] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + pension_age_severe_disability_premium: 0 + # Legacy disability premium (single) 44.85 * 52 = 2,332.20. + benefits_premiums: 44.85 * 52 + +- name: Scottish pension-age CTR applicable amount includes the severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 80} + pip_dl_category: {2026: STANDARD} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: SCOTLAND} + region: {2026: SCOTLAND} + tenure_type: {2026: OWNED_OUTRIGHT} + output: + # SSI 2012/319 Sch 1 para 7: PIP daily living at the standard rate + # qualifies. (256.00 + 86.05) * 52 = 17,786.60. + benefits_premiums: 86.05 * 52 + council_tax_reduction_applicable_amount: 17_786.60 diff --git a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py new file mode 100644 index 0000000000..9bccc4464e --- /dev/null +++ b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py @@ -0,0 +1,222 @@ +"""Pension-age severe disability premium and the Guarantee Credit boundary. + +Pension Credit's minimum guarantee included the severe disability addition +for an Attendance Allowance recipient, but the Housing Benefit and Council +Tax Reduction applicable amounts did not include the severe disability +premium, because the legacy premium tested a narrower, tax-credit style +disability flag. While Guarantee Credit was paid, Housing Benefit was +passported to its maximum and hid the gap; when Guarantee Credit ended, +Housing Benefit fell by 65% of it. A single pensioner on Attendance +Allowance lost £2,040.52 of net income when private pension rose from +£11,900 to £12,000 in 2026. + +Invariants, for single people and couples over State Pension age without +children or carers, who rent from the council in England in 2026: + +1. Structural: where Housing Benefit is available, its applicable amount is + at least the Pension Credit minimum guarantee, and so is the Council Tax + Reduction applicable amount. In law the pension-age personal allowances + (HB(SPC) Regs 2006 Sch 3 para 1; CTR (Prescribed Requirements) (England) + Regs 2012 Sch 2 para 1) are at least the standard minimum guarantee (SPC + Regs 2002 reg 6(1)), and the severe disability premium (Sch 3 paras 6 and + 12(1)) has the same conditions and amounts as the severe disability + addition (SPC Regs Sch I para 1 and reg 6(5)). +2. Boundary: on the private-pension step where Guarantee Credit ends, + household net income, excluding the TV licence fee, does not fall. The + free TV licence for over-75s on Pension Credit is a genuine statutory + cliff, so it is excluded. +3. Differential: over State Pension age the premium equals the Pension + Credit severe disability addition; below it, benefits_premiums is still + the sum of the four legacy premiums. + +Carers are not drawn. Pension Credit income leaves out Carer's Allowance and +two carers get one carer premium, so the invariants do not yet hold for +carers; nor does full monotonicity in income, because Housing Benefit and +Council Tax Reduction income leave out Savings Credit. Those are separate +fixes. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PENSIONS = np.arange(0, 20_001, 500) +PROPERTY_SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DISABILITY = st.sampled_from( + [ + {}, + {"aa_category": "LOWER"}, + {"aa_category": "HIGHER"}, + {"dla_sc_category": "LOWER"}, + {"dla_sc_category": "MIDDLE"}, + {"dla_sc_category": "HIGHER"}, + {"pip_dl_category": "STANDARD"}, + {"pip_dl_category": "ENHANCED"}, + ] +) + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +@st.composite +def adult(draw): + return dict( + age=draw(st.integers(67, 95)), + disability=draw(DISABILITY), + blind=draw(st.booleans()), + state_pension=draw(money(15_000)), + ) + + +@st.composite +def family(draw): + return dict( + adults=[draw(adult()) for _ in range(draw(st.integers(1, 2)))], + rent=draw(money(12_000)), + council_tax=draw(money(3_000)), + savings=draw(st.one_of(st.just(0.0), money(15_000))), + ) + + +def situation(families): + """Each family once per private pension on the PENSIONS grid.""" + people, benunits, households = {}, {}, {} + for i, fam in enumerate(families): + for k, pension in enumerate(PENSIONS): + names = [] + for j, a in enumerate(fam["adults"]): + name = f"p{i}_{k}_{j}" + person = { + "age": {YEAR: a["age"]}, + "state_pension": {YEAR: a["state_pension"]}, + # All private pension goes to the first adult. + "private_pension_income": {YEAR: float(pension) * (j == 0)}, + "is_blind": {YEAR: a["blind"]}, + } + for variable, value in a["disability"].items(): + person[variable] = {YEAR: value} + people[name] = person + names.append(name) + benunits[f"b{i}_{k}"] = {"members": names} + households[f"h{i}_{k}"] = { + "members": names, + "country": {YEAR: "ENGLAND"}, + "region": {YEAR: "NORTH_WEST"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: fam["rent"]}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def grid(families): + simulation = Simulation(situation=situation(families)) + shape = (len(families), len(PENSIONS)) + return { + variable: np.asarray(simulation.calculate(variable, YEAR)).reshape(shape) + for variable in [ + "household_net_income", + "tv_licence", + "minimum_guarantee", + "guarantee_credit", + "housing_benefit_eligible", + "housing_benefit_applicable_amount", + "council_tax_reduction_applicable_amount", + "housing_benefit", + "council_tax_benefit", + ] + } + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_pension_age_applicable_amounts_cover_the_minimum_guarantee(families): + g = grid(families) + for i, fam in enumerate(families): + eligible = g["housing_benefit_eligible"][i].astype(bool) + minimum_guarantee = g["minimum_guarantee"][i] + hb = g["housing_benefit_applicable_amount"][i] + ctr = g["council_tax_reduction_applicable_amount"][i] + assert np.all(hb[eligible] >= minimum_guarantee[eligible] - 0.01), fam + assert np.all(ctr >= minimum_guarantee - 0.01), fam + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_net_income_does_not_fall_where_guarantee_credit_ends(families): + g = grid(families) + net = g["household_net_income"] + g["tv_licence"] + step = np.diff(net, axis=1) + gc = g["guarantee_credit"] + ends = (gc[:, :-1] > 0) & (gc[:, 1:] <= 0) + for i, fam in enumerate(families): + for k in np.flatnonzero(ends[i]): + assert step[i, k] >= -0.01, ( + fam, + f"private pension {PENSIONS[k]} -> {PENSIONS[k + 1]}", + f"net income change {step[i, k]:.2f}", + f"HB {g['housing_benefit'][i, k]:.2f} -> " + f"{g['housing_benefit'][i, k + 1]:.2f}", + f"CTR {g['council_tax_benefit'][i, k]:.2f} -> " + f"{g['council_tax_benefit'][i, k + 1]:.2f}", + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(adult(), min_size=1, max_size=2), + st.integers(18, 95), +) +def test_premiums_follow_the_pension_age_and_working_age_schedules(adults, age): + people = {} + for j, a in enumerate(adults): + person = { + "age": {YEAR: age if j == 0 else a["age"]}, + "is_blind": {YEAR: a["blind"]}, + } + for variable, value in a["disability"].items(): + person[variable] = {YEAR: value} + people[f"p{j}"] = person + simulation = Simulation( + situation={ + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + ) + + def get(variable): + return float(simulation.calculate(variable, YEAR)[0]) + + pension_age = bool(simulation.calculate("is_SP_age", YEAR).any()) + if pension_age: + assert ( + abs( + get("pension_age_severe_disability_premium") + - get("severe_disability_minimum_guarantee_addition") + ) + < 0.005 + ) + expected = get("pension_age_severe_disability_premium") + get("carer_premium") + else: + expected = sum( + get(variable) + for variable in [ + "disability_premium", + "enhanced_disability_premium", + "severe_disability_premium", + "carer_premium", + ] + ) + assert abs(get("benefits_premiums") - expected) < 0.005 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 new file mode 100644 index 0000000000..33bdecc876 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class pension_age_severe_disability_premium(Variable): + value_type = float + entity = BenUnit + label = "Severe disability premium for pension-age Housing Benefit and CTR" + documentation = ( + "The severe disability premium in the Housing Benefit and Council Tax " + "Reduction applicable amount of a family over State Pension age. In " + "law its qualifying benefits (Attendance Allowance, the care component " + "of DLA at the middle or highest rate, the daily living component of " + "PIP at either rate, Armed Forces Independence Payment and their " + "Scottish equivalents), its conditions and its amounts (one rate, or " + "two for a couple who both qualify and have no paid carer) are the " + "same as those of the Pension Credit severe disability additional " + "amount. So this premium reads that variable, and Housing Benefit, " + "Council Tax Reduction and Pension Credit share one definition: the " + "premium cannot drop out of the Housing Benefit applicable amount " + "when Guarantee Credit ends. It therefore also shares that variable's " + "modelling simplifications." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/12", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/paragraph/6", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3035/schedule/2/paragraph/6", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", + ) + + def formula(benunit, period, parameters): + return benunit("severe_disability_minimum_guarantee_addition", period) diff --git a/policyengine_uk/variables/household/income/benefits_premiums.py b/policyengine_uk/variables/household/income/benefits_premiums.py index 071f8c4fb6..0b3d0b5fa6 100644 --- a/policyengine_uk/variables/household/income/benefits_premiums.py +++ b/policyengine_uk/variables/household/income/benefits_premiums.py @@ -1,16 +1,50 @@ from policyengine_uk.model_api import * +WORKING_AGE_PREMIUMS = [ + "disability_premium", + "enhanced_disability_premium", + "severe_disability_premium", + "carer_premium", +] + +# The pension-age schedules (HB(SPC) Regs 2006 Sch 3 Part 3; CTR (Prescribed +# Requirements) (England) Regs 2012 Sch 2 Part 3; the Scottish and Welsh +# pension-age CTR schedules) have only the severe disability, enhanced +# disability (children only), disabled child and carer premiums. The two +# child premiums are not modelled. +PENSION_AGE_PREMIUMS = [ + "pension_age_severe_disability_premium", + "carer_premium", +] + class benefits_premiums(Variable): value_type = float entity = BenUnit label = "Value of premiums for disability and carer status" + documentation = ( + "Premiums in the Housing Benefit, Council Tax Reduction and Income " + "Support applicable amounts. A family with a member over State " + "Pension age uses the pension-age schedules: the severe disability " + "premium, shared with the Pension Credit severe disability addition, " + "and the carer premium. Those schedules have no adult disability or " + "enhanced disability premium." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3", + ) - adds = [ - "disability_premium", - "enhanced_disability_premium", - "severe_disability_premium", - "carer_premium", - ] + def formula(benunit, period, parameters): + # Same switch as the aged personal allowance in the Housing Benefit + # and Council Tax Reduction applicable amounts. Income Support is not + # paid to a family with a member over State Pension age. + pension_age = benunit.any(benunit.members("is_SP_age", period)) + return where( + pension_age, + add(benunit, period, PENSION_AGE_PREMIUMS), + add(benunit, period, WORKING_AGE_PREMIUMS), + ) From eca613478ea1ffe27fe079a2cd30c00e773a725a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:48:00 -0400 Subject: [PATCH 11/75] Compare State Pension with DWP's forecast and fix the gap explanation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of the State Pension docs: - Compare the model with DWP's Spring Forecast 2026 less payments abroad, which agrees with the OBR's March 2026 forecast from 2026-27; the OBR's 2024-25 and 2025-26 figures are £1.4bn and £0.1bn higher. The data-year gap is then £12.2bn in both the table and the text. - Replace the unsourced explanation that an "SRP" figure caps SERPS in the FRS: state_pension_reported comes from the FRS benefits table (and is imputed on Survey of Personal Incomes donor rows), and the FRS's mean State Pension matches DWP's. - Point the open items at their tracking issues: the enhanced FRS shortfall (policyengine-uk-data#493) and the projected pension-age population and new-cohort awards (#1929). - Drop an unsourced reference note and a reference the page no longer uses. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 95 ++++++++++----------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 5cd763c2b5..c061de9a46 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -118,38 +118,52 @@ These flags exist so that policy reforms can disable individual limbs The model's State Pension covers UK private households: the Family Resources Survey covers private households only, not nursing homes and -other communal establishments. The OBR's State Pension line matches DWP's -forecast of State Pension spending, which covers Great Britain and UK State -Pensions paid to people living abroad, but not Northern Ireland, whose -State Pension the Department for Communities pays. The table compares full -microsimulation runs on the enhanced FRS 2024-25 -(policyengine-uk-data-private 1.57.4) with that line less DWP's payments +other communal establishments. DWP's State Pension spending covers Great +Britain and UK State Pensions paid to people living abroad, but not +Northern Ireland, whose State Pension the Department for Communities pays. +DWP's Spring Forecast 2026 is consistent with the OBR's March 2026 forecast. +From 2026-27 the two differ by about £2m a year. The OBR's figures are +£1.4bn higher in 2024-25, which the OBR records as outturn, and £0.1bn +higher in 2025-26. + +The table compares full microsimulation runs on the enhanced FRS 2024-25 +(policyengine-uk-data-private 1.57.4) with DWP's spending less its payments abroad. | £bn | 2024-25 | 2025-26 | 2026-27 | 2027-28 | 2028-29 | 2029-30 | 2030-31 | |---|---:|---:|---:|---:|---:|---:|---:| | Model State Pension | 119.0 | 124.8 | 129.6 | 130.9 | 132.9 | 136.9 | 140.9 | | OBR March 2026 EFO, State Pension | 138.0 | 146.2 | 154.2 | 158.9 | 164.0 | 172.2 | 180.7 | -| of which paid abroad (DWP) | 5.3 | 5.6 | 5.9 | 6.1 | 6.2 | 6.4 | 6.6 | -| Model minus OBR less paid abroad | -13.6 | -15.8 | -18.6 | -21.9 | -24.8 | -28.9 | -33.2 | - -The model also includes Northern Ireland, which the OBR line does not, so -the like-for-like gap is larger by Northern Ireland's State Pension. In -2024-25, the data year, DWP's outturn for residents of Great Britain is -£131.2bn, £12.2bn above the model. The FRS's own grossed estimate for -the UK that year, with benefit amounts linked to DWP records, is £130.6bn, -against £135.1bn from administrative data, so most of the model's shortfall -arises in building the enhanced FRS rather than in the survey's coverage. - -The gap widens in later years for two reasons: +| DWP Spring Forecast 2026, State Pension | 136.6 | 146.1 | 154.2 | 158.9 | 164.0 | 172.2 | 180.7 | +| of which paid abroad | 5.3 | 5.6 | 5.9 | 6.1 | 6.2 | 6.4 | 6.6 | +| Model minus DWP less paid abroad | -12.2 | -15.6 | -18.6 | -21.9 | -24.8 | -28.9 | -33.1 | + +The model also includes Northern Ireland, which DWP's figures do not, so the +like-for-like gap is larger by Northern Ireland's State Pension. + +**The data year.** In 2024-25 the model has 11.36m State Pension recipients +averaging £201.6 a week. The FRS's own grossed figures are 11.53m recipients +averaging £212 a week, and DWP's administrative figures are 11.88m and £212 +(FRS methodology tables M.6a and M.6b). The FRS's grossed estimate for the +UK, with benefit amounts linked to DWP records, is £130.6bn, against +£135.1bn from administrative data. So most of the model's £16bn shortfall +against administrative data for the UK arises in building the enhanced FRS, +not in the survey. For FRS respondents, `state_pension_reported` is the +FRS benefits table's weekly State Pension amount times 52 +(policyengine-uk-data's `datasets/frs.py`). On the rows the enhanced FRS +adds from the Survey of Personal Incomes, it is imputed +(`datasets/imputations/frs_only.py`). The shortfall is tracked in +[policyengine-uk-data#493](https://github.com/PolicyEngine/policyengine-uk-data/issues/493). + +**Projected years.** The gap widens after the data year for two reasons, +tracked in [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929): - **Caseload.** Survey ages are held fixed in projected years and household weights grow with total population - (`policyengine_uk/data/uprating_indices.yaml`), so the - pension-age population keeps the data year's age structure. From 2025-26 - to 2030-31 the model's State Pension recipients fall 3.0%, as State - Pension age rises to 67, while DWP's caseload less those paid abroad rises - 4.8%. + (`policyengine_uk/data/uprating_indices.yaml`), so the pension-age + population keeps the data year's age structure. From 2025-26 to 2030-31 + the model's State Pension recipients fall 3.0%, as State Pension age rises + to 67, while DWP's caseload less those paid abroad rises 4.8%. - **Awards.** Each record keeps its reported amount, uprated by the flat rate, including records whose cohort moves from basic to new State Pension. The model's State Pension per recipient grows 16.4% over those @@ -160,9 +174,9 @@ The gap widens in later years for two reasons: Until the fix for [#1921](https://github.com/PolicyEngine/policyengine-uk/issues/1921), `additional_state_pension` paid the band between the flat rates twice for records moving from basic to new State Pension cohorts (see Components -above): £0.8bn in 2025-26, rising to £6.8bn in 2030-31. -That made the model's State Pension per recipient grow 21.2%, faster than -DWP's, and hid part of the gap. +above): £0.8bn in 2025-26, rising to £6.8bn in 2030-31. That made the +model's State Pension per recipient grow 21.2%, faster than DWP's, and hid +part of the widening. ### What's been fixed @@ -175,39 +189,24 @@ DWP's, and hid part of the gap. ### What's still open -The data year's gap appears to come from the **data side** rather than -the formula. The FRS records State Pension as a single weekly -benefit value (`state_pension_reported`), which is derived from the -DWP-administered single weekly figure (SRP). For BASIC-type retirees -who reported exactly the maximum basic rate, the formula assigns ASP = -0 — but in reality many of those retirees also received SERPS / S2P -top-ups that the single weekly figure either caps or rounds. - -The proposed data-side fix lives in `policyengine-uk-data` and would: - -- Impute an ASP component on BASIC-type rows whose reported state - pension matches the max basic rate exactly, using the DWP-published - share of SERPS / S2P recipients in that band. -- Source the ASP-by-band distribution from ONS *National Pensioners - Survey* breakdowns or DWP administrative caseload by pension type. - -Projected years also need the pension-age population to follow its -cohorts, and records that move to a new State Pension cohort to get that -cohort's awards; neither is modelled. +- The data year's shortfall in the enhanced FRS: + [policyengine-uk-data#493](https://github.com/PolicyEngine/policyengine-uk-data/issues/493). +- The pension-age population and new-cohort awards in projected years: + [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929). -This is tracked under [#1632](https://github.com/PolicyEngine/policyengine-uk/issues/1632) +Both are part of [#1632](https://github.com/PolicyEngine/policyengine-uk/issues/1632) and the broader UK pipeline-alignment tracker [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). ## References - DWP, [New State Pension](https://www.gov.uk/new-state-pension) and [Basic State Pension](https://www.gov.uk/state-pension) user-facing pages. -- HMRC, [State Pension forecast](https://www.gov.uk/check-state-pension) (the underlying SP1 figure that lands in the FRS). +- GOV.UK, [Check your State Pension forecast](https://www.gov.uk/check-state-pension). - [Pensions Act 2014](https://www.legislation.gov.uk/ukpga/2014/19/contents) — introduces the New State Pension and the BASIC / NEW boundary. - [Social Security Contributions and Benefits Act 1992, Part 2](https://www.legislation.gov.uk/ukpga/1992/4/part/II) — primary statute for the basic scheme. - OBR, [Economic and fiscal outlook, March 2026: detailed forecast tables, expenditure](https://obr.uk/download/march-2026-economic-and-fiscal-outlook-detailed-forecast-tables-expenditure/), table 4.9, "State pension". - DWP, [Benefit expenditure and caseload tables 2026: outturn and forecast, Spring Forecast 2026](https://assets.publishing.service.gov.uk/media/69dcdc8c6b695d635c34dcc4/outturn-and-forecast-tables-spring-forecast-2026.xlsx), "State Pension" sheet: total, paid outside the UK, components and caseload. -- DWP, [State Pension expenditure by country of residence, 2024-25](https://assets.publishing.service.gov.uk/media/693ffff9cfacd5e888491fb6/State-Pension-by-country-2024-25.ods), for the 2024-25 outturn for residents of Great Britain. - DWP, [Family Resources Survey, integrating administrative data for benefits: tables](https://assets.publishing.service.gov.uk/media/69c416acb66ff902f45441f0/family-resources-survey-transformation-benefits.xlsx), sheet 16, State Pension: admin-linked survey and administrative estimates, 2024-25. +- DWP, [Family Resources Survey 2024-25: methodology and standard error tables](https://assets.publishing.service.gov.uk/media/69c3c0d2471d520038d0f571/ch1_methodology_and_standard_errors.xlsx), tables M.6a and M.6b: State Pension recipients and weekly amounts, FRS against administrative data. - DWP, [Family Resources Survey 2024-25: background information and methodology](https://www.gov.uk/government/statistics/family-resources-survey-financial-year-2024-to-2025/family-resources-survey-background-information-and-methodology), section 4.1, for the survey's coverage of private households. - House of Commons Library, [State Pension triple lock](https://commonslibrary.parliament.uk/research-briefings/cbp-7812/) — context for the triple-lock parameters. From dce3fbc1c5a5b4b76574211995f9004b4747c5f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:01:32 -0400 Subject: [PATCH 12/75] Keep the State Pension age guard; don't tie the tests to flat-rate uprating Address review on #1922: - Keep the is_SP_age factor in additional_state_pension as a guard. A computed state_pension_type is already NONE below State Pension age, so results are unchanged (checked with a full microsimulation rerun), and it also holds when the type is an input. - The tests now check that the components partition the reported amount: the flat-rate part is the amount up to the full rate uprated by the full rate, and the part above it is uprated by add_on_uprating, which is the model's flat-rate ratio for now. In law additional pensions and protected payments rise with CPI (SI 2026/148 arts 4(3), 6(3)); #1941 tracks that, and fixing it only needs that helper changed. - Docs: say that the model uprates add-ons by the full rate while the law uses CPI (#1941), and that records moving to a new State Pension cohort keep their reported amounts, one reason award growth lags DWP's. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 16 +++- .../tests/test_state_pension_components.py | 77 ++++++++++++++----- .../gov/dwp/additional_state_pension.py | 6 +- 3 files changed, 74 insertions(+), 25 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index c061de9a46..14f881d969 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -30,9 +30,15 @@ Three variables under `gov/dwp/`: All three split the amount a person reported in the data year (the dataset's first year) by the person's `state_pension_type` in the year -simulated, and uprate it by that type's flat rate: the year's full rate -over the data year's. Together they pay the reported amount uprated by the -flat rate, for anyone over State Pension age. +simulated. The part up to that type's full rate in the data year is basic +or new State Pension, and the part above it is additional State Pension. +Both parts are uprated by the type's full rate (the year's over the data +year's), so together they pay the reported amount uprated by the flat rate, +for anyone over State Pension age. In law, additional pensions and +protected payments rise with prices instead: the Social Security Benefits +Up-rating Order 2026 raised them by 3.8% in April 2026 (articles 4(3) and +6(3)), and the full rates by 4.8%. This is tracked in +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941). Survey ages are held fixed in the years a dataset is projected to, so a record's birth cohort moves one year later for each year projected, and its @@ -44,7 +50,9 @@ in 2021, on the new State Pension. Until the fix for used the year's, so for records like his the part of the reported amount between the basic and new flat rates was paid twice. The records keep their reported amounts: a record that moves to a new State Pension cohort is not -given a new State Pension award. +given a new State Pension award. That is one reason the model's State +Pension per recipient grows more slowly than DWP's (see Known aggregate gap +below). The flag-up split (`state_pension_type`) is settled by [PR #1618](https://github.com/PolicyEngine/policyengine-uk/pull/1618): classification is based on whether the person reaches State Pension age diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 1a84da2336..eb3d5b28ce 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -5,8 +5,9 @@ across the years a dataset is projected to, so a record's birth cohort moves one year later for each year projected, and its State Pension type in the period can differ from its type in the data year. All three components split -by the period's type, so together they pay the reported amount uprated by that -type's flat rate. +by the period's type, so together they split the reported amount with no +overlap and no gap: the part up to the type's full rate is uprated by the full +rate, and the part above it by the add-on's uprating (see add_on_uprating). The YAML runner builds simulations without a dataset, where the data year is the period by construction, so these cases build simulations from data. @@ -76,10 +77,29 @@ def flat_rates(sim, year: int) -> dict: } +def flat_uprating(sim, year: int, pension_type: str) -> float: + """The type's full rate in the year over its full rate in the data year.""" + return ( + flat_rates(sim, year)[pension_type] / flat_rates(sim, DATA_YEAR)[pension_type] + ) + + +def add_on_uprating(sim, year: int, pension_type: str) -> float: + """Uprating of the part above the full rate (additional pension, protected + payments). The model uprates it by the full rate; in law these rise with + prices (Social Security Benefits Up-rating Order 2026 arts 4(3) and 6(3)), + tracked in PolicyEngine/policyengine-uk#1941. Change it here with the model.""" + return flat_uprating(sim, year, pension_type) + + def uprated_reported(sim, year: int, pension_type: str, weekly: float) -> float: - """The data-year amount uprated by the flat rate of the period's type.""" - now, then = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) - return weekly * WEEKS_IN_YEAR * now[pension_type] / then[pension_type] + """The data-year amount, its part up to the full rate uprated by the full + rate and the part above it by the add-on's uprating.""" + full = flat_rates(sim, DATA_YEAR)[pension_type] + return WEEKS_IN_YEAR * ( + min(weekly, full) * flat_uprating(sim, year, pension_type) + + max(weekly - full, 0) * add_on_uprating(sim, year, pension_type) + ) def calculate(sim, variable: str, year: int) -> np.ndarray: @@ -114,18 +134,18 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): 2014 on the basic State Pension; held at 72 she stands, by 2030-31, for a woman born in 1958, on the new State Pension. Her £240 a week exceeds the new State Pension's full rate, so the excess is additional State Pension - (a protected payment) at the new State Pension's uprating.""" + (a protected payment), uprated as the model uprates add-ons.""" person = {"age": 72, "male": False, "months": 3, "weekly": 240} sim = projected_simulation([person]) assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "BASIC" assert calculate(sim, "state_pension_type", 2030)[0] == "NEW" rates, data_rates = flat_rates(sim, 2030), flat_rates(sim, DATA_YEAR) - uprating = rates["NEW"] / data_rates["NEW"] assert calculate(sim, "new_state_pension", 2030)[0] == pytest.approx( rates["NEW"] * WEEKS_IN_YEAR, abs=0.01 ) assert calculate(sim, "additional_state_pension", 2030)[0] == pytest.approx( - (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * uprating, abs=0.01 + (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030, "NEW"), + abs=0.01, ) @@ -204,27 +224,46 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): ) +def by_type(pension_type: np.ndarray, basic, new) -> np.ndarray: + return np.select([pension_type == "BASIC", pension_type == "NEW"], [basic, new], 0) + + def assert_components_add_up(sim, people: list, years) -> None: """For each person and year: below State Pension age, no component and type - NONE; over it, only the period type's flat-rate component, and the three - components sum to the reported amount uprated by that type's flat rate.""" + NONE. Over it, only the period type's flat-rate component, which is the + reported amount up to the type's full rate, uprated by the full rate; and, + deflating each component by its own uprating, the three components add up + to exactly the reported amount, with no overlap and no gap.""" weekly = np.array([p["weekly"] for p in people]) for year in years: pension_type = calculate(sim, "state_pension_type", year).astype(str) over_spa = calculate(sim, "is_SP_age", year).astype(bool) basic, new, additional = (calculate(sim, v, year) for v in COMPONENTS) rates, data_rates = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) - uprating = np.select( - [pension_type == "BASIC", pension_type == "NEW"], - [ - rates["BASIC"] / data_rates["BASIC"], - rates["NEW"] / data_rates["NEW"], - ], - 0, + full = by_type(pension_type, data_rates["BASIC"], data_rates["NEW"]) + flat_up = by_type( + pension_type, + flat_uprating(sim, year, "BASIC"), + flat_uprating(sim, year, "NEW"), ) - expected = over_spa * weekly * WEEKS_IN_YEAR * uprating + add_up = by_type( + pension_type, + add_on_uprating(sim, year, "BASIC"), + add_on_uprating(sim, year, "NEW"), + ) + reported = weekly * WEEKS_IN_YEAR assert np.array_equal(pension_type == "NONE", ~over_spa), year - assert np.allclose(basic + new + additional, expected, rtol=1e-5, atol=0.01) + flat = basic + new + expected_flat = over_spa * np.minimum(reported, full * WEEKS_IN_YEAR) * flat_up + assert np.allclose(flat, expected_flat, rtol=1e-5, atol=0.01) + deflated = np.where( + over_spa, + flat / np.where(over_spa, flat_up, 1) + + additional / np.where(over_spa, add_up, 1), + 0, + ) + assert np.allclose(deflated, over_spa * reported, rtol=1e-5, atol=0.01) + assert np.all(additional[~over_spa] == 0) assert np.all(basic[pension_type != "BASIC"] == 0) assert np.all(new[pension_type != "NEW"] == 0) assert np.all(additional >= 0) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index 3a8c5204f2..9dd31f7c5d 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -58,5 +58,7 @@ def formula(person, period, parameters): max_for_type_period / max_for_type_data, 1, ) - # The type is NONE below State Pension age, so nothing is paid there. - return amount_in_data_year * uprating * WEEKS_IN_YEAR + # No State Pension is paid before State Pension age. A computed type + # is already NONE there; this also holds if the type is an input. + is_sp_age = person("is_SP_age", period) + return is_sp_age * amount_in_data_year * uprating * WEEKS_IN_YEAR From 2f3548f1698c6a548853f5060363920593cd86e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 13:06:51 -0400 Subject: [PATCH 13/75] Stop children and a claimant's own carer benefit withholding the addition Review of this PR found that sharing the Pension Credit severe disability addition made pension-age HB and CTR inherit two deviations from SPC Regs Sch I: a child in the benefit unit withheld it, although the residence test counts only people aged 18 or over (para 2(2)(f)), and a claimant's own Carer's Allowance withheld it, although only one paid "in respect of caring for him" does (para 1(1)(a)(iii)). Each qualifying adult is now withheld only when another member of the benefit unit receives a carer benefit. A couple who both qualify, one paid Carer's Allowance for the other, now gets one rate, as reg 6(5)(a) gives. Also: the pension-age premium is zero below State Pension age; YAML cases that assert an applicable amount use claimants who reached State Pension age before April 2021, so they hold with the allowance cohort split; the schedule property checks whole multiples of the weekly rate. Co-Authored-By: Claude Opus 5.5 --- ...ion-age-severe-disability-premium.fixed.md | 3 +- .../housing_benefit/pension_age_premiums.yaml | 110 +++++++++++++++++- .../tests/test_carer_support_payment.py | 16 ++- ...ge_severe_disability_premium_properties.py | 25 ++-- ...e_disability_minimum_guarantee_addition.py | 58 +++++---- .../pension_age_severe_disability_premium.py | 4 +- 6 files changed, 173 insertions(+), 43 deletions(-) diff --git a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md index 25e9cbde97..c7cfc12176 100644 --- a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md +++ b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md @@ -1 +1,2 @@ -- Include the severe disability premium in the pension-age Housing Benefit and Council Tax Reduction applicable amounts on the same qualifying benefits as the Pension Credit severe disability addition (Attendance Allowance, DLA care middle or highest rate, PIP daily living at either rate, Armed Forces Independence Payment), and drop the adult disability and enhanced disability premiums, which the pension-age schedules do not have (HB(SPC) Regs 2006 Sch 3; CTR schemes for pensioners in England, Wales and Scotland). Previously an Attendance Allowance recipient lost up to about £2,000 a year of Housing Benefit when their Guarantee Credit ended. +- Include the severe disability premium in the pension-age Housing Benefit and Council Tax Reduction applicable amounts on the same qualifying benefits as the Pension Credit severe disability addition (Attendance Allowance, DLA care middle or highest rate, PIP daily living at either rate, Armed Forces Independence Payment), and drop the adult disability and enhanced disability premiums, which the pension-age schedules do not have (HB(SPC) Regs 2006 Sch 3; CTR schemes for pensioners in England, Wales and Scotland). Previously Housing Benefit fell sharply when Guarantee Credit ended for an Attendance Allowance recipient: by about £2,100 a year for a single person and £4,700 for a couple. +- The Pension Credit severe disability addition is no longer withheld because the benefit unit includes a child, since the residence test counts only people aged 18 or over (SPC Regs Sch I para 2(2)(f)), or because a claimant receives Carer's Allowance or Carer Support Payment themselves; only another member's carer benefit, standing in for one paid for caring for them, withholds it (Sch I para 1(1)(a)(iii)). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml index 046ca31c8f..3624aa1c5d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml @@ -6,7 +6,9 @@ # disability or enhanced disability premium. 2026-27 weekly rates: pension-age # personal allowance (attained pensionable age before 1 April 2021) 256.00, # couple 383.35; severe disability premium 86.05 (172.10 for a couple who -# both qualify); carer premium 48.15. +# both qualify); carer premium 48.15. Cases that assert an applicable amount +# use claimants who reached State Pension age before 1 April 2021, whose +# allowance does not depend on the April 2021 cohort split. - name: AA claimant just above the Guarantee Credit boundary keeps the severe disability premium in HB and CTR period: 2026 @@ -33,10 +35,9 @@ severe_disability_minimum_guarantee_addition: 86.05 * 52 pension_age_severe_disability_premium: 86.05 * 52 benefits_premiums: 86.05 * 52 - # (256.00 + 86.05) * 52 = 17,786.60 - housing_benefit_applicable_amount: 17_786.60 - council_tax_reduction_applicable_amount: 17_786.60 - # HB income 16,548.80 is below the applicable amount: maximum HB. + # HB income 16,548.80 is below the applicable amount, (238.00 or 256.00 + # + 86.05) * 52 = 16,850.60 or 17,786.60 whichever allowance cohort + # applies, so HB and CTR are at their maximum. housing_benefit: 6_240 council_tax_benefit: 2_000 @@ -71,7 +72,7 @@ input: people: claimant: - age: {2026: 70} + age: {2026: 80} dla_sc_category: {2026: HIGHER} is_disabled_for_benefits: {2026: true} benunits: @@ -180,3 +181,100 @@ # qualifies. (256.00 + 86.05) * 52 = 17,786.60. benefits_premiums: 86.05 * 52 council_tax_reduction_applicable_amount: 17_786.60 + +- name: A dependent child does not withhold the pensioner's severe disability premium + # SPC Regs 2002 Sch I para 1(1)(a)(ii) counts only residents aged 18 or + # over, and para 2(2)(f) ignores qualifying young people. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: {2026: 80} + dla_sc_category: {2026: HIGHER} + child: + age: {2026: 12} + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + severe_disability_minimum_guarantee_addition: 86.05 * 52 + pension_age_severe_disability_premium: 86.05 * 52 + +- name: A pensioner's own Carer's Allowance does not withhold their severe disability premium + # The addition is withheld only for a carer benefit paid "in respect of + # caring for him" (Sch I para 1(1)(a)(iii); HB(SPC) Sch 3 para 6(2)(a)(iii)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 80} + dla_sc_category: {2026: HIGHER} + care_hours: {2026: 40} + carers_allowance_reported: {2026: 4_000} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + receives_carer_benefit: true + pension_age_severe_disability_premium: 86.05 * 52 + # Severe disability premium plus carer premium. + benefits_premiums: (86.05 + 48.15) * 52 + +- name: Couple who both receive AA, one paid Carer's Allowance for the other, get one rate + # Sch I para 1(1)(b) and reg 6(5)(a): one rate where a carer benefit is + # paid for caring for one only of the partners. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 80} + aa_category: {2026: HIGHER} + partner: + age: {2026: 78} + aa_category: {2026: LOWER} + care_hours: {2026: 40} + carers_allowance_reported: {2026: 4_000} + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: {2026: ENGLAND} + tenure_type: {2026: RENT_FROM_COUNCIL} + rent: {2026: 6_240} + output: + severe_disability_minimum_guarantee_addition: 86.05 * 52 + +- name: The pension-age premium is zero below State Pension age + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: {2026: 40} + pip_dl_category: {2026: STANDARD} + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: {2026: ENGLAND} + output: + pension_age_severe_disability_premium: 0 diff --git a/policyengine_uk/tests/test_carer_support_payment.py b/policyengine_uk/tests/test_carer_support_payment.py index b812935aa5..c0554335a7 100644 --- a/policyengine_uk/tests/test_carer_support_payment.py +++ b/policyengine_uk/tests/test_carer_support_payment.py @@ -1,3 +1,4 @@ +import pytest from policyengine_uk import Simulation @@ -43,7 +44,7 @@ def test_scottish_carers_move_to_csp_in_2025(): assert sim.calculate("carer_support_payment", YEAR_2025)[0] > 0 -def test_csp_counts_for_pension_credit_carer_additions_and_blocks_severe_disability(): +def test_csp_counts_for_pension_credit_carer_additions_but_not_against_own_severe_disability(): sim = Simulation( situation=_situation( YEAR_2025, @@ -59,9 +60,18 @@ def test_csp_counts_for_pension_credit_carer_additions_and_blocks_severe_disabil assert sim.calculate("carer_minimum_guarantee_addition", YEAR_2025)[0] == ( expected_carer_addition ) - assert ( - sim.calculate("severe_disability_minimum_guarantee_addition", YEAR_2025)[0] == 0 + # Carer Support Payment withholds the severe disability addition only when + # it is paid for caring for the disabled person (SPC Regs 2002 Sch I para + # 1(1)(a)(iii)), so a carer's own payment never withholds theirs. + expected_severe_disability_addition = ( + float( + parameters.gov.dwp.pension_credit.guarantee_credit.severe_disability.addition + ) + * 52 ) + assert sim.calculate("severe_disability_minimum_guarantee_addition", YEAR_2025)[ + 0 + ] == pytest.approx(expected_severe_disability_addition) def test_csp_counts_for_uc_non_dep_exemption_and_housing_benefit_income(): diff --git a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py index 9bccc4464e..a1cb1501af 100644 --- a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py @@ -25,9 +25,11 @@ household net income, excluding the TV licence fee, does not fall. The free TV licence for over-75s on Pension Credit is a genuine statutory cliff, so it is excluded. -3. Differential: over State Pension age the premium equals the Pension - Credit severe disability addition; below it, benefits_premiums is still - the sum of the four legacy premiums. +3. Schedule: over State Pension age the premium is a whole number (0, 1 or + 2) of weekly severe disability rates, equals the Pension Credit severe + disability addition, and is the only premium besides the carer premium; + below State Pension age it is zero and benefits_premiums is the sum of the + four legacy premiums, so working-age applicable amounts are unchanged. Carers are not drawn. Pension Credit income leaves out Carer's Allowance and two carers get one carer premium, so the invariants do not yet hold for @@ -200,16 +202,21 @@ def get(variable): return float(simulation.calculate(variable, YEAR)[0]) pension_age = bool(simulation.calculate("is_SP_age", YEAR).any()) + premium = get("pension_age_severe_disability_premium") if pension_age: + weekly_rate = float( + simulation.tax_benefit_system.parameters( + YEAR + ).gov.dwp.pension_credit.guarantee_credit.severe_disability.addition + ) + rates = premium / (weekly_rate * 52) + assert min(abs(rates - k) for k in (0, 1, 2)) < 1e-6, rates assert ( - abs( - get("pension_age_severe_disability_premium") - - get("severe_disability_minimum_guarantee_addition") - ) - < 0.005 + abs(premium - get("severe_disability_minimum_guarantee_addition")) < 0.005 ) - expected = get("pension_age_severe_disability_premium") + get("carer_premium") + expected = premium + get("carer_premium") else: + assert premium == 0 expected = sum( get(variable) for variable in [ 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 2a95fd2cae..9a7605cad0 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 @@ -3,38 +3,50 @@ class severe_disability_minimum_guarantee_addition(Variable): label = "Severe disability-related increase" + documentation = ( + "The Pension Credit severe disability additional amount: one rate for " + "each adult in the benefit unit who receives a qualifying disability " + "benefit and for whom no other member of the benefit unit receives a " + "carer benefit. Pension-age Housing Benefit and Council Tax Reduction " + "use the same amount as their severe disability premium." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/2", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", + ) def formula(benunit, period, parameters): - # 1. At least one adult receives a qualifying benefit - # 2. No children (except children receiving qualifying benefits) - # 3. Nobody receives Carer's Allowance (technically 'for one of the - # claimants', but we assume this is true). This is receipt, not - # entitlement: the addition is withheld only where a carer's - # allowance is "entitled to and in receipt of" for the claimant, so - # a carer with underlying entitlement alone does not block it. + # Sch I para 1(1)(a)(iii) and (b): the addition is withheld where a + # person is entitled to and in receipt of a carer benefit "in respect + # of caring for" the qualifying person. Who is cared for is not + # modelled, so another member of the benefit unit receiving a carer + # benefit stands in for it; a person's own carer benefit never blocks + # their addition, and carers outside the benefit unit are not seen. + # Receipt, not underlying entitlement, is what counts. + # Children and qualifying young people never block the addition: the + # residence test counts only people aged 18 or over, and para 2(2)(f) + # ignores qualifying young people. + # Retained simplifications: one rate for each qualifying adult, + # without para 1(1)(b)-(c)'s both-partners-or-blind-partner condition, + # and no test for other adults living in the household. severe_disability = parameters( period ).gov.dwp.pension_credit.guarantee_credit.severe_disability - relevant_benefits = severe_disability.relevant_benefits person = benunit.members - person_receives_qualifying_benefits = add(person, period, relevant_benefits) > 0 - is_adult = person("is_adult", period) - count_eligible_adults = benunit.sum( - is_adult & person_receives_qualifying_benefits + qualifies = add(person, period, severe_disability.relevant_benefits) > 0 + receives_carer_benefit = person("receives_carer_benefit", period) + carer_benefits_received_by_others = ( + benunit.project(benunit.sum(receives_carer_benefit)) + - receives_carer_benefit ) - any_children_without_benefits = ( - benunit.sum(~is_adult & ~person_receives_qualifying_benefits) > 0 - ) - carer_benefit_received = add(benunit, period, ["receives_carer_benefit"]) > 0 - eligible = ~any_children_without_benefits & ~carer_benefit_received - return ( - eligible - * count_eligible_adults - * severe_disability.addition - * WEEKS_IN_YEAR + counted = ( + person("is_adult", period) + & qualifies + & (carer_benefits_received_by_others == 0) ) + return benunit.sum(counted) * severe_disability.addition * WEEKS_IN_YEAR 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 33bdecc876..8ac841cfc7 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 @@ -33,4 +33,6 @@ class pension_age_severe_disability_premium(Variable): ) def formula(benunit, period, parameters): - return benunit("severe_disability_minimum_guarantee_addition", period) + pension_age = benunit.any(benunit.members("is_SP_age", period)) + addition = benunit("severe_disability_minimum_guarantee_addition", period) + return where(pension_age, addition, 0) From 46b9ff3ef3417633d1dbfe2b749d73d4ca036b57 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:08:23 -0400 Subject: [PATCH 14/75] State the carer and savings-credit dependencies without dating them Co-Authored-By: Claude Opus 5.5 --- ...ension_age_severe_disability_premium_properties.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py index a1cb1501af..27f50a9450 100644 --- a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py @@ -31,11 +31,12 @@ below State Pension age it is zero and benefits_premiums is the sum of the four legacy premiums, so working-age applicable amounts are unchanged. -Carers are not drawn. Pension Credit income leaves out Carer's Allowance and -two carers get one carer premium, so the invariants do not yet hold for -carers; nor does full monotonicity in income, because Housing Benefit and -Council Tax Reduction income leave out Savings Credit. Those are separate -fixes. +Carers are not drawn: the invariants for carers also depend on Pension +Credit income counting Carer's Allowance and on the carer premium per carer, +which are separate changes. Full monotonicity in income is not asserted: it +also depends on the savings-credit-only income rules for Housing Benefit and +Council Tax Reduction (HB(SPC) Regs 2006 reg 27 and the CTR equivalents), +which are likewise separate. """ import numpy as np From c07c4e2f93b494b390c3288ddfe0cd736b1cf92b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:05:26 -0400 Subject: [PATCH 15/75] Deduct only each person's own tax and NI on earnings from UC earned income uc_earned_income subtracted benunit_tax, the whole benefit unit's income tax and NI on all income, from the unit's gross earnings. UC Regs 2013 reg. 55(5)(b) and reg. 57(2) step 3 deduct only tax and NI paid by the person in respect of their employment or trade. - uc_income_tax_on_earnings: income tax on the person's earnings, taken as the lowest slice of their non-savings income after their allowances (savings and dividends sit above it under ITA 2007 s. 16, property income above it in the engine and under s. 16A from 2027-28). - uc_national_insurance_on_earnings: Class 1 employee, Class 2, Class 4. - uc_individual_earned_income: gross earnings less the person's own pension contributions, tax and NI, floored at nil. - uc_earned_income: sum over the unit, less the work allowance. Fixes #1942. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-earned-income-own-tax.fixed.md | 1 + .../income/uc_earned_income.yaml | 46 +++- .../income/uc_earnings_deductions.yaml | 250 ++++++++++++++++++ .../income/uc_earned_income.py | 21 +- .../income/uc_income_tax_on_earnings.py | 81 ++++++ .../income/uc_individual_earned_income.py | 41 +++ .../uc_national_insurance_on_earnings.py | 30 +++ 7 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 changelog.d/uc-earned-income-own-tax.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py diff --git a/changelog.d/uc-earned-income-own-tax.fixed.md b/changelog.d/uc-earned-income-own-tax.fixed.md new file mode 100644 index 0000000000..0a800b871c --- /dev/null +++ b/changelog.d/uc-earned-income-own-tax.fixed.md @@ -0,0 +1 @@ +Deduct from each person's Universal Credit earnings only their own income tax and National Insurance on that employment or self-employment (UC Regs 2013 reg. 55(5)(b), reg. 57(2) step 3), not the whole benefit unit's tax on all income, so tax on pensions, State Pension, property, savings and dividends, the High Income Child Benefit Charge and voluntary Class 3 contributions no longer reduce earned income. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml index 3647f0e3d4..378941b5d7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml @@ -5,9 +5,10 @@ employment_income: 1_000 miscellaneous_income: 100 uc_work_allowance: 10 - benunit_tax: 1 + uc_income_tax_on_earnings: 1 output: - uc_earned_income: 1100 - 11 + uc_individual_earned_income: 1_100 - 1 + uc_earned_income: 1_100 - 11 - name: Capped at 0 period: 2025 @@ -16,6 +17,45 @@ employment_income: 1_000 miscellaneous_income: 100 uc_work_allowance: 2_000 - benunit_tax: 1 + uc_income_tax_on_earnings: 1 output: uc_earned_income: 0 + +- name: Each person's deductions come off only their own earnings + period: 2025 + absolute_error_margin: 0 + input: + people: + earner: + employment_income: 1_000 + uc_income_tax_on_earnings: 100 + uc_national_insurance_on_earnings: 10 + partner: + uc_income_tax_on_earnings: 500 + pension_contributions: 50 + benunits: + benunit: + members: [earner, partner] + uc_work_allowance: 0 + output: + uc_individual_earned_income: [890, 0] + uc_earned_income: 890 + +- name: Work allowance comes off combined earnings after each person's deductions + period: 2025 + absolute_error_margin: 0 + input: + people: + earner: + employment_income: 1_000 + uc_income_tax_on_earnings: 100 + partner: + employment_income: 500 + pension_contributions: 50 + benunits: + benunit: + members: [earner, partner] + uc_work_allowance: 300 + output: + uc_individual_earned_income: [900, 450] + uc_earned_income: 1_050 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 new file mode 100644 index 0000000000..2e9505a83e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -0,0 +1,250 @@ +# Universal Credit deducts from a person's earnings only the income tax and +# National Insurance that person pays in respect of their own employment +# (UC Regs 2013 reg. 55(5)(b)) or trade (reg. 57(2) step 3), plus their own +# relievable pension contributions (reg. 55(5)(a)). Tax on pensions, State +# Pension, property, savings and dividends is not deducted, and one partner's +# tax never reduces the other's earnings. Earnings are taken as the lowest +# slice of the person's non-savings income, after their allowances; savings +# and dividends sit above all non-savings income (ITA 2007 s. 16). +# +# Hand calculations use the 2026-27 rates: +# - personal allowance 12,570; basic rate 20% on the first 37,700 of taxable +# income; dividend allowance 500, dividend ordinary rate 10.75%; property +# allowance 1,000; trading allowance 1,000; +# - primary Class 1 NI 8% above the primary threshold of 241.73 a week +# (12,569.96 a year); Class 4 NI 6% above the lower profits limit of 12,570; +# Class 2 NI nil; +# - UC standard allowance 424.90 a month (single, 25 or over) or 666.97 +# (couple, one 25 or over); child element 303.94 a month; work allowance +# 427 a month with housing costs; taper 55%. + +- name: Single parent with earnings and property income, only tax on earnings is deducted + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 20_000 + property_income: 5_000 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + # Earnings take the personal allowance first: (20,000 - 12,570) x 20%. + # Property income is taxed on (5,000 - 1,000) x 20% = 800, not deducted. + income_tax: [2_286, 0] + uc_income_tax_on_earnings: [1_486, 0] + # (20,000 - 12,569.96) x 8%. + uc_national_insurance_on_earnings: [594.40, 0] + # 20,000 - 1,486 - 594.40. + uc_individual_earned_income: [17_919.60, 0] + # Less the work allowance of 427 x 12 = 5,124. + uc_earned_income: 12_795.60 + uc_unearned_income: 5_000 + # 424.90 x 12 + 303.94 x 12 + 9,600 = 5,098.80 + 3,647.28 + 9,600. + uc_maximum_amount: 18_346.08 + # 18,346.08 - (0.55 x 12,795.60 + 5,000). Deducting all of the tax, as + # before this fix, gave 6,748.50: 0.55 x 800 = 440 too much. + universal_credit: 6_308.50 + +- name: Mixed-age couple, the pensioner's tax on State Pension does not reduce the partner's earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 16_000 + partner: + age: 45 + employment_income: 13_000 + benunits: + benunit: + members: [pensioner, partner] + households: + household: + members: [pensioner, partner] + tenure_type: RENT_FROM_COUNCIL + rent: 20_000 + output: + # Pensioner: (16,000 - 12,570) x 20% = 686, on State Pension. + # Partner: (13,000 - 12,570) x 20% = 86 and (13,000 - 12,569.96) x 8% + # = 34.40. Both are above the personal allowance, so there is no + # Marriage Allowance. + income_tax: [686, 86] + uc_income_tax_on_earnings: [0, 86] + uc_national_insurance_on_earnings: [0, 34.40] + # 13,000 - 86 - 34.40. + uc_earned_income: 12_879.60 + uc_unearned_income: 16_000 + # 666.97 x 12 + 20,000. + uc_maximum_amount: 28_003.64 + # 28,003.64 - (0.55 x 12,879.60 + 16,000). Deducting the whole benefit + # unit's tax, as before this fix, gave 5,297.16: 0.55 x 686 = 377.30 + # too much. + universal_credit: 4_919.86 + +- name: Self-employed claimant with pension and dividend income, only tax on the trade is deducted + period: 2026 + absolute_error_margin: 0.01 + input: + age: 55 + self_employment_income: 18_000 + # In a start-up period, so the minimum income floor does not apply. + uc_is_in_startup_period: true + private_pension_income: 4_000 + dividend_income: 3_000 + tenure_type: RENT_FROM_COUNCIL + rent: 12_000 + output: + # Trade: taxable profit 18,000 - 1,000 trading allowance = 17,000; it + # takes the personal allowance first, (17,000 - 12,570) x 20% = 886. + # Pension: 4,000 x 20% = 800. Dividends: (3,000 - 500) x 10.75% = + # 268.75. Neither is deducted. + income_tax: 1_954.75 + uc_income_tax_on_earnings: 886 + # Class 4: (18,000 - 12,570) x 6%. + uc_national_insurance_on_earnings: 325.80 + # 18,000 - 886 - 325.80, with no work allowance. + uc_earned_income: 16_788.20 + uc_unearned_income: 7_000 + # 424.90 x 12 + 12,000. + uc_maximum_amount: 17_098.80 + # 17,098.80 - (0.55 x 16,788.20 + 7,000). Deducting all of the tax and + # NI, as before this fix, gave 1,453.10: 0.55 x 1,068.75 = 587.81 too + # much. + universal_credit: 865.29 + +- name: Scottish taxpayer, earnings sit below pension income in the Scottish bands + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + region: SCOTLAND + output: + # 2026-27 Scottish bands above the personal allowance: starter 19% on the + # first 3,967, basic 20% to 16,956, intermediate 21% to 31,092. + # Earnings slice 7,430: 3,967 x 19% + 3,463 x 20% = 753.73 + 692.60. + uc_income_tax_on_earnings: 1_446.33 + # Pension slice 7,430 to 17,430: 9,526 x 20% + 474 x 21% = 2,004.74. + income_tax: 3_451.07 + +- name: Couple with Marriage Allowance, the recipient's reduction lowers the tax on their earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 15_000 + dividend_income: 5_000 + transferor: + age: 38 + employment_income: 10_000 + benunits: + benunit: + members: [recipient, transferor] + households: + household: + members: [recipient, transferor] + output: + # The transferor's unused allowance of 2,570 exceeds the 1,257 cap, which + # rounds up to 1,260. Tax on the recipient's earnings: + # (15,000 - 12,570 - 1,260) x 20% = 234, the same as 486 less the + # 20% x 1,260 = 252 reduction. Dividends: (5,000 - 500) x 10.75% = 483.75, + # not deducted. + marriage_allowance: [1_260, 0] + income_tax: [717.75, 0] + uc_income_tax_on_earnings: [234, 0] + # (15,000 - 12,569.96) x 8%. + uc_national_insurance_on_earnings: [194.40, 0] + uc_individual_earned_income: [14_571.60, 10_000] + uc_earned_income: 24_571.60 + +- name: Voluntary Class 3 NI is not deducted + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 13_000 + ni_class_3: 900 + output: + national_insurance: 934.40 + uc_national_insurance_on_earnings: 34.40 + # 13,000 - 86 - 34.40. + uc_earned_income: 12_879.60 + +- name: Pension contributions come off only the contributor's own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 20_000 + employee_pension_contributions: 1_000 + would_claim_marriage_allowance: false + partner: + age: 38 + personal_pension_contributions: 1_000 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [earner, partner] + households: + household: + members: [earner, partner] + output: + # Relief on the earner's contributions: (20,000 - 12,570 - 1,000) x 20%. + uc_income_tax_on_earnings: [1_286, 0] + # 20,000 - 1,000 - 1,286 - 594.40. The partner has no earnings, so their + # own contributions reduce nothing. + uc_individual_earned_income: [17_119.60, 0] + uc_earned_income: 17_119.60 + +- name: High Income Child Benefit Charge is not deducted + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 70_000 + child: + age: 3 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + # Child Benefit 27.05 x 52 = 1,406.60; the charge takes + # (70,000 - 60,000) / 200 = 50% of it, 703.30. + CB_HITC: [703.30, 0] + income_tax: [16_135.30, 0] + # 37,700 x 20% + (57,430 - 37,700) x 40%. + uc_income_tax_on_earnings: [15_432, 0] + +- name: From 2027 property income has its own rates, and its tax is still not deducted + period: 2027 + absolute_error_margin: 0.01 + input: + age: 35 + employment_income: 20_000 + property_income: 5_000 + output: + # Property: (5,000 - 1,000) x 22% = 880. + property_income_tax: 880 + income_tax: 2_366 + uc_income_tax_on_earnings: 1_486 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 8ff40e4a84..1880148d36 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 @@ -4,17 +4,18 @@ class uc_earned_income(Variable): value_type = float entity = BenUnit - label = "Universal Credit earned income (after disregards and tax)" + label = "Universal Credit earned income (after deductions and work allowance)" definition_period = YEAR unit = GBP + reference = dict( + title="Universal Credit Regulations 2013 reg. 22(1)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + ) def formula(benunit, period, parameters): - personal_gross_earned_income = add( - benunit, period, ["uc_mif_capped_earned_income"] - ) - disregards = add( - benunit, - period, - ["uc_work_allowance", "benunit_tax", "pension_contributions"], - ) - return max_(0, personal_gross_earned_income - disregards) + # Each person's earned income is net of their own deductions + # (reg. 55(5), reg. 57(2)); the work allowance then comes off the + # combined earned income before the taper (reg. 22(1)(b)). + earned_income = add(benunit, period, ["uc_individual_earned_income"]) + work_allowance = benunit("uc_work_allowance", period) + return max_(0, earned_income - work_allowance) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py new file mode 100644 index 0000000000..3ac15ab56f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -0,0 +1,81 @@ +from policyengine_uk.model_api import * + + +class uc_income_tax_on_earnings(Variable): + value_type = float + entity = Person + label = "Universal Credit deduction for income tax on the person's earnings" + documentation = ( + "Income tax the person pays in respect of their own employment and " + "self-employment, which Universal Credit deducts from their earned " + "income. Tax on pensions, State Pension, property, savings and " + "dividends is not deducted, nor is the High Income Child Benefit " + "Charge or the pension annual allowance charge." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2), step 3", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + dict( + title="Income Tax Act 2007 s. 16", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", + ), + ] + + def formula(person, period, parameters): + # 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". When the person has other income too, earnings are + # taken as the lowest slice of their non-savings income, after the + # allowances they actually have. Savings and dividends sit above all + # non-savings income (ITA 2007 s. 16) and property income above the + # rest of it, so tax on them never falls on earnings. Other + # non-savings income (private pensions, State Pension, taxable + # benefits) sits above earnings. This is the tax a standard tax code + # deducts from a sole or main employment. + p = parameters(period) + earnings_components = [ + "taxable_employment_income", + "taxable_self_employment_income", + "taxable_miscellaneous_income", + ] + # Match the gross earnings in uc_mif_capped_earned_income. + bi = p.gov.contrib.ubi_center.basic_income.interactions + if bi.include_in_means_tests and bi.include_in_taxable_income: + earnings_components.append("basic_income") + earnings = add(person, period, earnings_components) + # earned_taxable_income is non-savings, non-property income after + # allowances. The part of it above earnings belongs to the person's + # other non-savings income. + non_savings_non_property_income = person( + "adjusted_net_income", period + ) - add( + person, + period, + [ + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + ], + ) + other_income = max_(0, non_savings_non_property_income - earnings) + taxable_earnings = max_( + 0, person("earned_taxable_income", period) - other_income + ) + rates = p.gov.hmrc.income_tax.rates + tax = where( + person("pays_scottish_income_tax", period), + rates.scotland.rates.calc(taxable_earnings), + rates.uk.calc(taxable_earnings), + ) + # Tax reductions (for example the married couple's allowance) can + # leave total income tax below the tax on the earnings slice; never + # deduct more than the person pays. + return min_(tax, person("income_tax", period)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py new file mode 100644 index 0000000000..7995416bcb --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class uc_individual_earned_income(Variable): + value_type = float + entity = Person + label = "Universal Credit earned income of the person" + documentation = ( + "The person's earned income for Universal Credit, after the " + "deductions for their own relievable pension contributions and their " + "own income tax and National Insurance in respect of their employment " + "and self-employment, before the work allowance." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + ] + + def formula(person, period, parameters): + # The deductions are the person's own and come off only their own + # earnings, so one partner's tax, NI or pension contributions never + # reduce the other partner's earned income. + gross_earnings = person("uc_mif_capped_earned_income", period) + deductions = add( + person, + period, + [ + "pension_contributions", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ], + ) + return max_(0, gross_earnings - deductions) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py new file mode 100644 index 0000000000..93ca6e8d71 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class uc_national_insurance_on_earnings(Variable): + value_type = float + entity = Person + label = "Universal Credit deduction for National Insurance on the person's earnings" + documentation = ( + "Primary Class 1 contributions on the person's employment and Class 2 " + "and Class 4 contributions on their trade, which Universal Credit " + "deducts from their earned income. Voluntary Class 3 contributions " + "are not in respect of an employment or trade, so are not deducted." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2), step 3", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + ] + adds = [ + "ni_class_1_employee", + "ni_class_2", + "ni_class_4", + ] From d0fc22f39f3da2b65e4eb96caa8b739f42441045 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:25:23 -0400 Subject: [PATCH 16/75] Add UC earnings-deduction property tests; widen State Pension properties - test_uc_earnings_deductions_properties.py: unearned income never changes earned income; UC is non-increasing in every kind of unearned income; the tax deducted equals the tax on earnings alone; earned income is never lower, and UC never higher, than under the pre-fix formula. - test_uc_state_pension_properties.py: the #1942 strict xfail now passes and loses its marker; pound-for-pound and the property-income equivalence cover families with earnings; a new strict xfail pins the Marriage Allowance transfer, which the model books on the recipient as the transferor's unused allowance (ITA 2007 s. 55B gives a fixed reduction). - Document the earnings deductions in the UC docs page. Co-Authored-By: Claude Opus 5.5 --- .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../test_uc_earnings_deductions_properties.py | 258 ++++++++++++++++++ .../tests/test_uc_state_pension_properties.py | 104 +++++-- .../income/uc_income_tax_on_earnings.py | 22 +- 4 files changed, 344 insertions(+), 42 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_earnings_deductions_properties.py diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index abc35df06f..c87f8f2514 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py new file mode 100644 index 0000000000..1d68ea2b29 --- /dev/null +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -0,0 +1,258 @@ +"""Property-based tests for the Universal Credit deductions from earnings. + +UC Regs 2013 reg. 55(5) and reg. 57(2) deduct from a person's earnings only +their own relievable pension contributions and the income tax and National +Insurance they pay in respect of their employment or trade. The model takes +earnings as the lowest slice of the person's non-savings income, after their +allowances, with other income above it (ITA 2007 s. 16 for savings and +dividends). + +Invariants, for any generated population of single people and couples with +earnings, self-employment and every kind of taxable unearned income: + +1. Unearned income never changes earned income: giving any adult more State + Pension, private pension, property, savings or dividend income leaves + every person's uc_individual_earned_income, and so uc_earned_income, + unchanged. +2. Monotone: the UC award, before and after the benefit cap, is + non-increasing in each kind of unearned income for each adult. +3. Differential against the tax engine: the income tax deducted equals the + income tax the same person pays when their earnings are their only + income, and the NI deducted equals their NI. +4. Differential against the formula this replaced: earned income is never + lower, and UC never higher, than when the whole benefit unit's tax was + deducted, because only deductions were removed. + +Invariants 1-3 hold only while unearned income leaves the person's +allowances alone, so the generated incomes keep adjusted net income below +the personal allowance taper (100,000), no one is old enough for the +married couple's allowance, and no one claims Marriage Allowance (see +test_uc_state_pension_properties.py for the Marriage Allowance deviation). +Invariant 4 runs with Marriage Allowance claimed. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import * +from policyengine_uk.utils.scenario import Scenario + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2020 has the temporary UC uplift; 2026 current rates; 2027 the new property +# and savings rates (Finance Act 2026). +YEARS = [2020, 2026, 2027] +TENURES = [ + "RENT_FROM_COUNCIL", + "RENT_FROM_HA", + "RENT_PRIVATELY", + "OWNED_OUTRIGHT", +] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +UNEARNED = [ + "state_pension", + "private_pension_income", + "property_income", + "savings_interest_income", + "dividend_income", +] +# Married couple's allowance needs a birth before 6 April 1935 (85 in 2020). +PENSION_AGE = st.integers(67, 84) +WORKING_AGE = st.integers(18, 60) +SHAPES = { + "single": [WORKING_AGE], + "couple": [WORKING_AGE, WORKING_AGE], + "mixed_age": [PENSION_AGE, WORKING_AGE], +} +# Per adult: earnings up to 50,000 and unearned income up to 5 x 7,000, +# plus a bump up to 9,000, keeps adjusted net income under 100,000. +earnings = st.one_of(st.just(0.0), st.floats(0, 30_000)) +self_employment = st.one_of(st.just(0.0), st.floats(0, 20_000)) +unearned = st.one_of(st.just(0.0), st.floats(0, 7_000)) +bumps = st.floats(1, 9_000) +UC_VARIABLES = [ + "universal_credit", + "universal_credit_pre_benefit_cap", + "uc_earned_income", +] +PERSON_VARIABLES = [ + "uc_individual_earned_income", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + "income_tax", + "national_insurance", +] + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(list(SHAPES))) + adults = [] + for age in [draw(age) for age in SHAPES[shape]]: + adult = dict( + age=age, + employment_income=draw(earnings), + self_employment_income=draw(self_employment), + # Some self-employed are in a start-up period, so the minimum + # income floor does not apply. + uc_is_in_startup_period=draw(st.booleans()), + ) + for variable in UNEARNED: + if variable == "state_pension" and age < 67: + continue + adult[variable] = draw(unearned) + adults.append(adult) + return dict( + adults=adults, + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.floats(0, 15_000)), + region=draw(st.sampled_from(REGIONS)), + ) + + +def situation(units, year, bump=None, earnings_only=False, marriage_allowance=False): + """One simulation holding every family. + + ``bump`` is (family index, adult index, variable, amount) to add. + ``earnings_only`` drops every kind of unearned income. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"p{i}_{j}" + person = {"state_pension": {year: 0.0}} + for variable, value in adult.items(): + if earnings_only and variable in UNEARNED: + continue + person[variable] = {year: value} + if bump is not None and bump[:2] == (i, j) and not earnings_only: + variable, amount = bump[2], bump[3] + person[variable] = {year: adult.get(variable, 0.0) + amount} + if not marriage_allowance: + person["would_claim_marriage_allowance"] = {year: False} + people[name] = person + names.append(name) + for k, age in enumerate(unit["children"]): + name = f"c{i}_{k}" + people[name] = {"age": {year: age}} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = { + "members": names, + "rent": {year: unit["rent"]}, + "tenure_type": {year: unit["tenure"]}, + "region": {year: unit["region"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, scenario=None, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs), scenario=scenario) + values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + for v in PERSON_VARIABLES: + values[v] = np.asarray(sim.calculate(v, year)) + values["is_adult_person"] = np.asarray(sim.calculate("age", year)) >= 18 + return values + + +@st.composite +def bumped(draw): + units = draw(st.lists(families(), min_size=1, max_size=10)) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + variables = UNEARNED if units[i]["adults"][j]["age"] >= 67 else UNEARNED[1:] + return units, (i, j, draw(st.sampled_from(variables)), draw(bumps)) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_unearned_income_never_changes_earned_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + for variable in ["uc_individual_earned_income", "uc_earned_income"]: + np.testing.assert_allclose( + high[variable], low[variable], atol=0.01, err_msg=f"{bump} {units}" + ) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_uc_is_non_increasing_in_unearned_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: + assert np.all(high[variable] <= low[variable] + 0.01), (variable, bump, units) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=10), + year=st.sampled_from(YEARS), +) +def test_deductions_equal_tax_and_ni_on_earnings_alone(units, year): + full = calculate(units, year) + alone = calculate(units, year, earnings_only=True) + adult = full["is_adult_person"] + np.testing.assert_allclose( + full["uc_income_tax_on_earnings"][adult], + alone["income_tax"][adult], + atol=0.01, + err_msg=str(units), + ) + np.testing.assert_allclose( + full["uc_national_insurance_on_earnings"][adult], + alone["national_insurance"][adult], + atol=0.01, + err_msg=str(units), + ) + # Never more than the person's own tax and NI. + assert np.all(full["uc_income_tax_on_earnings"] <= full["income_tax"] + 0.01) + assert np.all( + full["uc_national_insurance_on_earnings"] <= full["national_insurance"] + 0.01 + ) + + +def _use_formula_before_1942(simulation): + class uc_earned_income(Variable): + value_type = float + entity = BenUnit + label = "UC earned income as computed before #1942" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + gross = add(benunit, period, ["uc_mif_capped_earned_income"]) + disregards = add( + benunit, + period, + ["uc_work_allowance", "benunit_tax", "pension_contributions"], + ) + return max_(0, gross - disregards) + + simulation.tax_benefit_system.update_variable(uc_earned_income) + + +BEFORE_1942 = Scenario(simulation_modifier=_use_formula_before_1942) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=10), + year=st.sampled_from(YEARS), +) +def test_fix_only_removes_deductions(units, year): + after = calculate(units, year, marriage_allowance=True) + before = calculate(units, year, scenario=BEFORE_1942, marriage_allowance=True) + assert np.all(after["uc_earned_income"] >= before["uc_earned_income"] - 0.01), units + for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: + assert np.all(after[variable] <= before[variable] + 0.01), (variable, units) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 9b68022746..d3b3963724 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -12,20 +12,22 @@ 1. Monotone: the UC award, before and after the benefit cap, is non-increasing in State Pension, and more State Pension adds exactly that much to unearned income without changing the maximum amount. -2. Pound for pound: with no earnings in the family, raising State Pension by - d lowers the award before the benefit cap by exactly min(d, award). +2. Pound for pound: raising State Pension by d lowers the award before the + benefit cap by exactly min(d, award), whatever the partner earns, because + tax on State Pension never comes off earnings (reg. 55(5)(b), reg. 57(2) + step 3). 3. Equivalence: UC with State Pension x equals UC with the same x of private pension income received by the same person instead (both are retirement - pension income, taxed the same way). With no earnings in the family, it - also equals UC with x of property income (held without property capital, - so it is not treated as capital yield under reg. 72). - -Invariants 2 and 3 are restricted to families without earnings because the -model deducts the whole benefit unit's income tax from its earnings -(PolicyEngine/policyengine-uk#1942), so tax on State Pension reduces earned -income. Reg. 55(5)(b) and reg. 57 step 3 allow only tax paid in respect of -the employment or trade. The strict xfail below pins that case and will flip -when #1942 is fixed; widen invariants 2 and 3 to all families then. + pension income, taxed the same way). It also equals UC with x of property + income (held without property capital, so it is not treated as capital + yield under reg. 72), although property income is taxed on less. + +Marriage Allowance is switched off for invariants 2 and 3. The model gives +the recipient min(partner's unused personal allowance, 10% of the personal +allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), +so State Pension that uses up the pensioner's unused allowance raises the +earning partner's tax on earnings. The strict xfail at the end pins that +deviation (PolicyEngine/policyengine-uk#MA_ISSUE). """ import numpy as np @@ -71,12 +73,12 @@ @st.composite -def families(draw, with_earnings=True): +def families(draw): # Weight towards mixed-age couples, the only shape UC can reach. shape = draw( st.sampled_from(["mixed_age", "mixed_age", "single_pension", "couple_pension"]) ) - earnings = draw(st.one_of(st.just(0.0), money)) if with_earnings else 0.0 + earnings = draw(st.one_of(st.just(0.0), money)) return dict( ages=[draw(age) for age in SHAPES[shape]], children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], @@ -88,14 +90,21 @@ def families(draw, with_earnings=True): ) -def situation(units, year, income_variable="state_pension", pension_bump=0.0): +def situation( + units, + year, + income_variable="state_pension", + pension_bump=0.0, + marriage_allowance=True, +): """Build one simulation holding every family. The eldest adult receives the family's State Pension (plus ``pension_bump``) under ``income_variable``; when that is not ``state_pension``, their State Pension is set to zero so the same amount arrives as the other income instead. A working-age partner receives the - family's earnings. + family's earnings. With ``marriage_allowance=False`` no one claims + Marriage Allowance. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -103,6 +112,8 @@ def situation(units, year, income_variable="state_pension", pension_bump=0.0): for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" person = {"age": {year: age}, "state_pension": {year: 0.0}} + if not marriage_allowance: + person["would_claim_marriage_allowance"] = {year: False} if j == 0: amount = unit["state_pension"] + pension_bump person[income_variable] = {year: amount} @@ -165,13 +176,15 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): - low = calculate(units, year) - high = calculate(units, year, pension_bump=bump) + # Earnings in the family change nothing: tax on State Pension is never + # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). + low = calculate(units, year, marriage_allowance=False) + high = calculate(units, year, pension_bump=bump, marriage_allowance=False) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -196,25 +209,24 @@ def test_state_pension_counts_like_private_pension(units, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), year=st.sampled_from(YEARS), ) def test_state_pension_counts_like_property_income(units, year): + # Property income is taxed on less (the 1,000 property allowance), but + # neither tax comes off the partner's earnings. assert_same( - calculate(units, year), - calculate(units, year, income_variable="property_income"), + calculate(units, year, marriage_allowance=False), + calculate( + units, + year, + income_variable="property_income", + marriage_allowance=False, + ), str(units), ) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the whole " - "benefit unit's income tax, including the pensioner's tax on State " - "Pension, from the partner's earnings" - ), -) def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): # 2026: pensioner aged 70 with State Pension 16,000 pays income tax of # (16,000 - 12,570) x 20% = 686. The partner aged 45 earns 13,000 and pays @@ -235,3 +247,35 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(12_879.60, abs=0.01) assert values["universal_credit"][0] == pytest.approx(4_919.86, abs=0.01) + + +@pytest.mark.xfail( + strict=True, + reason=( + "PolicyEngine/policyengine-uk#MA_ISSUE: Marriage Allowance is booked on " + "the recipient as the transferor's unused personal allowance, so the " + "transferor's State Pension raises the recipient's tax on earnings" + ), +) +def test_state_pension_does_not_change_partners_marriage_allowance(): + # 2026: pensioner aged 70, partner aged 45 earning 20,000, council rent + # 20,000. Once the pensioner elects, ITA 2007 s. 55B gives the partner a + # fixed reduction of 20% x 1,260 = 252 and cuts the pensioner's own + # allowance by 1,260 (s. 55B(6)); the pensioner's State Pension of 12,000 + # keeps them within the basic rate, so they can elect (s. 55C(1)(c)). + # Partner's tax on earnings = (20,000 - 12,570) x 20% - 252 = 1,234 and + # NI (20,000 - 12,569.96) x 8% = 594.40, so earned income = 18,171.60 + # at any State Pension up to the pensioner's election limit. + # UC = 8,003.64 + 20,000 - (0.55 x 18,171.60 + 12,000) = 6,009.26. + unit = dict( + ages=[70, 45], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=20_000.0, + savings=0.0, + earnings=20_000.0, + state_pension=12_000.0, + ) + values = calculate([unit], 2026) + assert values["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) + assert values["universal_credit"][0] == pytest.approx(6_009.26, abs=0.01) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 3ac15ab56f..630b5320c9 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -32,14 +32,16 @@ class uc_income_tax_on_earnings(Variable): def formula(person, period, parameters): # 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". When the person has other income too, earnings are - # taken as the lowest slice of their non-savings income, after the - # allowances they actually have. Savings and dividends sit above all - # non-savings income (ITA 2007 s. 16) and property income above the - # rest of it, so tax on them never falls on earnings. Other - # non-savings income (private pensions, State Pension, taxable - # benefits) sits above earnings. This is the tax a standard tax code - # deducts from a sole or main employment. + # 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 all 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. p = parameters(period) earnings_components = [ "taxable_employment_income", @@ -54,9 +56,7 @@ def formula(person, period, parameters): # earned_taxable_income is non-savings, non-property income after # allowances. The part of it above earnings belongs to the person's # other non-savings income. - non_savings_non_property_income = person( - "adjusted_net_income", period - ) - add( + non_savings_non_property_income = person("adjusted_net_income", period) - add( person, period, [ From 54f4c7da480f79963967dc6689af022e82673636 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:19:41 -0400 Subject: [PATCH 17/75] Add share-like wealth component inputs corporate_wealth bundles three assets the means tests value differently: quoted shares less 10% for the expenses of sale (ADM H1665), unit trusts at the manager's withdrawal price with no deduction (H1673-H1674), and stocks and shares ISAs at their withdrawal value (H1656). Add household inputs for the two parts not yet modelled, directly_held_shares (UK shares and employee shares and options) and unit_and_investment_trusts, and uprate them, cash_isa and stocks_and_shares_isa with per-capita GDP like corporate_wealth, so a dataset's identity corporate_wealth == sum of components survives projection. The inputs have no consumers yet. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 1 + docs/book/assumptions/growthfactors.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 4 ++ .../tests/test_wealth_components.py | 46 +++++++++++++++++++ .../variables/input/corporate_wealth.py | 10 ++-- .../variables/input/directly_held_shares.py | 22 +++++++++ .../input/unit_and_investment_trusts.py | 21 +++++++++ 7 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 changelog.d/wealth-share-components.added.md create mode 100644 policyengine_uk/tests/test_wealth_components.py create mode 100644 policyengine_uk/variables/input/directly_held_shares.py create mode 100644 policyengine_uk/variables/input/unit_and_investment_trusts.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md new file mode 100644 index 0000000000..78001b6ac6 --- /dev/null +++ b/changelog.d/wealth-share-components.added.md @@ -0,0 +1 @@ +Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, and uprate them and the ISA balances with per-capita GDP. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 1b093f7b61..3bab5902dd 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `sublet_income` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 4fbc8bc819..5e962bb401 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -83,7 +83,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains_badr - capital_gains_residential_property - capital_gains_carried_interest +- cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - lump_sum_income @@ -102,7 +104,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_income - savings - savings_interest_income +- stocks_and_shares_isa - sublet_income +- unit_and_investment_trusts gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income gov.economic_assumptions.yoy_growth.obr.private_pension_index: diff --git a/policyengine_uk/tests/test_wealth_components.py b/policyengine_uk/tests/test_wealth_components.py new file mode 100644 index 0000000000..98e4799477 --- /dev/null +++ b/policyengine_uk/tests/test_wealth_components.py @@ -0,0 +1,46 @@ +"""corporate_wealth's components share its entity, quantity type and uprating, so +a dataset's identity corporate_wealth == sum of components survives projection.""" + +from pathlib import Path + +import yaml + +from policyengine_uk import CountryTaxBenefitSystem + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) + +system = CountryTaxBenefitSystem() +UPRATING_INDICES = yaml.safe_load( + (Path(__file__).parents[1] / "data" / "uprating_indices.yaml").read_text() +) + + +def _uprating_index(variable): + return [index for index, names in UPRATING_INDICES.items() if variable in names] + + +def test_components_match_corporate_wealth(): + corporate_wealth = system.variables["corporate_wealth"] + for name in COMPONENTS: + variable = system.variables[name] + assert variable.is_input_variable() + assert variable.entity.key == corporate_wealth.entity.key + assert variable.quantity_type == corporate_wealth.quantity_type + assert variable.uprating == corporate_wealth.uprating + assert _uprating_index(name) == _uprating_index("corporate_wealth") + + +def test_cash_isa_is_uprated_with_savings(): + assert system.variables["cash_isa"].uprating == system.variables["savings"].uprating + assert _uprating_index("cash_isa") == _uprating_index("savings") + + +def test_components_are_not_part_of_total_wealth(): + """total_wealth adds corporate_wealth, which already holds the components.""" + adds = system.variables["total_wealth"].adds + assert "corporate_wealth" in adds + assert not set(COMPONENTS) & set(adds) diff --git a/policyengine_uk/variables/input/corporate_wealth.py b/policyengine_uk/variables/input/corporate_wealth.py index e1dabe6b62..c86bda946e 100644 --- a/policyengine_uk/variables/input/corporate_wealth.py +++ b/policyengine_uk/variables/input/corporate_wealth.py @@ -4,11 +4,11 @@ class corporate_wealth(Variable): label = "corporate wealth" documentation = ( - "Wealth held in corporations directly or through investment funds: UK " - "shares, employee shares and options, unit and investment trusts, and " - "stocks and shares ISAs, imputed from the Wealth and Assets Survey. The " - "stocks and shares ISA component is also exported on its own as " - "stocks_and_shares_isa, so the two must never be summed. Private pension " + "Wealth held in corporations directly or through investment funds, " + "outside pensions: directly_held_shares (UK shares and employee shares " + "and options), unit_and_investment_trusts and stocks_and_shares_isa. " + "Datasets that carry those components build this variable as their " + "exact sum, so it must never be summed with any of them. Private pension " "wealth is carried separately in private_pension_wealth; datasets built " "before that split folded it into this variable." ) diff --git a/policyengine_uk/variables/input/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py new file mode 100644 index 0000000000..71f82217f4 --- /dev/null +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class directly_held_shares(Variable): + label = "directly held shares" + documentation = ( + "Value of shares the household holds directly, outside ISAs and pooled " + "funds: shares in UK companies, listed or not, and employee shares and " + "share options. Part of corporate_wealth, so the two must never be " + "summed. The Enhanced FRS imputes it from the Wealth and Assets Survey " + "(UK shares plus employee shares and options); overseas shares are not " + "yet imputed. Quoted shares are valued for the means tests at their " + "price less 10% for the expenses of sale (Advice for Decision Making " + "H1665)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 diff --git a/policyengine_uk/variables/input/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py new file mode 100644 index 0000000000..07e59aaee1 --- /dev/null +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class unit_and_investment_trusts(Variable): + label = "unit and investment trusts" + documentation = ( + "Value of the household's holdings of unit trusts and investment trusts " + "outside ISAs. The Wealth and Assets Survey asks about both in one " + "question, so the Enhanced FRS cannot separate them. Part of " + "corporate_wealth, so the two must never be summed. Unit trusts are " + "valued for the means tests at the price the manager pays on " + "withdrawal, with no deduction for expenses of sale (Advice for " + "Decision Making H1673-H1674)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 From b967481e40357d85c837b0724f0b796cc6419f71 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 23:14:26 -0400 Subject: [PATCH 18/75] Share one carer attribution between the legacy severe disability premium and the Pension Credit addition Both rules ask whether anyone is entitled to and in receipt of Carer's Allowance or Carer Support Payment in respect of caring for the claimant or partner (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (2)(b); SPC Regs 2002 Sch I para 1(1)). The legacy premium counted only carers in the claimant's own benefit unit; the addition also attributed carers in other benefit units of the household. Both now read is_cared_for_by_carer_benefit_recipient, a person-level attribution over the people a carer benefit can be paid for (SSCBA 1992 s.70(2)). The residence tests stay separate (HB reg 3 non-dependants with para 14(4); SPC Sch I para 2). Fixes #1972 Co-Authored-By: Claude Opus 5.5 --- changelog.d/sdp-carer-attribution.fixed.md | 1 + .../qualifying_disability_benefits.yaml | 31 ++ .../family/severe_disability_premium.yaml | 184 ++++++++++++ ...evere_disability_residence_and_carers.yaml | 31 +- ..._cared_for_by_carer_benefit_recipient.yaml | 283 ++++++++++++++++++ ...t_severe_disability_addition_properties.py | 66 +++- ...st_severe_disability_premium_properties.py | 97 ++++-- ...isability_addition_qualifiers_cared_for.py | 102 ------- ...e_disability_minimum_guarantee_addition.py | 18 +- ...is_cared_for_by_carer_benefit_recipient.py | 109 +++++++ ..._severely_disabled_for_carers_allowance.py | 27 ++ .../demographic/severe_disability_premium.py | 29 +- 12 files changed, 818 insertions(+), 160 deletions(-) create mode 100644 changelog.d/sdp-carer-attribution.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml create mode 100644 policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml delete mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py create mode 100644 policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py create mode 100644 policyengine_uk/variables/household/demographic/is_severely_disabled_for_carers_allowance.py diff --git a/changelog.d/sdp-carer-attribution.fixed.md b/changelog.d/sdp-carer-attribution.fixed.md new file mode 100644 index 0000000000..ceda1cf9a4 --- /dev/null +++ b/changelog.d/sdp-carer-attribution.fixed.md @@ -0,0 +1 @@ +The legacy severe disability premium and the Pension Credit severe disability addition now share one attribution of whom each carer benefit is paid for caring for (`is_cared_for_by_carer_benefit_recipient`), so a carer in another benefit unit of the household bars the legacy premium as it already barred the addition, even when that carer is ignored as a non-dependant. diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml new file mode 100644 index 0000000000..b4b95cdf9c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml @@ -0,0 +1,31 @@ +description: > + Benefits whose receipt makes a person severely disabled for Carer's + Allowance, so that a carer benefit may be paid for caring for them: + attendance allowance, the care component of disability living allowance at + the highest or middle rate, the daily living component of personal + independence payment and armed forces independence payment. The Scottish + equivalents are not separate model variables, and the constant attendance + allowances prescribed by regulation 3 of the Invalid Care Allowance + Regulations are not modelled. +metadata: + label: Carer's Allowance qualifying disability benefits + period: year + unit: list + reference: + - title: Social Security Contributions and Benefits Act 1992 s. 70(2) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/70 + - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 5 + href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/5 + - title: The Armed Forces and Reserve Forces (Compensation Scheme) (Consequential Provisions - Primary Legislation) Order 2013 art. 3(2) + href: https://www.legislation.gov.uk/uksi/2013/796/article/3 + - title: The Social Security (Invalid Care Allowance) Regulations 1976 reg. 3 + href: https://www.legislation.gov.uk/uksi/1976/409/regulation/3 +values: + 1992-07-01: + - attendance_allowance + - dla_sc_middle_plus + 2013-04-08: + - attendance_allowance + - dla_sc_middle_plus + - pip_dl + - armed_forces_independence_payment diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/severe_disability_premium.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/severe_disability_premium.yaml index ea89ee3350..f9182746bb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/severe_disability_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/severe_disability_premium.yaml @@ -520,6 +520,190 @@ output: severe_disability_premium: [4474.60, 0] +# Carers in other benefit units (para 14(2)(a)(iii) and (2)(b), with 14(4)). +# The carer condition is separate from the non-dependant condition: a +# non-dependant whose presence is ignored under para 14(4) can still be the +# person who receives a carer benefit for caring for the claimant. Whom each +# carer benefit is paid for is attributed by +# is_cared_for_by_carer_benefit_recipient, shared with the Pension Credit +# severe disability addition. + +- name: A non-dependant ignored for receiving a qualifying benefit still bars the premium as the claimant's carer + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + pip_dl_category: STANDARD + son: + age: 25 + pip_dl_category: STANDARD + receives_carer_benefit: true + benunits: + claimant_family: + members: [claimant] + son_family: + members: [son] + households: + household: + members: [claimant, son] + output: + # Each is ignored as the other's non-dependant (para 14(4)(a)). The son's + # Carer's Allowance is for caring for the claimant, so the claimant fails + # para 14(2)(a)(iii); no one is paid for caring for the son. + has_non_dependant_for_severe_disability_premium: [false, false] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: [0, 4474.60] + +- name: A blind non-dependant who receives a carer benefit bars the premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + pip_dl_category: STANDARD + son: + age: 25 + is_blind: true + receives_carer_benefit: true + benunits: + claimant_family: + members: [claimant] + son_family: + members: [son] + households: + household: + members: [claimant, son] + output: + # The son is ignored as a non-dependant (para 14(4)(b)) but is the + # claimant's carer. + has_non_dependant_for_severe_disability_premium: [false, false] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: [0, 0] + +- name: A couple who both qualify get the single rate when an ignored non-dependant is paid for caring for one of them + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + pip_dl_category: STANDARD + partner: + age: 48 + pip_dl_category: ENHANCED + son: + age: 25 + pip_dl_category: STANDARD + receives_carer_benefit: true + benunits: + couple: + members: [claimant, partner] + son_family: + members: [son] + households: + household: + members: [claimant, partner, son] + output: + # One award is for one person: a carer for only one of the couple leaves + # the single rate (para 14(2)(b); Sch 3 para 20(6)(b)(i)). + is_cared_for_by_carer_benefit_recipient: [true, false, false] + severe_disability_premium: [4474.60, 4474.60] + +- name: A non-dependant who receives a carer benefit and has a severely disabled partner is treated as caring for their partner + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + pip_dl_category: STANDARD + son: + age: 30 + pip_dl_category: STANDARD + receives_carer_benefit: true + sons_partner: + age: 28 + pip_dl_category: STANDARD + benunits: + claimant_family: + members: [claimant] + son_family: + members: [son, sons_partner] + households: + household: + members: [claimant, son, sons_partner] + output: + # A carer cares within their own benefit unit first, so the claimant has + # no carer. The son and his partner both qualify with a carer paid for + # one of them: the single rate. + is_cared_for_by_carer_benefit_recipient: [false, false, true] + severe_disability_premium: [4474.60, 4474.60] + +- name: One carer's award bars the premium of only one of two other benefit units, the one with the eldest member + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 60 + pip_dl_category: STANDARD + younger: + age: 55 + pip_dl_category: STANDARD + carer: + age: 30 + pip_dl_category: STANDARD + receives_carer_benefit: true + benunits: + older_family: + members: [older] + younger_family: + members: [younger] + carer_family: + members: [carer] + households: + household: + members: [older, younger, carer] + output: + is_cared_for_by_carer_benefit_recipient: [true, false, false] + severe_disability_premium: [0, 4474.60, 4474.60] + +- name: The legacy premium and the Pension Credit addition read the same carer attribution + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + aa_category: LOWER + working_age: + age: 45 + pip_dl_category: STANDARD + carer: + age: 40 + pip_dl_category: STANDARD + receives_carer_benefit: true + benunits: + pensioner_family: + members: [pensioner] + working_age_family: + members: [working_age] + carer_family: + members: [carer] + households: + household: + members: [pensioner, working_age, carer] + output: + # The one carer benefit is for the pensioner under both rules, so the + # working-age claimant keeps the premium and the pensioner loses the + # addition. + is_cared_for_by_carer_benefit_recipient: [true, false, false] + severe_disability_premium: [0, 4474.60, 4474.60] + severe_disability_minimum_guarantee_addition: [0, 4474.60, 4474.60] + # Flow-through to the applicable amounts. - name: The premium is part of the legacy benefit premiums diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml index 75e75aafbd..c5473a5028 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml @@ -4,7 +4,8 @@ # (para 1(1)(a)(iii), (b)(ii) and (c)(iv)). 2026-27 weekly rates: one rate # 86.05 (4,474.60 a year), two rates 172.10 (8,949.20 a year). # Carer benefits are attributed as documented on -# num_severe_disability_addition_qualifiers_cared_for. +# is_cared_for_by_carer_benefit_recipient, which the legacy severe disability +# premium shares. - name: A single claimant on Attendance Allowance who lives alone gets one rate period: 2026 @@ -212,7 +213,7 @@ country: ENGLAND output: receives_carer_benefit: true - num_severe_disability_addition_qualifiers_cared_for: 0 + is_cared_for_by_carer_benefit_recipient: false severe_disability_minimum_guarantee_addition: 4_474.60 - name: Carer's Allowance paid to another household member stops a single claimant's addition @@ -241,7 +242,7 @@ members: [claimant, daughter] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 0] + is_cared_for_by_carer_benefit_recipient: [true, false] severe_disability_minimum_guarantee_addition: [0, 4_474.60] - name: A couple who both qualify get one rate when the claimant receives Carer's Allowance @@ -267,7 +268,7 @@ members: [claimant, partner] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: 1 + is_cared_for_by_carer_benefit_recipient: [false, true] severe_disability_minimum_guarantee_addition: 4_474.60 - name: A couple who both qualify get one rate when another household member receives Carer's Allowance @@ -296,7 +297,7 @@ members: [claimant, partner, daughter] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 0] + is_cared_for_by_carer_benefit_recipient: [true, false, false] severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] - name: A couple get nothing when carer benefits are paid for both partners @@ -329,7 +330,7 @@ members: [claimant, partner, daughter] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [2, 0] + is_cared_for_by_carer_benefit_recipient: [true, true, false] severe_disability_minimum_guarantee_addition: [0, 4_474.60] - name: A qualifying partner's own Carer's Allowance does not stop the blind-partner route @@ -356,7 +357,7 @@ members: [claimant, partner] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: 0 + is_cared_for_by_carer_benefit_recipient: [false, false] severe_disability_minimum_guarantee_addition: 4_474.60 - name: The blind-partner route also needs no other adult resident @@ -649,7 +650,7 @@ members: [younger, elder, carer] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [0, 1, 0] + is_cared_for_by_carer_benefit_recipient: [false, true, false] severe_disability_minimum_guarantee_addition: [4_474.60, 0, 0] - name: Two carers in the household cover two benefit units @@ -685,7 +686,7 @@ members: [younger, elder, carer_1, carer_2] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 1, 0, 0] + is_cared_for_by_carer_benefit_recipient: [true, true, false, false] severe_disability_minimum_guarantee_addition: [0, 0, 0, 0] - name: Two single pensioners who each receive Carer's Allowance care for each other @@ -713,7 +714,7 @@ members: [first, second] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 1] + is_cared_for_by_carer_benefit_recipient: [true, true] severe_disability_minimum_guarantee_addition: [0, 0] - name: A resident who has just attained 18 and is not a qualifying young person counts @@ -804,7 +805,7 @@ members: [grandmother, daughter, child] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [0, 0] + is_cared_for_by_carer_benefit_recipient: [false, false, true] severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] - name: A second carer in a pensioner's unit is attributed to a pensioner in another unit @@ -839,7 +840,7 @@ members: [first, carer_1, carer_2, second] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 1] + is_cared_for_by_carer_benefit_recipient: [true, false, false, true] severe_disability_minimum_guarantee_addition: [0, 0] - name: When two benefit units' eldest members are the same age, the one whose eldest member comes first in person order takes a carer @@ -869,7 +870,7 @@ members: [listed_first, listed_second, carer] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 0, 0] + is_cared_for_by_carer_benefit_recipient: [true, false, false] severe_disability_minimum_guarantee_addition: [0, 4_474.60, 0] - name: A carer who is the only qualifying member of their own unit cares for someone in another unit @@ -903,7 +904,7 @@ members: [grandmother, father, daughter] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 0] + is_cared_for_by_carer_benefit_recipient: [true, false, false] severe_disability_minimum_guarantee_addition: [0, 0] - name: The two young carers cover both pensioners when each is in their own unit too @@ -941,5 +942,5 @@ members: [first, carer_1, carer_2, second] country: ENGLAND output: - num_severe_disability_addition_qualifiers_cared_for: [1, 0, 0, 1] + is_cared_for_by_carer_benefit_recipient: [true, false, false, true] severe_disability_minimum_guarantee_addition: [0, 0, 0, 0] diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml new file mode 100644 index 0000000000..c4e84b774a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml @@ -0,0 +1,283 @@ +# Whom a carer benefit is paid for caring for: the carer condition of the +# legacy severe disability premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and +# (2)(b)) and of the Pension Credit severe disability addition (SPC Regs 2002 +# Sch I para 1(1)). A carer benefit is paid for caring for a severely disabled +# person (SSCBA 1992 s.70(2)), one award per carer (s.70(7)) and one carer per +# severely disabled person (s.70(7ZA)). The data do not say whom a carer cares +# for, so each award is attributed: within the carer's own benefit unit first +# (a claimant or partner, then another member), then to a claimant or partner +# of another benefit unit in the household, eldest unit first. + +- name: Attendance Allowance, DLA care at the middle rate, PIP daily living and AFIP make a person severely disabled for Carer's Allowance + period: 2026 + input: + people: + attendance_allowance: + age: 70 + aa_category: LOWER + dla_care_middle: + age: 40 + dla_sc_category: MIDDLE + dla_care_lowest: + age: 40 + dla_sc_category: LOWER + pip_daily_living: + age: 40 + pip_dl_category: STANDARD + pip_mobility_only: + age: 40 + pip_m_category: ENHANCED + afip: + age: 40 + armed_forces_independence_payment: 1_000 + none: + age: 40 + output: + is_severely_disabled_for_carers_allowance: [true, true, false, true, false, true, false] + +- name: No one is their own carer + period: 2026 + input: + age: 40 + pip_dl_category: STANDARD + receives_carer_benefit: true + output: + is_cared_for_by_carer_benefit_recipient: false + +- name: A carer benefit is not paid for caring for someone who is not severely disabled + period: 2026 + input: + people: + claimant: + age: 40 + partner: + age: 40 + receives_carer_benefit: true + benunits: + couple: + members: [claimant, partner] + output: + is_cared_for_by_carer_benefit_recipient: [false, false] + +- name: A partner who receives a carer benefit cares for the claimant + period: 2026 + input: + people: + claimant: + age: 40 + pip_dl_category: STANDARD + partner: + age: 40 + receives_carer_benefit: true + benunits: + couple: + members: [claimant, partner] + output: + is_cared_for_by_carer_benefit_recipient: [true, false] + +- name: When both partners are severely disabled and one receives a carer benefit, the other is cared for + period: 2026 + input: + people: + claimant: + age: 40 + pip_dl_category: STANDARD + receives_carer_benefit: true + partner: + age: 40 + pip_dl_category: STANDARD + benunits: + couple: + members: [claimant, partner] + output: + is_cared_for_by_carer_benefit_recipient: [false, true] + +- name: When both partners receive a carer benefit, each cares for the other + period: 2026 + input: + people: + claimant: + age: 40 + pip_dl_category: STANDARD + receives_carer_benefit: true + partner: + age: 40 + pip_dl_category: STANDARD + receives_carer_benefit: true + benunits: + couple: + members: [claimant, partner] + output: + is_cared_for_by_carer_benefit_recipient: [true, true] + +- name: One carer for two severely disabled partners cares for the first in person order + period: 2026 + input: + people: + claimant: + age: 45 + pip_dl_category: STANDARD + partner: + age: 45 + pip_dl_category: STANDARD + young_carer: + age: 17 + receives_carer_benefit: true + benunits: + family: + members: [claimant, partner, young_carer] + output: + is_cared_for_by_carer_benefit_recipient: [true, false, false] + +- name: A parent who receives a carer benefit cares for their disabled child + period: 2026 + input: + people: + parent: + age: 40 + receives_carer_benefit: true + child: + age: 10 + dla_sc_category: MIDDLE + benunits: + family: + members: [parent, child] + output: + is_cared_for_by_carer_benefit_recipient: [false, true] + +- name: A carer cares for a severely disabled partner before a disabled child + period: 2026 + input: + people: + claimant: + age: 40 + pip_dl_category: STANDARD + partner: + age: 40 + receives_carer_benefit: true + child: + age: 10 + dla_sc_category: MIDDLE + benunits: + family: + members: [claimant, partner, child] + output: + is_cared_for_by_carer_benefit_recipient: [true, false, false] + +- name: Two carers in a benefit unit care for the severely disabled partner and the disabled child + period: 2026 + input: + people: + claimant: + age: 45 + pip_dl_category: STANDARD + partner: + age: 45 + receives_carer_benefit: true + young_carer: + age: 17 + receives_carer_benefit: true + child: + age: 10 + dla_sc_category: MIDDLE + benunits: + family: + members: [claimant, partner, young_carer, child] + output: + is_cared_for_by_carer_benefit_recipient: [true, false, false, true] + +- name: A carer with no one to care for in their own benefit unit cares for a claimant in another + period: 2026 + input: + people: + claimant: + age: 50 + pip_dl_category: STANDARD + son: + age: 25 + receives_carer_benefit: true + benunits: + claimant_family: + members: [claimant] + son_family: + members: [son] + households: + household: + members: [claimant, son] + output: + is_cared_for_by_carer_benefit_recipient: [true, false] + +- name: A carer in another benefit unit cares for a claimant or partner, not a disabled child + period: 2026 + input: + people: + parent: + age: 40 + child: + age: 10 + dla_sc_category: MIDDLE + grandparent: + age: 70 + receives_carer_benefit: true + benunits: + family: + members: [parent, child] + grandparent_family: + members: [grandparent] + households: + household: + members: [parent, child, grandparent] + output: + # The attribution places a carer from another benefit unit only with a + # claimant or partner, so the grandparent's award is for someone outside + # the household. + is_cared_for_by_carer_benefit_recipient: [false, false, false] + +- name: One carer's award is for one person, in the benefit unit with the eldest member + period: 2026 + input: + people: + younger: + age: 55 + pip_dl_category: STANDARD + older: + age: 60 + pip_dl_category: STANDARD + carer: + age: 30 + receives_carer_benefit: true + benunits: + younger_family: + members: [younger] + older_family: + members: [older] + carer_family: + members: [carer] + households: + household: + members: [younger, older, carer] + output: + is_cared_for_by_carer_benefit_recipient: [false, true, false] + +- name: A carer in another household is not attributed + period: 2026 + input: + people: + claimant: + age: 50 + pip_dl_category: STANDARD + daughter: + age: 25 + receives_carer_benefit: true + benunits: + claimant_family: + members: [claimant] + daughter_family: + members: [daughter] + households: + claimant_household: + members: [claimant] + daughter_household: + members: [daughter] + output: + is_cared_for_by_carer_benefit_recipient: [false, false] diff --git a/policyengine_uk/tests/test_severe_disability_addition_properties.py b/policyengine_uk/tests/test_severe_disability_addition_properties.py index c03142839b..f698e9f43d 100644 --- a/policyengine_uk/tests/test_severe_disability_addition_properties.py +++ b/policyengine_uk/tests/test_severe_disability_addition_properties.py @@ -27,12 +27,28 @@ unit's addition. 5. Symmetry: swapping the drawn attributes of a couple's two partners leaves their addition unchanged. +6. Shared carer attribution, differential: the claimants and partners that + is_cared_for_by_carer_benefit_recipient marks in each benefit unit are one + of the sets the reference's person-to-person matching allows. Structural: + only a person who is severely disabled for Carer's Allowance is marked, a + household has no more people cared for than carer benefits, and a + household's only carer is never marked as caring for themselves. +7. Cross-programme, differential: the legacy severe disability premium (HB + Regs 2006 Sch 3 para 14) reads the same attribution, and its qualifying + benefits, couple, blind-partner and carer rules and rates are those of the + addition. The two residence tests differ only over 16- to 19-year-olds + (HB reg 3 non-dependants against SPC Sch I para 2(2)(f)), so in a + household with no one of that age the premium equals the addition for + every benefit unit, carers in other benefit units included. """ import numpy as np from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from severe_disability_addition_reference import reference_rates +from severe_disability_addition_reference import ( + household_carer_assignments, + reference_rates, +) from policyengine_uk import Simulation @@ -139,6 +155,10 @@ def calc(variable): "qualifies": calc("receives_severe_disability_addition_qualifying_benefit"), "carer": calc("receives_carer_benefit"), "is_couple": calc("is_couple"), + "cared_for": calc("is_cared_for_by_carer_benefit_recipient"), + "could_be_cared_for": calc("is_severely_disabled_for_carers_allowance"), + "addition": calc("severe_disability_minimum_guarantee_addition"), + "legacy_premium": calc("severe_disability_premium"), } @@ -279,3 +299,47 @@ def test_swapping_partners_leaves_a_couples_addition_unchanged(original): result = simulate([original, swapped]) before, after = rates_by_household(result) assert before[0] == after[0] + + +@PROPERTY_SETTINGS +@given(st.lists(household(), min_size=1, max_size=6)) +def test_shared_carer_attribution_matches_the_reference_matching(households): + result = simulate(households) + cared_for = result["cared_for"].astype(bool) + # Only someone a carer benefit can be paid for is marked. + assert not np.any(cared_for & ~result["could_be_cared_for"].astype(bool)) + np.testing.assert_array_equal(result["could_be_cared_for"], result["qualifies"]) + k = 0 + for h, layout in enumerate(result["layout"]): + allowed = household_carer_assignments(reference_household(result, h)) + names = [name for unit in layout for name, _, _ in unit] + marked = {name for i, name in enumerate(names) if cared_for[k + i]} + carers = int(result["carer"][k : k + len(names)].sum()) + # Each award is for one person. + assert len(marked) <= carers, (layout, marked) + if carers == 1 and len(names) > 0: + (carer,) = [n for i, n in enumerate(names) if result["carer"][k + i]] + assert carer not in marked, (layout, marked) + for b, unit in enumerate(layout): + claimants = {name for name, role, _ in unit if role == "cp"} + assert frozenset(marked & claimants) in allowed[b], ( + layout, + marked, + allowed[b], + ) + k += len(names) + + +def no_one_aged_16_to_19(units): + return not any( + 16 <= p["age"] <= 19 + for unit in units + for p in unit["claimants"] + unit["dependants"] + ) + + +@PROPERTY_SETTINGS +@given(st.lists(household().filter(no_one_aged_16_to_19), min_size=1, max_size=6)) +def test_legacy_premium_equals_the_addition_without_young_people(households): + result = simulate(households) + np.testing.assert_allclose(result["legacy_premium"], result["addition"], atol=0.01) diff --git a/policyengine_uk/tests/test_severe_disability_premium_properties.py b/policyengine_uk/tests/test_severe_disability_premium_properties.py index d34cc8461c..00af23dc76 100644 --- a/policyengine_uk/tests/test_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_severe_disability_premium_properties.py @@ -15,15 +15,19 @@ 1. Range: the premium is 0, the single rate or the double rate, and the double rate goes only to a couple who both qualify. 2. Oracle: the premium equals an independent statement of the rule in which - the carer condition is a brute-force assignment of each carer award to a - benefit-unit member other than its recipient (the model does not observe - who is cared for, so it takes the assignment that covers the most - qualifying members). + the carer condition is the person-to-person matching of + severe_disability_addition_reference.household_carer_assignments, the + reference the Pension Credit severe disability addition is tested against + (the model does not observe who is cared for; both rules read the one + attribution, is_cared_for_by_carer_benefit_recipient). Carers in other + benefit units of the household are attributed too, so a non-dependant + whose presence is ignored can still bar the premium as the claimant's + carer. 3. Metamorphic: adding a household member aged 18 or over, in another benefit unit, who receives no qualifying benefit and is not blind, removes the premium. The oracle also treats a benefit-unit member aged 18 or 19 who is not a qualifying young person as a non-dependant (HB Regs regs 3, 19). -4. Metamorphic: paying anyone in the benefit unit a carer benefit never +4. Metamorphic: paying anyone in the household a carer benefit never increases the premium. 5. Metamorphic: giving anyone in the household a qualifying benefit, or making them blind, never reduces the premium. @@ -34,11 +38,10 @@ two encodings of the same rule in step; it is not an independent oracle. """ -from itertools import product - import numpy as np from hypothesis import HealthCheck, event, example, given, settings from hypothesis import strategies as st +from severe_disability_addition_reference import household_carer_assignments from policyengine_uk import Simulation @@ -175,9 +178,41 @@ def simulate_before_after(units, changed): return premium[: len(units)], premium[len(units) :] +def claimants_and_partners_cared_for(unit): + """Every allowed set of the family's adults a carer benefit is paid for. + + The household as the shared carer reference reads it: the family's adults + are its claimant and partner, and each other person is a benefit unit of + their own, its claimant from age 16. People are listed in simulation + order. + """ + family = family_members(unit) + people, benunits = {}, {} + benunits["family"] = {"claimant_or_partner": [], "others": []} + for i, p in enumerate(family): + role = "claimant_or_partner" if i < len(unit["adults"]) else "others" + benunits["family"][role].append(("family", i)) + people[("family", i)] = p + for j, p in enumerate(unit["others"]): + role = "claimant_or_partner" if p["age"] >= 16 else "others" + benunits[("other", j)] = {"claimant_or_partner": [], "others": []} + benunits[("other", j)][role].append(("other", j)) + people[("other", j)] = p + people = { + pid: dict( + age=p["age"], + qualifying_benefit=qualifies(p), + receives_carer_benefit=p["carer"], + ) + for pid, p in people.items() + } + allowed = household_carer_assignments({"people": people, "benunits": benunits}) + return [{i for _, i in cared_for} for cared_for in allowed["family"]] + + def oracle(unit): """The premium by the statutory rule, written independently of the model.""" - adults, family = unit["adults"], family_members(unit) + adults = unit["adults"] # Non-dependants: other benefit units in the household, and anyone in the # benefit unit who is not the claimant, partner, a child or a qualifying # young person (HB Regs regs 3(2)(a), 19). @@ -198,17 +233,18 @@ def oracle(unit): return 0.0 elif len(adults) == 1 and not qualifying: return 0.0 - # Assign each carer award to one family member other than its recipient, - # and count the most qualifying members that can be covered. - carers = [i for i, p in enumerate(family) if p["carer"]] - cared_for = 0 - for assignment in product(range(len(family)), repeat=len(carers)): - if any(c == target for c, target in zip(carers, assignment)): - continue - cared_for = max(cared_for, len(set(assignment) & set(qualifying))) - if len(adults) == 2 and len(qualifying) == 2: - return (2 - cared_for) * SINGLE - return SINGLE if cared_for == 0 else 0.0 + # Which qualifying members a carer benefit is paid for caring for. Where + # the attribution leaves a choice between partners, the premium must not + # depend on it. + premiums = set() + for cared_for in claimants_and_partners_cared_for(unit): + cared_for = len(cared_for & set(qualifying)) + if len(adults) == 2 and len(qualifying) == 2: + premiums.add((2 - cared_for) * SINGLE) + else: + premiums.add(SINGLE if cared_for == 0 else 0.0) + assert len(premiums) == 1, (unit, premiums) + return premiums.pop() def _adult(benefit, blind=False, carer=False): @@ -246,6 +282,20 @@ def _adult(benefit, blind=False, carer=False): young=[dict(_adult("none"), age=19, in_education=True, entry_age=17)], others=[], ), + # A non-dependant who receives a qualifying benefit is ignored as a + # resident (para 14(4)(a)) but, receiving a carer benefit, is the + # claimant's carer: no premium. + dict( + adults=[_adult("pip_standard")], + children=[], + others=[_adult("pip_standard", carer=True)], + ), + # The same for a couple who both qualify: the single rate. + dict( + adults=[_adult("pip_standard"), _adult("aa_lower")], + children=[], + others=[_adult("none", blind=True, carer=True)], + ), ] ) def test_premium_matches_statutory_oracle_and_range(units): @@ -272,13 +322,14 @@ def test_counted_non_dependant_removes_premium(units, age): @PROPERTY_SETTINGS -@given(st.lists(households(), min_size=1, max_size=10), st.integers(0, 2)) +@given(st.lists(households(), min_size=1, max_size=10), st.integers(0, 4)) def test_carer_benefit_never_increases_premium(units, index): def pay_carer(unit): family = family_members(unit) - i = index % len(family) - family = [dict(p, carer=p["carer"] or k == i) for k, p in enumerate(family)] - return with_family(unit, family) + everyone = family + unit["others"] + i = index % len(everyone) + paid = [dict(p, carer=p["carer"] or k == i) for k, p in enumerate(everyone)] + return dict(with_family(unit, paid[: len(family)]), others=paid[len(family) :]) before, after = simulate_before_after(units, [pay_carer(u) for u in units]) assert np.all(after <= before + 0.01) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py deleted file mode 100644 index da7e25d637..0000000000 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/num_severe_disability_addition_qualifiers_cared_for.py +++ /dev/null @@ -1,102 +0,0 @@ -from policyengine_uk.model_api import * - - -class num_severe_disability_addition_qualifiers_cared_for(Variable): - value_type = int - entity = BenUnit - label = ( - "Qualifying claimants or partners with a carer benefit paid for caring for them" - ) - documentation = ( - "How many of the claimant and partner who receive a qualifying " - "disability benefit have someone entitled to and in receipt of " - "Carer's Allowance or Carer Support Payment in respect of caring for " - "them. The data do not say whom a carer cares for. A carer benefit " - "needs the person cared for to receive a qualifying disability " - "benefit, and each award is for one person, who has only one such " - "carer (SSCBA 1992 s.70(7) and (7ZA)), so each carer is attributed as " - "follows. No one is their own carer. A carer cares for someone in " - "their own benefit unit who receives a qualifying benefit if there is " - "one: a qualifying claimant or partner first, then another member such " - "as a disabled child, each such person having one carer. Every other " - "carer in the household, including one left over in their own unit, " - "is allocated to a qualifying claimant or partner not yet cared for in " - "another benefit unit, one each, taking the units in descending order " - "of their eldest member's age (ties in person order, the order people " - "are listed in the input or dataset). Carers outside the household, " - "and Universal Credit awards that include the carer element, are not " - "counted. Reading Universal Credit here would be circular, because the " - "Universal Credit non-dependant deduction exemption reads Pension " - "Credit; a Universal Credit carer in the household already stops the " - "addition through the residence condition unless their presence is " - "ignored. Receipt is receives_carer_benefit, so it inherits " - "carers_allowance, which does not apply the overlapping-benefit rule " - "that stops Carer's Allowance being paid alongside a higher State " - "Pension." - ) - definition_period = YEAR - reference = ( - "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", - "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", - "https://www.legislation.gov.uk/ukpga/1992/4/section/70", - ) - - def formula(benunit, period, parameters): - person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - receives = person( - "receives_severe_disability_addition_qualifying_benefit", period - ) - qualifies = claimant_or_partner & receives - dependant_receives = ~claimant_or_partner & receives - carer = person("receives_carer_benefit", period) - qualifying = benunit.sum(qualifies) - receiving_dependants = benunit.sum(dependant_receives) - # People in the carer's own unit the carer could be caring for. - qualifiers_in_own_unit = benunit.project(qualifying) - qualifies - dependants_in_own_unit = ( - benunit.project(receiving_dependants) - dependant_receives - ) - cares_for_a_qualifier = carer & (qualifiers_in_own_unit > 0) - cares_within_unit = carer & ( - (qualifiers_in_own_unit + dependants_in_own_unit) > 0 - ) - # Qualifying claimants and partners first, one carer each. - cared_for_within = min_(qualifying, benunit.sum(cares_for_a_qualifier)) - not_yet_cared_for = qualifying - cared_for_within - placed_within = min_( - benunit.sum(cares_within_unit), qualifying + receiving_dependants - ) - # Every other carer benefit is for someone in another unit. - outside_here = benunit.sum(carer) - placed_within - outside_members = where( - person.get_rank(person.benunit, -person("age", period)) == 0, - benunit.project(outside_here), - 0, - ) - outside_in_household = benunit.max(person.household.sum(outside_members)) - # Order the household's units by their eldest member (ties by person order) - # and give each unit, in turn, carers from the other units. - age = person("age", period) - representative = person.get_rank(person.benunit, -age) == 0 - order = person.get_rank(person.household, -age, condition=representative) - unit_order = benunit.max(where(representative, order, -1)) - allocated_in_household = np.zeros_like(outside_here) - cared_for_from_other_units = np.zeros_like(outside_here) - for rank in range(int(np.max(unit_order, initial=-1)) + 1): - available = max_( - 0, - min_( - outside_in_household - outside_here, - outside_in_household - allocated_in_household, - ), - ) - allocated = where(unit_order == rank, min_(not_yet_cared_for, available), 0) - cared_for_from_other_units = cared_for_from_other_units + allocated - allocated_by_representative = where( - representative, benunit.project(allocated), 0 - ) - allocated_in_household = allocated_in_household + benunit.max( - person.household.sum(allocated_by_representative) - ) - return cared_for_within + cared_for_from_other_units 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 9e8d299a53..56cf7b0bca 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 @@ -23,10 +23,11 @@ class severe_disability_minimum_guarantee_addition(Variable): "either partner, to qualify where the other partner is blind. Not " "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 unmodelled para 2 and 3 " - "exceptions and carer attribution described on " - "is_counted_resident_for_severe_disability_addition and " - "num_severe_disability_addition_qualifiers_cared_for. Blindness " + "(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. Blindness " "(is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) @@ -54,8 +55,13 @@ def formula(benunit, period, parameters): other_partner_blind = benunit.any( claimant_or_partner & ~qualifies & person("is_blind", period) ) - cared_for = benunit( - "num_severe_disability_addition_qualifiers_cared_for", period + # How many qualifying claimants and partners someone receives a carer + # benefit for caring for (the attribution is shared with the legacy + # severe disability premium). + cared_for = benunit.sum( + claimant_or_partner + & qualifies + & person("is_cared_for_by_carer_benefit_recipient", period) ) # Para 1(1)(b) with reg 6(5): two rates, less one for each partner a # carer benefit is paid for (none if paid for both). Para 1(1)(c): one 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 new file mode 100644 index 0000000000..289e4777e8 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -0,0 +1,109 @@ +from policyengine_uk.model_api import * + + +class is_cared_for_by_carer_benefit_recipient(Variable): + value_type = bool + entity = Person + 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 " + "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); 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 in their own " + "unit, cares for a claimant or partner not yet cared for in another " + "benefit unit, taking the units in descending order of their eldest " + "member's age (ties in person order, the order people are listed in " + "the input or dataset). Where only one of two people can have a " + "carer, it is one who is not a carer themselves, then the first in " + "person order; no count depends on that choice. Carers outside the " + "household are not observed. 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, so it inherits " + "carers_allowance, which does not apply the overlapping-benefit rule " + "that stops Carer's Allowance being paid alongside a higher State " + "Pension." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/70", + "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/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", + ) + + 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) + 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). + priority = carer.astype(int) + + # Within the benefit unit: claimants and partners, then other members. + cp_target = claimant_or_partner & could_be_cared_for + cp_targets = person.benunit.sum(cp_target) + targets = person.benunit.sum(could_be_cared_for) + can_care_for_cp = carer & (cp_targets - cp_target > 0) + can_care_within = carer & (targets - could_be_cared_for > 0) + cp_cared_for_within = min_(cp_targets, person.benunit.sum(can_care_for_cp)) + cared_for_within = min_(targets, person.benunit.sum(can_care_within)) + cp_within = cp_target & ( + person.get_rank(person.benunit, priority, condition=cp_target) + < cp_cared_for_within + ) + other_target = could_be_cared_for & ~claimant_or_partner + other_within = other_target & ( + person.get_rank(person.benunit, priority, condition=other_target) + < cared_for_within - cp_cared_for_within + ) + + # Every carer not placed in their own unit cares for a claimant or + # partner of another unit. Units take them in descending order of + # their eldest member's age, each taking as many as it needs from the + # carers of other units that are not yet placed. + left_over = person.benunit.sum(carer) - cared_for_within + eldest = person.get_rank(person.benunit, -age) == 0 + left_over_in_household = person.household.sum(where(eldest, left_over, 0)) + needed = cp_targets - cp_cared_for_within + unit_order = person.benunit.max( + where(eldest, person.get_rank(person.household, -age, condition=eldest), -1) + ) + from_other_units = np.zeros_like(needed) + placed_in_household = np.zeros_like(needed) + for rank in range(int(np.max(unit_order, initial=-1)) + 1): + available = max_( + 0, + min_( + left_over_in_household - left_over, + left_over_in_household - placed_in_household, + ), + ) + taken = where(unit_order == rank, min_(needed, available), 0) + from_other_units = from_other_units + taken + placed_in_household = placed_in_household + person.household.sum( + where(eldest, taken, 0) + ) + cp_not_yet = cp_target & ~cp_within + cp_from_other_units = cp_not_yet & ( + person.get_rank(person.benunit, priority, condition=cp_not_yet) + < from_other_units + ) + return cp_within | other_within | cp_from_other_units diff --git a/policyengine_uk/variables/household/demographic/is_severely_disabled_for_carers_allowance.py b/policyengine_uk/variables/household/demographic/is_severely_disabled_for_carers_allowance.py new file mode 100644 index 0000000000..8d70f6a67c --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_severely_disabled_for_carers_allowance.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class is_severely_disabled_for_carers_allowance(Variable): + value_type = bool + entity = Person + label = "Severely disabled person for Carer's Allowance" + documentation = ( + "A severely disabled person within SSCBA 1992 s.70(2): one for whom " + "attendance allowance, the care component of disability living " + "allowance at the highest or middle rate, the daily living component " + "of personal independence payment, or armed forces independence " + "payment is payable. A carer benefit can only be paid for caring for " + "such a person. The Scottish equivalents, which Carer Support Payment " + "also accepts, are not separate model variables, and the constant " + "attendance allowances prescribed by the Invalid Care Allowance " + "Regulations 1976 reg 3 are not modelled." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/70", + "https://www.legislation.gov.uk/uksi/1976/409/regulation/3", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.carers_allowance + return add(person, period, p.qualifying_disability_benefits) > 0 diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index cc54020a5d..fa173834ab 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -19,8 +19,11 @@ 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. The law also counts a Universal Credit award with the " - "carer element, which the model does not." + "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." ) definition_period = YEAR reference = ( @@ -34,12 +37,11 @@ class severe_disability_premium(Variable): unit = GBP def formula(benunit, period, parameters): - # The person cared for is not observed. A carer benefit paid to - # someone in the benefit unit is assumed to be for a claimant or - # partner other than its recipient, one person per award. Carers in - # other benefit units are not counted, and carers in other households - # are not observed. The hospital rules that keep a patient treated as - # in receipt (and then pay the single rate) are not modelled. + # Whom a carer benefit is paid for caring for is attributed by + # is_cared_for_by_carer_benefit_recipient, which the Pension Credit + # severe disability addition shares. The hospital rules that keep a + # patient treated as in receipt (and then pay the single rate) are not + # modelled. p = parameters(period).gov.dwp.disability_premia person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) @@ -51,9 +53,10 @@ def formula(benunit, period, parameters): partner_treated_as_absent = benunit.any( claimant_or_partner & ~qualifies & person("is_blind", period) ) - carer = person("receives_carer_benefit", period) - carers = benunit.sum(carer) - carers_for_qualifying_member = benunit.sum(carer & ~qualifying_member) + cared_for = benunit.sum( + qualifying_member + & person("is_cared_for_by_carer_benefit_recipient", period) + ) # Number of single rates payable: two for a couple who both qualify # with no carer paid for either, one when a carer is paid for only # one of them, and one for a single claimant (or a couple whose @@ -63,10 +66,10 @@ def formula(benunit, period, parameters): couple, (qualifying == 1) & partner_treated_as_absent, qualifying >= 1, - ) & (carers_for_qualifying_member == 0) + ) & (cared_for == 0) rates = where( couple & (qualifying >= 2), - max_(2 - carers, 0), + max_(2 - cared_for, 0), single_condition, ) weekly_amount = select( From 1c30130b05dc33b6dc92f18d274ba4dc2092c84b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 23:31:58 -0400 Subject: [PATCH 19/75] Address review: cite s.70(7ZA)-(7ZC), test other members and three flags, pin the qualifying lists - The attribution docstring says an unplaced award is for someone outside the household, and notes the s.70(1A) run-on. - The person-level property now checks other members (such as disabled children) against the reference's within-unit matching. - YAML cases for three claimant-or-partner flags. - A test that the Carer's Allowance, legacy premium and Pension Credit qualifying lists are the same model variables from 8 April 2013. Co-Authored-By: Claude Opus 5.5 --- .../qualifying_disability_benefits.yaml | 2 +- ...evere_disability_residence_and_carers.yaml | 1 + ..._cared_for_by_carer_benefit_recipient.yaml | 80 ++++++++++++++++++- ...t_severe_disability_addition_properties.py | 45 ++++++++++- ...is_cared_for_by_carer_benefit_recipient.py | 28 ++++--- 5 files changed, 138 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml index b4b95cdf9c..19c81815df 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/qualifying_disability_benefits.yaml @@ -16,7 +16,7 @@ metadata: href: https://www.legislation.gov.uk/ukpga/1992/4/section/70 - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 5 href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/5 - - title: The Armed Forces and Reserve Forces (Compensation Scheme) (Consequential Provisions - Primary Legislation) Order 2013 art. 3(2) + - title: "The Armed Forces and Reserve Forces (Compensation Scheme) (Consequential Provisions: Primary Legislation) Order 2013 art. 3(2)" href: https://www.legislation.gov.uk/uksi/2013/796/article/3 - title: The Social Security (Invalid Care Allowance) Regulations 1976 reg. 3 href: https://www.legislation.gov.uk/uksi/1976/409/regulation/3 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml index c5473a5028..e10994ac62 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/severe_disability_residence_and_carers.yaml @@ -773,6 +773,7 @@ members: [first, second, third] country: ENGLAND output: + is_cared_for_by_carer_benefit_recipient: [true, true, true] severe_disability_minimum_guarantee_addition: 0 - name: A carer whose own disabled child qualifies is treated as caring for the child diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml index c4e84b774a..2b067dd810 100644 --- a/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml +++ b/policyengine_uk/tests/policy/baseline/household/demographic/is_cared_for_by_carer_benefit_recipient.yaml @@ -3,7 +3,7 @@ # (2)(b)) and of the Pension Credit severe disability addition (SPC Regs 2002 # Sch I para 1(1)). A carer benefit is paid for caring for a severely disabled # person (SSCBA 1992 s.70(2)), one award per carer (s.70(7)) and one carer per -# severely disabled person (s.70(7ZA)). The data do not say whom a carer cares +# severely disabled person (s.70(7ZA) to (7ZC)). The data do not say whom a carer cares # for, so each award is attributed: within the carer's own benefit unit first # (a claimant or partner, then another member), then to a claimant or partner # of another benefit unit in the household, eldest unit first. @@ -281,3 +281,81 @@ members: [daughter] output: is_cared_for_by_carer_benefit_recipient: [false, false] + +- name: Three members flagged as claimant or partner, each a carer, each care for another + period: 2026 + input: + people: + first: + age: 75 + aa_category: HIGHER + receives_carer_benefit: true + is_claimant_or_partner: true + second: + age: 74 + aa_category: HIGHER + receives_carer_benefit: true + is_claimant_or_partner: true + third: + age: 73 + aa_category: HIGHER + receives_carer_benefit: true + is_claimant_or_partner: true + benunits: + benunit: + members: [first, second, third] + output: + is_cared_for_by_carer_benefit_recipient: [true, true, true] + +- name: Three members flagged as claimant or partner with one carer in another benefit unit, who cares for the first in person order + period: 2026 + input: + people: + first: + age: 75 + aa_category: HIGHER + is_claimant_or_partner: true + second: + age: 74 + aa_category: HIGHER + is_claimant_or_partner: true + third: + age: 73 + aa_category: HIGHER + is_claimant_or_partner: true + carer: + age: 40 + receives_carer_benefit: true + benunits: + benunit: + members: [first, second, third] + carer_family: + members: [carer] + households: + household: + members: [first, second, third, carer] + output: + is_cared_for_by_carer_benefit_recipient: [true, false, false, false] + +- name: Three members flagged as claimant or partner, one of them the only carer, who cares for the first other one + period: 2026 + input: + people: + first: + age: 75 + aa_category: HIGHER + receives_carer_benefit: true + is_claimant_or_partner: true + second: + age: 74 + aa_category: HIGHER + is_claimant_or_partner: true + third: + age: 73 + aa_category: HIGHER + is_claimant_or_partner: true + benunits: + benunit: + members: [first, second, third] + output: + is_cared_for_by_carer_benefit_recipient: [false, true, false] diff --git a/policyengine_uk/tests/test_severe_disability_addition_properties.py b/policyengine_uk/tests/test_severe_disability_addition_properties.py index f698e9f43d..86c2659998 100644 --- a/policyengine_uk/tests/test_severe_disability_addition_properties.py +++ b/policyengine_uk/tests/test_severe_disability_addition_properties.py @@ -29,10 +29,12 @@ their addition unchanged. 6. Shared carer attribution, differential: the claimants and partners that is_cared_for_by_carer_benefit_recipient marks in each benefit unit are one - of the sets the reference's person-to-person matching allows. Structural: - only a person who is severely disabled for Carer's Allowance is marked, a - household has no more people cared for than carer benefits, and a - household's only carer is never marked as caring for themselves. + of the sets the reference's person-to-person matching allows, and the + number of other members marked (such as disabled children) is the number + the reference's within-unit matching covers. Structural: only a person + who is severely disabled for Carer's Allowance is marked, a household has + no more people cared for than carer benefits, and a household's only + carer is never marked as caring for themselves. 7. Cross-programme, differential: the legacy severe disability premium (HB Regs 2006 Sch 3 para 14) reads the same attribution, and its qualifying benefits, couple, blind-partner and carer rules and rates are those of the @@ -46,6 +48,7 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from severe_disability_addition_reference import ( + _best_within_unit, household_carer_assignments, reference_rates, ) @@ -320,6 +323,7 @@ def test_shared_carer_attribution_matches_the_reference_matching(households): if carers == 1 and len(names) > 0: (carer,) = [n for i, n in enumerate(names) if result["carer"][k + i]] assert carer not in marked, (layout, marked) + reference = reference_household(result, h) for b, unit in enumerate(layout): claimants = {name for name, role, _ in unit if role == "cp"} assert frozenset(marked & claimants) in allowed[b], ( @@ -327,6 +331,20 @@ def test_shared_carer_attribution_matches_the_reference_matching(households): marked, allowed[b], ) + # Other members (such as a disabled child) are cared for only by + # carers of their own unit, after its claimants and partners. + others = {name for name, role, _ in unit if role != "cp"} + unit_carers = sum( + reference["people"][n]["receives_carer_benefit"] + for n in claimants | others + ) + within_cp, left_over = _best_within_unit( + reference["people"], reference["benunits"][b] + ) + assert len(marked & others) == unit_carers - left_over - within_cp, ( + layout, + marked, + ) k += len(names) @@ -343,3 +361,22 @@ def no_one_aged_16_to_19(units): def test_legacy_premium_equals_the_addition_without_young_people(households): result = simulate(households) np.testing.assert_allclose(result["legacy_premium"], result["addition"], atol=0.01) + + +def test_the_three_qualifying_benefit_lists_agree(): + """The attribution's candidates are the Carer's Allowance list (SSCBA 1992 + s.70(2)); the premium and the addition each count their own qualifying + claimants and partners among those marked. In the model's variables the + three lists are the same from 8 April 2013, when PIP and AFIP joined + s.70(2), which is what makes the two programmes' cared-for counts agree.""" + from policyengine_uk import CountryTaxBenefitSystem + + parameters = CountryTaxBenefitSystem().parameters + for instant in ("2013-04-08", "2020-01-01", "2026-01-01", "2030-01-01"): + dwp = parameters(instant).gov.dwp + carers_allowance = set(dwp.carers_allowance.qualifying_disability_benefits) + legacy = set(dwp.disability_premia.severe_qualifying_benefits) + pension_credit = set( + dwp.pension_credit.guarantee_credit.severe_disability.relevant_benefits + ) + assert carers_allowance == legacy == pension_credit, instant 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 289e4777e8..bad997eecd 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 @@ -16,18 +16,22 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "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); 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 in their own " - "unit, cares for a claimant or partner not yet cared for in another " - "benefit unit, taking the units in descending order of their eldest " - "member's age (ties in person order, the order people are listed in " - "the input or dataset). Where only one of two people can have a " - "carer, it is one who is not a carer themselves, then the first in " - "person order; no count depends on that choice. Carers outside the " - "household are not observed. A Universal Credit award that includes " + "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 " + "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 " + "in their own unit, cares for a claimant or partner not yet cared for " + "in another benefit unit if there is one, taking the units in " + "descending order of their eldest member's age (ties in person order, " + "the order people are listed in the input or dataset); otherwise the " + "award is taken to be for someone outside the household. 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)). 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 " From 514132fc0c6cee963a0d3e3c67cfcfea1631c85c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:16:04 -0400 Subject: [PATCH 20/75] Fix the UC docs means-test sentence; point the Marriage Allowance pin at #1947 The docs edit left 'plus all of its unearned income' hanging off the earnings taper, which read as if unearned income were tapered. State the two deductions separately. The strict xfail's reason and the module docstring now cite PolicyEngine/policyengine-uk#1947. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/universal-credit.ipynb | 2 +- policyengine_uk/tests/test_uc_state_pension_properties.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index c87f8f2514..074f529c60 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index d3b3963724..9eefc6eeef 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -27,7 +27,7 @@ allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), so State Pension that uses up the pensioner's unused allowance raises the earning partner's tax on earnings. The strict xfail at the end pins that -deviation (PolicyEngine/policyengine-uk#MA_ISSUE). +deviation (PolicyEngine/policyengine-uk#1947). """ import numpy as np @@ -252,7 +252,7 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): @pytest.mark.xfail( strict=True, reason=( - "PolicyEngine/policyengine-uk#MA_ISSUE: Marriage Allowance is booked on " + "PolicyEngine/policyengine-uk#1947: Marriage Allowance is booked on " "the recipient as the transferor's unused personal allowance, so the " "transferor's State Pension raises the recipient's tax on earnings" ), From 79a570227dd3ce07493b32e7e0d3a16bbe97a863 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:43:28 -0400 Subject: [PATCH 21/75] Count Carer Support Payment as Council Tax Reduction income Scottish carers receive Carer Support Payment in place of Carer's Allowance, but council_tax_reduction_applicable_income listed only carers_allowance, so a Scottish carer's reduction ignored the carer's benefit. Scottish CTR counts the Carer Support Payment component in full: SSI 2021/249 reg 57(1)(b)(iva) at working age and SSI 2012/319 reg 27(1)(j) at pension age. Neither counts the Scottish Carer Supplement (reg 57(1) is a closed list that omits it; reg 27(1)(j)(xxib) excepts it), so scottish_carer_supplement, split out in #1952, stays off the list. Adds YAML cases from statutory rates and Hypothesis properties: CTR income rises by exactly the CSP component when the carer claims, is invariant to the supplement except through income tax, and matches Housing Benefit income for carers in all three nations. Fixes #1955 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1955.fixed.md | 1 + ...l_tax_reduction_carer_support_payment.yaml | 329 ++++++++++++++++++ ...l_tax_reduction_carer_income_properties.py | 308 ++++++++++++++++ ...council_tax_reduction_applicable_income.py | 7 + 4 files changed, 645 insertions(+) create mode 100644 changelog.d/1955.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py diff --git a/changelog.d/1955.fixed.md b/changelog.d/1955.fixed.md new file mode 100644 index 0000000000..056db824ef --- /dev/null +++ b/changelog.d/1955.fixed.md @@ -0,0 +1 @@ +Council Tax Reduction income now counts Carer Support Payment, which Scottish carers receive in place of Carer's Allowance (Council Tax Reduction (Scotland) Regulations 2021 reg 57(1)(b)(iva); Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg 27(1)(j)). The Scottish Carer Supplement stays out. 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 new file mode 100644 index 0000000000..175ca561de --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -0,0 +1,329 @@ +# Carer Support Payment in Council Tax Reduction income. +# Scottish carers receive Carer Support Payment (CSP) in place of Carer's +# Allowance (CA); the model moves them in 2025. Scottish Council Tax Reduction +# counts the CSP component in full: +# - working age: Council Tax Reduction (Scotland) Regulations 2021 (SSI +# 2021/249) reg 57(1)(b)(iva), a closed list of unearned income; +# - pension age: Council Tax Reduction (State Pension Credit) (Scotland) +# Regulations 2012 (SSI 2012/319) reg 27(1)(j), social security benefits +# other than those it lists. +# 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. It is +# taxable, so it reaches Council Tax Reduction income only through income tax. +# Neither instrument disregards CSP above the CA amount. +# +# 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, carer premium 46.38, personal allowance 92.04. 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. +# The working-age cases take no Universal Credit, so the award does not turn +# on how Universal Credit treats CSP. + +- name: Carer Support Payment counts as working-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + private_pension_income: 7_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + carers_allowance: 0 + carer_support_payment: 86.45 * 52 + scottish_carer_supplement: 11.70 * 52 + # 7,000 + 4,495.40 + 608.40 is below the 12,570 personal allowance. + income_tax: 0 + # The CSP component only: 7,000 + 4,495.40. Counting the supplement too + # would give 12,103.80. + council_tax_reduction_applicable_income: 7_000 + 86.45 * 52 + council_tax_reduction_applicable_amount: (95.55 + 48.15) * 52 + # 2,000 - 0.2 x (11,495.40 - 7,472.40) + council_tax_benefit: 2_000 - 0.2 * (7_000 + 86.45 * 52 - (95.55 + 48.15) * 52) + council_tax_reduction: 1_195.40 + +- name: A reported carer's award in Scotland counts as Council Tax Reduction income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + private_pension_income: 7_000 + carers_allowance_reported: 1 + care_hours: 0 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + carer_support_payment: 86.45 * 52 + council_tax_reduction_applicable_income: 7_000 + 86.45 * 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 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + private_pension_income: 7_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: WALES + region: WALES + council_tax: 2_000 + savings: 0 + output: + 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 + +- name: A Scottish carer who does not claim Carer Support Payment has none counted + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + private_pension_income: 7_000 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + carer_support_payment: 0 + scottish_carer_supplement: 0 + council_tax_reduction_applicable_income: 7_000 + # The caring hours still give the carer premium, and 7,000 is below the + # applicable amount of 7,472.40. + council_tax_reduction: 2_000 + +- name: Carer Support Payment counts in 2025, before the Scottish Carer Supplement + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + private_pension_income: 7_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + 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) + +- name: In 2024 the Scottish carer is still on Carer's Allowance, which was already counted + period: 2024 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + private_pension_income: 7_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + carers_allowance: 81.90 * 52 + carer_support_payment: 0 + council_tax_reduction_applicable_income: 7_000 + 81.90 * 52 + # 2,000 - 0.2 x (11,258.80 - 7,077.20) = 1,163.68. The award no longer + # 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 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 70 + state_pension: 0 + private_pension_income: 14_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + carer_support_payment: 86.45 * 52 + scottish_carer_supplement: 11.70 * 52 + # Taxable income is 14,000 + 4,495.40 + 608.40 - 12,570 = 6,533.80: the + # 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 + 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 + +- name: A pension-age couple's Council Tax Reduction counts the partner's Carer Support Payment + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 0 + private_pension_income: 12_000 + partner: + age: 70 + state_pension: 0 + private_pension_income: 6_500 + care_hours: 35 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + # 86.45 x 52 and 11.70 x 52, for the partner. + carer_support_payment: [0, 4_495.40] + scottish_carer_supplement: [0, 608.40] + # Each is below the personal allowance: 12,000, and 6,500 + 4,495.40 + + # 608.40 = 11,603.80. + income_tax: [0, 0] + council_tax_reduction_applicable_income: 12_000 + 6_500 + 86.45 * 52 + council_tax_reduction_applicable_amount: (383.35 + 48.15) * 52 + # Income is above the minimum guarantee of (363.25 + 48.15) x 52 = + # 21,392.80. + guarantee_credit: 0 + # 2,000 - 0.2 x (22,995.40 - 22,438.00) + council_tax_reduction: 1_888.52 + +- name: A pension-age couple below the applicable amount keeps full Council Tax Reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 0 + private_pension_income: 9_000 + partner: + age: 70 + state_pension: 0 + private_pension_income: 6_500 + care_hours: 35 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 9_000 + 6_500 + 86.45 * 52 + # Guarantee Credit is paid: 21,392.80 - 19,995.40. + guarantee_credit: (363.25 + 48.15) * 52 - (9_000 + 6_500 + 86.45 * 52) + council_tax_reduction: 2_000 + +- name: An English pensioner carer's Council Tax Reduction is unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 70 + state_pension: 0 + private_pension_income: 14_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + households: + household: + members: [carer] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + savings: 0 + output: + carers_allowance: 86.45 * 52 + carer_support_payment: 0 + scottish_carer_supplement: 0 + # (14,000 + 4,495.40 - 12,570) x 20% + income_tax: 1_185.08 + council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_185.08 + # 2,000 - 0.2 x (17,310.32 - 15,815.80) + council_tax_reduction: 1_701.10 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 new file mode 100644 index 0000000000..826bca7549 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -0,0 +1,308 @@ +"""Carer Support Payment in Council Tax Reduction income. + +Scottish carers receive Carer Support Payment in place of Carer's Allowance. +Council Tax Reduction income counted Carer's Allowance but left Carer Support +Payment out, so a Scottish carer's reduction was assessed as if the carer's +benefit were not paid. Scottish Council Tax Reduction counts the Carer Support +Payment component in full: the Council Tax Reduction (Scotland) Regulations +2021 (SSI 2021/249) reg 57(1)(b)(iva) at working age, and the Council Tax +Reduction (State Pension Credit) (Scotland) Regulations 2012 (SSI 2012/319) +reg 27(1)(j) at pension age. + +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. The supplement is taxable (SI 2026/93), and the +model's Council Tax Reduction income is net of income tax, so the supplement +reaches it only through the tax charged on 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 +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 + component (86.45 a week), not by the component plus the supplement. The + reduction itself never rises, and where both awards are partial it falls + by 20% of the rise in income. +2. Invariant to the supplement: setting the Scottish Carer Supplement to zero + leaves Carer Support Payment and every benefit counted in Council Tax + Reduction income 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. +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. This holds in + Scotland, where the carer's benefit is Carer Support Payment, and in + England and Wales, where it is Carer's Allowance. + +These compare the model's own income measures, so they hold whatever carer's +benefit the model pays. The model does not yet reduce Carer Support Payment +by State Pension (the Carer's Assistance (Carer Support Payment) (Scotland) +Regulations 2023 reg 16(2)), and it applies the Housing Benefit applicable +amounts to every nation's Council Tax Reduction. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PENSIONS = np.arange(0, 40_001, 2_000) +WEEKS = 52 +CARER_SUPPORT_PAYMENT = 86.45 * WEEKS +SCOTTISH_CARER_SUPPLEMENT = 11.70 * WEEKS +WITHDRAWAL_RATE = 0.2 +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +REGIONS = { + "ENGLAND": "NORTH_WEST", + "WALES": "WALES", + "SCOTLAND": "SCOTLAND", +} +NO_SCOTTISH_CARER_SUPPLEMENT = { + "gov.social_security_scotland.carer_support_payment.supplement": { + "2026-01-01.2100-12-31": 0 + } +} +# The benefits Council Tax Reduction income counts besides the personal ones. +OTHER_COUNTED_BENEFITS = [ + "child_benefit", + "income_support", + "jsa_income", + "esa_income", + "universal_credit", + "tax_credits", +] +BENUNIT_VARIABLES = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_benefit", + "guarantee_credit", + "housing_benefit_applicable_income", + "housing_benefit_applicable_income_disregard", + "housing_benefit_applicable_income_childcare_element", + "housing_benefit_tariff_income", + *OTHER_COUNTED_BENEFITS, +] +PERSON_VARIABLES = [ + "carers_allowance", + "carer_support_payment", + "scottish_carer_supplement", + "income_tax", + "national_insurance", +] +CLAIMS = (True, False) + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +@st.composite +def family(draw, countries=("SCOTLAND",)): + pension_age = draw(st.booleans()) + adults = [] + for _ in range(draw(st.integers(1, 2))): + if pension_age: + adult = dict( + age=draw(st.integers(67, 95)), + state_pension=draw(money(15_000)), + ) + else: + adult = dict( + age=draw(st.integers(25, 60)), + employment_income=draw(st.one_of(st.just(0.0), money(30_000))), + ) + adults.append(adult) + return dict( + pension_age=pension_age, + adults=adults, + carer=draw(st.integers(0, len(adults) - 1)), + # The carer qualifies either by caring hours or by a reported award. + by_hours=draw(st.booleans()), + country=draw(st.sampled_from(countries)), + council_tax=draw(st.floats(500, 3_000, allow_nan=False)), + ) + + +def situation(families): + """Each family at each private pension, claiming and not claiming.""" + people, benunits, households = {}, {}, {} + for i, fam in enumerate(families): + for k, pension in enumerate(PENSIONS): + for c, claims in enumerate(CLAIMS): + names = [] + for j, a in enumerate(fam["adults"]): + name = f"p{i}_{k}_{c}_{j}" + person = { + "age": {YEAR: a["age"]}, + "state_pension": {YEAR: a.get("state_pension", 0.0)}, + "employment_income": {YEAR: a.get("employment_income", 0.0)}, + # All private pension goes to the first adult. + "private_pension_income": {YEAR: float(pension) * (j == 0)}, + } + if j == fam["carer"]: + if fam["by_hours"]: + person["care_hours"] = {YEAR: 35} + else: + person["carers_allowance_reported"] = {YEAR: 1} + person["would_claim_carers_allowance"] = {YEAR: claims} + people[name] = person + names.append(name) + benunits[f"b{i}_{k}_{c}"] = { + "members": names, + "would_claim_uc": {YEAR: False}, + } + households[f"h{i}_{k}_{c}"] = { + "members": names, + "country": {YEAR: fam["country"]}, + "region": {YEAR: REGIONS[fam["country"]]}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: 0.0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def grid(families, reform=None): + """Arrays indexed by family, private pension and whether the carer claims + (index 0 claims, index 1 does not).""" + simulation = Simulation(situation=situation(families), reform=reform) + shape = (len(families), len(PENSIONS), len(CLAIMS)) + g = { + variable: np.asarray(simulation.calculate(variable, YEAR)).reshape(shape) + for variable in BENUNIT_VARIABLES + } + for variable in PERSON_VARIABLES: + values = simulation.calculate(variable, YEAR, map_to="benunit") + g[variable] = np.asarray(values).reshape(shape) + g["other_counted_benefits"] = sum(g[v] for v in OTHER_COUNTED_BENEFITS) + # Council Tax Reduction income before the taxes deducted from it and apart + # from the other benefits it counts. + g["income_before_tax"] = ( + g["council_tax_reduction_applicable_income"] + + g["income_tax"] + + g["national_insurance"] + - g["other_counted_benefits"] + ) + return g + + +def partial(g, i): + """Where the award is neither nil nor the whole council tax bill.""" + award = g["council_tax_benefit"][i] + return (award > 0.01) & (award < np.max(award) - 0.01) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=3)) +def test_council_tax_reduction_income_counts_the_carer_support_payment_component( + families, +): + g = grid(families) + income = g["council_tax_reduction_applicable_income"] + award = g["council_tax_benefit"] + for i, fam in enumerate(families): + claimed, unclaimed = g["carer_support_payment"][i].T + assert np.allclose(claimed, CARER_SUPPORT_PAYMENT, atol=0.01), fam + assert np.all(unclaimed == 0), fam + assert np.allclose( + g["scottish_carer_supplement"][i, :, 0], + SCOTTISH_CARER_SUPPLEMENT, + 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. + compared = (income[i, :, 0] > 0) & (income[i, :, 1] > 0) + 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, + rise[compared], + ) + # Claiming never raises the reduction. A carer who qualifies by hours + # has the carer premium either way; one who qualifies by the award + # gains the premium with it, so only the hours case is compared. + if fam["by_hours"]: + assert np.all(award[i, :, 0] <= award[i, :, 1] + 0.01), fam + both_partial = partial(g, i)[:, 0] & partial(g, i)[:, 1] + fall = award[i, :, 1] - award[i, :, 0] + income_rise = income[i, :, 0] - income[i, :, 1] + assert np.allclose( + fall[both_partial], + WITHDRAWAL_RATE * income_rise[both_partial], + atol=0.05, + ), (fam, fall[both_partial], income_rise[both_partial]) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=3)) +def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( + families, +): + g = grid(families) + without = grid(families, reform=NO_SCOTTISH_CARER_SUPPLEMENT) + for i, fam in enumerate(families): + # The carer claims in column 0. + assert np.all(g["scottish_carer_supplement"][i, :, 0] > 0), fam + assert np.all(without["scottish_carer_supplement"][i] == 0), fam + for variable in ["carer_support_payment", "other_counted_benefits"]: + assert np.allclose(g[variable][i], without[variable][i], atol=0.01), ( + fam, + variable, + ) + 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. + compared = (income > 0) & (income_without > 0) + assert np.allclose( + (income + g["income_tax"][i])[compared], + (income_without + without["income_tax"][i])[compared], + atol=0.05, + ), fam + award = g["council_tax_benefit"][i] + award_without = without["council_tax_benefit"][i] + # The supplement can only add tax, which lowers income. + assert np.all(award >= award_without - 0.01), fam + both_partial = partial(g, i) & partial(without, i) + 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 + # Without a claim there is no supplement, so nothing moves. + assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam + + +@PROPERTY_SETTINGS +@given(st.lists(family(countries=tuple(REGIONS)), min_size=1, max_size=3)) +def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( + families, +): + g = grid(families) + for i, fam in enumerate(families): + carer_benefit = g["carers_allowance"][i] + g["carer_support_payment"][i] + assert np.all(carer_benefit[:, 0] > 0), fam + ctr_income = g["council_tax_reduction_applicable_income"][i] + hb_income = g["housing_benefit_applicable_income"][i] + # Guarantee Credit passports pension-age Housing Benefit to nil + # income; the zero floors must not bind. + passported = fam["pension_age"] & (g["guarantee_credit"][i] > 0) + compared = ~passported & (hb_income > 0) & (ctr_income > 0) + hb_before_adjustments = ( + hb_income + + g["housing_benefit_applicable_income_disregard"][i] + + g["housing_benefit_applicable_income_childcare_element"][i] + - g["housing_benefit_tariff_income"][i] + ) + assert np.allclose( + hb_before_adjustments[compared], ctr_income[compared], atol=0.05 + ), (fam, hb_before_adjustments[compared], ctr_income[compared]) 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 8cf5f764b8..9ca5cbe362 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 @@ -18,6 +18,13 @@ def formula(benunit, period, parameters): ] personal_benefits = [ "carers_allowance", + # Scottish carers receive Carer Support Payment in place of Carer's + # Allowance. Scottish Council Tax Reduction counts it in full: + # SSI 2021/249 reg 57(1)(b)(iva) at working age and SSI 2012/319 + # reg 27(1)(j) at pension age. Neither counts the Scottish Carer + # Supplement (reg 57(1) does not list it; reg 27(1)(j)(xxib) + # excepts it), so scottish_carer_supplement is not listed. + "carer_support_payment", "esa_contrib", "jsa_contrib", "state_pension", From 1f361a38cf97323d24b95b2b550bbe40bdad3649 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:56:42 -0400 Subject: [PATCH 22/75] Say the supplement reaches CTR income only through the model's tax deduction Both Scottish CTR instruments deduct income tax only from earnings (SSI 2021/249 Part 6 Chapter 3; SSI 2012/319 regs 33 and 36), so in law the Scottish Carer Supplement does not touch CTR. The model deducts all income tax, so the property and YAML comments now say the dependence on the supplement through income tax is that existing approximation. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_carer_support_payment.yaml | 6 ++++-- ...st_council_tax_reduction_carer_income_properties.py | 10 +++++++--- 2 files changed, 11 insertions(+), 5 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 175ca561de..e08c1ca883 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 @@ -8,8 +8,10 @@ # Regulations 2012 (SSI 2012/319) reg 27(1)(j), social security benefits # other than those it lists. # 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. It is -# taxable, so it reaches Council Tax Reduction income only through income tax. +# 2026: reg 57(1) does not list it and reg 27(1)(j)(xxib) excepts it. Both +# instruments deduct income tax only from earnings, but 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. # 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/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 826bca7549..4eb4c81d4a 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 @@ -11,9 +11,13 @@ 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. The supplement is taxable (SI 2026/93), and the -model's Council Tax Reduction income is net of income tax, so the supplement -reaches it only through the tax charged on it. +27(1)(j)(xxib) excepts it. Both instruments deduct income tax only from +earnings (SSI 2021/249 Part 6 Chapter 3; SSI 2012/319 regs 33 and 36), 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. 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 From aa043f96e758575c8f9d58ce889db1ec922a7977 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 10:04:10 -0400 Subject: [PATCH 23/75] Placeholder: boarder and lodger rent inputs from the companion branch Stand-ins for rent_paid_as_boarder, rent_paid_as_lodger and pays_rent_to_householder, which the householder-side branch (boarder-lodger-rent) adds. This commit is dropped when that branch is merged in. Co-Authored-By: Claude Opus 5.5 --- .../consumption/rent/pays_rent_to_householder.py | 16 ++++++++++++++++ .../consumption/rent/rent_paid_as_boarder.py | 13 +++++++++++++ .../consumption/rent/rent_paid_as_lodger.py | 13 +++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py create mode 100644 policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py create mode 100644 policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py diff --git a/policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py b/policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py new file mode 100644 index 0000000000..7a510c4020 --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class pays_rent_to_householder(Variable): + value_type = bool + entity = Person + label = "Family pays rent to the householder" + documentation = ( + "Whether any member of this person's benefit unit pays the " + "householder rent as a boarder or lodger." + ) + definition_period = YEAR + + def formula(person, period, parameters): + payments = add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) + return person.benunit.any(payments > 0) diff --git a/policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py b/policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py new file mode 100644 index 0000000000..47d6d220da --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class rent_paid_as_boarder(Variable): + value_type = float + entity = Person + label = "Rent paid as a boarder" + documentation = ( + "Rent this person pays the householder for board and lodging " + "(accommodation with at least some meals) in the householder's home." + ) + definition_period = YEAR + unit = GBP diff --git a/policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py b/policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py new file mode 100644 index 0000000000..e53796d733 --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class rent_paid_as_lodger(Variable): + value_type = float + entity = Person + label = "Rent paid as a lodger" + documentation = ( + "Rent this person pays the householder for lodging only in the " + "householder's home." + ) + definition_period = YEAR + unit = GBP From 358c72060d16781ac2841c4eb07a4b3b526e4455 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 10:16:26 -0400 Subject: [PATCH 24/75] Give sharers, boarders and lodgers their own rent, tenure and LHA category - personal_rent / benunit_rent: each family's own liability. The household's rent is shared equally among the people liable for it (the household head's family plus any family liable_for_share_of_household_rent: UC Regs 2013 Sch 4 para 24(4); HB Regs 2006 reg 12B(4)), plus what boarders and lodgers pay the householder (UC Sch 1 para 2(b); HB reg 12(1)). - benunit_tenure_type: a boarder or lodger rents privately whatever the household's tenure (UC Sch 4 para 20; HB reg 13C(5)(a)); in_social_housing and uc_housing_costs_element follow the family's tenure. - Non-dependants: only the household head's family has them, and families liable for rent are never non-dependants (UC Sch 4 para 9(2)(d)-(f); HB reg 3(2)(d)-(e), 3(4)). Applied to UC and HB deductions, UC bedrooms and the specified-renter test. - LHA category: Universal Credit by entitlement only (Sch 4 para 25(2)(b)); a new Housing Benefit category (reg 13D(2)-(3)) with its own size criteria and rate, including the shared rate for a claimant entitled to one bedroom who lacks exclusive use, used by LHA_cap. - Housing Benefit meals: fixed weekly amounts (Sch 1 para 2, 2008-2026) and a meals_included_in_rent input; deducted from HB eligible rent (reg 12B(2)(b), 13(13)). Co-Authored-By: Claude Opus 5.5 --- .../at_least_three_a_day/aged_16_or_over.yaml | 31 +++++ .../meals/at_least_three_a_day/under_16.yaml | 31 +++++ .../meals/breakfast_only.yaml | 31 +++++ .../aged_16_or_over.yaml | 31 +++++ .../fewer_than_three_a_day/under_16.yaml | 31 +++++ .../family/LHA_claimant_composition.yaml | 47 ++++--- .../housing_benefit_pension_age_claims.yaml | 4 +- .../housing_benefit_non_dep_deductions.yaml | 46 ++++++- .../uc_non_dep_deductions.yaml | 46 ++++++- .../variables/gov/dwp/LHA_allowed_bedrooms.py | 83 +++++++----- policyengine_uk/variables/gov/dwp/LHA_cap.py | 28 +++- .../variables/gov/dwp/LHA_category.py | 30 ++++- .../housing_benefit_entitlement.py | 8 +- .../housing_benefit_meals_deduction.py | 48 +++++++ .../housing_benefit_non_dep_deductions.py | 22 ++- .../housing_benefit_LHA_allowed_bedrooms.py | 37 ++++++ .../gov/dwp/housing_benefit_LHA_category.py | 58 ++++++++ .../gov/dwp/housing_benefit_LHA_rate.py | 28 ++++ .../housing_benefit_shares_accommodation.py | 36 +++++ .../is_housing_benefit_young_individual.py | 23 ++++ .../dwp/is_non_dependant_of_household_head.py | 33 +++++ .../gov/dwp/lha_renter_has_non_dependant.py | 28 ++-- .../gov/dwp/uncapped_BRMA_LHA_rate.py | 125 ++++++++++-------- .../uc_non_dep_deductions.py | 27 ++-- .../uc_housing_costs_element.py | 6 +- .../household/consumption/benunit_rent.py | 4 +- .../household/consumption/personal_rent.py | 43 +++++- .../rent/is_liable_for_household_rent.py | 22 +++ .../liable_for_share_of_household_rent.py | 21 +++ .../rent/meals_included_in_rent.py | 22 +++ .../demographic/benunit_tenure_type.py | 26 +++- .../demographic/in_social_housing.py | 8 +- uv.lock | 2 +- 33 files changed, 894 insertions(+), 172 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/aged_16_or_over.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/under_16.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/breakfast_only.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/aged_16_or_over.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/under_16.yaml create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_meals_deduction.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py create mode 100644 policyengine_uk/variables/gov/dwp/is_housing_benefit_young_individual.py create mode 100644 policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py create mode 100644 policyengine_uk/variables/household/consumption/rent/is_liable_for_household_rent.py create mode 100644 policyengine_uk/variables/household/consumption/rent/liable_for_share_of_household_rent.py create mode 100644 policyengine_uk/variables/household/consumption/rent/meals_included_in_rent.py diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/aged_16_or_over.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/aged_16_or_over.yaml new file mode 100644 index 0000000000..0baccceb3c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/aged_16_or_over.yaml @@ -0,0 +1,31 @@ +description: Weekly amount ineligible for meals, at least three meals a day, for the claimant and each family member aged 16 or over. +values: + 2008-04-01: 21.60 + 2009-04-01: 22.95 + 2010-04-01: 23.35 + 2011-04-01: 24.05 + 2012-04-01: 25.30 + 2013-04-01: 25.85 + 2014-04-01: 26.55 + 2015-04-01: 26.85 + 2017-04-01: 27.10 + 2019-04-01: 28.55 + 2020-04-01: 29.05 + 2021-04-01: 29.20 + 2022-04-01: 30.10 + 2023-04-01: 33.15 + 2024-04-01: 35.35 + 2025-04-01: 35.95 + 2026-04-01: 37.30 +metadata: + unit: currency-GBP + period: week + label: housing benefit meals deduction, three or more meals a day, aged 16 or over + reference: + - title: Housing Benefit Regulations 2006 Sch 1 para 2(2) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/1/paragraph/2 + - title: Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Sch 1 para 2(2) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/1/paragraph/2 + - title: Social Security Benefits Up-rating Order 2026 (S.I. 2026/148) art. 23(4) + href: https://www.legislation.gov.uk/uksi/2026/148/article/23 + note: Amounts from 2008 to 2025 are the point-in-time texts of Sch 1 para 2 on legislation.gov.uk, set by each year's up-rating order (S.I. 2008/632, S.I. 2009/497, S.I. 2010/793, S.I. 2011/821, S.I. 2012/780, S.I. 2013/574, S.I. 2014/516, S.I. 2015/457, S.I. 2017/260, S.I. 2019/480, S.I. 2020/234, S.I. 2021/162, S.I. 2022/292, S.I. 2023/316, S.I. 2024/242, S.I. 2025/295, S.I. 2026/148). diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/under_16.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/under_16.yaml new file mode 100644 index 0000000000..dd7fd5e79d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/at_least_three_a_day/under_16.yaml @@ -0,0 +1,31 @@ +description: Weekly amount ineligible for meals, at least three meals a day, for each family member under 16. +values: + 2008-04-01: 10.90 + 2009-04-01: 11.60 + 2010-04-01: 11.80 + 2011-04-01: 12.15 + 2012-04-01: 12.80 + 2013-04-01: 13.10 + 2014-04-01: 13.45 + 2015-04-01: 13.60 + 2017-04-01: 13.75 + 2019-04-01: 14.50 + 2020-04-01: 14.75 + 2021-04-01: 14.80 + 2022-04-01: 15.25 + 2023-04-01: 16.80 + 2024-04-01: 17.95 + 2025-04-01: 18.25 + 2026-04-01: 18.95 +metadata: + unit: currency-GBP + period: week + label: housing benefit meals deduction, three or more meals a day, under 16 + reference: + - title: Housing Benefit Regulations 2006 Sch 1 para 2(2)(b)(ii) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/1/paragraph/2 + - title: Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Sch 1 para 2(2)(b)(ii) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/1/paragraph/2 + - title: Social Security Benefits Up-rating Order 2026 (S.I. 2026/148) art. 23(4) + href: https://www.legislation.gov.uk/uksi/2026/148/article/23 + note: Amounts from 2008 to 2025 are the point-in-time texts of Sch 1 para 2 on legislation.gov.uk, set by each year's up-rating order (S.I. 2008/632, S.I. 2009/497, S.I. 2010/793, S.I. 2011/821, S.I. 2012/780, S.I. 2013/574, S.I. 2014/516, S.I. 2015/457, S.I. 2017/260, S.I. 2019/480, S.I. 2020/234, S.I. 2021/162, S.I. 2022/292, S.I. 2023/316, S.I. 2024/242, S.I. 2025/295, S.I. 2026/148). diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/breakfast_only.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/breakfast_only.yaml new file mode 100644 index 0000000000..92f8b0712c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/breakfast_only.yaml @@ -0,0 +1,31 @@ +description: Weekly amount ineligible for meals, breakfast only, for the claimant and each family member. +values: + 2008-04-01: 2.65 + 2009-04-01: 2.80 + 2010-04-01: 2.85 + 2011-04-01: 2.95 + 2012-04-01: 3.10 + 2013-04-01: 3.15 + 2014-04-01: 3.25 + 2015-04-01: 3.30 + 2017-04-01: 3.35 + 2019-04-01: 3.55 + 2020-04-01: 3.60 + 2021-04-01: 3.60 + 2022-04-01: 3.70 + 2023-04-01: 4.05 + 2024-04-01: 4.30 + 2025-04-01: 4.35 + 2026-04-01: 4.50 +metadata: + unit: currency-GBP + period: week + label: housing benefit meals deduction, breakfast only + reference: + - title: Housing Benefit Regulations 2006 Sch 1 para 2(5) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/1/paragraph/2 + - title: Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Sch 1 para 2(5) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/1/paragraph/2 + - title: Social Security Benefits Up-rating Order 2026 (S.I. 2026/148) art. 23(4) + href: https://www.legislation.gov.uk/uksi/2026/148/article/23 + note: Amounts from 2008 to 2025 are the point-in-time texts of Sch 1 para 2 on legislation.gov.uk, set by each year's up-rating order (S.I. 2008/632, S.I. 2009/497, S.I. 2010/793, S.I. 2011/821, S.I. 2012/780, S.I. 2013/574, S.I. 2014/516, S.I. 2015/457, S.I. 2017/260, S.I. 2019/480, S.I. 2020/234, S.I. 2021/162, S.I. 2022/292, S.I. 2023/316, S.I. 2024/242, S.I. 2025/295, S.I. 2026/148). diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/aged_16_or_over.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/aged_16_or_over.yaml new file mode 100644 index 0000000000..324209dc68 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/aged_16_or_over.yaml @@ -0,0 +1,31 @@ +description: Weekly amount ineligible for meals, fewer than three meals a day, for the claimant and each family member aged 16 or over. +values: + 2008-04-01: 14.35 + 2009-04-01: 15.25 + 2010-04-01: 15.50 + 2011-04-01: 16.00 + 2012-04-01: 16.85 + 2013-04-01: 17.20 + 2014-04-01: 17.65 + 2015-04-01: 17.85 + 2017-04-01: 18.05 + 2019-04-01: 19.05 + 2020-04-01: 19.35 + 2021-04-01: 19.45 + 2022-04-01: 20.05 + 2023-04-01: 22.10 + 2024-04-01: 23.60 + 2025-04-01: 24.00 + 2026-04-01: 24.90 +metadata: + unit: currency-GBP + period: week + label: housing benefit meals deduction, fewer than three meals a day, aged 16 or over + reference: + - title: Housing Benefit Regulations 2006 Sch 1 para 2(3) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/1/paragraph/2 + - title: Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Sch 1 para 2(3) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/1/paragraph/2 + - title: Social Security Benefits Up-rating Order 2026 (S.I. 2026/148) art. 23(4) + href: https://www.legislation.gov.uk/uksi/2026/148/article/23 + note: Amounts from 2008 to 2025 are the point-in-time texts of Sch 1 para 2 on legislation.gov.uk, set by each year's up-rating order (S.I. 2008/632, S.I. 2009/497, S.I. 2010/793, S.I. 2011/821, S.I. 2012/780, S.I. 2013/574, S.I. 2014/516, S.I. 2015/457, S.I. 2017/260, S.I. 2019/480, S.I. 2020/234, S.I. 2021/162, S.I. 2022/292, S.I. 2023/316, S.I. 2024/242, S.I. 2025/295, S.I. 2026/148). diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/under_16.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/under_16.yaml new file mode 100644 index 0000000000..12c50c9487 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/ineligible_charges/meals/fewer_than_three_a_day/under_16.yaml @@ -0,0 +1,31 @@ +description: Weekly amount ineligible for meals, fewer than three meals a day, for each family member under 16. +values: + 2008-04-01: 7.20 + 2009-04-01: 7.65 + 2010-04-01: 7.80 + 2011-04-01: 8.05 + 2012-04-01: 8.45 + 2013-04-01: 8.65 + 2014-04-01: 8.90 + 2015-04-01: 9.00 + 2017-04-01: 9.10 + 2019-04-01: 9.55 + 2020-04-01: 9.70 + 2021-04-01: 9.75 + 2022-04-01: 10.05 + 2023-04-01: 11.05 + 2024-04-01: 11.80 + 2025-04-01: 12.00 + 2026-04-01: 12.45 +metadata: + unit: currency-GBP + period: week + label: housing benefit meals deduction, fewer than three meals a day, under 16 + reference: + - title: Housing Benefit Regulations 2006 Sch 1 para 2(3)(b)(ii) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/1/paragraph/2 + - title: Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Sch 1 para 2(3)(b)(ii) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/1/paragraph/2 + - title: Social Security Benefits Up-rating Order 2026 (S.I. 2026/148) art. 23(4) + href: https://www.legislation.gov.uk/uksi/2026/148/article/23 + note: Amounts from 2008 to 2025 are the point-in-time texts of Sch 1 para 2 on legislation.gov.uk, set by each year's up-rating order (S.I. 2008/632, S.I. 2009/497, S.I. 2010/793, S.I. 2011/821, S.I. 2012/780, S.I. 2013/574, S.I. 2014/516, S.I. 2015/457, S.I. 2017/260, S.I. 2019/480, S.I. 2020/234, S.I. 2021/162, S.I. 2022/292, S.I. 2023/316, S.I. 2024/242, S.I. 2025/295, S.I. 2026/148). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml index b1435b45d5..2038e23465 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml @@ -115,7 +115,7 @@ # couple room = 1: B under the existing bedroom calculation. LHA_category: B -- name: A 17-year-old claimant in another benefit unit blocks the shared rate +- name: A 17-year-old in another family is a non-dependant of the household head only period: 2025 input: people: @@ -134,13 +134,16 @@ home: members: [renter, apprentice] output: - lha_renter_has_non_dependant: [true, true] - is_lha_shared_accommodation_rate_specified_renter: [false, false] - # Other unit's claimant blocks A; 1 + 1 occupant aged 16+ outside = 2: C. - # Renter previously A because the other claimant was under 18. - LHA_category: [C, C] + lha_renter_has_non_dependant: [true, false] + is_lha_shared_accommodation_rate_specified_renter: [false, true] + # The renter (the household head, the elder) has the apprentice as a + # non-dependant: 1 + 1 non-dependant aged 16+ = 2: C. Only the household + # head's family has non-dependants from other families (UC Sch 4 para + # 9(2)(e)-(f)), so the apprentice's own family has none: single, 17 < 35, + # no dependants: A. + LHA_category: [C, A] -- name: A 17-year-old student claimant in another unit also blocks the shared rate +- name: A 17-year-old student in another family is also the household head's non-dependant period: 2025 input: people: @@ -158,12 +161,13 @@ home: members: [renter, student_claimant] output: - lha_renter_has_non_dependant: [true, true] - # Other claimant blocks A even if their age/education meets the QYP test. - # 1 + 1 occupant aged 16+ outside = 2: C (renter previously A). - LHA_category: [C, C] + lha_renter_has_non_dependant: [true, false] + # The other family's claimant is the household head's non-dependant even + # if their age and education meet the QYP test: 1 + 1 = 2: C. The + # student's own family has no non-dependant: A. + LHA_category: [C, A] -- name: Rent liability of another benefit unit does not exempt its claimant +- name: A family liable for rent is not a non-dependant period: 2025 input: people: @@ -183,9 +187,12 @@ members: [renter, other_renter] output: eldest_claimant_or_partner_age: [30, 40] - lha_renter_has_non_dependant: [true, true] - # Conservative proxy retained: 1 + 1 occupant aged 16+ outside = 2: C. - LHA_category: [C, C] + lha_renter_has_non_dependant: [false, false] + # A person liable on a commercial basis for their occupation is not a + # non-dependant (UC Sch 4 para 9(2)(d)), so neither family adds a bedroom + # to the other. Renter: single, 30 < 35, no non-dependant: A. Household + # head (40): 1 bedroom: B. + LHA_category: [A, B] - name: Occupants in a different household do not block the shared rate period: 2025 @@ -211,15 +218,19 @@ # Other renter 40 >= 35; 1 bedroom: B. LHA_category: [A, B] -- name: Actual shared accommodation retains its separate route to category A +- name: Actual shared accommodation gives the shared rate for Housing Benefit only period: 2025 input: age: 40 is_shared_accommodation: true output: is_lha_shared_accommodation_rate_specified_renter: false - # 40 >= 35, but actual shared accommodation independently gives A. - LHA_category: A + # Universal Credit sets the category by entitlement alone (UC Sch 4 para + # 25(2)(b)): 40 >= 35 and 1 bedroom: B, whether or not the accommodation + # is shared. Housing Benefit gives the shared rate to a claimant entitled + # to one bedroom who lacks exclusive use (HB Regs 2006 reg 13D(2)(a)(ii)). + LHA_category: B + housing_benefit_LHA_category: A - name: A disabled 17-year-old renter is excepted from the shared accommodation rate # UC Sch 4 para 29(5)(c): a renter under 35 receiving the PIP daily living diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_claims.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_claims.yaml index 394d7ba16e..80544aada7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_claims.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_claims.yaml @@ -40,7 +40,7 @@ benunits: benunit: members: [older, younger] - BRMA_LHA_rate: 8_000 + housing_benefit_LHA_rate: 8_000 households: household: members: [older, younger] @@ -62,7 +62,7 @@ benunits: benunit: members: [person] - BRMA_LHA_rate: 12_000 + housing_benefit_LHA_rate: 12_000 housing_benefit_applicable_income: 20_000 households: household: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml index 46c2b6ac44..81d1b71bcb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml @@ -1,9 +1,15 @@ +# Non-dependant deductions fall on the household head's family only: joint +# tenants and other sharers of the rent, boarders and lodgers are not +# non-dependants, and a sharer, boarder or lodger has no non-dependants from +# the household head's family (UC Regs 2013 Sch 4 para 9(2)(d)-(f); HB Regs +# 2006 reg 3(2)(d)-(e) and 3(4)). - name: Single family, no deductions period: 2025 absolute_error_margin: 0 input: people: person_1: + is_household_head: true household_benefits_individual_non_dep_deduction: 1 person_2: household_benefits_individual_non_dep_deduction: 2 @@ -16,12 +22,13 @@ output: housing_benefit_non_dep_deductions: 0 -- name: Two families, each affecting only the other +- name: Two families, the household head's family bears the other's deduction period: 2025 absolute_error_margin: 0 input: people: person_1: + is_household_head: true household_benefits_individual_non_dep_deduction: 1 person_2: household_benefits_individual_non_dep_deduction: 2 @@ -34,9 +41,10 @@ household: members: [person_1, person_2] output: - housing_benefit_non_dep_deductions: [2, 1] + # Only the head's family has non-dependants: 2; the other family has none. + housing_benefit_non_dep_deductions: [2, 0] -- name: Three families, each affecting only the other +- name: Three families, the household head's family bears both others period: 2025 absolute_error_margin: 0 input: @@ -44,6 +52,7 @@ person_1: household_benefits_individual_non_dep_deduction: 1 person_2: + is_household_head: true household_benefits_individual_non_dep_deduction: 2 person_3: household_benefits_individual_non_dep_deduction: 3 @@ -58,4 +67,33 @@ household: members: [person_1, person_2, person_3] output: - housing_benefit_non_dep_deductions: [5, 4, 3] + # The head's family: 1 + 3 = 4. + housing_benefit_non_dep_deductions: [0, 4, 0] + +- name: A family liable for rent is not a non-dependant + period: 2025 + absolute_error_margin: 0 + input: + people: + person_1: + is_household_head: true + household_benefits_individual_non_dep_deduction: 1 + person_2: + household_benefits_individual_non_dep_deduction: 2 + person_3: + household_benefits_individual_non_dep_deduction: 3 + benunits: + benunit_1: + members: person_1 + benunit_2: + members: person_2 + # A lodger or a sharer of the rent. + benunit_is_rent_liable: true + benunit_3: + members: person_3 + households: + household: + members: [person_1, person_2, person_3] + output: + # Only person 3 is a non-dependant of the head's family: 3. + housing_benefit_non_dep_deductions: [3, 0, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.yaml index bcb7998b05..16852634d7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.yaml @@ -1,9 +1,15 @@ +# Non-dependant deductions fall on the household head's family only: joint +# tenants and other sharers of the rent, boarders and lodgers are not +# non-dependants, and a sharer, boarder or lodger has no non-dependants from +# the household head's family (UC Regs 2013 Sch 4 para 9(2)(d)-(f); HB Regs +# 2006 reg 3(2)(d)-(e) and 3(4)). - name: Single family, no deductions period: 2022 absolute_error_margin: 0 input: people: person_1: + is_household_head: true uc_individual_non_dep_deduction: 1 person_2: uc_individual_non_dep_deduction: 2 @@ -16,12 +22,13 @@ output: uc_non_dep_deductions: 0 -- name: Two families, each affecting only the other +- name: Two families, the household head's family bears the other's deduction period: 2022 absolute_error_margin: 0 input: people: person_1: + is_household_head: true uc_individual_non_dep_deduction: 1 person_2: uc_individual_non_dep_deduction: 2 @@ -34,9 +41,10 @@ household: members: [person_1, person_2] output: - uc_non_dep_deductions: [2, 1] + # Only the head's family has non-dependants: 2; the other family has none. + uc_non_dep_deductions: [2, 0] -- name: Three families, each affecting only the other +- name: Three families, the household head's family bears both others period: 2022 absolute_error_margin: 0 input: @@ -44,6 +52,7 @@ person_1: uc_individual_non_dep_deduction: 1 person_2: + is_household_head: true uc_individual_non_dep_deduction: 2 person_3: uc_individual_non_dep_deduction: 3 @@ -58,4 +67,33 @@ household: members: [person_1, person_2, person_3] output: - uc_non_dep_deductions: [5, 4, 3] + # The head's family: 1 + 3 = 4. + uc_non_dep_deductions: [0, 4, 0] + +- name: A family liable for rent is not a non-dependant + period: 2022 + absolute_error_margin: 0 + input: + people: + person_1: + is_household_head: true + uc_individual_non_dep_deduction: 1 + person_2: + uc_individual_non_dep_deduction: 2 + person_3: + uc_individual_non_dep_deduction: 3 + benunits: + benunit_1: + members: person_1 + benunit_2: + members: person_2 + # A lodger or a sharer of the rent. + benunit_is_rent_liable: true + benunit_3: + members: person_3 + households: + household: + members: [person_1, person_2, person_3] + output: + # Only person 3 is a non-dependant of the head's family: 3. + uc_non_dep_deductions: [3, 0, 0] diff --git a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py index 9dff0c03ca..14d4d0b3e7 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py @@ -6,46 +6,61 @@ warnings.filterwarnings("ignore") +def bedrooms_for_children(benunit, period): + """Bedrooms for the family's children under 16. + + Children must share rooms in pairs unless they are opposite-sex and one + is 10 or over. This is the minimum number of bedrooms that allocates them + under those rules. + """ + person = benunit.members + age = person("age", period) + male = person("is_male", period) + under_16 = age < 16 + under_10 = age < 10 + child_over_10 = ~under_10 & under_16 + boys_under_10 = benunit.sum(under_10 & male) + boys_over_10 = benunit.sum(child_over_10 & male) + girls_under_10 = benunit.sum(under_10 & ~male) + girls_over_10 = benunit.sum(child_over_10 & ~male) + # First, have over-10s share where possible + over_10_rooms = (boys_over_10 + 1) // 2 + (girls_over_10 + 1) // 2 + # There may children over 10 still not sharing + space_for_boy_under_10 = boys_over_10 % 2 + space_for_girl_under_10 = girls_over_10 % 2 + # Have those spaces filled where possible by children under 10 + left_over_boys_under_10 = max_(boys_under_10 - space_for_boy_under_10, 0) + left_over_girls_under_10 = max_(girls_under_10 - space_for_girl_under_10, 0) + # The remaining children must share in pairs + under_10_rooms = (left_over_boys_under_10 + left_over_girls_under_10 + 1) // 2 + return over_10_rooms + under_10_rooms + + class LHA_allowed_bedrooms(Variable): value_type = float entity = BenUnit label = "The number of bedrooms covered by LHA for the benefit unit" + documentation = ( + "Universal Credit size criteria: one bedroom for the renter or " + "couple, one for each non-dependant aged 16 or over, and the " + "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; only the household head's family has " + "non-dependants (see is_non_dependant_of_household_head)." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/10/2021-04-06" + reference = ( + "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(benunit, period, parameters): - """ - LHA allows for one room for: - a) The benefit unit adult(s) - b) Each person over 16 outside the benefit unit - but within the household - Children must share rooms in pairs unless they are - opposite-sex and one is over 10. The number of bedrooms - allowed under LHA rules is the minimum number of bedrooms - required to allocate people satisfying these rules. - """ person = benunit.members - age = person("age", period) - male = person("is_male", period) - under_16 = age < 16 - under_10 = age < 10 - child_over_10 = ~under_10 & under_16 - # One room each for over-16s outside the benefit unit - non_dependants = benunit.max(person.household.sum(~under_16)) - benunit.sum( - ~under_16 + 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. + non_dependant = aged_16_or_over & person( + "is_non_dependant_of_household_head", period ) - boys_under_10 = benunit.sum(under_10 & male) - boys_over_10 = benunit.sum(child_over_10 & male) - girls_under_10 = benunit.sum(under_10 & ~male) - girls_over_10 = benunit.sum(child_over_10 & ~male) - # First, have over-10s share where possible - over_10_rooms = (boys_over_10 + 1) // 2 + (girls_over_10 + 1) // 2 - # There may children over 10 still not sharing - space_for_boy_under_10 = boys_over_10 % 2 - space_for_girl_under_10 = girls_over_10 % 2 - # Have those spaces filled where possible by children under 10 - left_over_boys_under_10 = max_(boys_under_10 - space_for_boy_under_10, 0) - left_over_girls_under_10 = max_(girls_under_10 - space_for_girl_under_10, 0) - # The remaining children must share in pairs - under_10_rooms = (left_over_boys_under_10 + left_over_girls_under_10 + 1) // 2 - return 1 + non_dependants + over_10_rooms + under_10_rooms + head_family = benunit.any(person("is_household_head", period)) + non_dependants = head_family * benunit.max(person.household.sum(non_dependant)) + return 1 + non_dependants + bedrooms_for_children(benunit, period) diff --git a/policyengine_uk/variables/gov/dwp/LHA_cap.py b/policyengine_uk/variables/gov/dwp/LHA_cap.py index d7139422ce..dedaa40a3b 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_cap.py +++ b/policyengine_uk/variables/gov/dwp/LHA_cap.py @@ -10,11 +10,33 @@ class LHA_cap(Variable): value_type = float entity = BenUnit label = "Applicable amount for LHA" - documentation = "Applicable amount for Local Housing Allowance" + documentation = ( + "Rent eligible for Housing Benefit where the Local Housing Allowance " + "applies: the lower of the rent and the Housing Benefit LHA rate. " + "Where the rent pays for meals, the rent officer route applies " + "instead and the fixed meals amount is deducted from the maximum " + "rent; the model takes the rent less that amount, capped at the LHA " + "rate, which stands in for the rent officer's determination." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13C", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + ) def formula(benunit, period, parameters): - rent = benunit("benunit_rent", period) - cap = benunit("BRMA_LHA_rate", period) + # HB Regs 2006 regs 12D(2)(a) and 13D(5): the maximum rent (LHA) is + # the LHA rate or, if lower, the cap rent. A rent officer finding + # that a substantial part of the rent is for board and attendance + # takes the case off the LHA (reg 13C(5)(e)); the maximum rent then + # has the Sch 1 para 2 amount for meals deducted (reg 13(13)). + rent = max_( + 0, + benunit("benunit_rent", period) + - benunit("housing_benefit_meals_deduction", period), + ) + cap = benunit("housing_benefit_LHA_rate", period) return min_(rent, cap) diff --git a/policyengine_uk/variables/gov/dwp/LHA_category.py b/policyengine_uk/variables/gov/dwp/LHA_category.py index f0f2d4eb76..ecd771d7d4 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_category.py +++ b/policyengine_uk/variables/gov/dwp/LHA_category.py @@ -18,21 +18,35 @@ class LHA_category(Variable): value_type = Enum entity = BenUnit label = "LHA category for the benefit unit, taking into account LHA rules on the number of LHA-covered bedrooms" + documentation = ( + "The Universal Credit category of accommodation, which depends only " + "on what the renter is entitled to: the shared accommodation rate " + "for a specified renter, otherwise the category for the number of " + "bedrooms in the size criteria. Whether the accommodation the renter " + "actually occupies is shared does not matter, so a single renter " + "aged 35 or over in a room gets the one-bedroom rate. Housing Benefit " + "has its own category: see housing_benefit_LHA_category." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/25", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/27", + "https://www.legislation.gov.uk/uksi/2013/382/schedule/1/paragraph/1", + ) definition_period = YEAR possible_values = LHACategory default_value = LHACategory.C def formula(benunit, period, parameters): + # UC Regs 2013 Sch 4 para 25(1) step 1 and para 25(2)(b): the + # category to which the renter is entitled under paras 8-12 and + # 26-29. num_rooms = benunit("LHA_allowed_bedrooms", period.this_year) - person = benunit.members - household = person.household - is_shared = benunit.any(household("is_shared_accommodation", period.this_year)) can_only_claim_shared = benunit( "is_lha_shared_accommodation_rate_specified_renter", period ) return select( [ - is_shared | can_only_claim_shared, + can_only_claim_shared, num_rooms == 1, num_rooms == 2, num_rooms == 3, @@ -123,11 +137,15 @@ def find_freeze_anchor(freeze_parameter: Parameter, period: str) -> str: MONTHLY_MAXIMUM_FIRST_YEAR = 2020 -def category_maximum(benunit, period, node_name: str): +def category_maximum( + benunit, period, node_name: str, category_variable: str = "LHA_category" +): """Per-category national maximum, read at the determination year. Frozen rates are held at the level last determined, so the maximum in force then is the one that binds, not the current year's. + ``category_variable`` names the category to look up: the Universal + Credit one by default, or the Housing Benefit one. """ lha = benunit.simulation.tax_benefit_system.parameters.gov.dwp.LHA @@ -137,6 +155,6 @@ def category_maximum(benunit, period, node_name: str): determination_period = str(period.start.year) node = getattr(lha, node_name) - category = benunit("LHA_category", period).decode_to_str() + category = benunit(category_variable, period).decode_to_str() caps = {cat: node.children[cat](determination_period) for cat in node.children} return pd.Series(category).map(caps).to_numpy(dtype=float) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py index f44c35d135..84e19c27c0 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py @@ -9,7 +9,13 @@ class housing_benefit_entitlement(Variable): unit = GBP def formula(benunit, period, parameters): - rent = benunit("benunit_rent", period) + # Charges for meals are not eligible to be met by Housing Benefit + # (HB Regs 2006 reg 12B(2)(b) and Sch 1 paras 1(a)(i) and 2). + rent = max_( + 0, + benunit("benunit_rent", period) + - benunit("housing_benefit_meals_deduction", period), + ) applicable_amount = benunit("housing_benefit_applicable_amount", period) income = benunit("housing_benefit_applicable_income", period) withdrawal_rate = parameters( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_meals_deduction.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_meals_deduction.py new file mode 100644 index 0000000000..faa79a30be --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_meals_deduction.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_meals_deduction(Variable): + value_type = float + entity = BenUnit + label = "Housing Benefit deduction for meals in the rent" + documentation = ( + "The amount of the rent that is ineligible for Housing Benefit " + "because it pays for meals: a fixed weekly amount for the claimant " + "and each family member, by the meals provided and whether the " + "person is 16 or over." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.ineligible_charges.meals + meals = benunit("meals_included_in_rent", period) + provision = meals.possible_values + person = benunit.members + # Sch 1 para 2(4): a person attains 16 on the first Monday in + # September after their 16th birthday; annual ages cannot place that + # date, so the age is used. + aged_16_or_over = benunit.sum(person("age", period) >= 16) + under_16 = benunit.sum(person("age", period) < 16) + members = aged_16_or_over + under_16 + weekly = select( + [ + meals == provision.AT_LEAST_THREE_A_DAY, + meals == provision.FEWER_THAN_THREE_A_DAY, + meals == provision.BREAKFAST_ONLY, + ], + [ + aged_16_or_over * p.at_least_three_a_day.aged_16_or_over + + under_16 * p.at_least_three_a_day.under_16, + aged_16_or_over * p.fewer_than_three_a_day.aged_16_or_over + + under_16 * p.fewer_than_three_a_day.under_16, + members * p.breakfast_only, + ], + default=0, + ) + return weekly * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py index 3c35be82d2..2cda438755 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py @@ -7,13 +7,21 @@ class housing_benefit_non_dep_deductions(Variable): label = "non-dependent deductions" definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + ) def formula(benunit, period, parameters): - deductions = benunit.members( + # Deductions are made for non-dependants residing with the claimant + # (HB Regs 2006 reg 74; HB (SPC) Regs 2006 reg 55). Joint occupiers, + # boarders, lodgers and the landlord's household are not + # non-dependants (reg 3(2)(d)-(e), 3(4)), so only the household + # head's family has them. + person = benunit.members + deductions = person( "household_benefits_individual_non_dep_deduction", period - ) - non_dep_deductions_in_hh = benunit.max( - benunit.members.household.sum(deductions) - ) - non_dep_deductions_in_bu = benunit.sum(deductions) - return non_dep_deductions_in_hh - non_dep_deductions_in_bu + ) * person("is_non_dependant_of_household_head", period) + head_family = benunit.any(person("is_household_head", period)) + return head_family * benunit.max(person.household.sum(deductions)) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py new file mode 100644 index 0000000000..005d00c653 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.LHA_allowed_bedrooms import ( + bedrooms_for_children, +) + + +class housing_benefit_LHA_allowed_bedrooms(Variable): + value_type = float + entity = BenUnit + label = "Bedrooms in the Housing Benefit size criteria" + documentation = ( + "Housing Benefit size criteria: one bedroom for the claimant or " + "couple, one for each other occupier aged 16 or over, and the " + "children's bedrooms. Occupiers are everyone who lives in the " + "dwelling as their home except a joint tenant outside the claimant's " + "household, so a householder's boarder or lodger adds a bedroom, as " + "does a non-dependant, but a sharer of the rent does not. A sharer's, " + "boarder's or lodger's own claim counts only their own family." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/13D", + ) + + def formula(benunit, period, parameters): + person = benunit.members + aged_16_or_over = person("age", period) >= 16 + head_family = person.benunit.any(person("is_household_head", period)) + sharer = person.benunit("liable_for_share_of_household_rent", period) + # HB Regs 2006 reg 13D(3), with "occupiers" as defined in 13D(12). + other_occupier = aged_16_or_over & ~head_family & ~sharer + is_head_family = benunit.any(person("is_household_head", period)) + other_occupiers = is_head_family * benunit.max( + person.household.sum(other_occupier) + ) + return 1 + other_occupiers + bedrooms_for_children(benunit, period) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py new file mode 100644 index 0000000000..305cf304bf --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py @@ -0,0 +1,58 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.LHA_category import LHACategory + + +class housing_benefit_LHA_category(Variable): + value_type = Enum + entity = BenUnit + label = "LHA category of dwelling (Housing Benefit)" + documentation = ( + "The Housing Benefit category of dwelling. The shared accommodation " + "rate applies to a young individual with no non-dependant, and to a " + "claimant entitled to one bedroom who lacks exclusive use of " + "self-contained accommodation, unless the severe disability premium " + "applies. Otherwise the category follows the number of bedrooms in " + "the Housing Benefit size criteria, up to four." + ) + definition_period = YEAR + possible_values = LHACategory + default_value = LHACategory.C + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/13D", + "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B", + ) + + def formula(benunit, period, parameters): + rooms = benunit("housing_benefit_LHA_allowed_bedrooms", period.this_year) + # Schedule 3 paragraph 14 (severe disability premium) applies. + severe_disability = benunit("severe_disability_premium", period) > 0 + # HB Regs 2006 reg 13D(2)(a)(i). + young_individual = ( + benunit("is_housing_benefit_young_individual", period) + & ~benunit("lha_renter_has_non_dependant", period) + & ~severe_disability + ) + # Reg 13D(2)(a)(ii): entitled to one bedroom but neither condition in + # 13D(2)(b) is met. + one_bedroom_shared = ( + (rooms == 1) + & benunit("housing_benefit_shares_accommodation", period) + & ~severe_disability + ) + return select( + [ + young_individual | one_bedroom_shared, + rooms == 1, + rooms == 2, + rooms == 3, + rooms > 3, + ], + [ + LHACategory.A, + LHACategory.B, + LHACategory.C, + LHACategory.D, + LHACategory.E, + ], + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py new file mode 100644 index 0000000000..b21e8c6e4d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.LHA_category import category_maximum +from policyengine_uk.variables.gov.dwp.uncapped_BRMA_LHA_rate import ( + lha_rate_for_category, +) + + +class housing_benefit_LHA_rate(Variable): + value_type = float + entity = BenUnit + label = "LHA rate (Housing Benefit)" + documentation = ( + "The Local Housing Allowance for the Housing Benefit category of " + "dwelling: the Broad Rental Market Area rate, capped at the weekly " + "national maximum for the category." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B", + ) + + def formula(benunit, period, parameters): + category = "housing_benefit_LHA_category" + rate = lha_rate_for_category(benunit, period, category) + maximum = category_maximum(benunit, period, "maximum", category) + return min_(rate, maximum * 52) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py b/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py new file mode 100644 index 0000000000..fd4031f384 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_shares_accommodation(Variable): + value_type = bool + entity = BenUnit + label = "Lacks exclusive use of self-contained accommodation (Housing Benefit)" + documentation = ( + "Whether the family lacks exclusive use of two or more rooms, or of " + "one room with a bathroom, toilet and kitchen. Rooms shared only with " + "the family's own household, its non-dependants or people who pay it " + "rent still count as exclusive. A boarder or lodger shares the " + "householder's rooms, and in a household whose rent is shared every " + "liable family shares the others' rooms. The household input " + "is_shared_accommodation marks any other case." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D" + + def formula(benunit, period, parameters): + # HB Regs 2006 reg 13D(2)(b): exclusive use of two or more rooms, or + # of one room, a bathroom and toilet and a kitchen or facilities for + # cooking, excluding rooms shared with anyone other than a member of + # the household, a non-dependant or a person who pays rent. + person = benunit.members + household_input = benunit.any( + person.household("is_shared_accommodation", period) + ) + boarder_or_lodger = benunit.any(person("pays_rent_to_householder", period)) + rent_is_shared = benunit.any( + person.household.any( + person.benunit("liable_for_share_of_household_rent", period) + ) + ) + liable = benunit("benunit_is_rent_liable", period) + return household_input | boarder_or_lodger | (rent_is_shared & liable) diff --git a/policyengine_uk/variables/gov/dwp/is_housing_benefit_young_individual.py b/policyengine_uk/variables/gov/dwp/is_housing_benefit_young_individual.py new file mode 100644 index 0000000000..f66e7af8ee --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_housing_benefit_young_individual.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class is_housing_benefit_young_individual(Variable): + value_type = bool + entity = BenUnit + label = "Young individual (Housing Benefit)" + documentation = ( + "A single claimant (no partner and not a lone parent) under the " + "shared accommodation age threshold. The exceptions for a housing " + "association landlord, care leavers, former hostel residents, " + "offenders under multi-agency management, people who need overnight " + "care, qualifying parents or carers, victims of domestic violence " + "and of modern slavery are not identified." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/2" + + def formula(benunit, period, parameters): + threshold = parameters(period).gov.dwp.LHA.shared_accommodation_age_threshold + return benunit("is_single_person", period) & ( + benunit("eldest_claimant_or_partner_age", period) < threshold + ) 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 new file mode 100644 index 0000000000..ebaf567b57 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class is_non_dependant_of_household_head(Variable): + value_type = bool + entity = Person + label = "Non-dependant of the household head's family" + documentation = ( + "Whether this person lives in the household outside the household " + "head's family and is not liable for rent. A joint tenant or other " + "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. The household head and their household are not " + "non-dependants of a boarder or lodger who pays them, and someone who " + "is already a non-dependant of one claimant is not one of another " + "claimant liable for the same accommodation, so in this model only " + "the household head's family has non-dependants from other families. " + "Foster children and carers engaged through a charity are not " + "identified." + ) + 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/214/regulation/3", + ) + + 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.any(person("is_household_head", period)) + liable_for_rent = person.benunit("benunit_is_rent_liable", period) + return ~head_family & ~liable_for_rent 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 347788ab60..31f652b563 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 @@ -4,14 +4,15 @@ class lha_renter_has_non_dependant(Variable): value_type = bool entity = BenUnit - label = "LHA renter has a non-dependant (household composition proxy)" + label = "LHA renter has a non-dependant" documentation = ( - "A conservative proxy: a claimant or partner of another benefit unit " - "in the household, or someone in this benefit unit who is neither a " - "claimant/partner nor a UC child or qualifying young person. The model " - "does not identify the statutory exclusions for joint renters, " - "commercial lodgers, landlords or foster children here. Rent liability " - "alone does not exclude another benefit unit's claimant." + "Someone in this benefit unit who is neither a claimant or partner " + "nor a child or young person, or, for the household head's family, a " + "claimant or partner of another family who is a non-dependant of the " + "household head (see is_non_dependant_of_household_head): joint " + "tenants and other sharers of the rent, boarders and lodgers are not " + "non-dependants, and a sharer, boarder or lodger has none from the " + "household head's family. Foster children are not identified." ) definition_period = YEAR reference = ( @@ -31,7 +32,12 @@ def formula(benunit, period, parameters): | ((age >= 16) & (age < 17)) ) within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_qyp) - other_benefit_unit_claimants = benunit.max( - person.household.sum(claimant_or_partner) - ) - benunit.sum(claimant_or_partner) - return within_benefit_unit | (other_benefit_unit_claimants > 0) + # 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 + ) + head_family = benunit.any(person("is_household_head", period)) + other_family_non_dependants = head_family * benunit.max( + person.household.sum(non_dependant_claimants) + ) + return within_benefit_unit | (other_family_non_dependants > 0) diff --git a/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py b/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py index 6ccef71675..27658fd46e 100644 --- a/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py +++ b/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py @@ -10,6 +10,72 @@ warnings.filterwarnings("ignore") +def lha_rate_for_category(benunit, period, category_variable="LHA_category"): + """The Broad Rental Market Area LHA rate (annual) for a category. + + ``category_variable`` names the category: the Universal Credit one by + default, or the Housing Benefit one. + """ + brma = benunit.value_from_first_person( + benunit.members.household("brma", period).decode_to_str() + ) + category = benunit(category_variable, period).decode_to_str() + + from policyengine_uk.parameters.gov.dwp.LHA import lha_list_of_rents + + parameters = benunit.simulation.tax_benefit_system.parameters + lha = parameters.gov.dwp.LHA + + # We first need to know what time period to collect rents from. If LHA is frozen, we need to look earlier + # than the current time period. + + frozen = lha.freeze(period) + if frozen: + # Rates are held at the level last determined, so every input to + # the determination is read at that year, not the current one. + freeze_anchor = find_freeze_anchor(lha.freeze, period.start) + lha_period = int(freeze_anchor[:4]) # Get year + else: + lha_period = int(period.start.year) + + determination_period = str(lha_period) + + private_rent_index = parameters.gov.indices.private_rent_index + lha_list_of_rents = time_shift_dataset( + lha_list_of_rents.copy(), lha_period, private_rent_index + ) + + percentile = lha.percentile(determination_period) + + lha_rates = lha_list_of_rents.groupby( + ["brma", "lha_category"] + ).weekly_rent.quantile(percentile) + + # Convert MultiIndex Series to DataFrame for merge + lha_rates_df = lha_rates.reset_index() + lha_rates_df.columns = ["brma", "lha_category", "weekly_rent"] + + # Determined rates are rounded to the nearest penny, half up + # (Schedule 3B paragraph 2(10)); np.round is half-even. Pence are + # snapped to 6dp first, because an exact half such as 298.835 is + # held as 29883.499999999996 once scaled and would round down. + lha_rates_df.weekly_rent = ( + np.floor(np.round(lha_rates_df.weekly_rent * 100, 6) + 0.5) / 100 + ) + + lha_lookup_table = pd.DataFrame( + { + "brma": brma, + "lha_category": category, + } + ) + # Use merge instead of row-by-row apply for vectorised lookup + lha_lookup_table = lha_lookup_table.merge( + lha_rates_df, on=["brma", "lha_category"], how="left" + ) + return lha_lookup_table.weekly_rent.values * 52 + + class uncapped_BRMA_LHA_rate(Variable): value_type = float entity = BenUnit @@ -20,61 +86,4 @@ class uncapped_BRMA_LHA_rate(Variable): reference = "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B" def formula(benunit, period, parameters): - brma = benunit.value_from_first_person( - benunit.members.household("brma", period).decode_to_str() - ) - category = benunit("LHA_category", period).decode_to_str() - - from policyengine_uk.parameters.gov.dwp.LHA import lha_list_of_rents - - parameters = benunit.simulation.tax_benefit_system.parameters - lha = parameters.gov.dwp.LHA - - # We first need to know what time period to collect rents from. If LHA is frozen, we need to look earlier - # than the current time period. - - frozen = lha.freeze(period) - if frozen: - # Rates are held at the level last determined, so every input to - # the determination is read at that year, not the current one. - freeze_anchor = find_freeze_anchor(lha.freeze, period.start) - lha_period = int(freeze_anchor[:4]) # Get year - else: - lha_period = int(period.start.year) - - determination_period = str(lha_period) - - private_rent_index = parameters.gov.indices.private_rent_index - lha_list_of_rents = time_shift_dataset( - lha_list_of_rents.copy(), lha_period, private_rent_index - ) - - percentile = lha.percentile(determination_period) - - lha_rates = lha_list_of_rents.groupby( - ["brma", "lha_category"] - ).weekly_rent.quantile(percentile) - - # Convert MultiIndex Series to DataFrame for merge - lha_rates_df = lha_rates.reset_index() - lha_rates_df.columns = ["brma", "lha_category", "weekly_rent"] - - # Determined rates are rounded to the nearest penny, half up - # (Schedule 3B paragraph 2(10)); np.round is half-even. Pence are - # snapped to 6dp first, because an exact half such as 298.835 is - # held as 29883.499999999996 once scaled and would round down. - lha_rates_df.weekly_rent = ( - np.floor(np.round(lha_rates_df.weekly_rent * 100, 6) + 0.5) / 100 - ) - - lha_lookup_table = pd.DataFrame( - { - "brma": brma, - "lha_category": category, - } - ) - # Use merge instead of row-by-row apply for vectorised lookup - lha_lookup_table = lha_lookup_table.merge( - lha_rates_df, on=["brma", "lha_category"], how="left" - ) - return lha_lookup_table.weekly_rent.values * 52 + return lha_rate_for_category(benunit, period) 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 59afb020d0..eca4abcd2a 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 @@ -7,18 +7,21 @@ class uc_non_dep_deductions(Variable): label = "Universal Credit non-dependent deductions" definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", + ) def formula(benunit, period, parameters): - # Deductions are made for non-dependents outside the benefit unit, - # but within the household, who meet certain conditions. To do this, - # we first calculate the non-dependent deduction for each person (from - # the perspective of a different benefit unit). Then, to calculate - # the deduction for non-dependents outside the benefit unit, we subtract - # the total non-dependent deductions for the benefit unit members from - # the deductions for household members. - deductions = benunit.members("uc_individual_non_dep_deduction", period) - non_dep_deductions_in_hh = benunit.max( - benunit.members.household.sum(deductions) + # A housing cost contribution is deducted for each non-dependant in + # the renter's extended benefit unit (UC Regs 2013 Sch 4 para 13). + # Non-dependants live outside the household head's family and are + # not liable for rent, and only the household head's family has them + # (para 9(2)(d)-(f)): a sharer, boarder or lodger has none from the + # household head's family. + person = benunit.members + deductions = person("uc_individual_non_dep_deduction", period) * person( + "is_non_dependant_of_household_head", period ) - non_dep_deductions_in_bu = benunit.sum(deductions) - return non_dep_deductions_in_hh - non_dep_deductions_in_bu + head_family = benunit.any(person("is_household_head", period)) + return head_family * benunit.max(person.household.sum(deductions)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py index ac1fbaf119..62f7c4d102 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py @@ -9,9 +9,9 @@ class uc_housing_costs_element(Variable): unit = GBP def formula(benunit, period, parameters): - tenure_type = benunit.value_from_first_person( - benunit.members.household("tenure_type", period) - ) + # The family's own tenure: a boarder or lodger is a private renter + # whatever the household's tenure (UC Regs 2013 Sch 4 para 20). + tenure_type = benunit("benunit_tenure_type", period) tenure_types = tenure_type.possible_values rent = benunit("benunit_rent", period) # Universal Credit has its own monthly national maximum, which is diff --git a/policyengine_uk/variables/household/consumption/benunit_rent.py b/policyengine_uk/variables/household/consumption/benunit_rent.py index 50ebc8cdb7..29d88d079a 100644 --- a/policyengine_uk/variables/household/consumption/benunit_rent.py +++ b/policyengine_uk/variables/household/consumption/benunit_rent.py @@ -6,7 +6,9 @@ class benunit_rent(Variable): entity = BenUnit label = "Rent" documentation = ( - "Gross rent that members of this family are liable for (social housing only)" + "Rent that members of this family are liable for: the family's share " + "of the household's rent, plus anything its members pay the " + "householder as boarders or lodgers." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/household/consumption/personal_rent.py b/policyengine_uk/variables/household/consumption/personal_rent.py index 8dbd3dd768..f121bee0f4 100644 --- a/policyengine_uk/variables/household/consumption/personal_rent.py +++ b/policyengine_uk/variables/household/consumption/personal_rent.py @@ -5,8 +5,45 @@ class personal_rent(Variable): value_type = float entity = Person label = "Rent liable" - documentation = "The gross rent this person is liable for" + documentation = ( + "The rent this person is liable for: an equal share of the " + "household's rent among the people liable for it, plus anything this " + "person pays the householder as a boarder or lodger. Where the " + "household head's family is the only one liable, its claimant and " + "partner share the whole rent, so the family's total is the " + "household's rent." + ) definition_period = YEAR unit = GBP - defined_for = "is_household_head" - adds = ["rent"] + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/24", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12B", + ) + + def formula(person, period, parameters): + # Where people in more than one family are liable for the same rent, + # each person's core rent is the total divided by the number of + # people liable (UC Regs 2013 Sch 4 para 24(4): A / B x C), which also + # apportions Housing Benefit eligible rent by the number of people + # liable (HB Regs 2006 reg 12B(4)). + rent = person.household("rent", period) + liable = person("is_liable_for_household_rent", period) + liable_people = person.household.sum(liable) + # A household with nobody marked liable (for example a head who is + # not a claimant or partner) leaves the rent with the household head. + head = person("is_household_head", period) + heads = person.household.sum(head) + share = where( + liable_people > 0, + liable / max_(liable_people, 1), + head / max_(heads, 1), + ) + # A licence or other permission to occupy is a rent payment (UC Regs + # 2013 Sch 1 para 2(b); HB Regs 2006 reg 12(1)): boarders and lodgers + # are liable for what they pay the householder. + paid_to_householder = add( + person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"] + ) + return rent * share + paid_to_householder diff --git a/policyengine_uk/variables/household/consumption/rent/is_liable_for_household_rent.py b/policyengine_uk/variables/household/consumption/rent/is_liable_for_household_rent.py new file mode 100644 index 0000000000..9ddfc87b32 --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/is_liable_for_household_rent.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class is_liable_for_household_rent(Variable): + value_type = bool + entity = Person + label = "Liable for the household's rent" + documentation = ( + "Whether this person is one of the people liable for the rent of the " + "household's accommodation: the claimant or partner of the household " + "head's family, or of a family liable for a share of that rent." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/24", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12B", + ) + + def formula(person, period, parameters): + head_family = person.benunit.any(person("is_household_head", period)) + sharer = person.benunit("liable_for_share_of_household_rent", period) + return person("is_claimant_or_partner", period) & (head_family | sharer) diff --git a/policyengine_uk/variables/household/consumption/rent/liable_for_share_of_household_rent.py b/policyengine_uk/variables/household/consumption/rent/liable_for_share_of_household_rent.py new file mode 100644 index 0000000000..e3cb0a395a --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/liable_for_share_of_household_rent.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class liable_for_share_of_household_rent(Variable): + value_type = bool + entity = BenUnit + label = "Liable for a share of the household's rent" + documentation = ( + "Whether this family shares liability for the household's rent with " + "the household head's family: a joint tenant, or a sharer with its " + "own agreement for part of the same accommodation (the Family " + "Resources Survey's shared household). The household head's family " + "is always liable, so this matters only for other families. Boarders " + "and lodgers, who pay the householder, are not sharers: see " + "rent_paid_as_boarder and rent_paid_as_lodger." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/24", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12B", + ) diff --git a/policyengine_uk/variables/household/consumption/rent/meals_included_in_rent.py b/policyengine_uk/variables/household/consumption/rent/meals_included_in_rent.py new file mode 100644 index 0000000000..cdcf2135c1 --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/meals_included_in_rent.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class MealsIncludedInRent(Enum): + NONE = "No meals" + BREAKFAST_ONLY = "Breakfast only" + FEWER_THAN_THREE_A_DAY = "Fewer than three meals a day" + AT_LEAST_THREE_A_DAY = "At least three meals a day" + + +class meals_included_in_rent(Variable): + value_type = Enum + possible_values = MealsIncludedInRent + default_value = MealsIncludedInRent.NONE + entity = BenUnit + label = "Meals included in the family's rent" + documentation = ( + "Meals the family's rent pays for, as for a boarder. The Family " + "Resources Survey records whether someone is a boarder but not how " + "many meals they get." + ) + definition_period = YEAR diff --git a/policyengine_uk/variables/household/demographic/benunit_tenure_type.py b/policyengine_uk/variables/household/demographic/benunit_tenure_type.py index 8496db50ca..45be66c94d 100644 --- a/policyengine_uk/variables/household/demographic/benunit_tenure_type.py +++ b/policyengine_uk/variables/household/demographic/benunit_tenure_type.py @@ -9,10 +9,32 @@ class benunit_tenure_type(Variable): possible_values = TenureType default_value = TenureType.RENT_PRIVATELY entity = BenUnit - label = "Tenure type of the family's household" + label = "Tenure type of the family" + documentation = ( + "The family's own tenure. A boarder or lodger rents privately from " + "the householder whatever the household's tenure, so a lodger in a " + "council or housing association home is a private renter. Other " + "families take the household's tenure." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/20", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13C", + ) def formula(benunit, period, parameters): - return benunit.value_from_first_person( + household_tenure = benunit.value_from_first_person( benunit.members.household("tenure_type", period) ) + # UC private rented sector rules apply to renters liable to anyone + # other than a provider of social housing (UC Regs 2013 Sch 4 para + # 20); a householder is not a social landlord (HB Regs 2006 reg + # 13C(5)(a)). + pays_householder = benunit.any( + benunit.members("pays_rent_to_householder", period) + ) + return where( + pays_householder, + TenureType.RENT_PRIVATELY, + household_tenure.decode(), + ) diff --git a/policyengine_uk/variables/household/demographic/in_social_housing.py b/policyengine_uk/variables/household/demographic/in_social_housing.py index f33c2e768e..84a03bcdd7 100644 --- a/policyengine_uk/variables/household/demographic/in_social_housing.py +++ b/policyengine_uk/variables/household/demographic/in_social_housing.py @@ -1,14 +1,18 @@ from policyengine_uk.model_api import * -import pandas as pd class in_social_housing(Variable): value_type = bool entity = Person label = "Whether this person lives in social housing" + documentation = ( + "Whether this person's family rents from a council or housing " + "association. A boarder or lodger in a social tenant's home rents " + "from the householder, not from the social landlord." + ) definition_period = YEAR def formula(person, period, parameters): - tenure = person.household("tenure_type", period.this_year) + tenure = person.benunit("benunit_tenure_type", period.this_year) tenures = tenure.possible_values return is_in(tenure, tenures.RENT_FROM_COUNCIL, tenures.RENT_FROM_HA) diff --git a/uv.lock b/uv.lock index 47b04e0136..73aa2719b7 100644 --- a/uv.lock +++ b/uv.lock @@ -1471,7 +1471,7 @@ wheels = [ [[package]] name = "policyengine-uk" -version = "2.102.3" +version = "2.102.6" source = { editable = "." } dependencies = [ { name = "microdf-python" }, From f333e17f266b848a05eed5f3f113757b6e3f6d32 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:00:07 -0400 Subject: [PATCH 25/75] Take tax reductions off the tax on earnings first; deduct only relievable pension contributions Review of #1949 found two defects in the new deductions. - uc_income_tax_on_earnings capped the tax on the earnings slice at the person's income_tax. When a tax reduction (the married couple's allowance) made that cap bind, the pension annual allowance charge and the High Income Child Benefit Charge raised the deduction, and so did more pension income. Reductions now come off the earnings slice first, as allowances do, so the deduction never includes a charge and does not move with other income. - uc_individual_earned_income deducted every pension contribution. Contributions paid after 75 are not relievable (Finance Act 2004 s. 188(3)(a)), so reg. 55(5)(a) does not deduct them. Adds YAML cases for both, draws a married couple's allowance in the property tests, and says in the test docstring that payroll giving (reg. 55(5)(c)) is not modelled. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 94 +++++++++++++++++++ .../test_uc_earnings_deductions_properties.py | 29 ++++-- .../income/uc_income_tax_on_earnings.py | 21 +++-- .../income/uc_individual_earned_income.py | 27 ++++-- 4 files changed, 146 insertions(+), 25 deletions(-) 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 2e9505a83e..43b86c108c 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 @@ -248,3 +248,97 @@ property_income_tax: 880 income_tax: 2_366 uc_income_tax_on_earnings: 1_486 + +- name: A tax reduction comes off the tax on earnings first + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 92 + employment_income: 20_000 + married_couples_allowance: 11_000 + would_claim_marriage_allowance: false + partner: + age: 45 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [older, partner] + households: + household: + members: [older, partner] + output: + # Married couple's allowance reduction: 10% x 11,000 = 1,100. + capped_mcad: [1_100, 0] + # (20,000 - 12,570) x 20% = 1,486, less 1,100. + income_tax: [386, 0] + uc_income_tax_on_earnings: [386, 0] + +- name: Pension income does not change the tax on earnings when a reduction applies + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 92 + employment_income: 20_000 + private_pension_income: 5_000 + married_couples_allowance: 11_000 + would_claim_marriage_allowance: false + partner: + age: 45 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [older, partner] + households: + household: + members: [older, partner] + output: + # 1,486 on earnings + 5,000 x 20% on the pension - 1,100. + income_tax: [1_386, 0] + # The reduction still comes off the earnings slice: 1,486 - 1,100. + uc_income_tax_on_earnings: [386, 0] + +- name: The pension annual allowance charge is not deducted, even with a tax reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 92 + employment_income: 20_000 + employer_pension_contributions: 65_000 + married_couples_allowance: 11_000 + would_claim_marriage_allowance: false + partner: + age: 45 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [older, partner] + households: + household: + members: [older, partner] + output: + # Contributions of 65,000 exceed the 60,000 annual allowance by 5,000, + # charged at the basic rate: 1,000. + personal_pension_contributions_tax: [1_000, 0] + income_tax: [1_386, 0] + uc_income_tax_on_earnings: [386, 0] + +- name: Pension contributions paid after 75 are not relievable, so are not deducted + period: 2026 + absolute_error_margin: 0.01 + input: + age: 76 + employment_income: 15_000 + employee_pension_contributions: 1_000 + output: + # No relief after 75 (FA 2004 s. 188(3)(a)): (15,000 - 12,570) x 20%. + uc_income_tax_on_earnings: 486 + # Over State Pension age, so no Class 1 NI. + uc_national_insurance_on_earnings: 0 + # 15,000 - 486, with the 1,000 of contributions not deducted. + uc_individual_earned_income: 14_514 diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 1d68ea2b29..7a4546510e 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -1,8 +1,9 @@ """Property-based tests for the Universal Credit deductions from earnings. -UC Regs 2013 reg. 55(5) and reg. 57(2) deduct from a person's earnings only -their own relievable pension contributions and the income tax and National -Insurance they pay in respect of their employment or trade. The model takes +UC Regs 2013 reg. 55(5) and reg. 57(2) deduct from a person's earnings their +own relievable pension contributions, the income tax and National Insurance +they pay in respect of their employment or trade, and payroll giving (reg. +55(5)(c), which the model does not have). The model takes earnings as the lowest slice of the person's non-savings income, after their allowances, with other income above it (ITA 2007 s. 16 for savings and dividends). @@ -25,10 +26,11 @@ Invariants 1-3 hold only while unearned income leaves the person's allowances alone, so the generated incomes keep adjusted net income below -the personal allowance taper (100,000), no one is old enough for the -married couple's allowance, and no one claims Marriage Allowance (see -test_uc_state_pension_properties.py for the Marriage Allowance deviation). -Invariant 4 runs with Marriage Allowance claimed. +the personal allowance taper (100,000) and no one claims Marriage Allowance +(see test_uc_state_pension_properties.py for the Marriage Allowance +deviation). Tax reductions are covered: adults born before 6 April 1935 can +have a married couple's allowance, which comes off the tax on earnings +first. Invariant 4 runs with Marriage Allowance claimed. """ import numpy as np @@ -62,8 +64,9 @@ "savings_interest_income", "dividend_income", ] -# Married couple's allowance needs a birth before 6 April 1935 (85 in 2020). -PENSION_AGE = st.integers(67, 84) +# The married couple's allowance needs a birth before 6 April 1935. +MCA_BIRTH_YEAR = 1934 +PENSION_AGE = st.integers(67, 96) WORKING_AGE = st.integers(18, 60) SHAPES = { "single": [WORKING_AGE], @@ -107,6 +110,10 @@ def families(draw): if variable == "state_pension" and age < 67: continue adult[variable] = draw(unearned) + # Used only where the adult is old enough in the simulated year. + adult["married_couples_allowance"] = draw( + st.one_of(st.just(0.0), st.floats(0, 12_000)) + ) adults.append(adult) return dict( adults=adults, @@ -132,6 +139,10 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa for variable, value in adult.items(): if earnings_only and variable in UNEARNED: continue + if variable == "married_couples_allowance" and ( + year - adult["age"] > MCA_BIRTH_YEAR + ): + continue person[variable] = {year: value} if bump is not None and bump[:2] == (i, j) and not earnings_only: variable, amount = bump[2], bump[3] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 630b5320c9..4c26346d9d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -35,11 +35,11 @@ def formula(person, period, parameters): # 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 all 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 + # 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. p = parameters(period) @@ -75,7 +75,10 @@ def formula(person, period, parameters): rates.scotland.rates.calc(taxable_earnings), rates.uk.calc(taxable_earnings), ) - # Tax reductions (for example the married couple's allowance) can - # leave total income tax below the tax on the earnings slice; never - # deduct more than the person pays. - return min_(tax, person("income_tax", period)) + # Tax reductions (for example the married couple's allowance) come + # off the tax on earnings first, as allowances do. So the deduction + # never exceeds the income tax the person pays, never includes a + # charge such as the High Income Child Benefit Charge, and does not + # move when other income absorbs more or less of a reduction. + reductions = add(person, period, p.gov.hmrc.income_tax.income_tax_subtractions) + return max_(0, tax - reductions) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py index 7995416bcb..201eedbb24 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -22,6 +22,10 @@ class uc_individual_earned_income(Variable): title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ), + dict( + title="Finance Act 2004 s. 188 (relievable pension contributions)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ), ] def formula(person, period, parameters): @@ -29,13 +33,22 @@ def formula(person, period, parameters): # earnings, so one partner's tax, NI or pension contributions never # reduce the other partner's earned income. gross_earnings = person("uc_mif_capped_earned_income", period) - deductions = add( + # Contributions paid after the person reaches 75 are not relievable + # (Finance Act 2004 s. 188(3)(a)). + age_limit = parameters( + period + ).gov.hmrc.pensions.pension_contributions_relief_age_limit + relievable_pension_contributions = person("pension_contributions", period) * ( + person("age", period) < age_limit + ) + tax_and_national_insurance = add( person, period, - [ - "pension_contributions", - "uc_income_tax_on_earnings", - "uc_national_insurance_on_earnings", - ], + ["uc_income_tax_on_earnings", "uc_national_insurance_on_earnings"], + ) + return max_( + 0, + gross_earnings + - relievable_pension_contributions + - tax_and_national_insurance, ) - return max_(0, gross_earnings - deductions) From f0f150d3021ce701eb86168effc1633cbdc418d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:10:45 -0400 Subject: [PATCH 26/75] Generate working pensioners with a tax reduction in the UC earnings property tests The random generator rarely produced an earner old enough for the married couple's allowance, so the properties did not exercise tax reductions. A dedicated family shape now does. With it, restoring the old min(tax, income_tax) cap fails both the invariance and the differential property. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_earnings_deductions_properties.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 7a4546510e..21672531fe 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -64,14 +64,19 @@ "savings_interest_income", "dividend_income", ] -# The married couple's allowance needs a birth before 6 April 1935. +# The married couple's allowance needs a birth before 6 April 1935, which +# every adult aged 93 or over meets in each simulated year. MCA_BIRTH_YEAR = 1934 PENSION_AGE = st.integers(67, 96) +MCA_AGE = st.integers(93, 96) WORKING_AGE = st.integers(18, 60) SHAPES = { "single": [WORKING_AGE], "couple": [WORKING_AGE, WORKING_AGE], "mixed_age": [PENSION_AGE, WORKING_AGE], + # An older partner who works and has a married couple's allowance, so a + # tax reduction applies to someone with earnings. + "mixed_age_with_tax_reduction": [MCA_AGE, WORKING_AGE], } # Per adult: earnings up to 50,000 and unearned income up to 5 x 7,000, # plus a bump up to 9,000, keeps adjusted net income under 100,000. @@ -98,9 +103,12 @@ def families(draw): shape = draw(st.sampled_from(list(SHAPES))) adults = [] for age in [draw(age) for age in SHAPES[shape]]: + has_tax_reduction = shape == "mixed_age_with_tax_reduction" and age >= 93 adult = dict( age=age, - employment_income=draw(earnings), + employment_income=( + draw(st.floats(15_000, 30_000)) if has_tax_reduction else draw(earnings) + ), self_employment_income=draw(self_employment), # Some self-employed are in a start-up period, so the minimum # income floor does not apply. @@ -111,8 +119,10 @@ def families(draw): continue adult[variable] = draw(unearned) # Used only where the adult is old enough in the simulated year. - adult["married_couples_allowance"] = draw( - st.one_of(st.just(0.0), st.floats(0, 12_000)) + adult["married_couples_allowance"] = ( + draw(st.floats(3_000, 12_000)) + if has_tax_reduction + else draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) ) adults.append(adult) return dict( From b8555a2d0133771fc5ef009054b86e56a55bd963 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:15:36 -0400 Subject: [PATCH 27/75] Follow the tax engine's exclusion list when slicing earnings uc_income_tax_on_earnings hard-coded savings, dividends and property as the incomes outside earned_taxable_income. A reform that adds another income to gov.hmrc.income_tax.earned_taxable_income_exclusions, such as one exempting pensions, then had that income subtracted twice, and the tax on earnings fell to nil. The formula now reads the list. Adds a YAML case with the reform as a parameter input. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 20 +++++++++++ .../income/uc_income_tax_on_earnings.py | 34 +++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) 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 43b86c108c..ef2abd1a6e 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 @@ -342,3 +342,23 @@ uc_national_insurance_on_earnings: 0 # 15,000 - 486, with the 1,000 of contributions not deducted. uc_individual_earned_income: 14_514 + +- name: A reform exempting pensions from income tax leaves the tax on earnings unchanged + 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: 20_000 + private_pension_income: 10_000 + output: + # The pension is outside the taxed base, so it is not above earnings + # either: (20,000 - 12,570) x 20%. + income_tax: 1_486 + uc_income_tax_on_earnings: 1_486 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 4c26346d9d..49dff530cc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -43,6 +43,17 @@ def formula(person, period, parameters): # actually taken on the job, which can include tax on a State # Pension coded against it; that is not modelled. p = parameters(period) + income_tax = p.gov.hmrc.income_tax + # earned_taxable_income is adjusted net income less the incomes and + # allowances on the exclusions list. Read that list, so a reform + # that changes it (for example one that exempts pensions) is + # followed here. + exclusions = list(income_tax.earned_taxable_income_exclusions) + excluded_incomes = [ + variable + for variable in exclusions + if variable in income_tax.adjusted_net_income_components + ] earnings_components = [ "taxable_employment_income", "taxable_self_employment_income", @@ -52,24 +63,25 @@ def formula(person, period, parameters): bi = p.gov.contrib.ubi_center.basic_income.interactions if bi.include_in_means_tests and bi.include_in_taxable_income: earnings_components.append("basic_income") - earnings = add(person, period, earnings_components) - # earned_taxable_income is non-savings, non-property income after - # allowances. The part of it above earnings belongs to the person's - # other non-savings income. - non_savings_non_property_income = person("adjusted_net_income", period) - add( + earnings = add( person, period, [ - "taxable_savings_interest_income", - "taxable_dividend_income", - "taxable_property_income", + variable + for variable in earnings_components + if variable not in exclusions ], ) - other_income = max_(0, non_savings_non_property_income - earnings) + # 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("adjusted_net_income", 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 ) - rates = p.gov.hmrc.income_tax.rates + rates = income_tax.rates tax = where( person("pays_scottish_income_tax", period), rates.scotland.rates.calc(taxable_earnings), @@ -80,5 +92,5 @@ def formula(person, period, parameters): # never exceeds the income tax the person pays, never includes a # charge such as the High Income Child Benefit Charge, and does not # move when other income absorbs more or less of a reduction. - reductions = add(person, period, p.gov.hmrc.income_tax.income_tax_subtractions) + reductions = add(person, period, income_tax.income_tax_subtractions) return max_(0, tax - reductions) From 613ff63e310329eaed7e0eb7735c492b6b808958 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:23:09 -0400 Subject: [PATCH 28/75] Split council tax among jointly liable sharers and apportion shared non-dependants - Council Tax Reduction: where the household's rent is shared, every family liable for it is jointly and severally liable for the council tax and claims on the council tax divided by the number of people jointly liable (SI 2012/2885 Sch 1 para 7(3)-(4); Wales and Scotland the same), with a shared non-dependant apportioned equally (para 8(5)). Joint tenants and lodgers are not non-dependants (reg 9(2)(d)-(e)). National schemes and the five local schemes. - Housing Benefit: a non-dependant of several joint occupiers is apportioned by their shares of the rent (reg 74(5); SPC reg 55(5)). - Cite the current HB reg 13(7) (as substituted by SI 2007/2868) for the meals deduction on the rent officer route. - Tests: meals amounts, sharers, boarders and lodgers, CTR joint liability. Co-Authored-By: Claude Opus 5.5 --- .../housing_benefit_meals_deduction.yaml | 128 +++++++ .../rent_of_sharers_boarders_and_lodgers.yaml | 326 ++++++++++++++++++ .../joint_liability.yaml | 156 +++++++++ policyengine_uk/variables/gov/dwp/LHA_cap.py | 2 +- .../housing_benefit_non_dep_deductions.py | 11 +- .../dwp/is_non_dependant_of_household_head.py | 12 +- .../council_tax_reduction/_legacy.py | 11 +- .../council_tax_reduction_claimant_benunit.py | 33 ++ ...n_individual_non_dep_deduction_eligible.py | 19 +- ...cil_tax_reduction_joint_liability_share.py | 38 ++ ...ouncil_tax_reduction_non_dep_deductions.py | 5 +- ...simulated_council_tax_reduction_benunit.py | 6 +- .../oxford_council_tax_reduction.py | 6 +- .../rent/share_of_household_rent.py | 30 ++ 14 files changed, 762 insertions(+), 21 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py create mode 100644 policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml new file mode 100644 index 0000000000..d86d6feaa9 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml @@ -0,0 +1,128 @@ +# Housing Benefit Regulations 2006 Sch 1 para 2 (amounts ineligible for meals) +# and reg 13(7); the same amounts are in the pension-age regulations +# (SI 2006/214 Sch 1 para 2). Weekly amounts x 52. + +- name: No meals, no deduction + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + output: + housing_benefit_meals_deduction: 0 + +- name: Single boarder with at least three meals a day, 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + boarder: + age: 40 + benunits: + family: + members: [boarder] + meals_included_in_rent: AT_LEAST_THREE_A_DAY + households: + home: + members: [boarder] + output: + # Para 2(2)(a): £37.30 a week (SI 2026/148). 37.30 x 52 = 1,939.60. + housing_benefit_meals_deduction: 1_939.60 + +- name: Couple with a child under 16, fewer than three meals a day, 2025-26 + period: 2025 + absolute_error_margin: 0.01 + input: + people: + adult_1: + age: 40 + adult_2: + age: 38 + child: + age: 10 + benunits: + family: + members: [adult_1, adult_2, child] + meals_included_in_rent: FEWER_THAN_THREE_A_DAY + households: + home: + members: [adult_1, adult_2, child] + output: + # Para 2(3)(b): £24.00 for each person aged 16 or over and £12.00 for each + # under 16 (SI 2025/295). (2 x 24.00 + 12.00) x 52 = 3,120. + housing_benefit_meals_deduction: 3_120 + +- name: Breakfast only, a flat amount per person + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 30 + child: + age: 5 + benunits: + family: + members: [adult, child] + meals_included_in_rent: BREAKFAST_ONLY + households: + home: + members: [adult, child] + output: + # Para 2(5): £4.50 for the claimant and each family member. + # 2 x 4.50 x 52 = 468. + housing_benefit_meals_deduction: 468 + +- name: Meals come off the rent before the LHA cap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + boarder: + age: 70 + rent_paid_as_boarder: 10_000 + benunits: + householder_family: + members: [householder] + boarder_family: + members: [boarder] + meals_included_in_rent: AT_LEAST_THREE_A_DAY + housing_benefit_LHA_rate: 9_000 + households: + home: + members: [householder, boarder] + tenure_type: OWNED_OUTRIGHT + output: + # Reg 13(7): rent 10,000 less 37.30 x 52 = 1,939.60 is 8,060.40, below + # the 9,000 rate standing in for the rent officer's figure. + LHA_cap: [0, 8_060.40] + +- name: Meals do not reduce the Universal Credit housing costs element + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + boarder: + age: 40 + rent_paid_as_boarder: 6_000 + benunits: + householder_family: + members: [householder] + boarder_family: + members: [boarder] + meals_included_in_rent: AT_LEAST_THREE_A_DAY + uncapped_BRMA_LHA_rate: 9_000 + households: + home: + members: [householder, boarder] + tenure_type: OWNED_OUTRIGHT + output: + # UC excludes food from service charges only in the social rented + # sector and for owner-occupiers (UC Sch 1 para 8(1), (6)(c)); a private + # renter's whole payment is core rent: min(6,000, 9,000) = 6,000. + uc_housing_costs_element: [0, 6_000] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml new file mode 100644 index 0000000000..60db0c179b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml @@ -0,0 +1,326 @@ +# Rent liability, tenure and LHA category of families other than the +# household head's: joint tenants and other sharers of the rent, boarders and +# lodgers. +# UC Regs 2013 (SI 2013/376) Sch 1 para 2, Sch 4 paras 9, 10, 20, 24, 25, 27-29. +# HB Regs 2006 (SI 2006/213) regs 3, 12, 12B, 13, 13C, 13D, Sch 1 para 2. + +- name: The household head's couple shares the household's rent equally + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + partner: + age: 38 + benunits: + family: + members: [head, partner] + households: + home: + members: [head, partner] + rent: 12_000 + output: + # Two people liable: 12,000 / 2 each; the family's total is the rent. + personal_rent: [6_000, 6_000] + benunit_rent: 12_000 + +- name: Sharers split the household's rent per person liable + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer_1: + age: 30 + sharer_1_partner: + age: 31 + sharer_2: + age: 45 + benunits: + head_family: + members: [head] + couple: + members: [sharer_1, sharer_1_partner] + liable_for_share_of_household_rent: true + single: + members: [sharer_2] + liable_for_share_of_household_rent: true + households: + home: + members: [head, sharer_1, sharer_1_partner, sharer_2] + rent: 20_000 + tenure_type: RENT_PRIVATELY + output: + # UC Sch 4 para 24(4): A / B x C with A = 20,000 and B = 4 people liable, + # so 5,000 for a single person and 10,000 for the couple (C = 2). + is_liable_for_household_rent: [true, true, true, true] + benunit_rent: [5_000, 10_000, 5_000] + benunit_tenure_type: [RENT_PRIVATELY, RENT_PRIVATELY, RENT_PRIVATELY] + # Joint tenants are not non-dependants (para 9(2)(d)): no deductions. + uc_non_dep_deductions: [0, 0, 0] + housing_benefit_non_dep_deductions: [0, 0, 0] + +- name: A family not marked as a sharer has no share of the rent + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + adult_son: + age: 25 + benunits: + parent: + members: [head] + son: + members: [adult_son] + households: + home: + members: [head, adult_son] + rent: 9_000 + output: + benunit_rent: [9_000, 0] + benunit_is_rent_liable: [true, false] + is_non_dependant_of_household_head: [false, true] + +- name: A lodger in a council home rents privately from the householder + period: 2025 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 50 + is_household_head: true + lodger: + age: 40 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + lodger_family: + members: [lodger] + households: + home: + members: [householder, lodger] + rent: 6_000 + tenure_type: RENT_FROM_COUNCIL + output: + # The householder is liable to the council for the whole rent and the + # lodger to the householder for 5,200 (a licence: UC Sch 1 para 2(b)). + benunit_rent: [6_000, 5_200] + # A householder is not a provider of social housing (UC Sch 4 para 20). + benunit_tenure_type: [RENT_FROM_COUNCIL, RENT_PRIVATELY] + in_social_housing: [true, false] + LHA_eligible: [false, true] + +- name: A lodger in an owner-occupied home is a private renter + period: 2025 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + lodger: + age: 40 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + lodger_family: + members: [lodger] + uncapped_BRMA_LHA_rate: 6_000 + households: + home: + members: [householder, lodger] + tenure_type: OWNED_OUTRIGHT + output: + benunit_rent: [0, 5_200] + benunit_tenure_type: [OWNED_OUTRIGHT, RENT_PRIVATELY] + # UC: single, 40 >= 35, 1 bedroom: B. Cap = min(rent 5,200, rate 6,000). + LHA_category: B + uc_LHA_cap: [0, 5_200] + uc_housing_costs_element: [0, 5_200] + +- name: The householder's non-dependant is not deducted from a lodger's UC + period: 2025 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 55 + is_household_head: true + adult_daughter: + age: 30 + lodger: + age: 40 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + uncapped_BRMA_LHA_rate: 12_000 + daughter: + members: [adult_daughter] + lodger_family: + members: [lodger] + uncapped_BRMA_LHA_rate: 6_000 + households: + home: + members: [householder, adult_daughter, lodger] + rent: 10_000 + tenure_type: RENT_PRIVATELY + output: + # The daughter is the householder's non-dependant; the lodger is no one's + # (UC Sch 4 para 9(2)(d)), and the householder's household is not the + # lodger's (para 9(2)(e)). + is_non_dependant_of_household_head: [false, true, false] + # One housing cost contribution, £93.02 a month for 2025-26 (Sch 4 para + # 14): 93.02 x 12 = 1,116.24. + uc_non_dep_deductions: [1_116.24, 0, 0] + # Householder: min(10,000, 12,000) - 1,116.24 = 8,883.76. The lodger's + # element is not reduced: min(5,200, 6,000) = 5,200. + uc_housing_costs_element: [8_883.76, 0, 5_200] + # UC bedrooms: householder 1 + daughter 1 = 2 (the lodger adds none). + LHA_allowed_bedrooms: [2, 1, 1] + +- name: A lodger adds a bedroom to the householder's HB size criteria but not UC + period: 2025 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + lodger: + age: 40 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + lodger_family: + members: [lodger] + households: + home: + members: [householder, lodger] + rent: 12_000 + tenure_type: RENT_PRIVATELY + output: + # UC: the lodger is not a non-dependant (Sch 4 para 9(2)(d)), so the + # householder is entitled to 1 bedroom: B. + LHA_allowed_bedrooms: [1, 1] + LHA_category: [B, B] + # HB: the lodger occupies the dwelling and is not a joint tenant, so is + # an "occupier" (reg 13D(12)): 1 + 1 = 2 bedrooms: C. The lodger's own + # claim counts only the lodger: 1 bedroom, shares the householder's rooms + # (reg 13D(2)(a)(ii)): A. + housing_benefit_LHA_allowed_bedrooms: [2, 1] + housing_benefit_shares_accommodation: [false, true] + housing_benefit_LHA_category: [C, A] + +- name: A lodger does not stop a householder under 35 being a specified renter + period: 2025 + input: + people: + householder: + age: 30 + is_household_head: true + lodger: + age: 45 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + lodger_family: + members: [lodger] + households: + home: + members: [householder, lodger] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + # UC Sch 4 para 28(4): no person is a non-dependant of the householder. + lha_renter_has_non_dependant: [false, false] + is_lha_shared_accommodation_rate_specified_renter: [true, false] + LHA_category: [A, B] + # HB reg 13D(2)(a)(i): a young individual with no non-dependant: A. + housing_benefit_LHA_category: [A, A] + +- name: A sharer aged 40 gets the UC one-bedroom rate and the HB shared rate + period: 2025 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + output: + # UC: entitlement only (Sch 4 para 25(2)(b)): 1 bedroom, not a specified + # renter (40 >= 35): B for both. + LHA_category: [B, B] + # HB: each is entitled to one bedroom and shares rooms with someone who + # is neither household, non-dependant nor paying rent to them (reg + # 13D(2)(b)), so the shared rate (13D(2)(a)(ii)): A for both. + housing_benefit_shares_accommodation: [true, true] + housing_benefit_LHA_category: [A, A] + +- name: The severe disability premium keeps a sharer off the HB shared rate + period: 2025 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + severe_disability_premium: 3_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + output: + # Reg 13D(2)(a)(ii) excludes a claimant to whom Sch 3 para 14 applies, + # so 13D(2)(c): 1 bedroom: B. + housing_benefit_LHA_category: [A, B] + +- name: The household input for shared accommodation affects Housing Benefit only + period: 2025 + input: + people: + person: + age: 50 + benunits: + family: + members: [person] + households: + home: + members: [person] + rent: 9_000 + tenure_type: RENT_PRIVATELY + is_shared_accommodation: true + output: + LHA_category: B + housing_benefit_LHA_category: A diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml new file mode 100644 index 0000000000..47b722480c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml @@ -0,0 +1,156 @@ +# Council Tax Reduction where the household's rent, and so its council tax, +# is shared: each jointly and severally liable family claims on the council +# tax divided by the number of people jointly liable, and a non-dependant of +# two or more of them is apportioned equally between them. Joint tenants are +# not non-dependants. +# England pensioners SI 2012/2885 reg 9(2)(d), Sch 1 paras 7(3)-(4) and 8(5); +# Wales SI 2013/3029 reg 9(2)(d), Sch 1 para 2(3)-(5), Sch 6 para 4(3)-(5). + +- name: Three sharers and a non-dependant in Wales + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer_1: + age: 30 + sharer_2: + age: 45 + adult_son: + age: 22 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_1_family: + members: [sharer_1] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + sharer_2_family: + members: [sharer_2] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + households: + home: + members: [head, sharer_1, sharer_2, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + council_tax_reduction_claimant_benunit: [true, true, true, false] + council_tax_reduction_joint_liability_share: [0.333333, 0.333333, 0.333333, 1] + # Joint tenants are not non-dependants; the adult son is. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, true] + # The son's 300 is apportioned equally: 100 each. + council_tax_reduction_non_dep_deductions: [100, 100, 100, 0] + # 1,800 / 3 x 100% - 100 = 500 each; the son is not liable and cannot claim. + simulated_council_tax_reduction_benunit: [500, 500, 500, 0] + council_tax_reduction: 1_500 + +- name: A couple sharing with one other person claims a third of the council tax + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + partner: + age: 40 + sharer: + age: 30 + benunits: + couple: + members: [head, partner] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, partner, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + # The regulations divide by every person jointly liable (three), with no + # multiplier for the claimant's partner; the partner-only exception does + # not apply because a third person is liable. + council_tax_reduction_joint_liability_share: [0.333333, 0.333333] + simulated_council_tax_reduction_benunit: [600, 600] + +- name: A couple liable only with each other claims the whole council tax + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + partner: + age: 40 + adult_son: + age: 22 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + couple: + members: [head, partner] + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + households: + home: + members: [head, partner, adult_son] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + # No one outside the couple is jointly liable: no division (para (4)). + council_tax_reduction_joint_liability_share: [1, 1] + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_non_dep_deductions: [300, 0] + simulated_council_tax_reduction_benunit: [1_500, 0] + +- name: A lodger is not a non-dependant of the householder + period: 2025 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 50 + is_household_head: true + lodger: + age: 30 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + council_tax_reduction_applicable_income: 0 + lodger_family: + members: [lodger] + households: + home: + members: [householder, lodger] + tenure_type: OWNED_OUTRIGHT + council_tax: 1_800 + country: WALES + region: WALES + output: + # SI 2013/3029 reg 9(2)(e): liable to make payments on a commercial basis + # to the applicant. A lodger is not jointly liable for council tax, so + # the householder claims the whole bill. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + council_tax_reduction_joint_liability_share: [1, 1] + simulated_council_tax_reduction_benunit: [1_800, 0] diff --git a/policyengine_uk/variables/gov/dwp/LHA_cap.py b/policyengine_uk/variables/gov/dwp/LHA_cap.py index dedaa40a3b..0d26ce5172 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_cap.py +++ b/policyengine_uk/variables/gov/dwp/LHA_cap.py @@ -32,7 +32,7 @@ def formula(benunit, period, parameters): # the LHA rate or, if lower, the cap rent. A rent officer finding # that a substantial part of the rent is for board and attendance # takes the case off the LHA (reg 13C(5)(e)); the maximum rent then - # has the Sch 1 para 2 amount for meals deducted (reg 13(13)). + # has the Sch 1 para 2 amount for meals deducted (reg 13(7)). rent = max_( 0, benunit("benunit_rent", period) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py index 2cda438755..e5e9e3a933 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py @@ -17,11 +17,14 @@ def formula(benunit, period, parameters): # Deductions are made for non-dependants residing with the claimant # (HB Regs 2006 reg 74; HB (SPC) Regs 2006 reg 55). Joint occupiers, # boarders, lodgers and the landlord's household are not - # non-dependants (reg 3(2)(d)-(e), 3(4)), so only the household - # head's family has them. + # non-dependants (reg 3(2)(d)-(e), 3(4)). A non-dependant of more + # than one joint occupier is apportioned between them by their shares + # of the payments (reg 74(5); SPC reg 55(5)), so each family liable + # for the household's rent bears its share; a boarder or lodger bears + # none. person = benunit.members deductions = person( "household_benefits_individual_non_dep_deduction", period ) * person("is_non_dependant_of_household_head", period) - head_family = benunit.any(person("is_household_head", period)) - return head_family * benunit.max(person.household.sum(deductions)) + share = benunit("share_of_household_rent", period) + return share * benunit.max(person.household.sum(deductions)) 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 ebaf567b57..2c80db6274 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 @@ -11,12 +11,12 @@ 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. The household head and their household are not " - "non-dependants of a boarder or lodger who pays them, and someone who " - "is already a non-dependant of one claimant is not one of another " - "claimant liable for the same accommodation, so in this model only " - "the household head's family has non-dependants from other families. " - "Foster children and carers engaged through a charity are not " - "identified." + "non-dependants of a boarder or lodger who pays them. Universal " + "Credit counts a non-dependant in one claim only, which the model " + "gives to the household head's family; Housing Benefit and Council " + "Tax Reduction apportion a non-dependant of several joint occupiers " + "between them. Foster children and carers engaged through a charity " + "are not identified." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 54883e9b4b..966d230ff7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -18,7 +18,9 @@ def legacy_council_tax_reduction( non_dep_deductions_variable, additional_applicable_income=0, ): - is_household_head_benunit = benunit("benunit_contains_household_head", period) + is_household_head_benunit = benunit( + "council_tax_reduction_claimant_benunit", period + ) would_claim = benunit("would_claim_council_tax_reduction", period) applicable_amount = benunit("council_tax_reduction_applicable_amount", period) applicable_income = benunit("council_tax_reduction_applicable_income", period) @@ -39,7 +41,7 @@ def legacy_council_tax_reduction( ) liability = benunit.household( "council_tax_reduction_maximum_eligible_liability", period - ) + ) * benunit("council_tax_reduction_joint_liability_share", period) non_dep_deductions = benunit(non_dep_deductions_variable, period) excess_income = max_(0, applicable_income - applicable_amount) excess_income = where( @@ -86,7 +88,10 @@ def local_non_dep_deductions( deductions_in_household = benunit.max( benunit.members.household.sum(deductions_to_count) ) - return deductions_in_household - deduction_for_benunit + # A non-dependant of two or more jointly liable people is apportioned + # equally between them (SI 2012/2885 Sch 1 para 8(5)). + share = benunit("council_tax_reduction_joint_liability_share", period) + return (deductions_in_household - deduction_for_benunit) * share def normal_gross_income_non_dep_deduction( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py new file mode 100644 index 0000000000..449b9d0808 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_claimant_benunit(Variable): + value_type = bool + entity = BenUnit + label = "Family claims Council Tax Reduction for the household" + documentation = ( + "Whether this family can claim Council Tax Reduction on the " + "household's council tax. Where the household's rent is shared, every " + "family liable for it is jointly and severally liable for the council " + "tax and claims on its part. Otherwise the family of the household's " + "oldest adult claims, as before." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + ) + + def formula(benunit, period, parameters): + person = benunit.members + rent_is_shared = benunit.any( + person.household.any( + person.benunit("liable_for_share_of_household_rent", period) + ) + ) + liable_family = benunit.any(person("is_liable_for_household_rent", period)) + return where( + rent_is_shared, + liable_family, + benunit("benunit_contains_household_head", period), + ) 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..459598f8d3 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 @@ -5,9 +5,24 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): value_type = bool entity = Person label = "eligible person for CTR non-dependent deduction" + documentation = ( + "An adult outside the claimant's family who is not liable for rent. " + "Someone jointly and severally liable for the council tax with the " + "claimant (a joint tenant or other sharer of the rent) and someone " + "liable to the claimant on a commercial basis (a boarder or lodger) " + "is not a non-dependant." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/wsi/2013/3029", + "https://www.legislation.gov.uk/ssi/2021/249", + ) def formula(person, period, parameters): - return (person("age", period) >= 18) & ~person.benunit( - "benunit_contains_household_head", period + # SI 2012/2885 reg 9(2)(d)-(e) and the Welsh and Scottish equivalents. + return ( + (person("age", period) >= 18) + & ~person.benunit("benunit_contains_household_head", period) + & ~person.benunit("benunit_is_rent_liable", period) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py new file mode 100644 index 0000000000..215992982e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_joint_liability_share(Variable): + value_type = float + entity = BenUnit + label = "Share of the council tax for a jointly liable Council Tax Reduction claim" + documentation = ( + "Where the claimant is jointly and severally liable for the council " + "tax with people other than their partner, the council tax used for " + "the maximum reduction is divided by the number of people jointly and " + "severally liable, and a deduction for a non-dependant of two or more " + "of them is apportioned equally between them. The model takes the " + "people liable for the household's rent as those jointly liable for " + "its council tax (residents with the same interest, Local Government " + "Finance Act 1992 s.6) and follows the regulations' wording, dividing " + "by every person liable, the claimant's partner included. Students " + "are not excluded from the count. Otherwise the share is one." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029", + "https://www.legislation.gov.uk/ssi/2021/249", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 Sch 1 para 7(3)-(4) and para 8(5); the Welsh and + # Scottish schemes have the same wording. + person = benunit.members + liable = person("is_liable_for_household_rent", period) + liable_people = benunit.max(person.household.sum(liable)) + in_family = benunit.sum(liable) + jointly_with_others = (in_family > 0) & (liable_people > in_family) + return where(jointly_with_others, 1 / max_(liable_people, 1), 1) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py index c6b5fc1d8d..6241abf4c7 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -14,4 +14,7 @@ def formula(benunit, period, parameters): ) deductions_in_household = benunit.max(benunit.members.household.sum(deductions)) deductions_in_benunit = benunit.sum(deductions) - return deductions_in_household - deductions_in_benunit + # A non-dependant of two or more jointly liable people is apportioned + # equally between them (SI 2012/2885 Sch 1 para 8(5)). + share = benunit("council_tax_reduction_joint_liability_share", period) + return (deductions_in_household - deductions_in_benunit) * share 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..6f3714fee8 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 @@ -38,11 +38,13 @@ def formula(benunit, period, parameters): wales = is_wales_scheme(country) national_scheme = england_pensioners | scotland | wales - is_household_head_benunit = benunit("benunit_contains_household_head", period) + is_household_head_benunit = benunit( + "council_tax_reduction_claimant_benunit", period + ) would_claim = benunit("would_claim_council_tax_reduction", period) liability = benunit.household( "council_tax_reduction_maximum_eligible_liability", period - ) + ) * benunit("council_tax_reduction_joint_liability_share", period) applicable_amount = benunit("council_tax_reduction_applicable_amount", period) applicable_income = benunit("council_tax_reduction_applicable_income", period) non_dep_deductions = benunit("council_tax_reduction_non_dep_deductions", period) diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index 332b9781c0..abeea551dc 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -20,7 +20,9 @@ def formula(benunit, period, parameters): household("country", period), household("council_tax_reduction_household_has_pensioner", period), ) - is_household_head_benunit = benunit("benunit_contains_household_head", period) + is_household_head_benunit = benunit( + "council_tax_reduction_claimant_benunit", period + ) would_claim = benunit("would_claim_council_tax_reduction", period) universal_credit = benunit("universal_credit", period) has_uc_award = universal_credit > 0 @@ -47,7 +49,7 @@ def formula(benunit, period, parameters): support_rate = ctr.income_band.maximum_support_rate.calc(weekly_income) liability = household( "council_tax_reduction_maximum_eligible_liability", period - ) + ) * benunit("council_tax_reduction_joint_liability_share", period) non_dep_deductions = benunit( "oxford_council_tax_reduction_non_dep_deductions", period ) diff --git a/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py b/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py new file mode 100644 index 0000000000..9588c5184d --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class share_of_household_rent(Variable): + value_type = float + entity = BenUnit + label = "Share of the household's rent" + documentation = ( + "The share of the household's rent this family is liable for: the " + "people liable for it in this family over all the people liable for " + "it. The household head's family has the whole rent unless other " + "families are liable for a share." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/24", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12B", + ) + + def formula(benunit, period, parameters): + person = benunit.members + liable = person("is_liable_for_household_rent", period) + liable_people = benunit.max(person.household.sum(liable)) + head_family = benunit.any(person("is_household_head", period)) + return where( + liable_people > 0, + benunit.sum(liable) / max_(liable_people, 1), + head_family, + ) From 24a88dac5f36bca6624193bcd1da8a6814014cce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:54:17 -0400 Subject: [PATCH 29/75] Property tests for sharers', boarders' and lodgers' rent, tenure and housing support Hypothesis invariants over generated households: rent conservation and bounds, boarder and lodger tenure, non-dependant exclusion, UC category independent of the shared-accommodation input, HB shared rate where exclusive use is lacking, meals never raising HB eligible rent, CTR claim shares in shared households, monotonicity in household rent, and a no-op check against the previous rent and deduction formulas. Co-Authored-By: Claude Opus 5.5 --- ...sharers_boarders_and_lodgers_properties.py | 274 ++++++++++++++++++ .../demographic/benunit_tenure_type.py | 12 +- 2 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py 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 new file mode 100644 index 0000000000..c3b3efca5a --- /dev/null +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -0,0 +1,274 @@ +"""Property-based tests for the rent, tenure and housing support of families +other than the household head's: sharers of the rent, boarders and lodgers. + +Invariants, for any generated population of households: + +1. Conservation: the families' shares of the household's rent sum to one + wherever anyone is liable for it, and the families' rents sum to the + household's rent plus what boarders and lodgers pay the householder. +2. Bounds: each share is in [0, 1]; each family's rent is non-negative; a + family that is neither the household head's, nor a sharer, nor a boarder + or lodger has no rent. +3. Tenure: a boarder's or lodger's family rents privately, is never in social + housing, and is LHA-eligible. +4. Non-dependants: a family liable for rent is never a non-dependant; only + the household head's family has Universal Credit non-dependant + deductions; a boarder's or lodger's family has no Housing Benefit or + Council Tax Reduction non-dependant deductions. +5. Category: the Universal Credit LHA category does not depend on the + household input is_shared_accommodation; the Housing Benefit category is + the shared rate for any family entitled to one bedroom that lacks + exclusive use and has no severe disability premium. +6. Meals: the Housing Benefit meals deduction is non-negative, and adding + meals never raises the Housing Benefit eligible rent (LHA_cap). +7. Council tax: in a household whose rent is shared, the jointly liable + claim shares never exceed one in total. +8. Monotonicity: a family's rent is non-decreasing in the household's rent. +9. No-op: in a household with no sharers, boarders or lodgers, the household + head's family has the whole rent and everyone else none, as before, and + the head's Universal Credit non-dependant deductions equal the previous + formula (every other family's deductions). +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 +PROPERTY_SETTINGS = settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +TENURES = [ + "RENT_FROM_COUNCIL", + "RENT_FROM_HA", + "RENT_PRIVATELY", + "OWNED_OUTRIGHT", + "OWNED_WITH_MORTGAGE", +] +ROLES = ["sharer", "boarder", "lodger", "non_dependant"] +MEALS = ["NONE", "BREAKFAST_ONLY", "FEWER_THAN_THREE_A_DAY", "AT_LEAST_THREE_A_DAY"] +money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def other_family(draw): + return dict( + role=draw(st.sampled_from(ROLES)), + ages=draw(st.lists(st.integers(18, 85), min_size=1, max_size=2)), + child_age=draw(st.one_of(st.none(), st.integers(0, 15))), + payment=draw(st.floats(1, 15_000, allow_nan=False)), + meals=draw(st.sampled_from(MEALS)), + sdp=draw(st.booleans()), + ) + + +@st.composite +def households(draw): + return dict( + head_ages=draw(st.lists(st.integers(18, 85), min_size=1, max_size=2)), + head_child_age=draw(st.one_of(st.none(), st.integers(0, 15))), + tenure=draw(st.sampled_from(TENURES)), + rent=draw(money), + council_tax=draw(st.floats(0, 3_000, allow_nan=False)), + others=draw(st.lists(other_family(), min_size=0, max_size=3)), + shared=draw(st.booleans()), + ) + + +population = st.lists(households(), min_size=1, max_size=6) + + +def build(population, shared_override=None, meals_override=None, rent_bump=0.0): + """One situation for the whole population; returns it and family roles.""" + people, benunits, homes, roles = {}, {}, {}, [] + for h, house in enumerate(population): + members = [] + + def add_family(name, ages, child_age, role, extra): + ids = [] + for i, age in enumerate(ages): + pid = f"{name}_adult_{i}" + people[pid] = {"age": age, "is_claimant_or_partner": True} + ids.append(pid) + if child_age is not None: + pid = f"{name}_child" + people[pid] = {"age": child_age, "is_claimant_or_partner": False} + ids.append(pid) + benunits[name] = {"members": ids, **extra} + members.extend(ids) + roles.append(role) + return ids + + head_ids = add_family( + f"h{h}_head", house["head_ages"], house["head_child_age"], "head", {} + ) + for pid in head_ids: + people[pid]["is_household_head"] = False + people[head_ids[0]]["is_household_head"] = True + for f, other in enumerate(house["others"]): + name = f"h{h}_f{f}" + extra = { + "liable_for_share_of_household_rent": other["role"] == "sharer", + "meals_included_in_rent": ( + meals_override or other["meals"] + if other["role"] == "boarder" + else "NONE" + ), + } + if other["sdp"]: + extra["severe_disability_premium"] = 3_000 + ids = add_family( + name, other["ages"], other["child_age"], other["role"], extra + ) + for pid in ids: + people[pid]["is_household_head"] = False + if other["role"] in ("boarder", "lodger"): + variable = f"rent_paid_as_{other['role']}" + people[ids[0]][variable] = other["payment"] + homes[f"h{h}"] = { + "members": members, + "tenure_type": house["tenure"], + "rent": house["rent"] + rent_bump, + "council_tax": house["council_tax"], + "is_shared_accommodation": ( + house["shared"] if shared_override is None else shared_override + ), + "brma": "MAIDSTONE", + } + situation = {"people": people, "benunits": benunits, "households": homes} + return situation, np.array(roles) + + +def calc(simulation, variable, map_to=None): + """Values as a plain array (enumerations come back decoded).""" + result = simulation.calculate(variable, YEAR, map_to=map_to) + return np.asarray(result) + + +@PROPERTY_SETTINGS +@given(population) +def test_conservation_bounds_and_tenure(population): + situation, roles = build(population) + sim = Simulation(situation=situation) + share = calc(sim, "share_of_household_rent") + rent = calc(sim, "benunit_rent") + household_rent = calc(sim, "rent") + paid = calc(sim, "rent_paid_as_boarder", "household") + calc( + sim, "rent_paid_as_lodger", "household" + ) + # 1. Conservation. + share_total = sim.map_result(share, "benunit", "household") + assert np.allclose(share_total, 1, atol=1e-6) + rent_total = sim.map_result(rent, "benunit", "household") + assert np.allclose(rent_total, household_rent + paid, rtol=1e-6, atol=0.01) + # 2. Bounds. + assert np.all((share >= -1e-9) & (share <= 1 + 1e-9)) + assert np.all(rent >= 0) + assert np.all(rent[roles == "non_dependant"] == 0) + assert np.all(share[np.isin(roles, ["boarder", "lodger", "non_dependant"])] == 0) + # 3. Tenure. + payer = np.isin(roles, ["boarder", "lodger"]) + tenure = sim.calculate("benunit_tenure_type", YEAR) + assert np.all(tenure[payer] == "RENT_PRIVATELY") + social = calc(sim, "in_social_housing", "benunit") > 0 + assert not np.any(social[payer]) + assert np.all(calc(sim, "LHA_eligible")[payer]) + + +@PROPERTY_SETTINGS +@given(population) +def test_non_dependants(population): + situation, roles = build(population) + sim = Simulation(situation=situation) + liable = calc(sim, "benunit_is_rent_liable") + non_dependant = calc(sim, "is_non_dependant_of_household_head", "benunit") > 0 + assert not np.any(non_dependant & liable) + uc = calc(sim, "uc_non_dep_deductions") + assert np.all(uc[roles != "head"] == 0) + payer = np.isin(roles, ["boarder", "lodger"]) + assert np.all(calc(sim, "housing_benefit_non_dep_deductions")[payer] == 0) + assert np.all(calc(sim, "council_tax_reduction_non_dep_deductions")[payer] == 0) + + +@PROPERTY_SETTINGS +@given(population) +def test_lha_categories(population): + with_input, _ = build(population, shared_override=True) + without_input, _ = build(population, shared_override=False) + a = Simulation(situation=with_input) + b = Simulation(situation=without_input) + # 5. The UC category is set by entitlement alone. + assert np.array_equal( + a.calculate("LHA_category", YEAR), + b.calculate("LHA_category", YEAR), + ) + for sim in (a, b): + rooms = calc(sim, "housing_benefit_LHA_allowed_bedrooms") + shares = calc(sim, "housing_benefit_shares_accommodation") + sdp = calc(sim, "severe_disability_premium") > 0 + category = sim.calculate("housing_benefit_LHA_category", YEAR) + assert np.all(category[(rooms == 1) & shares & ~sdp] == "A") + + +@PROPERTY_SETTINGS +@given(population) +def test_meals_and_council_tax(population): + with_meals, roles = build(population, meals_override="AT_LEAST_THREE_A_DAY") + no_meals, _ = build(population, meals_override="NONE") + a = Simulation(situation=with_meals) + b = Simulation(situation=no_meals) + # 6. Meals. + assert np.all(calc(a, "housing_benefit_meals_deduction") >= 0) + assert np.all(calc(a, "LHA_cap") <= calc(b, "LHA_cap") + 1e-6) + # 7. Council tax shares, where the rent is shared. (Elsewhere the family + # of the household's oldest adult claims, and families tied for oldest + # both do: a separate, existing issue.) + claimant = calc(a, "council_tax_reduction_claimant_benunit") + share = calc(a, "council_tax_reduction_joint_liability_share") + total = a.map_result(claimant * share, "benunit", "household") + sharer = calc(a, "liable_for_share_of_household_rent") + shared = a.map_result(sharer, "benunit", "household") > 0 + assert np.all(total[shared] <= 1 + 1e-6) + + +@PROPERTY_SETTINGS +@given(population, st.floats(1, 10_000, allow_nan=False)) +def test_rent_is_monotone_in_household_rent(population, bump): + base, _ = build(population) + raised, _ = build(population, rent_bump=bump) + before = calc(Simulation(situation=base), "benunit_rent") + after = calc(Simulation(situation=raised), "benunit_rent") + assert np.all(after >= before - 1e-6) + + +@PROPERTY_SETTINGS +@given(population) +def test_no_op_without_sharers_boarders_or_lodgers(population): + for house in population: + for other in house["others"]: + other["role"] = "non_dependant" + situation, roles = build(population) + sim = Simulation(situation=situation) + # 9. The head's family has the whole rent; everyone else none. + rent = calc(sim, "benunit_rent") + head = roles == "head" + assert np.all(rent[~head] == 0) + rent_total = sim.map_result(rent, "benunit", "household") + assert np.allclose(rent_total, calc(sim, "rent"), atol=0.01) + # The previous formula: the head's family was charged for everyone + # outside it (and every other family likewise). + individual = calc(sim, "uc_individual_non_dep_deduction") + person_head_family = sim.map_result(head, "benunit", "person") > 0 + everyone = sim.map_result(individual, "person", "household") + head_family_own = sim.map_result( + individual * person_head_family, "person", "household" + ) + head_deductions = sim.map_result( + calc(sim, "uc_non_dep_deductions") * head, "benunit", "household" + ) + assert np.allclose(head_deductions, everyone - head_family_own, atol=0.01) diff --git a/policyengine_uk/variables/household/demographic/benunit_tenure_type.py b/policyengine_uk/variables/household/demographic/benunit_tenure_type.py index 45be66c94d..c244b584a6 100644 --- a/policyengine_uk/variables/household/demographic/benunit_tenure_type.py +++ b/policyengine_uk/variables/household/demographic/benunit_tenure_type.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_core.enums import EnumArray from policyengine_uk.variables.household.demographic.tenure_type import ( TenureType, ) @@ -33,8 +34,11 @@ def formula(benunit, period, parameters): pays_householder = benunit.any( benunit.members("pays_rent_to_householder", period) ) - return where( - pays_householder, - TenureType.RENT_PRIVATELY, - household_tenure.decode(), + return EnumArray( + where( + pays_householder, + TenureType.RENT_PRIVATELY.index, + household_tenure, + ).astype(np.int16), + TenureType, ) From 53c327d362df73774bc73594c44fa7df53c3173a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:54:55 -0400 Subject: [PATCH 30/75] Use the companion branch's boarder and lodger input definitions verbatim rent_paid_as_boarder, rent_paid_as_lodger and pays_rent_to_householder are copied byte for byte from boarder-lodger-rent (PolicyEngine/policyengine-uk#2002) at the same paths, so the two branches merge without conflict in either order. Co-Authored-By: Claude Opus 5.5 --- .../rent/pays_rent_to_householder.py | 16 ------------ .../consumption/rent/rent_paid_as_boarder.py | 13 ---------- .../consumption/rent/rent_paid_as_lodger.py | 13 ---------- .../income/pays_rent_to_householder.py | 25 +++++++++++++++++++ .../variables/input/rent_paid_as_boarder.py | 17 +++++++++++++ .../variables/input/rent_paid_as_lodger.py | 17 +++++++++++++ 6 files changed, 59 insertions(+), 42 deletions(-) delete mode 100644 policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py delete mode 100644 policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py delete mode 100644 policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py create mode 100644 policyengine_uk/variables/household/income/pays_rent_to_householder.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/policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py b/policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py deleted file mode 100644 index 7a510c4020..0000000000 --- a/policyengine_uk/variables/household/consumption/rent/pays_rent_to_householder.py +++ /dev/null @@ -1,16 +0,0 @@ -from policyengine_uk.model_api import * - - -class pays_rent_to_householder(Variable): - value_type = bool - entity = Person - label = "Family pays rent to the householder" - documentation = ( - "Whether any member of this person's benefit unit pays the " - "householder rent as a boarder or lodger." - ) - definition_period = YEAR - - def formula(person, period, parameters): - payments = add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) - return person.benunit.any(payments > 0) diff --git a/policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py b/policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py deleted file mode 100644 index 47d6d220da..0000000000 --- a/policyengine_uk/variables/household/consumption/rent/rent_paid_as_boarder.py +++ /dev/null @@ -1,13 +0,0 @@ -from policyengine_uk.model_api import * - - -class rent_paid_as_boarder(Variable): - value_type = float - entity = Person - label = "Rent paid as a boarder" - documentation = ( - "Rent this person pays the householder for board and lodging " - "(accommodation with at least some meals) in the householder's home." - ) - definition_period = YEAR - unit = GBP diff --git a/policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py b/policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py deleted file mode 100644 index e53796d733..0000000000 --- a/policyengine_uk/variables/household/consumption/rent/rent_paid_as_lodger.py +++ /dev/null @@ -1,13 +0,0 @@ -from policyengine_uk.model_api import * - - -class rent_paid_as_lodger(Variable): - value_type = float - entity = Person - label = "Rent paid as a lodger" - documentation = ( - "Rent this person pays the householder for lodging only in the " - "householder's home." - ) - definition_period = YEAR - unit = GBP 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/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" From 190482e33a9629fa88d141c04b1dbd0bd38893da Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 12:02:28 -0400 Subject: [PATCH 31/75] Changelog for #2005 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2005.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2005.added.md diff --git a/changelog.d/2005.added.md b/changelog.d/2005.added.md new file mode 100644 index 0000000000..9680d4e64a --- /dev/null +++ b/changelog.d/2005.added.md @@ -0,0 +1 @@ +- Give joint tenants and other sharers their share of the household's rent, boarders and lodgers their own rent and private tenure, a Housing Benefit LHA category (with its own size criteria and shared-accommodation rule) beside the Universal Credit one, statutory non-dependant exclusions and apportionment, the Housing Benefit meals deduction, and Council Tax Reduction on the council tax divided among jointly liable people. From b065a48d2ccedc7f90e9e86a9c18195b89072538 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:03:13 -0400 Subject: [PATCH 32/75] Keep each family's share of the household's rent on one person personal_rent puts the family's share (share_of_household_rent, per person liable) on the household head for the head's family and on the family's head otherwise, so the household head still carries the whole rent when no one else is liable (test_deterministic_variables TestHeadOrdering). Family totals and the UC Sch 4 para 24(4) split are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../rent_of_sharers_boarders_and_lodgers.yaml | 7 ++-- .../household/consumption/personal_rent.py | 34 ++++++------------- .../rent/share_of_household_rent.py | 3 +- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml index 60db0c179b..e1ee4a617d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml @@ -4,7 +4,7 @@ # UC Regs 2013 (SI 2013/376) Sch 1 para 2, Sch 4 paras 9, 10, 20, 24, 25, 27-29. # HB Regs 2006 (SI 2006/213) regs 3, 12, 12B, 13, 13C, 13D, Sch 1 para 2. -- name: The household head's couple shares the household's rent equally +- name: The household head carries the rent of a couple liable for it period: 2025 absolute_error_margin: 0.01 input: @@ -22,8 +22,9 @@ members: [head, partner] rent: 12_000 output: - # Two people liable: 12,000 / 2 each; the family's total is the rent. - personal_rent: [6_000, 6_000] + # The family's share (the whole rent) sits on the household head. + share_of_household_rent: 1 + personal_rent: [12_000, 0] benunit_rent: 12_000 - name: Sharers split the household's rent per person liable diff --git a/policyengine_uk/variables/household/consumption/personal_rent.py b/policyengine_uk/variables/household/consumption/personal_rent.py index f121bee0f4..8166a552a3 100644 --- a/policyengine_uk/variables/household/consumption/personal_rent.py +++ b/policyengine_uk/variables/household/consumption/personal_rent.py @@ -6,12 +6,12 @@ class personal_rent(Variable): entity = Person label = "Rent liable" documentation = ( - "The rent this person is liable for: an equal share of the " - "household's rent among the people liable for it, plus anything this " - "person pays the householder as a boarder or lodger. Where the " - "household head's family is the only one liable, its claimant and " - "partner share the whole rent, so the family's total is the " - "household's rent." + "The rent this person is liable for. Each family's share of the " + "household's rent (see share_of_household_rent) sits on one person: " + "the household head for the household head's family, otherwise the " + "family's head. Anything a person pays the householder as a boarder " + "or lodger is their own. Where the household head's family is the " + "only one liable, the household head carries the whole rent." ) definition_period = YEAR unit = GBP @@ -23,27 +23,15 @@ class personal_rent(Variable): ) def formula(person, period, parameters): - # Where people in more than one family are liable for the same rent, - # each person's core rent is the total divided by the number of - # people liable (UC Regs 2013 Sch 4 para 24(4): A / B x C), which also - # apportions Housing Benefit eligible rent by the number of people - # liable (HB Regs 2006 reg 12B(4)). rent = person.household("rent", period) - liable = person("is_liable_for_household_rent", period) - liable_people = person.household.sum(liable) - # A household with nobody marked liable (for example a head who is - # not a claimant or partner) leaves the rent with the household head. - head = person("is_household_head", period) - heads = person.household.sum(head) - share = where( - liable_people > 0, - liable / max_(liable_people, 1), - head / max_(heads, 1), - ) + share = person.benunit("share_of_household_rent", period) + household_head = person("is_household_head", period) + head_family = person.benunit.any(household_head) + holder = where(head_family, household_head, person("is_benunit_head", period)) # A licence or other permission to occupy is a rent payment (UC Regs # 2013 Sch 1 para 2(b); HB Regs 2006 reg 12(1)): boarders and lodgers # are liable for what they pay the householder. paid_to_householder = add( person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"] ) - return rent * share + paid_to_householder + return rent * share * holder + paid_to_householder diff --git a/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py b/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py index 9588c5184d..4b63374370 100644 --- a/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py +++ b/policyengine_uk/variables/household/consumption/rent/share_of_household_rent.py @@ -8,7 +8,8 @@ class share_of_household_rent(Variable): documentation = ( "The share of the household's rent this family is liable for: the " "people liable for it in this family over all the people liable for " - "it. The household head's family has the whole rent unless other " + "it (UC Regs 2013 Sch 4 para 24(4): A / B x C; HB Regs 2006 reg " + "12B(4)). The household head's family has the whole rent unless other " "families are liable for a share." ) definition_period = YEAR From 01d140e7742bde0d25496d497d78c3744fdf022b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:36:02 -0400 Subject: [PATCH 33/75] Address the independent review of #2006 - UC non-dependants count in one claim: the first family liable for the rent that is eligible for and claims UC, not always the household head's (Sch 4 para 9(2)(f)). New uc_non_dependants_counted, used by deductions, bedrooms and the specified-renter test. - HB meals are deducted only where a rent officer has found a substantial part of the rent is board and attendance (reg 13C(5)(e), 13(7)); the LHA cap rent is gross (regs 12D, 13D(12)). New input housing_benefit_board_and_attendance_determination. - HB severe disability premium conditions (Sch 3 para 14; SPC Sch 3 para 6) replace the tax credit severe disability test in the HB LHA category, so a PIP daily living recipient living alone keeps the one-bedroom rate. - HB size criteria over the claim's occupiers: one room per couple, one per other occupier aged 16 or over, children paired across occupiers (reg 13D(3), (12)). - CTR non-dependants exclude claimant families, sharers and people paying the householder, not any rent-liable family; excluded students are left out of the joint-liability count and do not claim as sharers (SI 2012/2885 Sch 1 para 7(5); Default Scheme Sch para 75(1)). - Property tests: generated earnings, a fixed household with every role, the single-claim and apportionment invariants, and bedroom and deduction no-op checks against the previous formulas. Co-Authored-By: Claude Opus 5.5 --- .../qualifying_benefits.yaml | 16 ++ .../housing_benefit_meals_deduction.yaml | 61 +++- .../rent_of_sharers_boarders_and_lodgers.yaml | 132 ++++++++- .../joint_liability.yaml | 105 +++++++ ...sharers_boarders_and_lodgers_properties.py | 264 +++++++++++++----- .../variables/gov/dwp/LHA_allowed_bedrooms.py | 44 +-- policyengine_uk/variables/gov/dwp/LHA_cap.py | 41 +-- .../housing_benefit_entitlement.py | 19 +- .../housing_benefit_has_non_dependant.py | 37 +++ ...nefit_severe_disability_premium_applies.py | 50 ++++ .../housing_benefit_LHA_allowed_bedrooms.py | 66 +++-- .../gov/dwp/housing_benefit_LHA_category.py | 6 +- .../housing_benefit_shares_accommodation.py | 10 +- .../dwp/is_non_dependant_of_household_head.py | 16 +- .../gov/dwp/lha_renter_has_non_dependant.py | 20 +- .../uc_non_dep_deductions.py | 11 +- .../uc_non_dependants_counted.py | 40 +++ .../council_tax_reduction_claimant_benunit.py | 11 +- ...n_individual_non_dep_deduction_eligible.py | 15 +- ...cil_tax_reduction_joint_liability_share.py | 25 +- ...efit_board_and_attendance_determination.py | 20 ++ 21 files changed, 824 insertions(+), 185 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/severe_disability_premium/qualifying_benefits.yaml create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_has_non_dependant.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py create mode 100644 policyengine_uk/variables/household/consumption/rent/housing_benefit_board_and_attendance_determination.py diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/severe_disability_premium/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/severe_disability_premium/qualifying_benefits.yaml new file mode 100644 index 0000000000..82d79f1db6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/severe_disability_premium/qualifying_benefits.yaml @@ -0,0 +1,16 @@ +description: Benefits whose receipt meets the qualifying-benefit condition of the Housing Benefit severe disability premium. +values: + 2013-04-08: + - attendance_allowance + - dla_sc_middle_plus + - pip_dl + - armed_forces_independence_payment +metadata: + unit: list + label: housing benefit severe disability premium qualifying benefits + reference: + - title: Housing Benefit Regulations 2006 Sch 3 para 14(2) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14 + - title: Housing Benefit (State Pension Credit) Regulations 2006 Sch 3 para 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6 + note: The Scottish benefits in the same list (adult disability payment, pension age disability payment, child disability payment and Scottish adult disability living allowance care components) have no separate variables in the model. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml index d86d6feaa9..9bd91118a0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_meals_deduction.yaml @@ -72,7 +72,7 @@ # 2 x 4.50 x 52 = 468. housing_benefit_meals_deduction: 468 -- name: Meals come off the rent before the LHA cap +- name: On the LHA route the cap rent is the gross rent, meals included period: 2026 absolute_error_margin: 0.01 input: @@ -95,10 +95,65 @@ members: [householder, boarder] tenure_type: OWNED_OUTRIGHT output: - # Reg 13(7): rent 10,000 less 37.30 x 52 = 1,939.60 is 8,060.40, below - # the 9,000 rate standing in for the rent officer's figure. + # Regs 12D(2)(a), 13D(5) and (12): without a rent officer's board and + # attendance finding, min(gross rent 10,000, LHA 9,000) = 9,000. + LHA_cap: [0, 9_000] + +- name: A rent officer's board finding takes meals off the rent instead + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + boarder: + age: 70 + rent_paid_as_boarder: 10_000 + benunits: + householder_family: + members: [householder] + boarder_family: + members: [boarder] + meals_included_in_rent: AT_LEAST_THREE_A_DAY + housing_benefit_board_and_attendance_determination: true + housing_benefit_LHA_rate: 9_000 + households: + home: + members: [householder, boarder] + tenure_type: OWNED_OUTRIGHT + output: + # Reg 13C(5)(e): off the LHA. Reg 13(7): rent 10,000 less + # 37.30 x 52 = 1,939.60 is 8,060.40. LHA_cap: [0, 8_060.40] +- name: Breakfast without a board finding leaves Housing Benefit on the gross rent + period: 2025 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + boarder: + age: 70 + rent_paid_as_boarder: 6_000 + benunits: + householder_family: + members: [householder] + boarder_family: + members: [boarder] + meals_included_in_rent: BREAKFAST_ONLY + housing_benefit_LHA_rate: 9_000 + housing_benefit_applicable_income: 0 + households: + home: + members: [householder, boarder] + tenure_type: OWNED_OUTRIGHT + output: + LHA_cap: [0, 6_000] + housing_benefit_entitlement: [0, 6_000] + - name: Meals do not reduce the Universal Credit housing costs element period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml index e1ee4a617d..990077e67b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml @@ -290,21 +290,23 @@ is_household_head: true sharer: age: 40 + pip_dl: 3_000 benunits: head_family: members: [head] sharer_family: members: [sharer] liable_for_share_of_household_rent: true - severe_disability_premium: 3_000 households: home: members: [head, sharer] rent: 18_000 tenure_type: RENT_PRIVATELY output: - # Reg 13D(2)(a)(ii) excludes a claimant to whom Sch 3 para 14 applies, - # so 13D(2)(c): 1 bedroom: B. + # The sharer receives the PIP daily living component and has no + # non-dependant, so Sch 3 para 14(2)(a) applies; reg 13D(2)(a)(ii) + # excludes them, so 13D(2)(c): 1 bedroom: B. + housing_benefit_severe_disability_premium_applies: [false, true] housing_benefit_LHA_category: [A, B] - name: The household input for shared accommodation affects Housing Benefit only @@ -325,3 +327,127 @@ output: LHA_category: B housing_benefit_LHA_category: A + +- name: A non-dependant counts in a sharer's UC claim when the head cannot claim UC + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + cotenant: + age: 30 + non_dependant: + age: 24 + employment_income: 20_000 + benunits: + head_family: + members: [head] + cotenant_family: + members: [cotenant] + liable_for_share_of_household_rent: true + non_dependant_family: + members: [non_dependant] + households: + home: + members: [head, cotenant, non_dependant] + rent: 18_000 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + # The pensioner head is not eligible for UC, so no other UC claim has + # treated the non-dependant as one (UC Sch 4 para 9(2)(f)): they count + # in the co-tenant's claim. Bedrooms 1 + 1 = 2: C. Core rent 18,000 / 2 + # = 9,000, below the Maidstone two-bedroom rate (11,892.40); less one + # contribution of 93.02 x 12 = 1,116.24: 7,883.76. + uc_non_dependants_counted: [false, true, false] + LHA_allowed_bedrooms: [1, 2, 1] + LHA_category: [B, C, A] + uc_non_dep_deductions: [0, 1_116.24, 0] + uc_housing_costs_element: [9_000, 7_883.76, 0] + +- name: A single PIP recipient living alone keeps the HB one-bedroom rate + period: 2025 + input: + people: + claimant: + age: 28 + pip_dl: 3_988.40 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + # Sch 3 para 14(2)(a): PIP daily living and no non-dependant, so the + # young-individual shared rate does not apply (reg 13D(2)(a)(i)); not + # sharing: one bedroom self-contained (13D(2)(b)): B. UC: excepted under + # Sch 4 para 29(5): B. + housing_benefit_severe_disability_premium_applies: true + housing_benefit_LHA_category: B + LHA_category: B + +- name: A lodger couple adds one HB bedroom, a non-dependant's child another + period: 2025 + input: + people: + head: + age: 72 + is_household_head: true + lodger_1: + age: 40 + rent_paid_as_lodger: 3_000 + lodger_2: + age: 42 + daughter: + age: 40 + grandchild: + age: 5 + benunits: + head_family: + members: [head] + lodgers: + members: [lodger_1, lodger_2] + daughter_family: + members: [daughter, grandchild] + households: + home: + members: [head, lodger_1, lodger_2, daughter, grandchild] + rent: 15_000 + tenure_type: RENT_PRIVATELY + output: + # Reg 13D(3): head 1; the lodger couple 1 (a couple); the daughter 1; + # the grandchild 1 (a child): 4 bedrooms for the head: E. The lodgers' + # own claim counts only themselves: 1 bedroom, shared: A. + housing_benefit_LHA_allowed_bedrooms: [4, 1, 2] + housing_benefit_LHA_category: [E, A, C] + +- name: A lodger with exclusive use of two rooms can rebut the shared presumption + period: 2025 + input: + people: + householder: + age: 60 + is_household_head: true + lodger: + age: 70 + rent_paid_as_lodger: 5_200 + benunits: + householder_family: + members: [householder] + lodger_family: + members: [lodger] + housing_benefit_shares_accommodation: false + households: + home: + members: [householder, lodger] + tenure_type: OWNED_OUTRIGHT + output: + # Reg 13D(2)(b)(i): the lodger has exclusive use of two rooms: one + # bedroom self-contained: B. The householder's size criteria count the + # lodger as an occupier (13D(12)): 2 bedrooms: C. + housing_benefit_LHA_category: [C, B] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml index 47b722480c..31efa46d3f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml @@ -154,3 +154,108 @@ council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] council_tax_reduction_joint_liability_share: [1, 1] simulated_council_tax_reduction_benunit: [1_800, 0] + +- name: An older parent who is not liable stays the claimant where no rent is shared + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + employment_income: 30_000 + father: + age: 72 + state_pension: 6_000 + benunits: + head_family: + members: [head] + father_family: + members: [father] + households: + home: + members: [head, father] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: LONDON + output: + # No sharer: the oldest adult's family claims, as before, and the + # rent-paying head is the father's non-dependant, as before. + council_tax_reduction_claimant_benunit: [false, true] + council_tax_reduction_individual_non_dep_deduction_eligible: [true, false] + simulated_council_tax_reduction_benunit: [0, 0] + +- name: A non-liable resident is a non-dependant of the sharers however old + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + cotenant: + age: 35 + father: + age: 72 + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + cotenant_family: + members: [cotenant] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + father_family: + members: [father] + households: + home: + members: [head, cotenant, father] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + council_tax_reduction_claimant_benunit: [true, true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + # 300 apportioned equally between the two liable persons: 150 each. + council_tax_reduction_non_dep_deductions: [150, 150, 0] + # 1,800 / 2 - 150 = 750 each. + simulated_council_tax_reduction_benunit: [750, 750, 0] + +- name: An excluded student sharer is not counted and does not claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_household_head: true + student: + age: 25 + in_HE: true + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + student_family: + members: [student] + liable_for_share_of_household_rent: true + households: + home: + members: [head, student] + rent: 9_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + country: WALES + region: WALES + output: + # Sch 1 para 7(5) (Wales Sch 1 para 2(5)): the student is left out of + # the count, so the pensioner's share is 1; a full-time student is + # excluded from entitlement (Default Scheme Sch para 75(1)). + council_tax_reduction_joint_liability_share: [1, 0.5] + council_tax_reduction_claimant_benunit: [true, false] + simulated_council_tax_reduction_benunit: [1_800, 0] 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 c3b3efca5a..aedadee764 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 @@ -1,6 +1,10 @@ """Property-based tests for the rent, tenure and housing support of families other than the household head's: sharers of the rent, boarders and lodgers. +Every generated population also contains one fixed household with a sharer, +a boarder, a lodger and a non-dependant, so no selection below is empty. +Adults have generated earnings, so non-dependant deductions are positive. + Invariants, for any generated population of households: 1. Conservation: the families' shares of the household's rent sum to one @@ -11,23 +15,31 @@ or lodger has no rent. 3. Tenure: a boarder's or lodger's family rents privately, is never in social housing, and is LHA-eligible. -4. Non-dependants: a family liable for rent is never a non-dependant; only - the household head's family has Universal Credit non-dependant - deductions; a boarder's or lodger's family has no Housing Benefit or - Council Tax Reduction non-dependant deductions. +4. Non-dependants: nobody in a family liable for rent is a non-dependant; at + most one family per household counts the household's non-dependants for + Universal Credit, it is liable for the household's rent, and only it has + Universal Credit deductions; Housing Benefit deductions over the families + liable for the rent sum to the household's non-dependants' deductions; a + boarder's or lodger's family has no Universal Credit or Housing Benefit + deductions; no sharer, boarder or lodger is a Council Tax Reduction + non-dependant. 5. Category: the Universal Credit LHA category does not depend on the household input is_shared_accommodation; the Housing Benefit category is the shared rate for any family entitled to one bedroom that lacks - exclusive use and has no severe disability premium. -6. Meals: the Housing Benefit meals deduction is non-negative, and adding - meals never raises the Housing Benefit eligible rent (LHA_cap). + exclusive use and does not meet the severe disability premium conditions. +6. Meals: the Housing Benefit meals deduction is non-negative; on the LHA + route (no rent officer board finding) the eligible rent does not depend + on the meals in the rent; with a board finding it is the rent less the + deduction, floored at zero. 7. Council tax: in a household whose rent is shared, the jointly liable claim shares never exceed one in total. 8. Monotonicity: a family's rent is non-decreasing in the household's rent. 9. No-op: in a household with no sharers, boarders or lodgers, the household - head's family has the whole rent and everyone else none, as before, and - the head's Universal Credit non-dependant deductions equal the previous - formula (every other family's deductions). + head's family has the whole rent and everyone else none, as before; and + where the head's family claims Universal Credit, its non-dependant + deductions and bedrooms equal the previous formulas (a deduction for + everyone outside the family and a bedroom for each of them aged 16 or + over). """ import numpy as np @@ -53,24 +65,45 @@ ROLES = ["sharer", "boarder", "lodger", "non_dependant"] MEALS = ["NONE", "BREAKFAST_ONLY", "FEWER_THAN_THREE_A_DAY", "AT_LEAST_THREE_A_DAY"] money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) +earnings = st.one_of(st.just(0.0), st.floats(1_000, 60_000, allow_nan=False)) +adult = st.tuples(st.integers(18, 85), earnings) +# One household with every role, so that no selection is empty. +SENTINEL = dict( + head_adults=[(45, 0.0)], + head_child_age=None, + tenure="RENT_PRIVATELY", + rent=18_000.0, + council_tax=1_800.0, + others=[ + dict(role=role, adults=[(age, pay)], child_age=None, payment=5_200.0) + | dict(meals="AT_LEAST_THREE_A_DAY", pip=False) + for role, age, pay in [ + ("sharer", 40, 0.0), + ("boarder", 50, 0.0), + ("lodger", 30, 0.0), + ("non_dependant", 25, 25_000.0), + ] + ], + shared=False, +) @st.composite def other_family(draw): return dict( role=draw(st.sampled_from(ROLES)), - ages=draw(st.lists(st.integers(18, 85), min_size=1, max_size=2)), + adults=draw(st.lists(adult, min_size=1, max_size=2)), child_age=draw(st.one_of(st.none(), st.integers(0, 15))), payment=draw(st.floats(1, 15_000, allow_nan=False)), meals=draw(st.sampled_from(MEALS)), - sdp=draw(st.booleans()), + pip=draw(st.booleans()), ) @st.composite def households(draw): return dict( - head_ages=draw(st.lists(st.integers(18, 85), min_size=1, max_size=2)), + head_adults=draw(st.lists(adult, min_size=1, max_size=2)), head_child_age=draw(st.one_of(st.none(), st.integers(0, 15))), tenure=draw(st.sampled_from(TENURES)), rent=draw(money), @@ -80,24 +113,44 @@ def households(draw): ) -population = st.lists(households(), min_size=1, max_size=6) +population = st.lists(households(), min_size=0, max_size=5).map( + lambda generated: [SENTINEL] + generated +) -def build(population, shared_override=None, meals_override=None, rent_bump=0.0): +def build( + population, + shared_override=None, + meals_override=None, + board_finding=False, + rent_bump=0.0, + roles_override=None, +): """One situation for the whole population; returns it and family roles.""" people, benunits, homes, roles = {}, {}, {}, [] for h, house in enumerate(population): members = [] - def add_family(name, ages, child_age, role, extra): + def add_family(name, adults, child_age, role, extra, pip=False): ids = [] - for i, age in enumerate(ages): + for i, (age, pay) in enumerate(adults): pid = f"{name}_adult_{i}" - people[pid] = {"age": age, "is_claimant_or_partner": True} + people[pid] = { + "age": age, + "employment_income": pay, + "is_claimant_or_partner": True, + "is_household_head": False, + } + if pip: + people[pid]["pip_dl"] = 3_988.40 ids.append(pid) if child_age is not None: pid = f"{name}_child" - people[pid] = {"age": child_age, "is_claimant_or_partner": False} + people[pid] = { + "age": child_age, + "is_claimant_or_partner": False, + "is_household_head": False, + } ids.append(pid) benunits[name] = {"members": ids, **extra} members.extend(ids) @@ -105,31 +158,31 @@ def add_family(name, ages, child_age, role, extra): return ids head_ids = add_family( - f"h{h}_head", house["head_ages"], house["head_child_age"], "head", {} + f"h{h}_head", house["head_adults"], house["head_child_age"], "head", {} ) - for pid in head_ids: - people[pid]["is_household_head"] = False people[head_ids[0]]["is_household_head"] = True for f, other in enumerate(house["others"]): - name = f"h{h}_f{f}" + role = roles_override or other["role"] + boarder = role == "boarder" extra = { - "liable_for_share_of_household_rent": other["role"] == "sharer", + "liable_for_share_of_household_rent": role == "sharer", "meals_included_in_rent": ( - meals_override or other["meals"] - if other["role"] == "boarder" - else "NONE" + (meals_override or other["meals"]) if boarder else "NONE" + ), + "housing_benefit_board_and_attendance_determination": ( + board_finding and boarder ), } - if other["sdp"]: - extra["severe_disability_premium"] = 3_000 ids = add_family( - name, other["ages"], other["child_age"], other["role"], extra + f"h{h}_f{f}", + other["adults"], + other["child_age"], + role, + extra, + pip=other["pip"], ) - for pid in ids: - people[pid]["is_household_head"] = False - if other["role"] in ("boarder", "lodger"): - variable = f"rent_paid_as_{other['role']}" - people[ids[0]][variable] = other["payment"] + if role in ("boarder", "lodger"): + people[ids[0]][f"rent_paid_as_{role}"] = other["payment"] homes[f"h{h}"] = { "members": members, "tenure_type": house["tenure"], @@ -150,6 +203,10 @@ def calc(simulation, variable, map_to=None): return np.asarray(result) +def per_household(simulation, benunit_values): + return simulation.map_result(benunit_values, "benunit", "household") + + @PROPERTY_SETTINGS @given(population) def test_conservation_bounds_and_tenure(population): @@ -162,10 +219,10 @@ def test_conservation_bounds_and_tenure(population): sim, "rent_paid_as_lodger", "household" ) # 1. Conservation. - share_total = sim.map_result(share, "benunit", "household") - assert np.allclose(share_total, 1, atol=1e-6) - rent_total = sim.map_result(rent, "benunit", "household") - assert np.allclose(rent_total, household_rent + paid, rtol=1e-6, atol=0.01) + assert np.allclose(per_household(sim, share), 1, atol=1e-6) + assert np.allclose( + per_household(sim, rent), household_rent + paid, rtol=1e-6, atol=0.01 + ) # 2. Bounds. assert np.all((share >= -1e-9) & (share <= 1 + 1e-9)) assert np.all(rent >= 0) @@ -173,7 +230,8 @@ def test_conservation_bounds_and_tenure(population): assert np.all(share[np.isin(roles, ["boarder", "lodger", "non_dependant"])] == 0) # 3. Tenure. payer = np.isin(roles, ["boarder", "lodger"]) - tenure = sim.calculate("benunit_tenure_type", YEAR) + assert payer.any() + tenure = calc(sim, "benunit_tenure_type") assert np.all(tenure[payer] == "RENT_PRIVATELY") social = calc(sim, "in_social_housing", "benunit") > 0 assert not np.any(social[payer]) @@ -185,14 +243,46 @@ def test_conservation_bounds_and_tenure(population): def test_non_dependants(population): situation, roles = build(population) sim = Simulation(situation=situation) - liable = calc(sim, "benunit_is_rent_liable") - non_dependant = calc(sim, "is_non_dependant_of_household_head", "benunit") > 0 - assert not np.any(non_dependant & liable) - uc = calc(sim, "uc_non_dep_deductions") - assert np.all(uc[roles != "head"] == 0) payer = np.isin(roles, ["boarder", "lodger"]) - assert np.all(calc(sim, "housing_benefit_non_dep_deductions")[payer] == 0) - assert np.all(calc(sim, "council_tax_reduction_non_dep_deductions")[payer] == 0) + 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 + assert not np.any(non_dependant & person_liable) + assert non_dependant.any() + # Universal Credit: one claim per household counts the non-dependants. + counted = calc(sim, "uc_non_dependants_counted") + assert np.all(per_household(sim, counted) <= 1) + household_liable = ( + sim.map_result(calc(sim, "is_liable_for_household_rent"), "person", "benunit") + > 0 + ) + assert not np.any(counted & ~household_liable) + uc = calc(sim, "uc_non_dep_deductions") + assert np.all(uc[~counted] == 0) + individual = calc(sim, "uc_individual_non_dep_deduction") * non_dependant + total = sim.map_result(individual, "person", "household") + assert np.all(per_household(sim, uc) <= total + 0.01) + # Housing Benefit: apportioned over the families liable for the rent. + hb = calc(sim, "housing_benefit_non_dep_deductions") + hb_individual = ( + calc(sim, "household_benefits_individual_non_dep_deduction") * non_dependant + ) + hb_total = sim.map_result(hb_individual, "person", "household") + assert hb_total.max() > 0 + assert np.allclose(per_household(sim, hb), hb_total, rtol=1e-5, atol=0.01) + assert np.all(hb[payer] == 0) + assert np.all(uc[payer] == 0) + # Council Tax Reduction: sharers, boarders and lodgers are not + # non-dependants. + ctr_eligible = ( + sim.map_result( + calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible"), + "person", + "benunit", + ) + > 0 + ) + assert not np.any(ctr_eligible[np.isin(roles, ["sharer", "boarder", "lodger"])]) @PROPERTY_SETTINGS @@ -203,16 +293,18 @@ def test_lha_categories(population): a = Simulation(situation=with_input) b = Simulation(situation=without_input) # 5. The UC category is set by entitlement alone. - assert np.array_equal( - a.calculate("LHA_category", YEAR), - b.calculate("LHA_category", YEAR), - ) + assert np.array_equal(calc(a, "LHA_category"), calc(b, "LHA_category")) for sim in (a, b): rooms = calc(sim, "housing_benefit_LHA_allowed_bedrooms") shares = calc(sim, "housing_benefit_shares_accommodation") - sdp = calc(sim, "severe_disability_premium") > 0 - category = sim.calculate("housing_benefit_LHA_category", YEAR) - assert np.all(category[(rooms == 1) & shares & ~sdp] == "A") + sdp = calc(sim, "housing_benefit_severe_disability_premium_applies") + category = calc(sim, "housing_benefit_LHA_category") + selected = (rooms == 1) & shares & ~sdp + assert selected.any() + assert np.all(category[selected] == "A") + # The severe disability premium conditions keep a claimant off the + # shared rate (reg 13D(2)(a)). + assert not np.any(category[sdp] == "A") @PROPERTY_SETTINGS @@ -220,19 +312,33 @@ def test_lha_categories(population): def test_meals_and_council_tax(population): with_meals, roles = build(population, meals_override="AT_LEAST_THREE_A_DAY") no_meals, _ = build(population, meals_override="NONE") + finding, _ = build( + population, meals_override="AT_LEAST_THREE_A_DAY", board_finding=True + ) a = Simulation(situation=with_meals) b = Simulation(situation=no_meals) + c = Simulation(situation=finding) + boarder = roles == "boarder" # 6. Meals. - assert np.all(calc(a, "housing_benefit_meals_deduction") >= 0) - assert np.all(calc(a, "LHA_cap") <= calc(b, "LHA_cap") + 1e-6) + deduction = calc(a, "housing_benefit_meals_deduction") + assert np.all(deduction >= 0) + assert np.all(deduction[boarder] > 0) + assert np.allclose(calc(a, "LHA_cap"), calc(b, "LHA_cap"), atol=0.01) + rent = calc(c, "benunit_rent") + assert np.allclose( + calc(c, "LHA_cap")[boarder], + np.maximum(0, rent - deduction)[boarder], + atol=0.01, + ) # 7. Council tax shares, where the rent is shared. (Elsewhere the family # of the household's oldest adult claims, and families tied for oldest # both do: a separate, existing issue.) claimant = calc(a, "council_tax_reduction_claimant_benunit") share = calc(a, "council_tax_reduction_joint_liability_share") - total = a.map_result(claimant * share, "benunit", "household") + total = per_household(a, claimant * share) sharer = calc(a, "liable_for_share_of_household_rent") - shared = a.map_result(sharer, "benunit", "household") > 0 + shared = per_household(a, sharer) > 0 + assert shared.any() assert np.all(total[shared] <= 1 + 1e-6) @@ -249,26 +355,38 @@ def test_rent_is_monotone_in_household_rent(population, bump): @PROPERTY_SETTINGS @given(population) def test_no_op_without_sharers_boarders_or_lodgers(population): - for house in population: - for other in house["others"]: - other["role"] = "non_dependant" - situation, roles = build(population) + situation, roles = build(population, roles_override="non_dependant") sim = Simulation(situation=situation) # 9. The head's family has the whole rent; everyone else none. rent = calc(sim, "benunit_rent") head = roles == "head" assert np.all(rent[~head] == 0) - rent_total = sim.map_result(rent, "benunit", "household") - assert np.allclose(rent_total, calc(sim, "rent"), atol=0.01) - # The previous formula: the head's family was charged for everyone - # outside it (and every other family likewise). - individual = calc(sim, "uc_individual_non_dep_deduction") + assert np.allclose(per_household(sim, rent), calc(sim, "rent"), atol=0.01) + # The previous formulas charged a family for everyone outside it, and + # gave it a bedroom for everyone aged 16 or over outside it. + claims_uc = calc(sim, "uc_non_dependants_counted") + assert np.array_equal( + claims_uc, + head & calc(sim, "is_uc_eligible") & calc(sim, "would_claim_uc"), + ) person_head_family = sim.map_result(head, "benunit", "person") > 0 - everyone = sim.map_result(individual, "person", "household") - head_family_own = sim.map_result( - individual * person_head_family, "person", "household" + individual = calc(sim, "uc_individual_non_dep_deduction") + outside = sim.map_result(individual * ~person_head_family, "person", "household") + head_deductions = per_household(sim, calc(sim, "uc_non_dep_deductions") * head) + head_claims = per_household(sim, claims_uc) > 0 + assert np.allclose(head_deductions[head_claims], outside[head_claims], atol=0.01) + # Bedrooms: what the head's family would have alone, plus one for each + # person aged 16 or over outside it. + alone = [dict(house, others=[]) for house in population] + alone_situation, _ = build(alone) + alone_sim = Simulation(situation=alone_situation) + bedrooms_alone = calc(alone_sim, "LHA_allowed_bedrooms") + aged_16_or_over = calc(sim, "age") >= 16 + adults_outside = sim.map_result( + aged_16_or_over & ~person_head_family, "person", "household" ) - head_deductions = sim.map_result( - calc(sim, "uc_non_dep_deductions") * head, "benunit", "household" + head_bedrooms = per_household(sim, calc(sim, "LHA_allowed_bedrooms") * head) + assert np.allclose( + head_bedrooms[head_claims], + (bedrooms_alone + adults_outside)[head_claims], ) - assert np.allclose(head_deductions, everyone - head_family_own, atol=0.01) diff --git a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py index 14d4d0b3e7..ce8e5543af 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py @@ -6,28 +6,18 @@ warnings.filterwarnings("ignore") -def bedrooms_for_children(benunit, period): - """Bedrooms for the family's children under 16. +def child_bedrooms(boys_under_10, boys_10_to_15, girls_under_10, girls_10_to_15): + """Bedrooms for children under 16, given counts by sex and age band. Children must share rooms in pairs unless they are opposite-sex and one is 10 or over. This is the minimum number of bedrooms that allocates them under those rules. """ - person = benunit.members - age = person("age", period) - male = person("is_male", period) - under_16 = age < 16 - under_10 = age < 10 - child_over_10 = ~under_10 & under_16 - boys_under_10 = benunit.sum(under_10 & male) - boys_over_10 = benunit.sum(child_over_10 & male) - girls_under_10 = benunit.sum(under_10 & ~male) - girls_over_10 = benunit.sum(child_over_10 & ~male) # First, have over-10s share where possible - over_10_rooms = (boys_over_10 + 1) // 2 + (girls_over_10 + 1) // 2 + over_10_rooms = (boys_10_to_15 + 1) // 2 + (girls_10_to_15 + 1) // 2 # There may children over 10 still not sharing - space_for_boy_under_10 = boys_over_10 % 2 - space_for_girl_under_10 = girls_over_10 % 2 + space_for_boy_under_10 = boys_10_to_15 % 2 + space_for_girl_under_10 = girls_10_to_15 % 2 # Have those spaces filled where possible by children under 10 left_over_boys_under_10 = max_(boys_under_10 - space_for_boy_under_10, 0) left_over_girls_under_10 = max_(girls_under_10 - space_for_girl_under_10, 0) @@ -36,6 +26,22 @@ def bedrooms_for_children(benunit, period): return over_10_rooms + under_10_rooms +def bedrooms_for_children(benunit, period): + """Bedrooms for the family's own children under 16.""" + person = benunit.members + age = person("age", period) + male = person("is_male", period) + under_16 = age < 16 + under_10 = age < 10 + child_over_10 = ~under_10 & under_16 + return child_bedrooms( + benunit.sum(under_10 & male), + benunit.sum(child_over_10 & male), + benunit.sum(under_10 & ~male), + benunit.sum(child_over_10 & ~male), + ) + + class LHA_allowed_bedrooms(Variable): value_type = float entity = BenUnit @@ -45,8 +51,8 @@ class LHA_allowed_bedrooms(Variable): "couple, one for each non-dependant aged 16 or over, and the " "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; only the household head's family has " - "non-dependants (see is_non_dependant_of_household_head)." + "to anyone's entitlement; the household's non-dependants count in one " + "Universal Credit claim only (see uc_non_dependants_counted)." ) definition_period = YEAR reference = ( @@ -61,6 +67,6 @@ def formula(benunit, period, parameters): non_dependant = aged_16_or_over & person( "is_non_dependant_of_household_head", period ) - head_family = benunit.any(person("is_household_head", period)) - non_dependants = head_family * benunit.max(person.household.sum(non_dependant)) + counted = benunit("uc_non_dependants_counted", period) + non_dependants = counted * benunit.max(person.household.sum(non_dependant)) return 1 + non_dependants + bedrooms_for_children(benunit, period) diff --git a/policyengine_uk/variables/gov/dwp/LHA_cap.py b/policyengine_uk/variables/gov/dwp/LHA_cap.py index 0d26ce5172..33e9436c1a 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_cap.py +++ b/policyengine_uk/variables/gov/dwp/LHA_cap.py @@ -11,12 +11,14 @@ class LHA_cap(Variable): entity = BenUnit label = "Applicable amount for LHA" documentation = ( - "Rent eligible for Housing Benefit where the Local Housing Allowance " - "applies: the lower of the rent and the Housing Benefit LHA rate. " - "Where the rent pays for meals, the rent officer route applies " - "instead and the fixed meals amount is deducted from the maximum " - "rent; the model takes the rent less that amount, capped at the LHA " - "rate, which stands in for the rent officer's determination." + "Rent eligible for Housing Benefit for a private renter. On the Local " + "Housing Allowance route it is the lower of the rent (gross, meals " + "included) and the Housing Benefit LHA rate. Where a rent officer has " + "found that a substantial part of the rent is for board and " + "attendance (housing_benefit_board_and_attendance_determination), the " + "LHA does not apply and the maximum rent is the rent officer's " + "figure less the fixed amount for meals; the model takes the rent " + "less that amount, as it has no rent officer determinations." ) definition_period = YEAR unit = GBP @@ -28,15 +30,20 @@ class LHA_cap(Variable): ) def formula(benunit, period, parameters): - # HB Regs 2006 regs 12D(2)(a) and 13D(5): the maximum rent (LHA) is - # the LHA rate or, if lower, the cap rent. A rent officer finding - # that a substantial part of the rent is for board and attendance - # takes the case off the LHA (reg 13C(5)(e)); the maximum rent then - # has the Sch 1 para 2 amount for meals deducted (reg 13(7)). - rent = max_( - 0, - benunit("benunit_rent", period) - - benunit("housing_benefit_meals_deduction", period), + rent = benunit("benunit_rent", period) + # HB Regs 2006 regs 12D(2)(a) and 13D(5), (12): the maximum rent + # (LHA) is the LHA rate or, if lower, the cap rent, which is the + # gross rent liability. + lha_route = min_(rent, benunit("housing_benefit_LHA_rate", period)) + # Reg 13C(5)(e): a rent officer finding that a substantial part of + # the rent is board and attendance takes the case off the LHA; the + # maximum rent then has the Sch 1 para 2 amount for meals deducted + # (reg 13(7)). + rent_officer_route = max_( + 0, rent - benunit("housing_benefit_meals_deduction", period) + ) + return where( + benunit("housing_benefit_board_and_attendance_determination", period), + rent_officer_route, + lha_route, ) - cap = benunit("housing_benefit_LHA_rate", period) - return min_(rent, cap) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py index 84e19c27c0..475ad97a20 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py @@ -10,12 +10,20 @@ class housing_benefit_entitlement(Variable): def formula(benunit, period, parameters): # Charges for meals are not eligible to be met by Housing Benefit - # (HB Regs 2006 reg 12B(2)(b) and Sch 1 paras 1(a)(i) and 2). - rent = max_( - 0, - benunit("benunit_rent", period) - - benunit("housing_benefit_meals_deduction", period), + # (HB Regs 2006 reg 12B(2)(b) and Sch 1 paras 1(a)(i) and 2), except + # on the Local Housing Allowance route, where the cap rent is the + # gross liability (regs 12D, 13D(12)); a rent officer's board and + # attendance finding takes a private renter off that route (reg + # 13C(5)(e)), as LHA_cap applies. + lha_eligible = benunit("LHA_eligible", period.this_year) + gross_rent = benunit("benunit_rent", period) + rent_less_meals = max_( + 0, gross_rent - benunit("housing_benefit_meals_deduction", period) + ) + lha_route = lha_eligible & ~benunit( + "housing_benefit_board_and_attendance_determination", period ) + rent = where(lha_route, gross_rent, rent_less_meals) applicable_amount = benunit("housing_benefit_applicable_amount", period) income = benunit("housing_benefit_applicable_income", period) withdrawal_rate = parameters( @@ -24,6 +32,5 @@ def formula(benunit, period, parameters): reduced_income = max_(0, income - applicable_amount) final_amount = max_(0, rent - reduced_income * withdrawal_rate) capped_final_amount = min_(final_amount, benunit("LHA_cap", period)) - lha_eligible = benunit("LHA_eligible", period.this_year) amount = where(lha_eligible, capped_final_amount, final_amount) return max_(0, amount - benunit("housing_benefit_non_dep_deductions", period)) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_has_non_dependant.py new file mode 100644 index 0000000000..a5904137be --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_has_non_dependant.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_has_non_dependant(Variable): + value_type = bool + entity = BenUnit + label = "Has a non-dependant residing with them (Housing Benefit)" + documentation = ( + "Someone in this benefit unit who is neither a claimant or partner " + "nor a child or young person, or, for a family liable for the " + "household's rent (the household head's or a sharer's), anyone in " + "the household who is a non-dependant (see " + "is_non_dependant_of_household_head). A non-dependant normally " + "residing in the dwelling resides with each joint occupier. A " + "boarder's or lodger's family has none from the householder's " + "household." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + ) + + def formula(benunit, period, parameters): + # HB Regs 2006 reg 3(1)-(2), (4) and reg 74(5). + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + age = person("age", period) + child_or_young_person = person( + "is_child_or_young_person_for_legacy_benefits", period + ) | ((age >= 16) & (age < 17)) + within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_young_person) + liable_family = benunit.any(person("is_liable_for_household_rent", period)) + non_dependants = benunit.max( + person.household.sum(person("is_non_dependant_of_household_head", period)) + ) + return within_benefit_unit | (liable_family & (non_dependants > 0)) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py new file mode 100644 index 0000000000..c8fff7fa8e --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py @@ -0,0 +1,50 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_severe_disability_premium_applies(Variable): + value_type = bool + entity = BenUnit + label = "Severe disability premium conditions met (Housing Benefit)" + documentation = ( + "Whether the claimant is a severely disabled person for the Housing " + "Benefit severe disability premium: the claimant (and any partner) " + "receives a qualifying disability benefit, and no non-dependant aged " + "18 or over normally resides with them, other than one who receives " + "a qualifying benefit. Not modelled: who receives Carer's Allowance, " + "Carer Support Payment or the UC carer element for caring for the " + "claimant, and the treatment of a blind partner. The legacy " + "severe_disability_premium variable uses the tax credit definition of " + "severe disability, which is narrower." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.severe_disability_premium + person = benunit.members + qualifying = add(person, period, p.qualifying_benefits) > 0 + claimant_or_partner = person("is_claimant_or_partner", period) + # Sch 3 para 14(2)(a)(i), (b)(i)-(ii): every claimant or partner + # receives a qualifying benefit. + all_qualify = benunit.all(qualifying | ~claimant_or_partner) & benunit.any( + claimant_or_partner + ) + # Para 14(2)(a)(ii), (b)(iii) with (4): no non-dependant aged 18 or + # over, disregarding one who receives a qualifying benefit. Within the + # family, an adult who is neither claimant nor partner nor a child or + # young person; outside it, as for housing_benefit_has_non_dependant. + age = person("age", period) + adult_non_dependant = (age >= 18) & ~qualifying + young_person = person("is_child_or_young_person_for_legacy_benefits", period) + within = benunit.any(adult_non_dependant & ~claimant_or_partner & ~young_person) + liable_family = benunit.any(person("is_liable_for_household_rent", period)) + outside = benunit.max( + person.household.sum( + adult_non_dependant + & person("is_non_dependant_of_household_head", period) + ) + ) + return all_qualify & ~within & ~(liable_family & (outside > 0)) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py index 005d00c653..a5b667f8d0 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_allowed_bedrooms.py @@ -1,6 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp.LHA_allowed_bedrooms import ( - bedrooms_for_children, + child_bedrooms, ) @@ -9,13 +9,17 @@ class housing_benefit_LHA_allowed_bedrooms(Variable): entity = BenUnit label = "Bedrooms in the Housing Benefit size criteria" documentation = ( - "Housing Benefit size criteria: one bedroom for the claimant or " - "couple, one for each other occupier aged 16 or over, and the " - "children's bedrooms. Occupiers are everyone who lives in the " - "dwelling as their home except a joint tenant outside the claimant's " - "household, so a householder's boarder or lodger adds a bedroom, as " - "does a non-dependant, but a sharer of the rent does not. A sharer's, " - "boarder's or lodger's own claim counts only their own family." + "Housing Benefit size criteria over the claim's occupiers: one " + "bedroom for each couple, one for each other occupier aged 16 or " + "over, and the children's bedrooms, pairing children across all the " + "occupiers. Occupiers are everyone who lives in the dwelling as their " + "home except a joint tenant outside the claimant's household. For " + "the household head's family or a sharer's, that is its own family, " + "the household's non-dependants and anyone paying the householder " + "rent (but not the other families liable for the rent). A boarder's " + "or lodger's own claim counts only their own family. Couples who " + "cannot share a bedroom and the additional bedrooms for overnight " + "care or foster parents are not modelled." ) definition_period = YEAR reference = ( @@ -24,14 +28,42 @@ class housing_benefit_LHA_allowed_bedrooms(Variable): ) def formula(benunit, period, parameters): + # HB Regs 2006 reg 13D(3) with "occupiers" as defined in 13D(12). person = benunit.members - aged_16_or_over = person("age", period) >= 16 - head_family = person.benunit.any(person("is_household_head", period)) - sharer = person.benunit("liable_for_share_of_household_rent", period) - # HB Regs 2006 reg 13D(3), with "occupiers" as defined in 13D(12). - other_occupier = aged_16_or_over & ~head_family & ~sharer - is_head_family = benunit.any(person("is_household_head", period)) - other_occupiers = is_head_family * benunit.max( - person.household.sum(other_occupier) + age = person("age", period) + claimant_or_partner = person("is_claimant_or_partner", period) + liable_family = person.benunit.any( + person("is_liable_for_household_rent", period) ) - return 1 + other_occupiers + bedrooms_for_children(benunit, period) + # Members of families not liable for the household's rent: + # non-dependants and people paying the householder. + other = ~liable_family + is_liable_claim = benunit.any(person("is_liable_for_household_rent", period)) + + def occupiers_total(values): + own = benunit.sum(values) + others = benunit.max(person.household.sum(values * other)) + return own + where(is_liable_claim, others, 0) + + # 13D(3)(zb), (a): one bedroom for each couple or single claimant. + adult_units = ( + occupiers_total( + claimant_or_partner & (person.benunit.sum(claimant_or_partner) > 0) + ) + - occupiers_total( + claimant_or_partner & (person.benunit.sum(claimant_or_partner) == 2) + ) + / 2 + ) + # 13D(3)(b): any other person who is not a child. + other_adults = occupiers_total((age >= 16) & ~claimant_or_partner) + under_16 = age < 16 + male = person("is_male", period) + under_10 = age < 10 + rooms_for_children = child_bedrooms( + occupiers_total(under_10 & male), + occupiers_total(~under_10 & under_16 & male), + occupiers_total(under_10 & ~male), + occupiers_total(~under_10 & under_16 & ~male), + ) + return max_(adult_units, 1) + other_adults + rooms_for_children diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py index 305cf304bf..4711d7db9a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_category.py @@ -26,11 +26,13 @@ class housing_benefit_LHA_category(Variable): def formula(benunit, period, parameters): rooms = benunit("housing_benefit_LHA_allowed_bedrooms", period.this_year) # Schedule 3 paragraph 14 (severe disability premium) applies. - severe_disability = benunit("severe_disability_premium", period) > 0 + severe_disability = benunit( + "housing_benefit_severe_disability_premium_applies", period + ) # HB Regs 2006 reg 13D(2)(a)(i). young_individual = ( benunit("is_housing_benefit_young_individual", period) - & ~benunit("lha_renter_has_non_dependant", period) + & ~benunit("housing_benefit_has_non_dependant", period) & ~severe_disability ) # Reg 13D(2)(a)(ii): entitled to one bedroom but neither condition in diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py b/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py index fd4031f384..07cc454d36 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_shares_accommodation.py @@ -9,10 +9,12 @@ class housing_benefit_shares_accommodation(Variable): "Whether the family lacks exclusive use of two or more rooms, or of " "one room with a bathroom, toilet and kitchen. Rooms shared only with " "the family's own household, its non-dependants or people who pay it " - "rent still count as exclusive. A boarder or lodger shares the " - "householder's rooms, and in a household whose rent is shared every " - "liable family shares the others' rooms. The household input " - "is_shared_accommodation marks any other case." + "rent still count as exclusive. The data record no rooms, so the " + "model presumes that a boarder or lodger, and every family liable for " + "a household's shared rent, lacks exclusive use, and the household " + "input is_shared_accommodation marks any other case. The presumption " + "is rebuttable: set this variable directly for a family with, for " + "example, an exclusive bedroom and sitting room." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D" 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 2c80db6274..a2e8556fe3 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 @@ -4,17 +4,17 @@ class is_non_dependant_of_household_head(Variable): value_type = bool entity = Person - label = "Non-dependant of the household head's family" + label = "Non-dependant of the families liable for the household's rent" documentation = ( - "Whether this person lives in the household outside the household " - "head's family and is not liable for rent. A joint tenant or other " + "Whether this person lives in the household outside every family " + "liable for its rent (the household head's family and any sharers) " + "and is not liable for rent themselves. A joint tenant or other " "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. The household head and their household are not " - "non-dependants of a boarder or lodger who pays them. Universal " - "Credit counts a non-dependant in one claim only, which the model " - "gives to the household head's family; Housing Benefit and Council " - "Tax Reduction apportion a non-dependant of several joint occupiers " + "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 " + "(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 " "are not identified." ) 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 31f652b563..b832db4ac4 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 @@ -4,15 +4,15 @@ class lha_renter_has_non_dependant(Variable): value_type = bool entity = BenUnit - label = "LHA renter has a non-dependant" + label = "LHA renter has a non-dependant (Universal Credit)" documentation = ( - "Someone in this benefit unit who is neither a claimant or partner " - "nor a child or young person, or, for the household head's family, a " - "claimant or partner of another family who is a non-dependant of the " - "household head (see is_non_dependant_of_household_head): joint " - "tenants and other sharers of the rent, boarders and lodgers are not " - "non-dependants, and a sharer, boarder or lodger has none from the " - "household head's family. Foster children are not identified." + "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." ) definition_period = YEAR reference = ( @@ -36,8 +36,8 @@ def formula(benunit, period, parameters): non_dependant_claimants = claimant_or_partner & person( "is_non_dependant_of_household_head", period ) - head_family = benunit.any(person("is_household_head", period)) - other_family_non_dependants = head_family * benunit.max( + counted = benunit("uc_non_dependants_counted", period) + other_family_non_dependants = counted * benunit.max( person.household.sum(non_dependant_claimants) ) 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/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 eca4abcd2a..c09e43cc46 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 @@ -15,13 +15,12 @@ class uc_non_dep_deductions(Variable): def formula(benunit, period, parameters): # A housing cost contribution is deducted for each non-dependant in # the renter's extended benefit unit (UC Regs 2013 Sch 4 para 13). - # Non-dependants live outside the household head's family and are - # not liable for rent, and only the household head's family has them - # (para 9(2)(d)-(f)): a sharer, boarder or lodger has none from the - # household head's family. + # 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. person = benunit.members deductions = person("uc_individual_non_dep_deduction", period) * person( "is_non_dependant_of_household_head", period ) - head_family = benunit.any(person("is_household_head", period)) - return head_family * benunit.max(person.household.sum(deductions)) + counted = benunit("uc_non_dependants_counted", period) + return counted * benunit.max(person.household.sum(deductions)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py new file mode 100644 index 0000000000..af1c22f5b9 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class uc_non_dependants_counted(Variable): + value_type = bool + entity = BenUnit + label = "Universal Credit claim that counts the household's non-dependants" + documentation = ( + "Whether this family's Universal Credit claim counts the household's " + "non-dependants. A non-dependant normally lives with every renter " + "liable for the household's rent, but is not one for a renter if " + "already treated as a non-dependant in another Universal Credit claim " + "by someone liable for the same accommodation. The model gives them " + "to the first such claim: the household head's family if it is " + "eligible for and claims Universal Credit, otherwise the first family " + "liable for a share of the rent that does (in the order the families " + "are given). A boarder or lodger has " + "none: the householder's household is excluded from their extended " + "benefit unit." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9" + + def formula(benunit, period, parameters): + # UC Regs 2013 Sch 4 para 9(2)(e)-(f). + person = benunit.members + liable_family = benunit.any(person("is_liable_for_household_rent", period)) + claims = ( + liable_family + & benunit("is_uc_eligible", period) + & benunit("would_claim_uc", period) + ) + head_family = benunit.any(person("is_household_head", period)) + # Order the claims: the household head's family first, then the + # others in the order the families were given. + position = np.arange(len(head_family)) + order = where(head_family, -1, position) + key = where(claims, order, np.inf) + first = benunit.max(person.household.min(benunit.project(key))) + return claims & (key == first) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py index 449b9d0808..8e5f3b9a47 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py @@ -9,13 +9,15 @@ class council_tax_reduction_claimant_benunit(Variable): "Whether this family can claim Council Tax Reduction on the " "household's council tax. Where the household's rent is shared, every " "family liable for it is jointly and severally liable for the council " - "tax and claims on its part. Otherwise the family of the household's " + "tax and claims on its part, through a liable member who is not an " + "excluded full-time student. Otherwise the family of the household's " "oldest adult claims, as before." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ukpga/1992/14/section/6", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/75", ) def formula(benunit, period, parameters): @@ -25,7 +27,12 @@ def formula(benunit, period, parameters): person.benunit("liable_for_share_of_household_rent", period) ) ) - liable_family = benunit.any(person("is_liable_for_household_rent", period)) + # A full-time student is excluded from entitlement (Default Scheme + # Sch para 75(1)); the model takes a person in higher education as + # one, so a sharer family claims through a liable non-student. + liable_family = benunit.any( + person("is_liable_for_household_rent", period) & ~person("in_HE", period) + ) return where( rent_is_shared, liable_family, 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 459598f8d3..861bd2dc56 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,11 +6,11 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): entity = Person label = "eligible person for CTR non-dependent deduction" documentation = ( - "An adult outside the claimant's family who is not liable for rent. " - "Someone jointly and severally liable for the council tax with the " - "claimant (a joint tenant or other sharer of the rent) and someone " - "liable to the claimant on a commercial basis (a boarder or lodger) " - "is not a non-dependant." + "An adult outside every family claiming Council Tax Reduction for the " + "household. Someone jointly and severally liable for the council tax " + "with the claimant (a joint tenant or other sharer of the rent) and " + "someone liable to the claimant on a commercial basis (a boarder or " + "lodger) is not a non-dependant." ) definition_period = YEAR reference = ( @@ -23,6 +23,7 @@ def formula(person, period, parameters): # SI 2012/2885 reg 9(2)(d)-(e) and the Welsh and Scottish equivalents. return ( (person("age", period) >= 18) - & ~person.benunit("benunit_contains_household_head", period) - & ~person.benunit("benunit_is_rent_liable", period) + & ~person.benunit("council_tax_reduction_claimant_benunit", period) + & ~person.benunit("liable_for_share_of_household_rent", period) + & ~person("pays_rent_to_householder", period) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py index 215992982e..f5baa192f3 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_joint_liability_share.py @@ -9,13 +9,14 @@ class council_tax_reduction_joint_liability_share(Variable): "Where the claimant is jointly and severally liable for the council " "tax with people other than their partner, the council tax used for " "the maximum reduction is divided by the number of people jointly and " - "severally liable, and a deduction for a non-dependant of two or more " - "of them is apportioned equally between them. The model takes the " - "people liable for the household's rent as those jointly liable for " - "its council tax (residents with the same interest, Local Government " - "Finance Act 1992 s.6) and follows the regulations' wording, dividing " - "by every person liable, the claimant's partner included. Students " - "are not excluded from the count. Otherwise the share is one." + "severally liable, leaving out students excluded from the scheme, and " + "a deduction for a non-dependant of two or more of them is apportioned " + "equally between them. The model takes the people liable for the " + "household's rent as those jointly liable for its council tax " + "(residents with the same interest, Local Government Finance Act 1992 " + "s.6), takes a person in higher education as an excluded full-time " + "student, and follows the regulations' wording, counting the " + "claimant's partner. Otherwise the share is one." ) definition_period = YEAR unit = "/1" @@ -23,6 +24,7 @@ class council_tax_reduction_joint_liability_share(Variable): "https://www.legislation.gov.uk/ukpga/1992/14/section/6", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/75", "https://www.legislation.gov.uk/wsi/2013/3029", "https://www.legislation.gov.uk/ssi/2021/249", ) @@ -35,4 +37,11 @@ def formula(benunit, period, parameters): liable_people = benunit.max(person.household.sum(liable)) in_family = benunit.sum(liable) jointly_with_others = (in_family > 0) & (liable_people > in_family) - return where(jointly_with_others, 1 / max_(liable_people, 1), 1) + # Para 7(5): a student excluded from the scheme (Default Scheme Sch + # para 75(1)) is not counted among those jointly liable with the + # applicant. The applicant always counts. + counted = liable & ~person("in_HE", period) + counted_people = benunit.max(person.household.sum(counted)) + applicant_is_student = benunit.sum(counted) == 0 + divisor = counted_people + applicant_is_student + return where(jointly_with_others, 1 / max_(divisor, 1), 1) diff --git a/policyengine_uk/variables/household/consumption/rent/housing_benefit_board_and_attendance_determination.py b/policyengine_uk/variables/household/consumption/rent/housing_benefit_board_and_attendance_determination.py new file mode 100644 index 0000000000..1562096b86 --- /dev/null +++ b/policyengine_uk/variables/household/consumption/rent/housing_benefit_board_and_attendance_determination.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_board_and_attendance_determination(Variable): + value_type = bool + entity = BenUnit + label = "Rent officer found the rent substantially for board and attendance" + documentation = ( + "Whether a rent officer has determined that a substantial part of the " + "family's rent is fairly attributable to board and attendance. Housing " + "Benefit then leaves the Local Housing Allowance for a rent officer " + "determination, from which the fixed amount for meals is deducted. " + "Knowing that the rent includes meals does not by itself establish " + "this." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13C", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + ) From e6f29a7669b5fadc2f4bf203b37ab8c90c116f8a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:55:04 -0400 Subject: [PATCH 34/75] Follow the Guarantee Credit passport in the pension-age CTR case; qualify the 20% invariant After merging main, a pension-age couple on Guarantee Credit has its whole income disregarded for Council Tax Reduction (SSI 2012/319 reg 24, modelled since #1909), so the case now expects applicable income of 0 and checks the Carer Support Payment itself. The property docstring says the 20% fall holds where claiming leaves the applicable amount unchanged, as the review of 1f361a38 asked. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_carer_support_payment.yaml | 8 ++++++-- .../test_council_tax_reduction_carer_income_properties.py | 5 +++-- 2 files changed, 9 insertions(+), 4 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 e08c1ca883..b2c16eeea1 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 @@ -295,9 +295,13 @@ council_tax: 2_000 savings: 0 output: - council_tax_reduction_applicable_income: 9_000 + 6_500 + 86.45 * 52 - # Guarantee Credit is paid: 21,392.80 - 19,995.40. + carer_support_payment: [0, 4_495.4] + # Guarantee Credit is paid: 21,392.80 - 19,995.40. Pension Credit income + # counts the Carer Support Payment component. guarantee_credit: (363.25 + 48.15) * 52 - (9_000 + 6_500 + 86.45 * 52) + # A guarantee credit recipient's whole income is disregarded (SSI 2012/319 + # reg 24), so the Carer Support Payment does not reach the means test. + council_tax_reduction_applicable_income: 0 council_tax_reduction: 2_000 - name: An English pensioner carer's Council Tax Reduction is unchanged 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 4eb4c81d4a..1da46f5ddc 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 @@ -27,8 +27,9 @@ Reduction income, before income tax and National Insurance 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. The - reduction itself never rises, and where both awards are partial it falls - by 20% of the rise in income. + reduction itself never rises. Where both awards are partial and claiming + leaves the applicable amount unchanged (a carer by hours, who has the + carer premium either way), it falls by 20% of the rise in income. 2. Invariant to the supplement: setting the Scottish Carer Supplement to zero leaves Carer Support Payment and every benefit counted in Council Tax Reduction income unchanged, and changes that income only through income From abef7e9a4c7a64a98146436cf4bdf93a98f8d27f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:47:32 -0400 Subject: [PATCH 35/75] Test that AFCS payments never confer severe disability; document AFCS vs AFIP Stacked on #1946, which reads armed_forces_independence_payment (not afcs) for CTC Regs 2002 reg 8(5) / WTC reg 17(4). This adds what #1946 lacks: - afcs_reported documented as FRS benefit code 8 (AFCS including guaranteed income payments, and war disablement pensions); armed_forces_independence_payment documented with AFCS Order 2011 art 24A. - YAML cases: AFCS with DLA care middle / PIP daily living standard; CTC severely disabled child element, WTC severe disability element (AFCS 0, AFIP 1,705 in 2024-25) and the UC higher disabled child addition (AFCS 0, PIP enhanced 5,950.44 in 2025-26). - Hypothesis properties: differential against the tax credit condition (CTC reg 8(3)-(5) + WTC reg 17(2) higher-rate AA); AFCS invariance of the flag and of every amount that reads it; AFIP monotone. Co-Authored-By: Claude Opus 5.5 --- changelog.d/afcs-severe-disability.changed.md | 1 + .../is_severely_disabled_for_benefits.yaml | 118 ++++++++++++ .../test_severe_disability_afcs_properties.py | 178 ++++++++++++++++++ .../variables/gov/dwp/afcs_reported.py | 8 + policyengine_uk/variables/gov/dwp/afip.py | 7 + 5 files changed, 312 insertions(+) create mode 100644 changelog.d/afcs-severe-disability.changed.md create mode 100644 policyengine_uk/tests/test_severe_disability_afcs_properties.py diff --git a/changelog.d/afcs-severe-disability.changed.md b/changelog.d/afcs-severe-disability.changed.md new file mode 100644 index 0000000000..e73d498f45 --- /dev/null +++ b/changelog.d/afcs-severe-disability.changed.md @@ -0,0 +1 @@ +Document that `afcs_reported` is Family Resources Survey benefit code 8 (Armed Forces Compensation Scheme payments, including guaranteed income payments, and war disablement pensions) and that armed forces independence payment is the separate `armed_forces_independence_payment`, and test that no AFCS payment other than armed forces independence payment makes anyone severely disabled or changes the Universal Credit higher disabled child addition, the CTC severely disabled child element, the WTC severe disability element or the severe disability premium. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml index cbc0d7f8a4..3978cd63f8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml @@ -68,3 +68,121 @@ output: # WTC Regs 2002 reg 17(2) and Sch 2 para 6: 1,705 a year in 2024-25. WTC_severely_disabled_element: 1_705 + +# Armed Forces Compensation Scheme payments other than armed forces +# independence payment (lump sums, supplementary awards, guaranteed income +# payments; AFCS Order 2011 art 15(1)) and war disablement pensions are in +# afcs. They meet no paragraph of CTC reg 8 or WTC reg 17, alone or with a +# lower award of another benefit. + +- name: DLA care component at the middle rate with an AFCS payment is not severe + period: 2025 + input: + age: 10 + dla_sc_category: MIDDLE + afcs_reported: 3_000 + output: + is_severely_disabled_for_benefits: false + +- name: PIP daily living component at the standard rate with an AFCS payment is not severe + period: 2025 + input: + age: 17 + pip_dl_category: STANDARD + afcs_reported: 3_000 + output: + is_severely_disabled_for_benefits: false + +- name: A child with an AFCS payment adds no CTC severely disabled child element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + child: + age: 8 + afcs_reported: 2_000 + benunits: + benunit: + members: [parent, child] + is_CTC_eligible: true + output: + CTC_severely_disabled_child_element: 0 + +- name: An AFCS payment adds no WTC severe disability element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 45 + afcs_reported: 12_000 + benunits: + benunit: + members: [adult] + is_WTC_eligible: true + output: + WTC_severely_disabled_element: 0 + +- name: WTC severe disability element for a claimant on armed forces independence payment + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 45 + # AFCS Order 2011 art 24A(3): 194.60 a week. + armed_forces_independence_payment: 10_119.20 + benunits: + benunit: + members: [adult] + is_WTC_eligible: true + output: + # WTC Regs 2002 reg 17(4) and Sch 2 para 6: 1,705 a year in 2024-25. + WTC_severely_disabled_element: 1_705 + +# UC Regs 2013 reg 24(2)(b) gives the higher rate of the disabled child +# addition for the DLA care component at the highest rate, the PIP daily living +# component at the enhanced rate (or a Scottish equivalent) or blindness; no +# armed forces payment is listed. Higher rate 2025-26: 495.87 a month (reg 36 +# table); 495.87 * 12 = 5,950.44. + +- name: A qualifying young person with an AFCS payment gets no UC higher disabled child addition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + is_in_non_advanced_education: true + afcs_reported: 5_000 + benunits: + benunit: + members: [parent, young_person] + output: + is_severely_disabled_for_benefits: [false, false] + uc_individual_severely_disabled_child_element: [0, 0] + +- name: A qualifying young person on the enhanced PIP daily living rate gets the UC higher addition + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + is_in_non_advanced_education: true + pip_dl_category: ENHANCED + benunits: + benunit: + members: [parent, young_person] + output: + is_severely_disabled_for_benefits: [false, true] + uc_individual_severely_disabled_child_element: [0, 5_950.44] diff --git a/policyengine_uk/tests/test_severe_disability_afcs_properties.py b/policyengine_uk/tests/test_severe_disability_afcs_properties.py new file mode 100644 index 0000000000..fbc1370470 --- /dev/null +++ b/policyengine_uk/tests/test_severe_disability_afcs_properties.py @@ -0,0 +1,178 @@ +"""Property-based tests for the severe disability test and Armed Forces +Compensation Scheme payments. + +Law: +- Child Tax Credit Regulations 2002 (SI 2002/2007) reg 8(3)-(5): a person is + severely disabled if the DLA care component is payable at the highest rate, + the PIP daily living component at the enhanced rate, or "an armed forces + independence payment is payable in respect of him". +- Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002 reg + 17(2)-(4): the same, plus Attendance Allowance at the higher rate. +- Armed forces independence payment is one of the injury benefits in the Armed + Forces and Reserve Forces (Compensation Scheme) Order 2011 art 15(1), next to + lump sums, supplementary awards and guaranteed income payments. Only it is + named; the afcs variables hold the other payments. + +Invariants, for any generated population of families: + +1. Differential: is_severely_disabled_for_benefits equals the tax credit + condition (CTC reg 8(3)-(5) with WTC reg 17(2)'s higher-rate Attendance + Allowance) recomputed here from the DLA care, PIP daily living and + Attendance Allowance categories and armed forces independence payment. +2. Metamorphic: changing every person's AFCS payment (to zero or another + amount) changes neither the severe disability test nor the amounts that read + it: the UC higher rate disabled child addition, the CTC severely disabled + child element, the WTC severe disability element and the severe disability + premium. +3. Monotone: adding armed forces independence payment to a person never turns + the severe disability test off, and turns it on. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +UC_YEAR = 2025 +TAX_CREDIT_YEAR = 2024 +YEARS = (TAX_CREDIT_YEAR, UC_YEAR) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DLA_CARE = ["NONE", "LOWER", "MIDDLE", "HIGHER"] +ATTENDANCE_ALLOWANCE = ["NONE", "LOWER", "HIGHER"] +PIP_DAILY_LIVING = ["NONE", "STANDARD", "ENHANCED"] +amounts = st.one_of(st.just(0.0), st.floats(1, 40_000)) + + +@st.composite +def people(draw, ages): + return dict( + age=draw(ages), + dla_sc_category=draw(st.sampled_from(DLA_CARE)), + pip_dl_category=draw(st.sampled_from(PIP_DAILY_LIVING)), + aa_category=draw(st.sampled_from(ATTENDANCE_ALLOWANCE)), + afcs_reported=draw(amounts), + armed_forces_independence_payment=draw( + st.one_of(st.just(0.0), st.just(0.0), st.floats(1, 15_000)) + ), + ) + + +@st.composite +def families(draw): + return dict( + adults=draw(st.lists(people(st.integers(25, 60)), min_size=1, max_size=2)), + # 16 to 19 year olds are in non-advanced education, so qualifying young + # persons; younger ones are children. + dependants=draw(st.lists(people(st.integers(0, 19)), max_size=2)), + ) + + +populations = st.lists(families(), min_size=10, max_size=20) + + +def situation(units, afcs=None, afip=None): + """Build a situation; afcs/afip map a person name to a replacement.""" + people_, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for role, members in (("a", unit["adults"]), ("d", unit["dependants"])): + for j, person in enumerate(members): + name = f"{role}{i}_{j}" + values = dict(person) + if afcs is not None: + values["afcs_reported"] = afcs(name, values["afcs_reported"]) + if afip is not None: + values["armed_forces_independence_payment"] = afip( + name, values["armed_forces_independence_payment"] + ) + if role == "a": + values["is_parent"] = bool(unit["dependants"]) + elif values["age"] >= 16: + values["is_in_non_advanced_education"] = True + people_[name] = {k: {y: v for y in YEARS} for k, v in values.items()} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "is_CTC_eligible": {TAX_CREDIT_YEAR: True}, + "is_WTC_eligible": {TAX_CREDIT_YEAR: True}, + } + households[f"h{i}"] = {"members": names} + return {"people": people_, "benunits": benunits, "households": households} + + +def reference_severely_disabled(units): + """CTC Regs 2002 reg 8(3)-(5) and WTC Regs 2002 reg 17(2)-(4), from the + inputs alone.""" + flags = [] + for unit in units: + for person in unit["adults"] + unit["dependants"]: + flags.append( + person["dla_sc_category"] == "HIGHER" + or person["pip_dl_category"] == "ENHANCED" + or person["aa_category"] == "HIGHER" + or person["armed_forces_independence_payment"] > 0 + ) + return np.array(flags) + + +def outputs(sim): + return dict( + flag=sim.calculate("is_severely_disabled_for_benefits", UC_YEAR), + uc_addition=sim.calculate( + "uc_individual_severely_disabled_child_element", UC_YEAR + ), + ctc_element=sim.calculate( + "CTC_severely_disabled_child_element", TAX_CREDIT_YEAR + ), + wtc_element=sim.calculate("WTC_severely_disabled_element", TAX_CREDIT_YEAR), + premium=sim.calculate("severe_disability_premium", UC_YEAR), + ) + + +@PROPERTY_SETTINGS +@given(populations) +def test_severe_disability_matches_the_tax_credit_condition(units): + sim = Simulation(situation=situation(units)) + flag = np.asarray(sim.calculate("is_severely_disabled_for_benefits", UC_YEAR)) + np.testing.assert_array_equal(flag, reference_severely_disabled(units)) + + +@PROPERTY_SETTINGS +@given(populations, st.data()) +def test_afcs_payments_never_change_severe_disability(units, data): + replacements = {} + + def new_afcs(name, _): + if name not in replacements: + replacements[name] = data.draw(amounts, label=f"afcs {name}") + return replacements[name] + + before = outputs(Simulation(situation=situation(units))) + no_afcs = outputs(Simulation(situation=situation(units, afcs=lambda n, v: 0.0))) + other_afcs = outputs(Simulation(situation=situation(units, afcs=new_afcs))) + for name, value in before.items(): + np.testing.assert_allclose(no_afcs[name], value, atol=1e-6, err_msg=name) + np.testing.assert_allclose(other_afcs[name], value, atol=1e-6, err_msg=name) + + +@PROPERTY_SETTINGS +@given(populations, st.floats(1, 15_000)) +def test_armed_forces_independence_payment_confers_severe_disability(units, amount): + before = np.asarray( + Simulation(situation=situation(units)).calculate( + "is_severely_disabled_for_benefits", UC_YEAR + ) + ) + after = np.asarray( + Simulation(situation=situation(units, afip=lambda n, v: amount)).calculate( + "is_severely_disabled_for_benefits", UC_YEAR + ) + ) + assert after.all() + assert (after >= before).all() diff --git a/policyengine_uk/variables/gov/dwp/afcs_reported.py b/policyengine_uk/variables/gov/dwp/afcs_reported.py index b42daaa611..66625a4cbb 100644 --- a/policyengine_uk/variables/gov/dwp/afcs_reported.py +++ b/policyengine_uk/variables/gov/dwp/afcs_reported.py @@ -5,6 +5,14 @@ class afcs_reported(Variable): value_type = float entity = Person label = "Armed Forces Compensation Scheme (reported)" + documentation = ( + "Reported Armed Forces Compensation Scheme and war disablement pension " + "payments. In the Family Resources Survey this is benefit code 8, asked " + "as the Armed Forces Compensation Scheme (formerly War Disablement " + "Pension), including Guaranteed Income Payments. Armed forces " + "independence payment is the separate armed_forces_independence_payment " + "variable." + ) definition_period = YEAR unit = GBP uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" diff --git a/policyengine_uk/variables/gov/dwp/afip.py b/policyengine_uk/variables/gov/dwp/afip.py index 69625ca858..1bea921f11 100644 --- a/policyengine_uk/variables/gov/dwp/afip.py +++ b/policyengine_uk/variables/gov/dwp/afip.py @@ -3,7 +3,14 @@ class armed_forces_independence_payment(Variable): label = "Armed Forces Independence Payment" + documentation = ( + "Armed forces independence payment under article 24A of the Armed " + "Forces and Reserve Forces (Compensation Scheme) Order 2011, payable " + "to people with a guaranteed income payment at a relevant percentage " + "of 50% or more. It is not part of afcs." + ) entity = Person definition_period = YEAR value_type = float unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2011/517/article/24A" From 8334c89b4cd50bd6c2fff73b2ef0f868bf749bf5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:53:55 -0400 Subject: [PATCH 36/75] Tighten the Pension Credit reported-capital docs and properties (review of #2018) - Document that the input must be countable capital (after Sch V disregards and reg 19 valuation), that it replaces every capital source, that capital source reforms do not reach a unit that records it, and that any negative value means none recorded; drop the unsupported "no one else's" from s.5. - Say why an unrecorded unit's proxy is not reduced by another unit's recorded capital (unlike uc_assessable_capital). - Monotonicity: the guarantee credit never rises with reported capital; total entitlement only where all income is savings credit qualifying. Pin the intended SPCA s.3 exception (contributory ESA: +20p a week per 500 pounds). - Locality gets its own strategy so a leak into the other unit would show. Co-Authored-By: Claude Opus 5.5 --- .../pc-reported-capital-followups.changed.md | 1 + .../pension_credit_reported_capital.yaml | 2 +- ...sion_credit_reported_capital_properties.py | 92 ++++++++++++++++--- .../pension_credit_assessable_capital.py | 6 +- .../pension_credit_reported_capital.py | 19 ++-- 5 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 changelog.d/pc-reported-capital-followups.changed.md diff --git a/changelog.d/pc-reported-capital-followups.changed.md b/changelog.d/pc-reported-capital-followups.changed.md new file mode 100644 index 0000000000..2573a7f887 --- /dev/null +++ b/changelog.d/pc-reported-capital-followups.changed.md @@ -0,0 +1 @@ +Document what `pension_credit_reported_capital` must contain (countable capital after the Schedule V disregards and reg. 19 valuation) and what it replaces, and restate its monotonicity property around the savings credit's intended exception. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml index 7bd52c0bab..1ed36adf50 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml @@ -1,6 +1,6 @@ # Expected values are hand-computed from SPC Regs 2002 reg 15(6): deemed income # is 1 pound a week for every 500 pounds, or part of 500 pounds, of capital -# above 10,000 pounds (2025: 52 weeks a year). +# above 10,000 pounds, times 52 weeks a year. - name: With no reported capital (the default, -1), household capital is the proxy. period: 2025 diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index cf5fef6f30..5d49ce944e 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -15,10 +15,19 @@ the unit's share of the household's pension-age adults, plus person-level sources of the claimant or partner), the formula before this change. 3. Locality: reporting capital for one unit never changes another unit's - assessable capital. + assessable capital. The strategy for this property always gives the other + unit someone over State Pension age, nothing recorded and household capital, + so a leak would show. 4. Bounds: assessable capital is never negative, and is 0 for a unit with no one over State Pension age. -5. Monotonicity: Pension Credit entitlement never rises with reported capital. +5. Monotonicity: the guarantee credit never rises with reported capital, and + nor does total entitlement when all income is savings credit qualifying + income (the generated households have no excluded income). + Intended exception (SPCA 2002 s. 3): with income that the savings credit + excludes (reg. 9), such as contributory ESA, deemed income from capital + phases the savings credit in at 60% but tapers it at only 40% above the + minimum guarantee, so entitlement can rise with capital. A pinned case + checks that it does. 6. Deemed income: for reported capital R it is ceil(max(0, R - 10,000) / 500) pounds a week (SPC Regs 2002 reg 15(6)). """ @@ -137,14 +146,37 @@ def test_override_default_bounds_and_deemed_income(case): assert np.isclose(capital[i], proxy(people, units, household, i), rtol=1e-6) +@st.composite +def two_unit_households(draw): + """Two units; the second has someone over State Pension age, records no + capital and shares non-zero household capital, so a leak would show.""" + people = { + "a0": {"age": draw(ages), "state_pension": draw(money)}, + "b0": {"age": draw(st.sampled_from([67, 72, 80, 90])), "state_pension": 0}, + } + if draw(st.booleans()): + people["b1"] = {"age": draw(ages), "state_pension": 0} + for person in people.values(): + person["lifetime_isa_countable_capital"] = draw(st.sampled_from([0, 5_000])) + units = [ + {"members": ["a0"], "reported": -1}, + {"members": [n for n in people if n.startswith("b")], "reported": -1}, + ] + household = { + "savings": draw(st.integers(min_value=1_000, max_value=200_000)), + "owned_land": draw(st.sampled_from([0, 25_000])), + "corporate_wealth": draw(st.sampled_from([0, 40_000])), + } + return people, units, household + + @SETTINGS -@given(households(), st.integers(min_value=0, max_value=60_000)) +@given(two_unit_households(), st.integers(min_value=0, max_value=60_000)) def test_locality(case, value): people, units, household = case - if len(units) < 2: - return - before = simulate(people, units, household, overrides=[-1, units[1]["reported"]]) - after = simulate(people, units, household, overrides=[value, units[1]["reported"]]) + before = simulate(people, units, household, overrides=[-1, -1]) + after = simulate(people, units, household, overrides=[value, -1]) + assert before.calculate("pension_credit_assessable_capital", YEAR)[1] > 0 assert np.isclose( before.calculate("pension_credit_assessable_capital", YEAR)[1], after.calculate("pension_credit_assessable_capital", YEAR)[1], @@ -158,17 +190,49 @@ def test_locality(case, value): st.integers(min_value=0, max_value=60_000), ) def test_entitlement_never_rises_with_reported_capital(case, a, b): + # The generated households have no income the savings credit excludes, + # so total entitlement is monotone here; see the intended exception below. people, units, household = case low, high = sorted([a, b]) overrides_low = [low] + [u["reported"] for u in units[1:]] overrides_high = [high] + [u["reported"] for u in units[1:]] - e_low = simulate(people, units, household, overrides_low).calculate( - "pension_credit_entitlement", YEAR - )[0] - e_high = simulate(people, units, household, overrides_high).calculate( - "pension_credit_entitlement", YEAR - )[0] - assert e_high <= e_low + 1e-6 + low_sim = simulate(people, units, household, overrides_low) + high_sim = simulate(people, units, household, overrides_high) + for variable in ["guarantee_credit", "pension_credit_entitlement"]: + assert ( + high_sim.calculate(variable, YEAR)[0] + <= low_sim.calculate(variable, YEAR)[0] + 1e-6 + ) + + +def test_intended_exception_savings_credit_rises_with_capital(): + """SPCA 2002 s. 3: with income the savings credit excludes (contributory + ESA, reg. 9), 500 pounds more capital adds 1 pound a week of deemed income + to both incomes: amount A rises by 60p and amount B by 40p, so the savings + credit rises by 20p a week (10.40 a year).""" + + def entitlement(capital): + sim = Simulation( + situation={ + "people": { + "p": { + "age": {str(YEAR): 80}, + "state_pension": {str(YEAR): 10_900}, + "esa_contrib": {str(YEAR): 1_300}, + } + }, + "benunits": { + "b": { + "members": ["p"], + "pension_credit_reported_capital": {str(YEAR): capital}, + } + }, + "households": {"h": {"members": ["p"]}}, + } + ) + return sim.calculate("pension_credit_entitlement", YEAR)[0] + + assert np.isclose(entitlement(10_500) - entitlement(10_000), 0.2 * WEEKS_IN_YEAR) def test_reported_capital_uprates_with_savings(): diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index 856bd49da3..7e9c575840 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -14,7 +14,11 @@ class pension_credit_assessable_capital(Variable): "claimant or partner (is_uc_claimant): a dependant's capital is not " "the claimant's. Where `pension_credit_reported_capital` records the " "benefit unit's own capital (0 or more), it replaces the household " - "proxy and the person-level sources." + "proxy and the person-level sources. Unlike `uc_assessable_capital`, " + "another benefit unit's recorded capital is not subtracted from the " + "household capital that an unrecorded unit's proxy shares out: the " + "recorded figure and the household sources come from different " + "measures, so the proxy is left as it was." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 6738da89f9..6e964e1a05 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -6,13 +6,18 @@ class pension_credit_reported_capital(Variable): entity = BenUnit label = "reported Pension Credit capital" documentation = ( - "Capital of the claimant and partner as recorded for this benefit unit, " - "such as a survey's benefit-unit capital measure. When it is 0 or more " - "it replaces the household-level proxy in Pension Credit assessable " - "capital: Pension Credit counts the claimant's capital and, under the " - "State Pension Credit Act 2002 s. 5, the partner's, and no one else's. " - "The default, -1, means none is " - "recorded and the household proxy applies. Set it to 0 to record no " + "Countable capital of the claimant and partner as recorded for this " + "benefit unit, such as a survey's benefit-unit capital measure. Pension " + "Credit counts the claimant's capital, and the State Pension Credit Act " + "2002 s. 5 treats the partner's as the claimant's. When this is 0 or " + "more it replaces every capital source in Pension Credit assessable " + "capital: the household proxy (savings, land, property and " + "corporate_wealth) and the person-level sources such as a Lifetime ISA. " + "The model applies neither the Schedule V disregards nor the reg. 19 " + "valuation to it, so it must already be the countable figure, and " + "reforms to the capital source parameters do not reach a benefit unit " + "that records it. Any negative value, including the default -1, means " + "none is recorded and the household proxy applies. 0 records no " "capital." ) definition_period = YEAR From 19a7235486c28ab8fcf89f511ba37749a06734a4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:15:28 -0400 Subject: [PATCH 37/75] Remove main's #1942 xfail on Carer Support Payment; pin the tests review's counterexamples - test_uc_unearned_benefits_properties.py (added on main by #1958) had a strict xfail pinned to #1942, which this fix turns into a pass. Remove it and widen the earnings-free invariants to all families, with Marriage Allowance off as in the State Pension file (#1947). - The tests review found that random draws missed two mutations. Pin its minimized counterexamples as @example cases: a pound of State Pension for a pensioner just above the personal allowance with an earning partner, and a pound of pension for an earner just above it. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_earnings_deductions_properties.py | 28 +++++- .../tests/test_uc_state_pension_properties.py | 21 ++++- .../test_uc_unearned_benefits_properties.py | 85 +++++++++---------- 3 files changed, 88 insertions(+), 46 deletions(-) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 21672531fe..435577e813 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -34,7 +34,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -195,6 +195,32 @@ def bumped(draw): @PROPERTY_SETTINGS @given(case=bumped(), year=st.sampled_from(YEARS)) +@example( + # An earner just above the personal allowance gains a pound of pension. + # Taking earnings as the top slice instead of the bottom one would tax + # that pound against earnings. + case=( + [ + dict( + adults=[ + dict( + age=18, + employment_income=12_571.0, + self_employment_income=0.0, + uc_is_in_startup_period=False, + married_couples_allowance=0.0, + ) + ], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=0.0, + region="LONDON", + ) + ], + (0, 0, "private_pension_income", 1.0), + ), + year=2026, +) def test_unearned_income_never_changes_earned_income(case, year): units, bump = case low = calculate(units, year) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 9eefc6eeef..41ae9c0250 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -32,7 +32,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -180,6 +180,25 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(YEARS), ) +@example( + # A partner just above the personal allowance and a pensioner just above + # it too, so a pound of State Pension is taxed while the partner's + # earnings reduce UC. Deducting the pensioner's tax from the partner's + # earnings (the formula before #1942) breaks pound for pound here. + units=[ + dict( + ages=[67, 18], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=12_000.0, + savings=0.0, + earnings=12_571.0, + state_pension=12_571.0, + ) + ], + bump=1.0, + year=2026, +) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): # Earnings in the family change nothing: tax on State Pension is never # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). diff --git a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py index c07bf8d808..26ee84e1eb 100644 --- a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py @@ -17,23 +17,20 @@ families that already received the benefit; starting to receive contributory ESA or industrial injuries benefit can lift the cap, because receipt exempts the family from it. -2. Pound for pound: with no earnings in the family, raising one of the - benefits by d lowers the award before the benefit cap by exactly - min(counted increase, award). -3. Equivalence: with no earnings in the family, UC before the benefit cap - with x of the benefit equals UC with the counted amount of private pension - received by the same person instead. For carer support payment both - families keep the carer's caring hours, so both get the carer element. - -Invariants 2 and 3, and invariant 1 for carer support payment, are -restricted to families without earnings because the model deducts the whole -benefit unit's income tax from its earnings -(PolicyEngine/policyengine-uk#1942), so tax on a taxable benefit or pension -reduces earned income. Carer support payment is taxable, so above the cap, -where more of it adds nothing to unearned income, the carer's extra tax -lowers the partner's earned income and raises the award. The strict xfail -below pins that case and will flip when #1942 is fixed; widen these -invariants to all families then. They compare the award before the benefit cap because +2. Pound for pound: raising one of the benefits by d lowers the award before + the benefit cap by exactly min(counted increase, award), whatever the + partner earns, because tax on a benefit never comes off anyone's earnings + (reg. 55(5)(b), reg. 57(2) step 3). +3. Equivalence: UC before the benefit cap with x of the benefit equals UC with + the counted amount of private pension received by the same person instead. + For carer support payment both families keep the carer's caring hours, so + both get the carer element. + +Marriage Allowance is switched off for invariants 2 and 3, and for invariant +1 for carer support payment. The model books it on the recipient as the +transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), +so a taxable benefit that uses up the first adult's allowance raises the +earning partner's tax on earnings. Invariants 2 and 3 compare the award before the benefit cap because contributory ESA and industrial injuries benefit trigger benefit cap exemptions and ESA counts towards the cap, which private pension does not. """ @@ -90,8 +87,8 @@ @st.composite -def families(draw, with_earnings=True): - earnings = draw(st.one_of(st.just(0.0), money)) if with_earnings else 0.0 +def families(draw): + earnings = draw(st.one_of(st.just(0.0), money)) return dict( ages=[draw(WORKING_AGE) for _ in range(draw(st.integers(1, 2)))], children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], @@ -103,14 +100,17 @@ def families(draw, with_earnings=True): ) -def situation(units, year, income_variable, bump=0.0, carer=False): +def situation( + units, year, income_variable, bump=0.0, carer=False, marriage_allowance=True +): """Build one simulation holding every family. The first adult receives the family's ``amount`` (plus ``bump``) under ``income_variable``; a partner receives the family's earnings. With ``carer``, the first adult cares for 40 hours a week in Scotland and has carer support payment set explicitly (zero unless it is the income - variable), so the carer element applies whatever the income. + variable), so the carer element applies whatever the income. With + ``marriage_allowance=False`` no one claims Marriage Allowance. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -118,6 +118,8 @@ def situation(units, year, income_variable, bump=0.0, carer=False): for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" person = {"age": {year: age}} + if not marriage_allowance: + person["would_claim_marriage_allowance"] = {year: False} if j == 0: if carer: person["care_hours"] = {year: 40} @@ -203,13 +205,14 @@ def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(CSP_YEARS), ) def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): - low = calculate(units, year, "carer_support_payment", carer=True) - high = calculate(units, year, "carer_support_payment", bump=bump, carer=True) + kwargs = dict(carer=True, marriage_allowance=False) + low = calculate(units, year, "carer_support_payment", **kwargs) + high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) increase = high["counted_csp"] - low["counted_csp"] assert np.all(increase >= -0.01), units assert_monotone(low, high, increase, units) @@ -217,14 +220,14 @@ def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), benefit=st.sampled_from(BENEFITS), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): - low = calculate(units, year, benefit) - high = calculate(units, year, benefit, bump=bump) + low = calculate(units, year, benefit, marriage_allowance=False) + high = calculate(units, year, benefit, bump=bump, marriage_allowance=False) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -236,13 +239,14 @@ def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(CSP_YEARS), ) def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year): - low = calculate(units, year, "carer_support_payment", carer=True) - high = calculate(units, year, "carer_support_payment", bump=bump, carer=True) + kwargs = dict(carer=True, marriage_allowance=False) + low = calculate(units, year, "carer_support_payment", **kwargs) + high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) award = low["universal_credit_pre_benefit_cap"] increase = high["counted_csp"] - low["counted_csp"] np.testing.assert_allclose( @@ -255,14 +259,14 @@ def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), benefit=st.sampled_from(BENEFITS), year=st.sampled_from(YEARS), ) def test_each_benefit_counts_like_private_pension(units, benefit, year): assert_same( - calculate(units, year, benefit), - calculate(units, year, "private_pension_income"), + calculate(units, year, benefit, marriage_allowance=False), + calculate(units, year, "private_pension_income", marriage_allowance=False), [v for v in UC_VARIABLES if v != "universal_credit"], str(units), ) @@ -270,15 +274,16 @@ def test_each_benefit_counts_like_private_pension(units, benefit, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), year=st.sampled_from(CSP_YEARS), ) def test_carer_support_payment_counts_like_capped_private_pension(units, year): - with_csp = calculate(units, year, "carer_support_payment", carer=True) + kwargs = dict(carer=True, marriage_allowance=False) + with_csp = calculate(units, year, "carer_support_payment", **kwargs) # The same family with the counted amount of Carer Support Payment # received as private pension instead. counted = [dict(unit, amount=c) for unit, c in zip(units, with_csp["counted_csp"])] - with_pension = calculate(counted, year, "private_pension_income", carer=True) + with_pension = calculate(counted, year, "private_pension_income", **kwargs) assert_same( with_csp, with_pension, @@ -287,14 +292,6 @@ def test_carer_support_payment_counts_like_capped_private_pension(units, year): ) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the whole " - "benefit unit's income tax, including the carer's tax on Carer Support " - "Payment, from the partner's earnings" - ), -) def test_carer_support_payment_above_the_cap_does_not_change_uc(): # 2026, Scotland: a carer aged 40 caring 40 hours a week with private # pension of 9,000 and a partner aged 38 earning 4,000 (under the personal From a57caf1ddea25e32de7faa7a16f78b57a6cdd0aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:18:35 -0400 Subject: [PATCH 38/75] Declare the claimant and partner in the UC earnings and State Pension property fixtures #1896 presumes that a member under 20 who is at least 16 years younger than the claimant is the claimant's child. The State Pension and earnings property tests generate couples such as 67 and 18 without relationship inputs, so the presumption would read them as a parent and child and the tests would pass without exercising a couple (including the tests review's pinned 67-and-18 example). Set is_claimant_or_partner from each generated role, as #1896 did for main's fixtures. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_earnings_deductions_properties.py | 10 ++++++++-- .../tests/test_uc_state_pension_properties.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 435577e813..5dae910538 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -145,7 +145,13 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa names = [] for j, adult in enumerate(unit["adults"]): name = f"p{i}_{j}" - person = {"state_pension": {year: 0.0}} + # The generated adults are the claimant and partner; say so, so the + # claimant-or-partner presumption (a member under 20 and much + # younger is the head's child) does not apply. Children get False. + person = { + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } for variable, value in adult.items(): if earnings_only and variable in UNEARNED: continue @@ -163,7 +169,7 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = { diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 41ae9c0250..31373ce0b3 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -111,7 +111,15 @@ def situation( names = [] for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" - person = {"age": {year: age}, "state_pension": {year: 0.0}} + # The generated adults are the claimant and partner; say so, so the + # claimant-or-partner presumption (a member under 20 and much + # younger is the head's child) does not turn a 67-and-18 couple + # into a parent and child. Children get False below. + person = { + "age": {year: age}, + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } if not marriage_allowance: person["would_claim_marriage_allowance"] = {year: False} if j == 0: @@ -123,7 +131,7 @@ def situation( names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = { From 7769fa3f38ee8bc8d8b7545ade3fbf2de4fdd70d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:24:47 -0400 Subject: [PATCH 39/75] Pin the Housing Benefit LHA rate in the #1926 LHA-order tests Housing Benefit reads housing_benefit_LHA_rate in this branch, so the tests that pin the rate as an input set that variable rather than BRMA_LHA_rate. Co-Authored-By: Claude Opus 5.5 --- .../entitlement/housing_benefit_lha_order.yaml | 2 +- .../test_housing_benefit_lha_order_properties.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml index 4714f22593..bf5af7f3f7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml @@ -43,7 +43,7 @@ # The model's 2026 rate for this household (default BRMA, Maidstone, # one-bedroom category) is also 9,467.64; it is an input here so the # test pins the order of the cap and the taper, not the LHA table. - BRMA_LHA_rate: 9_467.64 + housing_benefit_LHA_rate: 9_467.64 # Inputs, so the earnings disregard and the pension-age allowance # cannot move the taper. The applicable income input overrides the # income computed from state_pension_reported, which only keeps diff --git a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py index a61eda1dfd..ba3818a4f9 100644 --- a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py @@ -170,7 +170,7 @@ def calculate(population): people[name] = {"age": {YEAR: 70}} benunits[f"b{i}"] = { "members": [name], - "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, + "housing_benefit_LHA_rate": {YEAR: unit["lha_rate"]}, "housing_benefit_applicable_amount": {YEAR: unit["applicable_amount"]}, "housing_benefit_applicable_income": {YEAR: unit["income"]}, "housing_benefit_non_dep_deductions": {YEAR: unit["non_dep_deductions"]}, @@ -188,7 +188,7 @@ def calculate(population): for variable in [ "housing_benefit_entitlement", "benunit_rent", - "BRMA_LHA_rate", + "housing_benefit_LHA_rate", "LHA_cap", "LHA_eligible", "housing_benefit_applicable_amount", @@ -231,7 +231,9 @@ def test_bounds_closed_form_and_differential(population): # LHA tenants are exactly the private renters here. assert np.array_equal(lha, tenures == "RENT_PRIVATELY") assert np.allclose( - values["LHA_cap"], np.minimum(rent, values["BRMA_LHA_rate"]), atol=TOLERANCE + values["LHA_cap"], + np.minimum(rent, values["housing_benefit_LHA_rate"]), + atol=TOLERANCE, ) # 1. Bounds. @@ -357,7 +359,7 @@ def end_to_end(population): names.append(name) benunits[f"b{i}"] = { "members": names, - "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, + "housing_benefit_LHA_rate": {YEAR: unit["lha_rate"]}, } household_members = list(names) if unit["non_dependant_earnings"] is not None: @@ -384,7 +386,7 @@ def end_to_end(population): "housing_benefit", "housing_benefit_eligible", "benunit_rent", - "BRMA_LHA_rate", + "housing_benefit_LHA_rate", "LHA_cap", "LHA_eligible", "housing_benefit_applicable_amount", @@ -424,5 +426,5 @@ def test_end_to_end_pension_age_housing_benefit(population): eligible = values["housing_benefit_eligible"].astype(bool) assert np.allclose(hb[eligible], expected[eligible], atol=TOLERANCE), population assert np.all(hb[~eligible] == 0) - lha_limit = np.minimum(rent, values["BRMA_LHA_rate"]) + lha_limit = np.minimum(rent, values["housing_benefit_LHA_rate"]) assert np.all(hb[lha] <= lha_limit[lha] + TOLERANCE), population From 33f25232c714f0883ae014a002d3b2cd161852c9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:30:42 -0400 Subject: [PATCH 40/75] Check CTR carer income on the Pension Credit routes separately Since #1909, a pension-age family on Guarantee Credit has its whole income disregarded for Council Tax Reduction, and one on savings credit only is assessed on Pension Credit income plus the Pension Credit paid (SSI 2012/319 regs 24 and 25). The general-income identities now apply only where neither route applies, and a new property checks both routes. A Scottish pensioner carer with no State Pension is pinned as an example: it crosses into savings credit only on the private pension grid and failed the old assertions. Addresses the review of e6f29a76 (finding 1). Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_carer_income_properties.py | 75 +++++++++++++++++-- 1 file changed, 67 insertions(+), 8 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 1da46f5ddc..d25171a0e7 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 @@ -43,6 +43,16 @@ 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 +disregarded (SSI 2012/319 reg 24), and one in receipt of savings credit only is +assessed on the Secretary of State's Pension Credit income plus the savings +credit (reg 25), so those cells are left out of 1 to 3 and checked instead by: + +4. Pension Credit routes: where the family is in receipt of a guarantee credit, + 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. + These compare the model's own income measures, so they hold whatever carer's benefit the model pays. The model does not yet reduce Carer Support Payment by State Pension (the Carer's Assistance (Carer Support Payment) (Scotland) @@ -51,7 +61,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -96,6 +106,10 @@ "housing_benefit_applicable_income_disregard", "housing_benefit_applicable_income_childcare_element", "housing_benefit_tariff_income", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + "pension_credit_income", + "pension_credit", *OTHER_COUNTED_BENEFITS, ] PERSON_VARIABLES = [ @@ -106,6 +120,16 @@ "national_insurance", ] CLAIMS = (True, False) +# A Scottish pensioner carer with no State Pension: across the private pension +# grid this family moves between the general rules and savings credit only. +SAVINGS_CREDIT_CARER = dict( + pension_age=True, + adults=[dict(age=80, state_pension=0.0)], + carer=0, + by_hours=True, + country="SCOTLAND", + council_tax=2_000.0, +) def money(high): @@ -198,6 +222,12 @@ def grid(families, reform=None): + g["national_insurance"] - g["other_counted_benefits"] ) + # Cells whose Council Tax Reduction income follows the general rules, not + # the Pension Credit routes of SSI 2012/319 regs 24 and 25. + g["general_rules"] = ~( + g["in_receipt_of_guarantee_credit"].astype(bool) + | g["in_receipt_of_savings_credit_only"].astype(bool) + ) return g @@ -209,6 +239,7 @@ def partial(g, i): @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=3)) +@example([SAVINGS_CREDIT_CARER]) def test_council_tax_reduction_income_counts_the_carer_support_payment_component( families, ): @@ -225,8 +256,10 @@ 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. - compared = (income[i, :, 0] > 0) & (income[i, :, 1] > 0) + # 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] + compared = (income[i, :, 0] > 0) & (income[i, :, 1] > 0) & general 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, @@ -237,7 +270,7 @@ def test_council_tax_reduction_income_counts_the_carer_support_payment_component # gains the premium with it, so only the hours case is compared. if fam["by_hours"]: assert np.all(award[i, :, 0] <= award[i, :, 1] + 0.01), fam - both_partial = partial(g, i)[:, 0] & partial(g, i)[:, 1] + both_partial = partial(g, i)[:, 0] & partial(g, i)[:, 1] & general fall = award[i, :, 1] - award[i, :, 0] income_rise = income[i, :, 0] - income[i, :, 1] assert np.allclose( @@ -249,6 +282,7 @@ def test_council_tax_reduction_income_counts_the_carer_support_payment_component @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=3)) +@example([SAVINGS_CREDIT_CARER]) def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( families, ): @@ -265,8 +299,10 @@ 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. - compared = (income > 0) & (income_without > 0) + # 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] + compared = (income > 0) & (income_without > 0) & general assert np.allclose( (income + g["income_tax"][i])[compared], (income_without + without["income_tax"][i])[compared], @@ -276,7 +312,7 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( award_without = without["council_tax_benefit"][i] # The supplement can only add tax, which lowers income. assert np.all(award >= award_without - 0.01), fam - both_partial = partial(g, i) & partial(without, i) + 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], @@ -285,6 +321,8 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( ), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose(income[:, 1], income_without[:, 1], atol=0.01), fam + # The supplement never lowers the award, even on the Pension Credit + # routes; on them income follows invariant 4. @PROPERTY_SETTINGS @@ -301,7 +339,9 @@ def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( # Guarantee Credit passports pension-age Housing Benefit to nil # income; the zero floors must not bind. passported = fam["pension_age"] & (g["guarantee_credit"][i] > 0) - compared = ~passported & (hb_income > 0) & (ctr_income > 0) + compared = ( + ~passported & g["general_rules"][i] & (hb_income > 0) & (ctr_income > 0) + ) hb_before_adjustments = ( hb_income + g["housing_benefit_applicable_income_disregard"][i] @@ -311,3 +351,22 @@ def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( assert np.allclose( hb_before_adjustments[compared], ctr_income[compared], atol=0.05 ), (fam, hb_before_adjustments[compared], ctr_income[compared]) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=3)) +@example([SAVINGS_CREDIT_CARER]) +def test_council_tax_reduction_income_on_the_pension_credit_routes(families): + g = grid(families) + for i, fam in enumerate(families): + income = g["council_tax_reduction_applicable_income"][i] + guarantee = g["in_receipt_of_guarantee_credit"][i].astype(bool) + savings_only = g["in_receipt_of_savings_credit_only"][i].astype(bool) + assert not np.any(guarantee & savings_only), fam + assert np.all(income[guarantee] == 0), fam + expected = g["pension_credit_income"][i] + g["pension_credit"][i] + assert np.allclose(income[savings_only], expected[savings_only], atol=0.01), ( + fam, + income[savings_only], + expected[savings_only], + ) From 40cc558e9ba5ff95aefe57f1720d2776b8ea0e70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:56:55 -0400 Subject: [PATCH 41/75] Correct the pension-age tax rule and add overlap tripwires for Scottish CTR Pension-age Scottish CTR does not deduct tax from earnings only: SSI 2012/319 reg 31(12) disregards the tax payable on any income taken into account. Working-age CTR (SSI 2021/249) deducts it from earnings only. Either way the Scottish Carer Supplement, which neither scheme counts, reaches CTR in law neither directly nor through its tax. Say so in the YAML header and the property docstring. Add two cases in which the carer's overlapping benefit (State Pension at pension age, contributory ESA at working age) exceeds the Carer Support Payment rate, so CSP Regs reg 16(2) would reduce the CSP given to nil. The model does not apply reg 16(2) yet. Whether CTR counts CSP before or after that reduction is open (SSI 2012/319 reg 27(3) and (5); SSI 2021/249 reg 57(1)(b)), so the cases fail if CTR income silently drops the CSP, and whoever applies reg 16(2) must settle it. Cite SSI 2021/249 reg 57 and SSI 2012/319 reg 27 on the CTR income variable, and note in scottish_carer_supplement that Scottish CTR does not count it. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_carer_support_payment.yaml | 102 +++++++++++++++++- ...l_tax_reduction_carer_income_properties.py | 10 +- ...council_tax_reduction_applicable_income.py | 2 + .../scottish_carer_supplement.py | 8 +- 4 files changed, 111 insertions(+), 11 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 b2c16eeea1..d1390f0f49 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 @@ -8,10 +8,13 @@ # Regulations 2012 (SSI 2012/319) reg 27(1)(j), social security benefits # other than those it lists. # 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. Both -# instruments deduct income tax only from earnings, but 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. +# 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 all income tax, so +# the taxable supplement reaches the model's Council Tax Reduction income +# through income tax and in no other way. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, @@ -139,6 +142,47 @@ # applicable amount of 7,472.40. council_tax_reduction: 2_000 +- name: A working-age carer on contributory ESA above the Carer Support Payment rate has it counted in Council Tax Reduction + # Contributory ESA (96.15 a week here) is an overlapping benefit, so CSP + # Regs (SSI 2023/302) reg 16(2)(b) would reduce the CSP given to nil. The + # model does not yet apply reg 16(2), so it pays and counts 86.45 x 52. + # SSI 2021/249 reg 57(1)(b) counts CSP "to which the applicant is entitled, + # subject to any adjustment ... in accordance with Regulations under + # section 73 of the Social Security Administration Act 1992"; reg 16(2) is + # made under the Social Security (Scotland) Act 2018, not section 73. + # Whoever applies reg 16(2) must settle which amount CTR counts and update + # this case; it fails if CTR income silently drops the CSP. Taxable income, + # 5,000 + 4,495.40 + 608.40 = 10,103.80, is below the personal allowance + # either way. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + esa_contrib_reported: 5_000 + care_hours: 35 + benunits: + benunit: + members: [carer] + would_claim_uc: false + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + esa_contrib: 5_000 + income_tax: 0 + # 5,000 + 4,495.40. Leaving the CSP out would give 5,000, below the + # 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 + # 2,000 - 0.2 x (9,495.40 - 7,472.40) + council_tax_reduction: 1_595.40 + - name: Carer Support Payment counts in 2025, before the Scottish Carer Supplement period: 2025 absolute_error_margin: 0.01 @@ -270,6 +314,56 @@ # 2,000 - 0.2 x (22,995.40 - 22,438.00) council_tax_reduction: 1_888.52 +- name: A pension-age carer whose State Pension exceeds the Carer Support Payment rate has it counted in Council Tax Reduction + # The partner's State Pension (142.31 a week) exceeds the CSP rate, so CSP + # Regs (SSI 2023/302) reg 16(2)(b) would reduce the CSP given to them to + # nil. The model does not yet apply reg 16(2), so it pays and counts + # 86.45 x 52. Whether pension-age CTR should count CSP before or after that + # reduction is open. SSI 2012/319 reg 27(3) counts a benefit before any + # deduction other than the reg 27(5) adjustments, and reg 27(5) lists the + # Overlapping Benefits Regulations 1979 but not reg 16(2). Whoever applies + # reg 16(2) must settle which amount CTR counts and update this case; it + # fails if CTR income silently drops the CSP. The figures isolate that + # choice. The couple is unmarried, so no marriage allowance links the + # claimant's tax to the partner's income. The partner's taxable income, + # 7,400 + 4,495.40 + 608.40 = 12,503.80, is below the personal allowance, + # so the tax is the same whether or not the CSP is paid. Guarantee Credit + # is nil either way. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 15_000 + partner: + age: 70 + state_pension: 7_400 + care_hours: 35 + benunits: + benunit: + members: [claimant, partner] + is_married: false + households: + household: + members: [claimant, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + savings: 0 + output: + # (15,000 - 12,570) x 19%, all in the Scottish starter band. + income_tax: [461.70, 0] + # Income is above the minimum guarantee of (363.25 + 48.15) x 52 = + # 21,392.80 with or without the CSP: 15,000 + 7,400 - 461.70 = 21,938.30. + guarantee_credit: 0 + # 15,000 + 7,400 + 86.45 x 52 - 461.70. Leaving the CSP out would give + # 21,938.30, below the applicable amount, and a full award of 2,000. + council_tax_reduction_applicable_income: 15_000 + 7_400 + 86.45 * 52 - 461.70 + council_tax_reduction_applicable_amount: (383.35 + 48.15) * 52 + # 2,000 - 0.2 x (26,433.70 - 22,438.00) + council_tax_reduction: 1_200.86 + - name: A pension-age couple below the applicable amount keeps full Council Tax Reduction 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 d25171a0e7..aacaf52fb4 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 @@ -11,10 +11,12 @@ 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. Both instruments deduct income tax only from -earnings (SSI 2021/249 Part 6 Chapter 3; SSI 2012/319 regs 33 and 36), 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 +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 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. 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 afcd74ae1d..775889cd39 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 @@ -23,6 +23,8 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/8", "https://www.legislation.gov.uk/ssi/2012/319/regulation/24", "https://www.legislation.gov.uk/ssi/2012/319/regulation/25", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/57", ] def formula(benunit, period, parameters): 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 d5a83e8a4f..476f38d968 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 @@ -8,9 +8,9 @@ class scottish_carer_supplement(Variable): documentation = ( "Paid for each week in which Carer Support Payment is payable, from 15 " "March 2026. It is a component of Carer Support separate from the " - "Carer Support Payment component. Pension Credit and Housing Benefit, " - "at pension age and working age, do not count it as income; it is " - "taxable." + "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 @@ -19,6 +19,8 @@ class scottish_carer_supplement(Variable): "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", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/57", "https://www.legislation.gov.uk/uksi/2026/93/made", ] From 5d2c49f45d00897caf5b142490d5ccb6ab3d2181 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 17:00:32 -0400 Subject: [PATCH 42/75] Fix pension-age HB and CTR severe disability premium conditions Apply the HB-specific residence condition to the shared Pension Credit addition, cover statutory couple and cross-family exclusions with final awards, and describe the removal of pension-age adult disability premiums. Co-Authored-By: Claude Opus 5.5 --- ...ion-age-severe-disability-premium.fixed.md | 3 +- .../housing_benefit/pension_age_premiums.yaml | 19 +- ...sion_age_severe_disability_conditions.yaml | 202 ++++++++++++++++++ ...ge_severe_disability_premium_properties.py | 65 +++--- ...dependant_for_severe_disability_premium.py | 6 +- .../pension_age_severe_disability_premium.py | 38 ++-- 6 files changed, 279 insertions(+), 54 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml diff --git a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md index c7cfc12176..f4bd3d5f3e 100644 --- a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md +++ b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md @@ -1,2 +1 @@ -- Include the severe disability premium in the pension-age Housing Benefit and Council Tax Reduction applicable amounts on the same qualifying benefits as the Pension Credit severe disability addition (Attendance Allowance, DLA care middle or highest rate, PIP daily living at either rate, Armed Forces Independence Payment), and drop the adult disability and enhanced disability premiums, which the pension-age schedules do not have (HB(SPC) Regs 2006 Sch 3; CTR schemes for pensioners in England, Wales and Scotland). Previously Housing Benefit fell sharply when Guarantee Credit ended for an Attendance Allowance recipient: by about £2,100 a year for a single person and £4,700 for a couple. -- The Pension Credit severe disability addition is no longer withheld because the benefit unit includes a child, since the residence test counts only people aged 18 or over (SPC Regs Sch I para 2(2)(f)), or because a claimant receives Carer's Allowance or Carer Support Payment themselves; only another member's carer benefit, standing in for one paid for caring for them, withholds it (Sch I para 1(1)(a)(iii)). +- Corrected pension-age Housing Benefit and Council Tax Reduction premiums by removing adult disability and adult enhanced disability premiums absent from their statutory schedules and applying the severe disability premium's qualifying-benefit, couple, Housing Benefit non-dependant and shared carer-attribution conditions. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml index 3624aa1c5d..7d7059d142 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml @@ -1,10 +1,9 @@ # Pension-age Housing Benefit and Council Tax Reduction premiums. # HB(SPC) Regs 2006 Sch 3 paras 4-12 and CTR (Prescribed Requirements) -# (England) Regs 2012 Sch 2 paras 4-12: the severe disability premium has the -# same conditions and amounts as the Pension Credit severe disability -# additional amount (SPC Regs 2002 Sch I para 1, reg 6(5)); there is no adult -# disability or enhanced disability premium. 2026-27 weekly rates: pension-age -# personal allowance (attained pensionable age before 1 April 2021) 256.00, +# (England) Regs 2012 Sch 2 paras 4-12: the severe disability premium applies +# HB's qualifying benefit, non-dependant and carer conditions; there is no +# adult disability or enhanced disability premium. 2026-27 weekly rates: +# pension-age personal allowance (attained pensionable age before 1 April 2021) 256.00, # couple 383.35; severe disability premium 86.05 (172.10 for a couple who # both qualify); carer premium 48.15. Cases that assert an applicable amount # use claimants who reached State Pension age before 1 April 2021, whose @@ -16,7 +15,7 @@ input: people: claimant: - age: {2026: 70} + age: {2026: 72} state_pension: {2026: 6_000} private_pension_income: {2026: 12_000} aa_category: {2026: HIGHER} @@ -35,9 +34,9 @@ severe_disability_minimum_guarantee_addition: 86.05 * 52 pension_age_severe_disability_premium: 86.05 * 52 benefits_premiums: 86.05 * 52 - # HB income 16,548.80 is below the applicable amount, (238.00 or 256.00 - # + 86.05) * 52 = 16,850.60 or 17,786.60 whichever allowance cohort - # applies, so HB and CTR are at their maximum. + # The claimant reached pension age before 1 April 2021, so the older + # allowance applies. HB income 16,548.80 is below the applicable amount, + # (256.00 + 86.05) * 52 = 17,786.60, so HB and CTR are at their maximum. housing_benefit: 6_240 council_tax_benefit: 2_000 @@ -47,7 +46,7 @@ input: people: claimant: - age: {2026: 70} + age: {2026: 72} state_pension: {2026: 6_000} private_pension_income: {2026: 11_900} aa_category: {2026: HIGHER} diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml new file mode 100644 index 0000000000..0bfcb2eb18 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml @@ -0,0 +1,202 @@ +# HB(SPC) Regs 2006 Sch 3 para 6, with the non-dependant definition in +# reg 3: these cases assert final awards without a Guarantee Credit passport. + +- name: One qualifying pensioner and a non-blind partner get no HB or CTR severe premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + state_pension: 13_000 + aa_category: HIGHER + partner: + age: 78 + state_pension: 13_000 + benunits: + pensioners: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_240 + council_tax: 2_000 + output: + guarantee_credit: 0 + severe_disability_minimum_guarantee_addition: 0 + pension_age_severe_disability_premium: 0 + housing_benefit: 2_883.79 + council_tax_benefit: 821.24 + +- name: Both qualifying pensioners keep two rates in their final HB and CTR awards + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + state_pension: 15_000 + aa_category: HIGHER + partner: + age: 78 + state_pension: 15_000 + aa_category: LOWER + benunits: + pensioners: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_240 + council_tax: 2_000 + output: + guarantee_credit: 0 + pension_age_severe_disability_premium: 172.10 * 52 + housing_benefit: 6_240 + council_tax_benefit: 1_857.22 + +- name: A qualifying pensioner with a blind non-qualifying partner gets one HB and CTR rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + state_pension: 13_000 + aa_category: HIGHER + partner: + age: 78 + state_pension: 13_000 + is_blind: true + benunits: + pensioners: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_240 + council_tax: 2_000 + output: + guarantee_credit: 0 + pension_age_severe_disability_premium: 86.05 * 52 + housing_benefit: 5_792.28 + council_tax_benefit: 1_704.27 + +- name: A non-ignored adult resident blocks the pensioner's HB and CTR severe premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + state_pension: 6_000 + private_pension_income: 12_000 + aa_category: HIGHER + resident: + age: 40 + benunits: + pensioner: + members: [claimant] + other_family: + members: [resident] + households: + household: + members: [claimant, resident] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_240 + council_tax: 2_000 + output: + guarantee_credit: [0, 0] + has_non_dependant_for_severe_disability_premium: [true, false] + pension_age_severe_disability_premium: [0, 0] + housing_benefit: [4_136.08, 0] + council_tax_benefit: [1_279.60, 0] + +- name: An 18-year-old of another family blocks HB and CTR although Pension Credit ignores him + # The daughter is ignored under para 6(6)(a); her son is not part of + # the claimant's family under reg 3(2)(a). SPC Sch I para 2(2)(f) differs. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + state_pension: 6_000 + private_pension_income: 12_000 + aa_category: HIGHER + daughter: + age: 50 + pip_dl_category: STANDARD + grandson: + age: 18 + current_education: UPPER_SECONDARY + benunits: + pensioner: + members: [claimant] + other_family: + members: [daughter, grandson] + households: + household: + members: [claimant, daughter, grandson] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_240 + council_tax: 2_000 + output: + guarantee_credit: [0, 0] + # 86.05 * 52 for each family; expected vectors use numeric literals. + severe_disability_minimum_guarantee_addition: [4_474.60, 4_474.60] + has_non_dependant_for_severe_disability_premium: [true, false] + pension_age_severe_disability_premium: [0, 0] + housing_benefit_applicable_amount: [13_312, 0] + housing_benefit: [4_136.08, 0] + council_tax_benefit: [1_097.91, 0] + +- name: A qualifying-benefit resident in another benefit unit can still be the pensioner's paid carer + # The carer's disability benefit exempts her from the residence test; + # withholding is therefore due to attribution, rather than residence. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + state_pension: 6_000 + private_pension_income: 12_000 + aa_category: HIGHER + carer: + age: 50 + pip_dl_category: STANDARD + care_hours: 40 + carers_allowance_reported: 4_000 + benunits: + pensioner: + members: [claimant] + # HB(SPC) reg 55(6) and England CTR Sch 1 para 8(6): AA exempts + # the claimant from deductions. Supply these independently of the + # current generic deduction formulas to isolate the premium rule. + housing_benefit_non_dep_deductions: 0 + council_tax_reduction_non_dep_deductions: 0 + other_family: + members: [carer] + households: + household: + members: [claimant, carer] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_240 + council_tax: 2_000 + output: + guarantee_credit: [0, 0] + has_non_dependant_for_severe_disability_premium: [false, false] + is_cared_for_by_carer_benefit_recipient: [true, false] + pension_age_severe_disability_premium: [0, 0] + housing_benefit: [4_136.08, 0] + council_tax_benefit: [1_279.60, 0] diff --git a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py index 27f50a9450..324bea9932 100644 --- a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py @@ -1,26 +1,27 @@ """Pension-age severe disability premium and the Guarantee Credit boundary. -Pension Credit's minimum guarantee included the severe disability addition -for an Attendance Allowance recipient, but the Housing Benefit and Council -Tax Reduction applicable amounts did not include the severe disability -premium, because the legacy premium tested a narrower, tax-credit style -disability flag. While Guarantee Credit was paid, Housing Benefit was -passported to its maximum and hid the gap; when Guarantee Credit ended, -Housing Benefit fell by 65% of it. A single pensioner on Attendance -Allowance lost £2,040.52 of net income when private pension rose from -£11,900 to £12,000 in 2026. - -Invariants, for single people and couples over State Pension age without -children or carers, who rent from the council in England in 2026: +These properties compare the pension-age Housing Benefit and Council Tax +Reduction applicable amounts with the Pension Credit minimum guarantee, +and exercise the income step where Guarantee Credit ends. + +The structural and boundary properties draw single people and couples aged +at least 72 without children, carers or other household residents, who rent +from the council in England in 2026. The schedule property also draws +younger claimants: 1. Structural: where Housing Benefit is available, its applicable amount is at least the Pension Credit minimum guarantee, and so is the Council Tax Reduction applicable amount. In law the pension-age personal allowances (HB(SPC) Regs 2006 Sch 3 para 1; CTR (Prescribed Requirements) (England) Regs 2012 Sch 2 para 1) are at least the standard minimum guarantee (SPC - Regs 2002 reg 6(1)), and the severe disability premium (Sch 3 paras 6 and - 12(1)) has the same conditions and amounts as the severe disability - addition (SPC Regs Sch I para 1 and reg 6(5)). + Regs 2002 reg 6(1)). The severe disability premium (Sch 3 paras 6 and + 12(1)) equals the severe disability addition (SPC Regs Sch I para 1 and + reg 6(5)) for these households. Their residence conditions differ: an + 18-year-old qualifying young person of another family can leave the + Pension Credit addition payable while barring the HB premium. The + property excludes other residents rather than asserting equivalence for + them. Where only one partner qualifies and the other is blind, the model + assumes the qualifying partner makes the HB claim, as reg 63(1) allows. 2. Boundary: on the private-pension step where Guarantee Credit ends, household net income, excluding the TV licence fee, does not fall. The free TV licence for over-75s on Pension Credit is a genuine statutory @@ -31,12 +32,9 @@ below State Pension age it is zero and benefits_premiums is the sum of the four legacy premiums, so working-age applicable amounts are unchanged. -Carers are not drawn: the invariants for carers also depend on Pension -Credit income counting Carer's Allowance and on the carer premium per carer, -which are separate changes. Full monotonicity in income is not asserted: it -also depends on the savings-credit-only income rules for Housing Benefit and -Council Tax Reduction (HB(SPC) Regs 2006 reg 27 and the CTR equivalents), -which are likewise separate. +Carers and other household residents have separate attribution and residence +cases in the YAML tests. The income property checks the Guarantee Credit +boundary rather than asserting monotonicity at every possible income step. """ import numpy as np @@ -72,9 +70,9 @@ def money(high): @st.composite -def adult(draw): +def adult(draw, minimum_age=67): return dict( - age=draw(st.integers(67, 95)), + age=draw(st.integers(minimum_age, 95)), disability=draw(DISABILITY), blind=draw(st.booleans()), state_pension=draw(money(15_000)), @@ -82,9 +80,15 @@ def adult(draw): @st.composite -def family(draw): +def standalone_family(draw): + """Only a claimant and optional partner, in the older allowance cohort. + + No other residents, children or carers are added by situation(). This + keeps the HB and Pension Credit residence conditions aligned, including + the absence of young people belonging to another benefit unit. + """ return dict( - adults=[draw(adult()) for _ in range(draw(st.integers(1, 2)))], + adults=[draw(adult(minimum_age=72)) for _ in range(draw(st.integers(1, 2)))], rent=draw(money(12_000)), council_tax=draw(money(3_000)), savings=draw(st.one_of(st.just(0.0), money(15_000))), @@ -143,8 +147,15 @@ def grid(families): @PROPERTY_SETTINGS -@given(st.lists(family(), min_size=1, max_size=4)) +@given(st.lists(standalone_family(), min_size=1, max_size=4)) def test_pension_age_applicable_amounts_cover_the_minimum_guarantee(families): + """Compare only older-cohort households with no other residents or carers. + + HB and Pension Credit do not have identical residence conditions: a + qualifying young person of another family may bar only HB's premium. + Those households are excluded by the generator. Couples can nominate + the qualifying partner as claimant where the other partner is blind. + """ g = grid(families) for i, fam in enumerate(families): eligible = g["housing_benefit_eligible"][i].astype(bool) @@ -156,7 +167,7 @@ def test_pension_age_applicable_amounts_cover_the_minimum_guarantee(families): @PROPERTY_SETTINGS -@given(st.lists(family(), min_size=1, max_size=4)) +@given(st.lists(standalone_family(), min_size=1, max_size=4)) def test_net_income_does_not_fall_where_guarantee_credit_ends(families): g = grid(families) net = g["household_net_income"] + g["tv_licence"] diff --git a/policyengine_uk/variables/household/demographic/has_non_dependant_for_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/has_non_dependant_for_severe_disability_premium.py index 77653a3c89..46a39ad674 100644 --- a/policyengine_uk/variables/household/demographic/has_non_dependant_for_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/has_non_dependant_for_severe_disability_premium.py @@ -7,7 +7,8 @@ class has_non_dependant_for_severe_disability_premium(Variable): label = "Has a non-dependant who blocks the severe disability premium" documentation = ( "A non-dependant aged 18 or over normally resides with the claimant, " - "which bars the legacy severe disability premium. Non-dependants who " + "which bars the working-age and pension-age Housing Benefit severe " + "disability premiums. Non-dependants who " "receive a severe disability premium qualifying benefit, or who are " "blind, are ignored. Every household member aged 18 or over is " "treated as a non-dependant unless they are in the claimant's family: " @@ -31,6 +32,9 @@ class has_non_dependant_for_severe_disability_premium(Variable): "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/19", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6", "https://www.legislation.gov.uk/uksi/1987/1967/regulation/3", "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", ) 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 8ac841cfc7..7e6e524093 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 @@ -7,27 +7,32 @@ class pension_age_severe_disability_premium(Variable): label = "Severe disability premium for pension-age Housing Benefit and CTR" documentation = ( "The severe disability premium in the Housing Benefit and Council Tax " - "Reduction applicable amount of a family over State Pension age. In " - "law its qualifying benefits (Attendance Allowance, the care component " - "of DLA at the middle or highest rate, the daily living component of " - "PIP at either rate, Armed Forces Independence Payment and their " - "Scottish equivalents), its conditions and its amounts (one rate, or " - "two for a couple who both qualify and have no paid carer) are the " - "same as those of the Pension Credit severe disability additional " - "amount. So this premium reads that variable, and Housing Benefit, " - "Council Tax Reduction and Pension Credit share one definition: the " - "premium cannot drop out of the Housing Benefit applicable amount " - "when Guarantee Credit ends. It therefore also shares that variable's " - "modelling simplifications." + "Reduction applicable amount of a family over State Pension age. " + "The Pension Credit severe disability addition supplies the qualifying " + "benefit, couple, carer and rate calculation. Both partners must " + "qualify unless the non-qualifying partner is blind. For that route " + "the model assumes the qualifying partner claims Housing Benefit, " + "as a couple may arrange under regulation 63(1). Housing Benefit's " + "own non-dependant test is applied separately: a qualifying young " + "person aged 18 or over in another family can block this premium " + "although Pension Credit ignores them. Its modelled residence " + "exceptions match the working-age Housing Benefit premium, so both " + "use has_non_dependant_for_severe_disability_premium. Carers are " + "attributed by is_cared_for_by_carer_benefit_recipient, including " + "carers in other benefit units in the household. The premium inherits " + "the documented limitations of those shared helpers, including the " + "omission of Universal Credit carer-element awards." ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/6", "https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/12", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/63", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/paragraph/6", "https://www.legislation.gov.uk/ssi/2012/319/schedule/1/paragraph/7", - "https://www.legislation.gov.uk/wsi/2013/3035/schedule/2/paragraph/6", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/2/paragraph/6", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", ) @@ -35,4 +40,9 @@ class pension_age_severe_disability_premium(Variable): def formula(benunit, period, parameters): pension_age = benunit.any(benunit.members("is_SP_age", period)) addition = benunit("severe_disability_minimum_guarantee_addition", period) - return where(pension_age, addition, 0) + # HB(SPC) Sch 3 para 6(6) and reg 3 have the same residence + # exceptions as working-age HB Sch 3 para 14(4) and reg 3. + no_non_dependant = ~benunit( + "has_non_dependant_for_severe_disability_premium", period + ) + return where(pension_age & no_non_dependant, addition, 0) From 7f93ed51f34170fbde7c50ff578e5e0b4bf6d2a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 17:42:58 -0400 Subject: [PATCH 43/75] Count a trading loss for Class 4 only in the year it is supplied for trading_loss carries into later years that are not set, so a one-off loss was deducted from Class 4 profits again in every later year. The new ni_class_4_trading_loss is the loss supplied for the year itself; relief, the carry-forward and the brought-forward fold read it, and the fold reads supplied values only, so its result no longer depends on what was calculated first. Co-Authored-By: Claude Opus 5.5 --- .../class-4-chapter-2-profits.fixed.md | 1 + .../gov/hmrc/national-insurance.ipynb | 2 +- policyengine_uk/tests/conftest.py | 13 ++ .../class_4/ni_class_4_profits.yaml | 95 +++++++++ .../tests/test_ni_class_4_maximum.py | 2 + .../test_ni_class_4_profits_properties.py | 195 ++++++++++++++++++ .../tests/test_ni_class_4_properties.py | 3 + .../test_trading_allowance_properties.py | 2 + policyengine_uk/utils/supplied_inputs.py | 78 +++++++ .../gov/hmrc/income_tax/bases/trading_loss.py | 6 +- .../class_4/ni_class_4_loss_relief.py | 20 +- .../ni_class_4_losses_brought_forward.py | 38 ++-- .../ni_class_4_losses_carried_forward.py | 10 +- .../class_4/ni_class_4_trading_loss.py | 38 ++++ 14 files changed, 471 insertions(+), 32 deletions(-) create mode 100644 changelog.d/class-4-chapter-2-profits.fixed.md create mode 100644 policyengine_uk/utils/supplied_inputs.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py diff --git a/changelog.d/class-4-chapter-2-profits.fixed.md b/changelog.d/class-4-chapter-2-profits.fixed.md new file mode 100644 index 0000000000..f8328f8229 --- /dev/null +++ b/changelog.d/class-4-chapter-2-profits.fixed.md @@ -0,0 +1 @@ +- Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`). diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index 73ceb409c1..20425241ec 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own `trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own loss, `ni_class_4_trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). A year's own loss is the `trading_loss` entered for that year, so a loss entered once is relieved once: it is not counted again in the later years that `trading_loss` carries into. Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", diff --git a/policyengine_uk/tests/conftest.py b/policyengine_uk/tests/conftest.py index 56ee3b207b..1a7d925b4b 100644 --- a/policyengine_uk/tests/conftest.py +++ b/policyengine_uk/tests/conftest.py @@ -12,6 +12,19 @@ os.environ["POLICYENGINE_UK_DEFAULT_DATASET"] = DEFAULT_TEST_DATASET_URL +@pytest.fixture(scope="module") +def cloned_uk_tax_benefit_system(): + """Load model code once, with independent model state per simulation.""" + import policyengine_uk.simulation as uk_simulation + + tax_benefit_system = uk_simulation.CountryTaxBenefitSystem() + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + uk_simulation, "CountryTaxBenefitSystem", tax_benefit_system.clone + ) + yield + + def pytest_collection_modifyitems(config, items): has_default_dataset = bool(os.environ.get("POLICYENGINE_UK_DEFAULT_DATASET")) if has_default_dataset: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml index 392d459075..76f96eb101 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_profits.yaml @@ -164,6 +164,101 @@ # (30,000 - 12,570) * 6% ni_class_4: 1_045.80 +- name: A loss entered for one year only is relieved once + period: 2026 + absolute_error_margin: 0.01 + input: + # Sch. 2 para. 3(4)(b): only the excess of a loss reduces later years' + # profits. trading_loss carries 2025's value into 2026 and 2027, which + # are not set, but that is the same loss, not a new one each year. + self_employment_income: + 2026: 40_000 + 2027: 40_000 + trading_loss: + 2025: 10_000 + output: + ni_class_4_trading_loss: + 2025: 10_000 + 2026: 0 + 2027: 0 + ni_class_4_profits_before_losses: + 2025: 0 + 2026: 40_000 + 2027: 40_000 + ni_class_4_losses_brought_forward: + 2026: 10_000 + 2027: 0 + ni_class_4_loss_relief: + 2026: 10_000 + 2027: 0 + ni_class_4_losses_carried_forward: + 2025: 10_000 + 2026: 0 + 2027: 0 + ni_class_4_profits: + 2026: 30_000 + 2027: 40_000 + # 2026-27 and 2027-28 both have a £12,570 lower profits limit, a £50,270 + # upper profits limit and a 6% main rate. + # (30,000 - 12,570) * 6% and (40,000 - 12,570) * 6% + ni_class_4: + 2026: 1_045.80 + 2027: 1_645.80 + +- name: An unrelieved loss runs down over the following years + period: 2028 + absolute_error_margin: 0.01 + input: + # No losses are entered after 2025: each later year deducts what is left. + self_employment_income: + 2025: 0 + 2026: 20_000 + 2027: 20_000 + 2028: 20_000 + trading_loss: + 2025: 50_000 + output: + ni_class_4_losses_brought_forward: + 2026: 50_000 + 2027: 30_000 + 2028: 10_000 + ni_class_4_profits: + 2026: 0 + 2027: 0 + 2028: 10_000 + ni_class_4_losses_carried_forward: + 2028: 0 + +- name: A year with no loss entered between two loss years adds no loss + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: + 2024: 0 + 2025: 2_000 + 2026: 20_000 + trading_loss: + 2024: 5_000 + 2026: 3_000 + output: + ni_class_4_trading_loss: + 2024: 5_000 + 2025: 0 + 2026: 3_000 + # 5,000 from 2024, less 2,000 used in 2025. + ni_class_4_losses_brought_forward: + 2025: 5_000 + 2026: 3_000 + ni_class_4_loss_relief: + 2025: 2_000 + 2026: 6_000 + ni_class_4_profits: + 2025: 0 + 2026: 14_000 + # (14,000 - 12,570) * 6% + ni_class_4: + 2026: 85.80 + - name: A supplied balance of unrelieved losses is brought forward period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_ni_class_4_maximum.py b/policyengine_uk/tests/test_ni_class_4_maximum.py index f2d3780870..a61f8afb37 100644 --- a/policyengine_uk/tests/test_ni_class_4_maximum.py +++ b/policyengine_uk/tests/test_ni_class_4_maximum.py @@ -3,6 +3,8 @@ from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + def test_class_4_annual_maximum_applies_case_3_steps(): year = 2026 diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index e15ae10c6d..e19ba93d38 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -27,6 +27,14 @@ 5. Carry-forward over many years matches the reference fold, with or without a supplied brought-forward balance, whatever order the years are calculated in, including beyond the engine's ten-step spiral limit. +6. Each loss is relieved once. With losses entered for only some years, the + model matches a reference in which a year without an entry makes no new + loss, though the engine carries the last entry into it. Total relief + never exceeds the losses entered (relief plus the final carry-forward + equals them), Class 4 profits are never negative, and a year with no + loss entered and none brought forward has no relief. Calculating income + tax's loss relief or the carried-over trading_loss first, or calculating + on a branch, changes none of this. test_ni_class_4_properties.py checks s. 15(3) and regulation 100 given these profits, for arbitrary thresholds and rates. Comparisons allow float32 @@ -39,9 +47,12 @@ import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.reforms import Reform from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + ALLOWANCE = 1_000 CASES = 24 # Each case is simulated twice: as drawn, and varied. PEOPLE = 2 * CASES @@ -496,3 +507,187 @@ def test_losses_carry_forward_beyond_the_spiral_limit(): ) assert sim.calculate("ni_class_4_losses_brought_forward", 2030)[0] == 20_000 assert sim.calculate("ni_class_4_profits", 2030)[0] == 30_000 + + +def single_person(person): + return Simulation( + situation={ + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + + +@settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.tuples( + st.one_of(st.just(0.0), st.floats(0, 80_000)), # profit + st.one_of(st.just(0.0), st.floats(0, 150_000)), # loss + st.booleans(), # whether the loss is entered for the year + ), + min_size=2, + max_size=12, + ), + st.randoms(use_true_random=False), +) +def test_a_loss_entered_for_some_years_is_relieved_once(years, random): + last_year = 2030 + first_year = last_year - len(years) + 1 + periods = list(range(first_year, last_year + 1)) + entered = { + period: loss + for period, (_, loss, is_entered) in zip(periods, years) + if is_entered + } + person = { + "age": {first_year: 40}, + "self_employment_income": dict( + zip(periods, (profit for profit, _, _ in years)) + ), + } + if entered: + person["trading_loss"] = entered + sim = single_person(person) + # Fill the engine's caches first, in random order: trading_loss carried + # into the years without an entry, and income tax's loss relief, which + # reads it there. Neither may count as a new loss for Class 4. + for period in random.sample(periods, random.randint(0, len(periods))): + sim.calculate(random.choice(["trading_loss", "loss_relief"]), period) + + # Independent reference: the stored (float32) inputs, a loss only in the + # years it is entered for, and the year-by-year fold. + stored_losses = { + period: exact(np.float32(loss)) for period, loss in entered.items() + } + expected = reference_losses( + [ + ( + reference_profits_before_losses( + exact(np.float32(profit)), Fraction(0), Fraction(0) + ), + stored_losses.get(period, Fraction(0)), + ) + for period, (profit, _, _) in zip(periods, years) + ] + ) + names = [ + "losses_brought_forward", + "loss_relief", + "losses_carried_forward", + "profits_before_losses", + "profits", + ] + queries = [(period, name) for period in periods for name in names] + random.shuffle(queries) + model = {period: {} for period in periods} + for period, name in queries: + model[period][name] = float(sim.calculate(f"ni_class_4_{name}", period)[0]) + + total_entered = float(sum(stored_losses.values())) + tol = tolerance(total_entered, *(profit for profit, _, _ in years)) + for period in periods: + reference_year = expected[period - first_year] + values = model[period] + for name, reference_name in [ + ("losses_brought_forward", "brought_forward"), + ("loss_relief", "relief"), + ("losses_carried_forward", "carried_forward"), + ("profits", "profits"), + ]: + assert abs(values[name] - float(reference_year[reference_name])) <= tol, ( + period, + name, + values[name], + float(reference_year[reference_name]), + ) + # Class 4 profits are never negative, nor above profits before losses. + assert 0 <= values["profits"] <= values["profits_before_losses"] + tol + assert values["loss_relief"] >= 0 + # No relief without a loss entered for the year or brought forward. + if stored_losses.get(period, 0) == 0 and values["losses_brought_forward"] == 0: + assert values["loss_relief"] == 0, (period, values) + # Conservation: each loss entered is relieved at most once, and what is + # not relieved is still being carried forward at the end. + total_relief = sum(model[period]["loss_relief"] for period in periods) + assert total_relief <= total_entered + tol + assert ( + abs(total_relief + model[last_year]["losses_carried_forward"] - total_entered) + <= tol + ) + + +def test_class_4_counts_only_supplied_losses_on_branches(): + # A £10,000 loss entered for 2025 only, with £40,000 of profit in 2026 + # and 2027. A branch made before any calculation adds a £5,000 loss in + # 2026. Each simulation counts only the losses supplied to it. + sim = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + branch = sim.get_branch("extra_2026_loss") + branch.set_input("trading_loss", 2026, np.array([5_000.0])) + # The engine carries the 2025 loss into 2027, but it is not a new loss. + assert sim.calculate("trading_loss", 2027)[0] == 10_000 + assert sim.calculate("ni_class_4_profits", 2026)[0] == 30_000 + assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 + # On the branch, 2026 relieves its own 5,000 and the 10,000 from 2025. + assert branch.calculate("ni_class_4_trading_loss", 2026)[0] == 5_000 + assert branch.calculate("ni_class_4_profits", 2026)[0] == 25_000 + assert branch.calculate("ni_class_4_profits", 2027)[0] == 40_000 + # The branch's input does not reach the simulation it was made from. + assert sim.calculate("ni_class_4_trading_loss", 2026)[0] == 0 + + +class neutralize_trading_loss(Reform): + def apply(self): + self.neutralize_variable("trading_loss") + + +def test_class_4_respects_a_neutralized_supplied_loss(): + sim = single_person( + { + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + assert sim.calculate("ni_class_4_profits", 2026)[0] == 30_000 + sim.apply_reform(neutralize_trading_loss) + # The reform leaves the raw input in storage, but it is no longer an + # effective loss. Read the current variable, not the holder's old one. + assert sim.calculate("trading_loss", 2025)[0] == 0 + assert sim.calculate("ni_class_4_trading_loss", 2025)[0] == 0 + for year in (2026, 2027): + assert sim.calculate("ni_class_4_loss_relief", year)[0] == 0 + assert sim.calculate("ni_class_4_profits", year)[0] == 40_000 + + +def test_class_4_does_not_restore_an_abolished_supplied_balance(): + sim = single_person( + { + "self_employment_income": {2026: 5_000, 2027: 30_000}, + "ni_class_4_losses_brought_forward": {2026: 7_000}, + } + ) + assert sim.calculate("ni_class_4_profits", 2027)[0] == 28_000 + sim.apply_reform( + { + "gov.abolitions.ni_class_4_losses_brought_forward": { + "2026-01-01.2026-12-31": True + } + } + ) + # The balance variable is enabled again in 2027. Its historical fold + # must still honour the 2026 abolition of the stored opening balance. + assert sim.calculate("ni_class_4_losses_brought_forward", 2026)[0] == 0 + assert sim.calculate("ni_class_4_losses_brought_forward", 2027)[0] == 0 + assert sim.calculate("ni_class_4_profits", 2027)[0] == 30_000 diff --git a/policyengine_uk/tests/test_ni_class_4_properties.py b/policyengine_uk/tests/test_ni_class_4_properties.py index da60adfcd8..64178ccc94 100644 --- a/policyengine_uk/tests/test_ni_class_4_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_properties.py @@ -32,11 +32,14 @@ from fractions import Fraction 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") + CLASS_4 = "gov.hmrc.national_insurance.class_4" CLASS_2_FLAT_RATE = "gov.hmrc.national_insurance.class_2.flat_rate" YEARS = [2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030] diff --git a/policyengine_uk/tests/test_trading_allowance_properties.py b/policyengine_uk/tests/test_trading_allowance_properties.py index 08ca9bfe31..f24978ca8b 100644 --- a/policyengine_uk/tests/test_trading_allowance_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_properties.py @@ -14,6 +14,8 @@ from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2027 ALLOWANCE = 1_000 OTHER_EMPLOYMENT_INCOME = [0, 8_000, 30_000, 60_000, 110_000, 200_000] diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py new file mode 100644 index 0000000000..96d566b9eb --- /dev/null +++ b/policyengine_uk/utils/supplied_inputs.py @@ -0,0 +1,78 @@ +"""Values supplied as inputs, as distinct from values the engine fills in. + +The engine stores more than the inputs it is given. An input variable that is +not set for a period may inherit a previously known value +(``auto_carry_over_input_variables``), and calculated values are cached next to +inputs. A holder's known periods include both. policyengine-core records each +(variable, branch, period) that ``set_input`` fills in +``Simulation._user_input_keys``, and nothing else adds to it, so it is +independent of what has been calculated before. +""" + +from typing import List, Optional + +import numpy as np +from policyengine_core.errors import ParameterNotFoundError +from policyengine_core.periods import Period + + +def _visible_branch_names(simulation) -> List[str]: + """This branch, then the branches it was made from, then the default.""" + names = [] + branch = simulation + while branch is not None: + names.append(branch.branch_name) + branch = getattr(branch, "parent_branch", None) + if "default" not in names: + names.append("default") + return names + + +def supplied_input_periods(population, variable_name: str) -> List[Period]: + """Periods for which ``variable_name`` was supplied as an input, on this + branch or one it was made from, earliest first.""" + simulation = population.simulation + branch_names = set(_visible_branch_names(simulation)) + input_keys = getattr(simulation, "_user_input_keys", None) or () + return sorted( + { + period + for name, branch_name, period in input_keys + if name == variable_name and branch_name in branch_names + }, + key=lambda period: period.start, + ) + + +def supplied_input( + population, variable_name: str, period: Period +) -> Optional[np.ndarray]: + """The value of ``variable_name`` for ``period`` if it was supplied as an + input, on this branch or the nearest one it was made from; otherwise + None. A value the engine carried over from an earlier period, or cached + after a calculation, is not an input.""" + simulation = population.simulation + input_keys = getattr(simulation, "_user_input_keys", None) or () + holder = population.get_holder(variable_name) + for branch_name in _visible_branch_names(simulation): + if (variable_name, branch_name, period) in input_keys: + # Read the stored input itself: Holder.get_array would fall back + # to other branches' values, including cached calculations. + value = holder._get_array_from_storage(period, branch_name) + if value is not None: + # Preserve the engine's reform handling while reading the + # supplied value rather than a branch's calculated cache. + system = simulation.tax_benefit_system + disabled = system.get_variable(variable_name).is_neutralized + try: + disabled = ( + disabled + or system.parameters(period).gov.abolitions[variable_name] + ) + except (ParameterNotFoundError, KeyError): + # Input variables have no generated abolition parameter. + pass + if disabled: + return holder.default_array() + return value + return None diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py index 75757eb01c..c7e92feaec 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py @@ -9,8 +9,10 @@ class trading_loss(Variable): "Trading losses made in the year. self_employment_income holds the " "profits of trades that made one, so a loss here alongside a profit " "comes from a different trade. Like other inputs, a value carries " - "into later years that are not set, so set them to zero for a " - "one-off loss." + "into later years that are not set, and income tax's loss_relief " + "reads it there, so set those years to zero for a one-off loss. " + "Class 4 counts a loss only in the year it is set for " + "(ni_class_4_trading_loss)." ) reference = dict( title="Income Tax Act 2007 s. 64", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py index 6e13485329..db9eb45d9a 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_loss_relief.py @@ -7,15 +7,15 @@ class ni_class_4_loss_relief(Variable): label = "Trading losses deducted from Class 4 profits" documentation = ( "Trading losses set against the year's Class 4 profits under SSCBA " - "1992 Sch. 2 para. 3: losses brought forward and the year's " - "trading_loss, up to the profits. Unlike income tax, a loss only " - "ever reduces trade profits for Class 4: where it is set against " - "other income for income tax, it still reduces the year's Class 4 " - "profits, and any excess is carried forward (para. 3(4)). Assumes " - "the year's loss is claimed in that year: carry-back claims (ITA " - "2007 ss. 64(2)(b), 72 and 89) are not modelled. Losses are pooled " - "across trades, though s. 83 carries a loss forward only against " - "the same trade." + "1992 Sch. 2 para. 3: losses brought forward and the year's own " + "loss (ni_class_4_trading_loss), up to the profits. Unlike income " + "tax, a loss only ever reduces trade profits for Class 4: where it " + "is set against other income for income tax, it still reduces the " + "year's Class 4 profits, and any excess is carried forward (para. " + "3(4)). Assumes the year's loss is claimed in that year: carry-back " + "claims (ITA 2007 ss. 64(2)(b), 72 and 89) are not modelled. Losses " + "are pooled across trades, though s. 83 carries a loss forward only " + "against the same trade." ) definition_period = YEAR unit = GBP @@ -25,7 +25,7 @@ class ni_class_4_loss_relief(Variable): ) def formula(person, period, parameters): - losses = max_(person("trading_loss", period), 0) + max_( + losses = person("ni_class_4_trading_loss", period) + max_( person("ni_class_4_losses_brought_forward", period), 0 ) profits = person("ni_class_4_profits_before_losses", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py index b642ae4003..e5d6d17bad 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import ( + supplied_input, + supplied_input_periods, +) class ni_class_4_losses_brought_forward(Variable): @@ -9,11 +13,12 @@ class ni_class_4_losses_brought_forward(Variable): "Trading losses from earlier years not yet deducted from Class 4 " "profits, equal to the previous year's " "ni_class_4_losses_carried_forward. By default, losses are carried " - "forward from the first year a trading_loss is known for, however " + "forward from the first year a trading_loss is supplied for, however " "many years back. Set this for a year to supply the unrelieved " "losses from before it; later years then build on that balance. " - "trading_loss carries into later years that are not set, so set " - "them to zero for a one-off loss." + "Each loss counts once, in the year it is supplied for " + "(ni_class_4_trading_loss), so a one-off loss needs no zeros in " + "later years." ) definition_period = YEAR unit = GBP @@ -29,29 +34,34 @@ class ni_class_4_losses_brought_forward(Variable): ] def formula(person, period, parameters): - supplied = person.get_holder("ni_class_4_losses_brought_forward") + # Only supplied values count. The engine also stores values it fills + # in (an input carried into a later year, or a cached calculation), + # and whether those exist depends on what was calculated first. earlier_years = [ - known_period.start.year - for holder in (person.get_holder("trading_loss"), supplied) - for known_period in holder.get_known_periods() - if known_period.start < period.start + supplied_period.start.year + for variable in ( + "trading_loss", + "ni_class_4_trading_loss", + "ni_class_4_losses_brought_forward", + ) + for supplied_period in supplied_input_periods(person, variable) + if supplied_period.start < period.start ] brought_forward = person.empty_array() if not earlier_years: - # No loss can be known for any earlier year. + # No loss is supplied for any earlier year. return brought_forward # Run the carry-forward year by year from the first year a loss is - # known for: each year's losses are deducted from that year's + # supplied for: each year's losses are deducted from that year's # profits and the rest carried to the next (ITA 2007 s. 84). A loop # rather than a formula on the previous year, which the engine would # cut off as a spiral after ten years. - branch_name = person.simulation.branch_name for years_back in range(period.start.year - min(earlier_years), 0, -1): year = period.offset(-years_back, "year") - balance = supplied.get_array(year, branch_name) + balance = supplied_input(person, "ni_class_4_losses_brought_forward", year) if balance is not None: - brought_forward = balance - losses = max_(person("trading_loss", year), 0) + max_(brought_forward, 0) + brought_forward = max_(balance, 0) + losses = person("ni_class_4_trading_loss", year) + brought_forward profits = person("ni_class_4_profits_before_losses", year) brought_forward = losses - min_(losses, profits) return brought_forward diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py index 75e23fc50f..72a82b6656 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_carried_forward.py @@ -7,10 +7,10 @@ class ni_class_4_losses_carried_forward(Variable): label = "Trading losses carried forward for Class 4" documentation = ( "Trading losses not yet deducted from Class 4 profits at the end of " - "the year: the year's trading_loss and losses brought forward, less " - "those deducted this year. They reduce the Class 4 profits of the " - "following years, earliest first (SSCBA 1992 Sch. 2 para. 3(4)(b); " - "ITA 2007 ss. 83 and 84)." + "the year: the year's own loss (ni_class_4_trading_loss) and losses " + "brought forward, less those deducted this year. They reduce the " + "Class 4 profits of the following years, earliest first (SSCBA 1992 " + "Sch. 2 para. 3(4)(b); ITA 2007 ss. 83 and 84)." ) definition_period = YEAR unit = GBP @@ -26,7 +26,7 @@ class ni_class_4_losses_carried_forward(Variable): ] def formula(person, period, parameters): - losses = max_(person("trading_loss", period), 0) + max_( + losses = person("ni_class_4_trading_loss", period) + max_( person("ni_class_4_losses_brought_forward", period), 0 ) return losses - person("ni_class_4_loss_relief", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py new file mode 100644 index 0000000000..fed2f5323d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import supplied_input + + +class ni_class_4_trading_loss(Variable): + value_type = float + entity = Person + label = "Trading losses made in the year, for Class 4" + documentation = ( + "The year's trading_loss where one is supplied for the year, and nil " + "otherwise. Like other inputs, trading_loss carries an earlier " + "year's value into later years that are not set, but for Class 4 a " + "loss is relieved once: it reduces the profits of the year it is " + "made, and only the excess reduces the profits of later years " + "(ni_class_4_losses_brought_forward). A loss supplied for one year " + "is therefore not a new loss in the years after it." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992, Sch. 2 para. 3(4)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + dict( + title="Income Tax Act 2007, s. 83(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/83", + ), + ] + + def formula(person, period, parameters): + # Only a supplied value is a loss made in the year. Reading + # trading_loss through the engine would return an earlier year's loss + # carried into this one, and relieve it again. + loss = supplied_input(person, "trading_loss", period) + if loss is None: + return person.empty_array() + return max_(loss, 0) From 7af8b302f48efa9b16c2d9468a868a02faafc941 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 23:23:38 -0400 Subject: [PATCH 44/75] Say that claimant-first carer attribution can withhold a premium The data do not say whom a carer cares for. Putting claimants and partners first, and placing left-over carers only with claimants and partners of other units, withholds the premium or Pension Credit addition where the carer in fact cares for a disabled child. Co-Authored-By: Claude Opus 5.5 --- .../is_cared_for_by_carer_benefit_recipient.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 bad997eecd..a9e510f4d1 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 @@ -25,7 +25,13 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "in another benefit unit if there is one, taking the units in " "descending order of their eldest member's age (ties in person order, " "the order people are listed in the input or dataset); otherwise the " - "award is taken to be for someone outside the household. Where not " + "award is taken to be for someone outside the household. The data " + "cannot say whether a carer cares for a claimant or partner or for " + "another disabled member, such as a child, so putting claimants and " + "partners first, and placing left-over carers only with claimants and " + "partners of other units, is a choice: where the carer in fact cares " + "for the child, it withholds a premium or addition the law would pay. " + "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 " From 295ca1ee4f111234990751ea97c862a19b495699 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:29:37 -0400 Subject: [PATCH 45/75] Update two UC earnings YAML cases for #1950 and #1881 on main - #1950: rent and dividends are not UC unearned income (reg. 66(1) is a closed list), so the single parent's unearned income is nil (UC 11,308.50) and the self-employed claimant's is the pension alone (UC 3,975.29). - #1881: without gross receipts, a trade profit above the trading allowance gets no allowance (ITTOIA 2005 s. 783AI), so the tax on the trade is (18,000 - 12,570) x 20% = 1,086. The tax and NI this PR deducts are unchanged in method; the cases' gaps against the pre-fix formula are still 0.55 x the tax on other income. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) 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 ef2abd1a6e..2df9a8fbb6 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 @@ -10,7 +10,8 @@ # Hand calculations use the 2026-27 rates: # - personal allowance 12,570; basic rate 20% on the first 37,700 of taxable # income; dividend allowance 500, dividend ordinary rate 10.75%; property -# allowance 1,000; trading allowance 1,000; +# allowance 1,000; trading allowance 1,000 (only for a trade profit within +# it when gross receipts are not given, ITTOIA 2005 s. 783AI); # - primary Class 1 NI 8% above the primary threshold of 241.73 a week # (12,569.96 a year); Class 4 NI 6% above the lower profits limit of 12,570; # Class 2 NI nil; @@ -48,12 +49,14 @@ uc_individual_earned_income: [17_919.60, 0] # Less the work allowance of 427 x 12 = 5,124. uc_earned_income: 12_795.60 - uc_unearned_income: 5_000 + # Rent from property is not UC unearned income (reg. 66(1) is a closed + # list). + uc_unearned_income: 0 # 424.90 x 12 + 303.94 x 12 + 9,600 = 5,098.80 + 3,647.28 + 9,600. uc_maximum_amount: 18_346.08 - # 18,346.08 - (0.55 x 12,795.60 + 5,000). Deducting all of the tax, as - # before this fix, gave 6,748.50: 0.55 x 800 = 440 too much. - universal_credit: 6_308.50 + # 18,346.08 - 0.55 x 12,795.60. Deducting all of the tax, as before this + # fix, gave 11,748.50: 0.55 x 800 = 440 too much. + universal_credit: 11_308.50 - name: Mixed-age couple, the pensioner's tax on State Pension does not reduce the partner's earnings period: 2026 @@ -105,23 +108,24 @@ tenure_type: RENT_FROM_COUNCIL rent: 12_000 output: - # Trade: taxable profit 18,000 - 1,000 trading allowance = 17,000; it - # takes the personal allowance first, (17,000 - 12,570) x 20% = 886. - # Pension: 4,000 x 20% = 800. Dividends: (3,000 - 500) x 10.75% = - # 268.75. Neither is deducted. - income_tax: 1_954.75 - uc_income_tax_on_earnings: 886 + # Trade: a profit of 18,000 is above the trading allowance, so no + # allowance applies without gross receipts. It takes the personal + # allowance first: (18,000 - 12,570) x 20% = 1,086. Pension: 4,000 x 20% + # = 800. Dividends: (3,000 - 500) x 10.75% = 268.75. Neither is deducted. + income_tax: 2_154.75 + uc_income_tax_on_earnings: 1_086 # Class 4: (18,000 - 12,570) x 6%. uc_national_insurance_on_earnings: 325.80 - # 18,000 - 886 - 325.80, with no work allowance. - uc_earned_income: 16_788.20 - uc_unearned_income: 7_000 + # 18,000 - 1,086 - 325.80, with no work allowance. + uc_earned_income: 16_588.20 + # The pension counts; dividends are not UC unearned income. + uc_unearned_income: 4_000 # 424.90 x 12 + 12,000. uc_maximum_amount: 17_098.80 - # 17,098.80 - (0.55 x 16,788.20 + 7,000). Deducting all of the tax and - # NI, as before this fix, gave 1,453.10: 0.55 x 1,068.75 = 587.81 too + # 17,098.80 - (0.55 x 16,588.20 + 4,000). Deducting all of the tax and + # NI, as before this fix, gave 4,563.10: 0.55 x 1,068.75 = 587.81 too # much. - universal_credit: 865.29 + universal_credit: 3_975.29 - name: Scottish taxpayer, earnings sit below pension income in the Scottish bands period: 2026 From ddfdfff629ebc9513a62cb972f6171626769920f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:36:58 -0400 Subject: [PATCH 46/75] Drop the abolition check: PolicyEngine-UK builds no gov.abolitions The UK system does not call core's add_abolition_parameters (it has not since #1274), so a supplied input can only be disabled by neutralizing its variable. Keep that check and drop the abolition lookup and its test, which applied a reform to a parameter that does not exist. Co-Authored-By: Claude Opus 5.5 --- .../test_ni_class_4_profits_properties.py | 22 ------------------- policyengine_uk/utils/supplied_inputs.py | 20 +++++------------ 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index e19ba93d38..fffe57d2a2 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -669,25 +669,3 @@ def test_class_4_respects_a_neutralized_supplied_loss(): for year in (2026, 2027): assert sim.calculate("ni_class_4_loss_relief", year)[0] == 0 assert sim.calculate("ni_class_4_profits", year)[0] == 40_000 - - -def test_class_4_does_not_restore_an_abolished_supplied_balance(): - sim = single_person( - { - "self_employment_income": {2026: 5_000, 2027: 30_000}, - "ni_class_4_losses_brought_forward": {2026: 7_000}, - } - ) - assert sim.calculate("ni_class_4_profits", 2027)[0] == 28_000 - sim.apply_reform( - { - "gov.abolitions.ni_class_4_losses_brought_forward": { - "2026-01-01.2026-12-31": True - } - } - ) - # The balance variable is enabled again in 2027. Its historical fold - # must still honour the 2026 abolition of the stored opening balance. - assert sim.calculate("ni_class_4_losses_brought_forward", 2026)[0] == 0 - assert sim.calculate("ni_class_4_losses_brought_forward", 2027)[0] == 0 - assert sim.calculate("ni_class_4_profits", 2027)[0] == 30_000 diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 96d566b9eb..55b9e654d8 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -12,7 +12,6 @@ from typing import List, Optional import numpy as np -from policyengine_core.errors import ParameterNotFoundError from policyengine_core.periods import Period @@ -60,19 +59,12 @@ def supplied_input( # to other branches' values, including cached calculations. value = holder._get_array_from_storage(period, branch_name) if value is not None: - # Preserve the engine's reform handling while reading the - # supplied value rather than a branch's calculated cache. - system = simulation.tax_benefit_system - disabled = system.get_variable(variable_name).is_neutralized - try: - disabled = ( - disabled - or system.parameters(period).gov.abolitions[variable_name] - ) - except (ParameterNotFoundError, KeyError): - # Input variables have no generated abolition parameter. - pass - if disabled: + # A neutralized variable reads as its default, as it does + # through the engine. (PolicyEngine-UK builds no + # gov.abolitions parameters, so there is no abolition switch + # to honour here.) + variable = simulation.tax_benefit_system.get_variable(variable_name) + if variable.is_neutralized: return holder.default_array() return value return None From 11fe6887adb9e8637e5bc36c525b3168f876a198 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:44:05 -0400 Subject: [PATCH 47/75] Address review: precise comments, scoped property claims, non-empty masks - Say Scottish CTR counts Carer Support Payment "with no cap at the Carer's Allowance amount" rather than "in full", which could be read as before the CSP Regs reg 16(2) reduction. - Label the 2025 YAML rates as the model's (46.38 and 92.04); the statutory 2025-26 amounts were 46.40 and 92.05 (#2110). - Property 1: claim that the award never rises only for carers by hours, which is what the test checks. Property 2: name the counted benefits the test compares. - Assert that each general-rules comparison covers at least one cell, so no property can pass on an empty mask; at the top of the pension grid every family follows the general rules with positive income. Co-Authored-By: Claude Opus 5.5 --- ...l_tax_reduction_carer_support_payment.yaml | 10 ++++++---- ...l_tax_reduction_carer_income_properties.py | 20 ++++++++++++------- ...council_tax_reduction_applicable_income.py | 3 ++- 3 files changed, 21 insertions(+), 12 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 d1390f0f49..d26a88c6f6 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 @@ -1,7 +1,7 @@ # Carer Support Payment in Council Tax Reduction income. # Scottish carers receive Carer Support Payment (CSP) in place of Carer's # Allowance (CA); the model moves them in 2025. Scottish Council Tax Reduction -# counts the CSP component in full: +# counts the CSP component, with no cap at the CA amount: # - working age: Council Tax Reduction (Scotland) Regulations 2021 (SSI # 2021/249) reg 57(1)(b)(iva), a closed list of unearned income; # - pension age: Council Tax Reduction (State Pension Credit) (Scotland) @@ -20,9 +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, carer premium 46.38, personal allowance 92.04. 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. +# 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 +# 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. # The working-age cases take no Universal Credit, so the award does not turn # on how Universal Credit treats CSP. 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 aacaf52fb4..afc9513f38 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 @@ -28,14 +28,15 @@ 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 - component (86.45 a week), not by the component plus the supplement. The - reduction itself never rises. Where both awards are partial and claiming - leaves the applicable amount unchanged (a carer by hours, who has the - carer premium either way), it falls by 20% of the rise in income. + component (86.45 a week), not by the component plus the supplement. For a + carer who qualifies by caring hours, and so has the carer premium either + way, the reduction itself never rises, and where both awards are partial + it falls by 20% of the rise in income. 2. Invariant to the supplement: setting the Scottish Carer Supplement to zero - leaves Carer Support Payment and every benefit counted in Council Tax - Reduction income unchanged, and changes that income only through income - tax: income plus income tax is unchanged. The supplement never lowers the + 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. 3. Differential: Council Tax Reduction and Housing Benefit assess the same @@ -262,6 +263,9 @@ def test_council_tax_reduction_income_counts_the_carer_support_payment_component # both runs follow the general rules. general = g["general_rules"][i, :, 0] & g["general_rules"][i, :, 1] 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 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, @@ -305,6 +309,7 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( # 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], @@ -344,6 +349,7 @@ def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( compared = ( ~passported & g["general_rules"][i] & (hb_income > 0) & (ctr_income > 0) ) + assert compared.any(), fam hb_before_adjustments = ( hb_income + g["housing_benefit_applicable_income_disregard"][i] 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 775889cd39..ca6c655ea0 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 @@ -47,7 +47,8 @@ def formula(benunit, period, parameters): personal_benefits = [ "carers_allowance", # Scottish carers receive Carer Support Payment in place of Carer's - # Allowance. Scottish Council Tax Reduction counts it in full: + # Allowance. Scottish Council Tax Reduction counts it, with no cap at + # the Carer's Allowance amount: # SSI 2021/249 reg 57(1)(b)(iva) at working age and SSI 2012/319 # reg 27(1)(j) at pension age. Neither counts the Scottish Carer # Supplement (reg 57(1) does not list it; reg 27(1)(j)(xxib) From 0b718f844c5c8a6a363183cc5434170c5ec6d4d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:53:52 -0400 Subject: [PATCH 48/75] Address review: 2024-25 AFIP rate, reg 8(2) wording, QYP ages, SDP check - The 2024 WTC case's AFIP input uses the 2024-25 rate, 184.30 a week (AFCS Order 2011 art 24A(3) as amended by SI 2024/347 art 4): 9,583.60. - The YAML comment says AFCS payments meet none of the *severe* disability paragraphs (CTC reg 8(3)-(6), WTC reg 17(2)-(6)); a middle-rate DLA care or standard-rate PIP daily living award still meets reg 8(2). - Generated dependants are 0 to 18, all children or qualifying young persons for UC and CTC; a 19-year-old needs the course-start condition too. - The severe disability premium reads its own qualifying-benefit list, so the property docstring calls its AFCS invariance an independent check. - Changelog fragment written as a short bullet (docs/engineering/skills/github-prs.md). Co-Authored-By: Claude Opus 5.5 --- changelog.d/afcs-severe-disability.changed.md | 2 +- .../family/is_severely_disabled_for_benefits.yaml | 11 +++++++---- .../test_severe_disability_afcs_properties.py | 15 ++++++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/changelog.d/afcs-severe-disability.changed.md b/changelog.d/afcs-severe-disability.changed.md index e73d498f45..0c9753835b 100644 --- a/changelog.d/afcs-severe-disability.changed.md +++ b/changelog.d/afcs-severe-disability.changed.md @@ -1 +1 @@ -Document that `afcs_reported` is Family Resources Survey benefit code 8 (Armed Forces Compensation Scheme payments, including guaranteed income payments, and war disablement pensions) and that armed forces independence payment is the separate `armed_forces_independence_payment`, and test that no AFCS payment other than armed forces independence payment makes anyone severely disabled or changes the Universal Credit higher disabled child addition, the CTC severely disabled child element, the WTC severe disability element or the severe disability premium. +- Documented that `afcs_reported` is FRS benefit code 8 (Armed Forces Compensation Scheme payments and war disablement pensions, not armed forces independence payment) and tested that AFCS payments never confer severe disability. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml index a2ecb969af..a8c8f27511 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/is_severely_disabled_for_benefits.yaml @@ -112,8 +112,10 @@ # Armed Forces Compensation Scheme payments other than armed forces # independence payment (lump sums, supplementary awards, guaranteed income # payments; AFCS Order 2011 art 15(1)) and war disablement pensions are in -# afcs. They meet no paragraph of CTC reg 8 or WTC reg 17, alone or with a -# lower award of another benefit. +# afcs. They meet none of the severe disability paragraphs (CTC reg 8(3)-(6); +# WTC reg 17(2)-(6)), alone or next to an award that is not severe. A DLA care +# award at the middle rate or a PIP daily living award at the standard rate +# still makes a child disabled under CTC reg 8(2), but not severely disabled. - name: DLA care component at the middle rate with an AFCS payment is not severe period: 2025 @@ -173,8 +175,9 @@ people: adult: age: 45 - # AFCS Order 2011 art 24A(3): 194.60 a week. - armed_forces_independence_payment: 10_119.20 + # AFCS Order 2011 art 24A(3), as amended by SI 2024/347 art 4: 184.30 + # a week in 2024-25; 184.30 * 52 = 9,583.60. + armed_forces_independence_payment: 9_583.60 benunits: benunit: members: [adult] diff --git a/policyengine_uk/tests/test_severe_disability_afcs_properties.py b/policyengine_uk/tests/test_severe_disability_afcs_properties.py index fbc1370470..0e6b2b32ae 100644 --- a/policyengine_uk/tests/test_severe_disability_afcs_properties.py +++ b/policyengine_uk/tests/test_severe_disability_afcs_properties.py @@ -22,8 +22,11 @@ 2. Metamorphic: changing every person's AFCS payment (to zero or another amount) changes neither the severe disability test nor the amounts that read it: the UC higher rate disabled child addition, the CTC severely disabled - child element, the WTC severe disability element and the severe disability - premium. + child element and the WTC severe disability element. The legacy severe + disability premium, which reads its own qualifying-benefit list + (receives_severe_disability_premium_qualifying_benefit) rather than this + flag, is checked the same way as an independent case: its list also names + armed forces independence payment and no other AFCS payment. 3. Monotone: adding armed forces independence payment to a person never turns the severe disability test off, and turns it on. """ @@ -67,9 +70,11 @@ def people(draw, ages): def families(draw): return dict( adults=draw(st.lists(people(st.integers(25, 60)), min_size=1, max_size=2)), - # 16 to 19 year olds are in non-advanced education, so qualifying young - # persons; younger ones are children. - dependants=draw(st.lists(people(st.integers(0, 19)), max_size=2)), + # 16 to 18 year olds are in non-advanced education, so qualifying young + # persons for both UC and CTC; younger ones are children. A 19-year-old + # would also need their course to have started before 19, so none is + # drawn. + dependants=draw(st.lists(people(st.integers(0, 18)), max_size=2)), ) From 5ea3a92a4527573e31a292dc9f61796e48ea2b36 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:56:43 -0400 Subject: [PATCH 49/75] Take the pension-age premium from the HB premium, not Pension Credit HB(SPC) Sch 3 para 6 has the working-age premium's conditions and para 12 its amounts, so the pension-age premium is severe_disability_premium for a family over State Pension age. A Pension Credit reform no longer moves pension-age HB or CTR. The schedule property keeps the Pension Credit addition as a cross-check. References add the CTR nomination rule and the other premium schedules; the conditions YAML notes the three income-rule defects its awards include (#1908, #2112, #2007). Co-Authored-By: Claude Opus 5.5 --- ...sion_age_severe_disability_conditions.yaml | 6 +++ ...ge_severe_disability_premium_properties.py | 12 ++++-- .../pension_age_severe_disability_premium.py | 39 +++++++++---------- .../household/income/benefits_premiums.py | 7 +++- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml index 0bfcb2eb18..f1382bb123 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml @@ -1,5 +1,11 @@ # HB(SPC) Regs 2006 Sch 3 para 6, with the non-dependant definition in # reg 3: these cases assert final awards without a Guarantee Credit passport. +# The final HB and CTR awards include the model's current income rules, +# including three known defects outside this PR: a flat, CPI-uprated couple +# earnings disregard applied to pensions (#1908), no Savings Credit in HB +# income for a savings-credit-only claimant (#2112), and no non-dependant +# deduction exemption for a claimant on Attendance Allowance (#2007). The +# premium and applicable-amount assertions do not depend on them. - name: One qualifying pensioner and a non-blind partner get no HB or CTR severe premium period: 2026 diff --git a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py index 324bea9932..1475662ab8 100644 --- a/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py +++ b/policyengine_uk/tests/test_pension_age_severe_disability_premium_properties.py @@ -27,8 +27,10 @@ free TV licence for over-75s on Pension Credit is a genuine statutory cliff, so it is excluded. 3. Schedule: over State Pension age the premium is a whole number (0, 1 or - 2) of weekly severe disability rates, equals the Pension Credit severe - disability addition, and is the only premium besides the carer premium; + 2) of weekly severe disability rates, equals severe_disability_premium + and, for these households in 2026, the separately calculated Pension + Credit severe disability addition, and is the only premium besides the + carer premium; below State Pension age it is zero and benefits_premiums is the sum of the four legacy premiums, so working-age applicable amounts are unchanged. @@ -219,10 +221,14 @@ def get(variable): weekly_rate = float( simulation.tax_benefit_system.parameters( YEAR - ).gov.dwp.pension_credit.guarantee_credit.severe_disability.addition + ).gov.dwp.disability_premia.severe_single ) rates = premium / (weekly_rate * 52) assert min(abs(rates - k) for k in (0, 1, 2)) < 1e-6, rates + assert abs(premium - get("severe_disability_premium")) < 0.005 + # A cross-check, not the definition: with no other residents the + # Pension Credit addition has the same conditions and, in 2026, the + # same amounts, but its own parameter and residence test. assert ( abs(premium - get("severe_disability_minimum_guarantee_addition")) < 0.005 ) 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 7e6e524093..7921327326 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 @@ -8,20 +8,22 @@ class pension_age_severe_disability_premium(Variable): documentation = ( "The severe disability premium in the Housing Benefit and Council Tax " "Reduction applicable amount of a family over State Pension age. " - "The Pension Credit severe disability addition supplies the qualifying " - "benefit, couple, carer and rate calculation. Both partners must " - "qualify unless the non-qualifying partner is blind. For that route " - "the model assumes the qualifying partner claims Housing Benefit, " - "as a couple may arrange under regulation 63(1). Housing Benefit's " - "own non-dependant test is applied separately: a qualifying young " - "person aged 18 or over in another family can block this premium " - "although Pension Credit ignores them. Its modelled residence " - "exceptions match the working-age Housing Benefit premium, so both " - "use has_non_dependant_for_severe_disability_premium. Carers are " - "attributed by is_cared_for_by_carer_benefit_recipient, including " - "carers in other benefit units in the household. The premium inherits " - "the documented limitations of those shared helpers, including the " - "omission of Universal Credit carer-element awards." + "HB(SPC) Regs 2006 Sch 3 para 6 sets the same conditions as the " + "working-age premium (HB Regs 2006 Sch 3 para 14): the qualifying " + "benefits, both partners qualifying unless the other is blind, no " + "non-dependant aged 18 or over with the same exceptions (para 6(6), " + "reg 3), and no carer benefit paid for caring for the claimant or " + "partner. Para 12 sets the same weekly amounts. So this is " + "severe_disability_premium for a family over State Pension age, with " + "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 does not count " + "Universal Credit carer-element awards. 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." ) definition_period = YEAR unit = GBP @@ -31,6 +33,7 @@ class pension_age_severe_disability_premium(Variable): "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", "https://www.legislation.gov.uk/uksi/2006/214/regulation/63", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/paragraph/6", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/8/paragraph/4", "https://www.legislation.gov.uk/ssi/2012/319/schedule/1/paragraph/7", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/2/paragraph/6", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", @@ -39,10 +42,4 @@ class pension_age_severe_disability_premium(Variable): def formula(benunit, period, parameters): pension_age = benunit.any(benunit.members("is_SP_age", period)) - addition = benunit("severe_disability_minimum_guarantee_addition", period) - # HB(SPC) Sch 3 para 6(6) and reg 3 have the same residence - # exceptions as working-age HB Sch 3 para 14(4) and reg 3. - no_non_dependant = ~benunit( - "has_non_dependant_for_severe_disability_premium", period - ) - return where(pension_age & no_non_dependant, addition, 0) + return where(pension_age, benunit("severe_disability_premium", period), 0) diff --git a/policyengine_uk/variables/household/income/benefits_premiums.py b/policyengine_uk/variables/household/income/benefits_premiums.py index 0b3d0b5fa6..ff6a0846c7 100644 --- a/policyengine_uk/variables/household/income/benefits_premiums.py +++ b/policyengine_uk/variables/household/income/benefits_premiums.py @@ -26,8 +26,7 @@ class benefits_premiums(Variable): "Premiums in the Housing Benefit, Council Tax Reduction and Income " "Support applicable amounts. A family with a member over State " "Pension age uses the pension-age schedules: the severe disability " - "premium, shared with the Pension Credit severe disability addition, " - "and the carer premium. Those schedules have no adult disability or " + "premium and the carer premium. Those schedules have no adult disability or " "enhanced disability premium." ) definition_period = YEAR @@ -35,7 +34,11 @@ class benefits_premiums(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/1", + "https://www.legislation.gov.uk/wsi/2013/3035/schedule/2", + "https://www.legislation.gov.uk/nisr/2006/406/schedule/3", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2", ) def formula(benunit, period, parameters): From 016a6069e92996b94341a36f984c344ed1b5f942 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 09:08:18 -0400 Subject: [PATCH 50/75] Say the attribution can also cut a couple's premium to the single rate Co-Authored-By: Claude Opus 5.5 --- .../is_cared_for_by_carer_benefit_recipient.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 a9e510f4d1..f321616545 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 @@ -30,15 +30,16 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "another disabled member, such as a child, so putting claimants and " "partners first, and placing left-over carers only with claimants and " "partners of other units, is a choice: where the carer in fact cares " - "for the child, it withholds a premium or addition the law would pay. " - "Where not " + "for the child and the claimant or partner otherwise qualifies, it " + "withholds a premium or addition the law would pay, or for a couple " + "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)). A " - "Universal Credit award that includes " - "the carer element also counts in law but is not attributed here: " + "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 " From c6412ee99b4a1b3e57c984a0b6e007a41145fb0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 09:08:54 -0400 Subject: [PATCH 51/75] Compare the capital-income award invariance only where allowances are unchanged The widened award clause of test_interest_dividends_and_rent_leave_the_award_unchanged gives one adult up to 270,000 of interest, dividends and rent beside up to 30,000 of earnings. Above 100,000 of adjusted net income the personal allowance tapers (ITA 2007 s. 35(2)), which raises the tax on the person's earnings and so the reg. 55(5)(b) deduction: the exception this PR states. Compare only benefit units whose personal allowances the income leaves unchanged, as test_uc_earnings_deductions_properties.py does by keeping adjusted net income under the taper. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_income_from_capital_properties.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index acecac2023..8b532df799 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -27,7 +27,11 @@ Invariant 2's award clause runs with Marriage Allowance switched off. The model books it on the recipient as the transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), so capital income that uses up one -partner's allowance raises the other partner's tax on earnings. +partner's allowance raises the other partner's tax on earnings. The clause +also compares only families whose personal allowances the income leaves +unchanged: above 100,000 of adjusted net income the allowance tapers (ITA 2007 +s. 35), which raises the tax a person pays on their earnings and so changes +the deduction from them (see test_uc_earnings_deductions_properties.py). """ import numpy as np @@ -167,6 +171,9 @@ def listed_sources(sim, year): def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + values["personal_allowances"] = np.asarray( + sim.calculate("personal_allowance", year, map_to="benunit") + ) sources = listed_sources(sim, year) # No actual income from capital is on the list in any year. assert not set(CAPITAL_INCOME) & set(sources), sources @@ -235,9 +242,17 @@ def test_interest_dividends_and_rent_do_not_enter_the_means_test(units, scale, y def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, year): base = calculate(units, year, marriage_allowance=False) scaled = calculate(units, year, income_scale=scale, marriage_allowance=False) + # Only one adult receives the capital income, so the unit's summed + # personal allowances are unchanged exactly when that adult's is. + unchanged = np.isclose( + scaled["personal_allowances"], base["personal_allowances"], atol=0.01 + ) for variable in UC_VARIABLES: np.testing.assert_allclose( - scaled[variable], base[variable], atol=0.01, err_msg=f"{variable}: {units}" + scaled[variable][unchanged], + base[variable][unchanged], + atol=0.01, + err_msg=f"{variable}: {units}", ) From a7abbd43b24a9e255283fd111552e6d0bfc103cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:34:25 -0400 Subject: [PATCH 52/75] Keep input provenance per simulation; use each year's allowance in the test reference A plain clone shared the original's record of supplied inputs, so a loss supplied on the clone made the original count its own carried-over loss again. UK simulations now copy that record when cloning and drop it when arrays are deleted, and the helpers ignore a key with no stored array. The property reference reads each year's trading allowance, which is nil before 2017-18. Co-Authored-By: Claude Opus 5.5 --- .../class-4-chapter-2-profits.fixed.md | 2 + policyengine_uk/simulation.py | 37 +++++- .../test_ni_class_4_profits_properties.py | 121 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 12 +- 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/changelog.d/class-4-chapter-2-profits.fixed.md b/changelog.d/class-4-chapter-2-profits.fixed.md index f8328f8229..e0f6bc5988 100644 --- a/changelog.d/class-4-chapter-2-profits.fixed.md +++ b/changelog.d/class-4-chapter-2-profits.fixed.md @@ -1 +1,3 @@ - Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`). +- Isolated supplied-input provenance between simulation clones and removed it for deleted inputs, preventing Class 4 from relieving cached carry-over losses again. +- Corrected the Class 4 property-test reference to use each year's trading allowance parameter, including the zero allowance in historical years. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 22ddaa61a1..2233bfb407 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -9,7 +9,7 @@ # PolicyEngine core imports from policyengine_core.data import Dataset from policyengine_core.enums import Enum as CoreEnum -from policyengine_core.periods import period as period_ +from policyengine_core.periods import Period, period as period_ from policyengine_core.parameters import Parameter from policyengine_core.reforms import Reform from policyengine_core.simulations import Simulation as CoreSimulation @@ -219,6 +219,41 @@ def __init__( if scenario.parameter_changes is not None: self.apply_parameter_changes(scenario.parameter_changes) + def clone( + self, + debug: bool = False, + trace: bool = False, + clone_tax_benefit_system: bool = True, + ) -> "Simulation": + clone = super().clone(debug, trace, clone_tax_benefit_system) + # policyengine-core 3.32.9: simulations/simulation.py::Simulation.clone + # shallow-copies __dict__, while holders/holder.py::Holder.clone copies + # value storage. Holder.set_input's provenance and context must belong + # to the same simulation as that storage, including for plain clones. + clone._user_input_keys = set(getattr(self, "_user_input_keys", ())) + clone._user_input_contexts = list(getattr(self, "_user_input_contexts", ())) + return clone + + def delete_arrays(self, variable: str, period: Period = None) -> None: + super().delete_arrays(variable, period) + # policyengine-core 3.32.9: Simulation.delete_arrays and + # holders/holder.py::Holder.delete_arrays remove storage, but retain + # provenance keys. Drop those keys before carry-over can refill storage. + # Inspect storage after core's deletion to honour period containment + # and the current branch, ancestor branches and default branch. + input_keys = getattr(self, "_user_input_keys", None) + if input_keys: + branch_names = set(self._get_visible_branch_names()) + holder = self.get_holder(variable) + deleted_keys = { + (name, branch_name, input_period) + for name, branch_name, input_period in input_keys + if name == variable + and branch_name in branch_names + and holder._get_array_from_storage(input_period, branch_name) is None + } + input_keys.difference_update(deleted_keys) + def reset_calculations(self): for variable in self.tax_benefit_system.variables: if variable not in self.input_variables: diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index fffe57d2a2..a53cee1559 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -47,13 +47,14 @@ import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as period_ from policyengine_core.reforms import Reform from policyengine_uk import Simulation +from policyengine_uk.utils.supplied_inputs import supplied_input, supplied_input_periods pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") -ALLOWANCE = 1_000 CASES = 24 # Each case is simulated twice: as drawn, and varied. PEOPLE = 2 * CASES PROPERTY_SETTINGS = settings( @@ -103,21 +104,29 @@ def exact(x): return Fraction(float(x)) -def reference_profits_before_losses(profit, receipts, capital_allowances): +def reference_allowance(simulation, year): + return exact( + simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.income_tax.allowances.trading_allowance + ) + + +def reference_profits_before_losses(profit, receipts, capital_allowances, allowance): """ITTOIA 2005 Part 2 Chapter 2 profits, after capital allowances and the trading allowance, before loss relief.""" if receipts > 0 and receipts >= profit: - if receipts <= ALLOWANCE: + if receipts <= allowance: # s. 783AF: the trade's profits are nil. return Fraction(0) # The better of actual deductions and the allowance (s. 783AI); # capital allowances are a deduction (CAA 2001 s. 247) that the # allowance replaces, so the two never stack. actual_deductions = receipts - profit + capital_allowances - return max(Fraction(0), receipts - max(actual_deductions, ALLOWANCE)) + return max(Fraction(0), receipts - max(actual_deductions, allowance)) # Receipts unknown: a profit within the allowance is taken to come from # receipts within it; a larger one already reflects the better deduction. - if profit <= ALLOWANCE: + if profit <= allowance: return Fraction(0) return max(Fraction(0), profit - capital_allowances) @@ -237,6 +246,7 @@ def calculate(variable, period): return { period: dict( # Stored (float32) inputs, not the float64 draws. + allowance=reference_allowance(sim, period), profit=calculate("self_employment_income", period), receipts=calculate("self_employment_gross_receipts", period), capital_allowances=calculate("capital_allowances", period), @@ -266,6 +276,7 @@ def reference(values, i): exact(v["profit"][i]), exact(v["receipts"][i]), exact(v["capital_allowances"][i]), + v["allowance"], ) for v in years ] @@ -454,6 +465,7 @@ def test_losses_carry_forward_over_many_years(years, opening_balance, random): exact(sim.calculate("self_employment_income", period)[0]), Fraction(0), Fraction(0), + reference_allowance(sim, period), ), exact(sim.calculate("trading_loss", period)[0]), ) @@ -519,6 +531,40 @@ def single_person(person): ) +def test_pre_allowance_profits_exhaust_the_loss_in_the_historical_reference(): + sim = single_person( + { + "self_employment_income": {2015: 500, 2016: 500}, + "trading_loss": {2015: 1_000, 2016: 0}, + } + ) + periods = (2015, 2016) + profits = [ + reference_profits_before_losses( + Fraction(500), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), + ) + for period in periods + ] + assert profits == [500, 500] + expected = reference_losses(zip(profits, (Fraction(1_000), Fraction(0)))) + assert [year["relief"] for year in expected] == [500, 500] + assert [year["carried_forward"] for year in expected] == [500, 0] + for period, reference_year in zip(periods, expected): + assert sim.calculate("ni_class_4_profits_before_losses", period)[0] == 500 + for name, reference_name in [ + ("losses_brought_forward", "brought_forward"), + ("loss_relief", "relief"), + ("losses_carried_forward", "carried_forward"), + ("profits", "profits"), + ]: + assert sim.calculate(f"ni_class_4_{name}", period)[0] == float( + reference_year[reference_name] + ) + + @settings( max_examples=20, deadline=None, @@ -570,7 +616,10 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): [ ( reference_profits_before_losses( - exact(np.float32(profit)), Fraction(0), Fraction(0) + exact(np.float32(profit)), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), ), stored_losses.get(period, Fraction(0)), ) @@ -648,6 +697,66 @@ def test_class_4_counts_only_supplied_losses_on_branches(): assert sim.calculate("ni_class_4_trading_loss", 2026)[0] == 0 +def test_class_4_counts_only_supplied_losses_on_plain_clones(): + sim = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + clone = sim.clone() + clone.set_input("trading_loss", 2026, np.array([5_000.0])) + # Warm the original's carry-over cache after the clone records its input. + # A shared provenance set would count this cached 10,000 as a new loss. + assert sim.calculate("trading_loss", 2026)[0] == 10_000 + observed = ( + float(sim.calculate("ni_class_4_profits", 2026)[0]), + round(float(sim.calculate("ni_class_4", 2026)[0]), 2), + ) + assert observed == (30_000, 1_045.80) + assert clone.calculate("ni_class_4_profits", 2026)[0] == 25_000 + for simulation in (sim, clone): + assert simulation.calculate("ni_class_4_profits", 2027)[0] == 40_000 + assert clone._user_input_keys is not sim._user_input_keys + assert clone._user_input_contexts is not sim._user_input_contexts + + +@pytest.mark.parametrize("on_branch", [False, True]) +def test_class_4_does_not_count_a_deleted_loss_after_recalculation(on_branch): + original = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + sim = original.get_branch("deleted_loss") if on_branch else original + sim.set_input("trading_loss", 2026, np.array([5_000.0])) + sim.delete_arrays("trading_loss", 2026) + # Deletion leaves the earlier loss available for engine carry-over, but + # that replacement cache must not retain the deleted input's provenance. + assert sim.calculate("trading_loss", 2026)[0] == 10_000 + observed = ( + float(sim.calculate("ni_class_4_profits", 2026)[0]), + round(float(sim.calculate("ni_class_4", 2026)[0]), 2), + ) + assert observed == (30_000, 1_045.80) + assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 + if on_branch: + assert original.calculate("ni_class_4_profits", 2026)[0] == 30_000 + + +def test_supplied_input_helpers_ignore_missing_stored_arrays(): + sim = single_person({"trading_loss": {2025: 10_000, 2026: 5_000}}) + population = sim.get_variable_population("trading_loss") + # Direct holder deletion leaves core's provenance keys behind. Neither + # helper may identify the missing stored value as an effective input. + population.get_holder("trading_loss").delete_arrays(period_(2026)) + assert supplied_input(population, "trading_loss", period_(2026)) is None + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + + class neutralize_trading_loss(Reform): def apply(self): self.neutralize_variable("trading_loss") diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 55b9e654d8..2caee278b0 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -5,8 +5,9 @@ (``auto_carry_over_input_variables``), and calculated values are cached next to inputs. A holder's known periods include both. policyengine-core records each (variable, branch, period) that ``set_input`` fills in -``Simulation._user_input_keys``, and nothing else adds to it, so it is -independent of what has been calculated before. +``Simulation._user_input_keys``. UK simulations isolate this provenance on +cloning and remove keys when deleting arrays; the helpers also require a +stored value, so missing arrays do not count as supplied inputs. """ from typing import List, Optional @@ -33,11 +34,14 @@ def supplied_input_periods(population, variable_name: str) -> List[Period]: simulation = population.simulation branch_names = set(_visible_branch_names(simulation)) input_keys = getattr(simulation, "_user_input_keys", None) or () + holder = population.get_holder(variable_name) return sorted( { period for name, branch_name, period in input_keys - if name == variable_name and branch_name in branch_names + if name == variable_name + and branch_name in branch_names + and holder._get_array_from_storage(period, branch_name) is not None }, key=lambda period: period.start, ) @@ -57,6 +61,8 @@ def supplied_input( if (variable_name, branch_name, period) in input_keys: # Read the stored input itself: Holder.get_array would fall back # to other branches' values, including cached calculations. + # Core 3.32.9's Holder.delete_arrays leaves provenance keys behind; + # a missing stored array must be ignored even if its key remains. value = holder._get_array_from_storage(period, branch_name) if value is not None: # A neutralized variable reads as its default, as it does From 8c88c7cd94746c756012025bf7436b721f4305b5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:40:26 -0400 Subject: [PATCH 53/75] Compare the capped award only where the benefit cap exemption is unchanged Review of c6412ee99 found the widened capital-income award invariance still false for the award after the cap. The model's earnings exception to the cap (reg. 82(1)(a)) nets tax on all of a person's income off their earnings (#1986), so dividends can switch the cap on while allowances and UC earned income are unchanged: a single London claimant earning 11,000 with 10,000 of dividends loses 2,081.80 to the cap. - Compare universal_credit only where is_benefit_cap_exempt is also unchanged; the other variables keep the personal allowance filter. - Pin the case as a strict xfail against #1986. - Qualify the YAML header's ITA 2007 s. 16 ordering comment. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 3 +- .../test_uc_income_from_capital_properties.py | 60 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) 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 2df9a8fbb6..0053eec145 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 @@ -5,7 +5,8 @@ # Pension, property, savings and dividends is not deducted, and one partner's # tax never reduces the other's earnings. Earnings are taken as the lowest # slice of the person's non-savings income, after their allowances; savings -# and dividends sit above all non-savings income (ITA 2007 s. 16). +# and dividends sit above it, as ITA 2007 s. 16 ranks them for income tax +# (apart from the items s. 1012 ranks higher, which the model does not have). # # Hand calculations use the 2026-27 rates: # - personal allowance 12,570; basic rate 20% on the first 37,700 of taxable diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 8b532df799..4a07d757fd 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -32,6 +32,11 @@ unchanged: above 100,000 of adjusted net income the allowance tapers (ITA 2007 s. 35), which raises the tax a person pays on their earnings and so changes the deduction from them (see test_uc_earnings_deductions_properties.py). +The award after the benefit cap is compared only where the cap exemption is +also unchanged: the model's earnings exception to the cap still nets tax on +all of a person's income off their earnings +(PolicyEngine/policyengine-uk#1986), so capital income can switch the cap on. +A strict xfail pins that case. """ import numpy as np @@ -82,6 +87,7 @@ "uc_maximum_amount", "uc_assessable_capital", "is_uc_eligible", + "is_benefit_cap_exempt", ] @@ -247,10 +253,18 @@ def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, yea unchanged = np.isclose( scaled["personal_allowances"], base["personal_allowances"], atol=0.01 ) + # The cap's earnings exception can change with the income (#1986), so + # the capped award is compared only where the exemption is the same. + same_cap = unchanged & ( + scaled["is_benefit_cap_exempt"] == base["is_benefit_cap_exempt"] + ) for variable in UC_VARIABLES: + if variable == "is_benefit_cap_exempt": + continue + compared = same_cap if variable == "universal_credit" else unchanged np.testing.assert_allclose( - scaled[variable][unchanged], - base[variable][unchanged], + scaled[variable][compared], + base[variable][compared], atol=0.01, err_msg=f"{variable}: {units}", ) @@ -307,3 +321,45 @@ def test_tax_on_dividends_does_not_raise_a_working_familys_award(): ) values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(10_000, abs=0.01) + + +@pytest.mark.xfail( + strict=True, + reason=( + "PolicyEngine/policyengine-uk#1986: the benefit cap earnings exception " + "nets tax on all of a person's income off their earnings" + ), +) +def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): + # 2026: single claimant aged 30 in London, council rent 20,000, earning + # 11,000 with no tax or NI on it. Without dividends the model applies no + # cap, because the earnings meet its exception test. 10,000 of dividends + # is taxed, but that tax is not in respect of the employment (reg. + # 55(5)(b)), so earned income, and the exception that rests on it (reg. + # 82(1)(a)), should not change. + unit = dict( + ages=[30], + children=[], + tenure="RENT_FROM_COUNCIL", + region="LONDON", + rent=20_000.0, + capital={source: 0.0 for source in CAPITAL_SOURCES}, + main_residence_value=0.0, + reported_capital=-1.0, + earnings=11_000.0, + private_pension_income=0.0, + capital_income={ + "savings_interest_income": 0.0, + "dividend_income": 10_000.0, + "property_income": 0.0, + }, + recipient=0, + ) + with_dividends = calculate([unit], 2026, marriage_allowance=False) + without = calculate([unit], 2026, income_scale=0.0, marriage_allowance=False) + assert with_dividends["uc_earned_income"][0] == pytest.approx( + without["uc_earned_income"][0], abs=0.01 + ) + assert with_dividends["universal_credit"][0] == pytest.approx( + without["universal_credit"][0], abs=0.01 + ) From 2ffe7142cda33ed7182172492b5d5b56c055cd86 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:47:52 -0400 Subject: [PATCH 54/75] Cite Schedule 4 for the Northern Ireland pension-age premiums SR 2006/406 Schedule 3 lists excluded tenancies; the applicable amounts, including the premiums, are in Schedule 4. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/household/income/benefits_premiums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/variables/household/income/benefits_premiums.py b/policyengine_uk/variables/household/income/benefits_premiums.py index ff6a0846c7..17780055eb 100644 --- a/policyengine_uk/variables/household/income/benefits_premiums.py +++ b/policyengine_uk/variables/household/income/benefits_premiums.py @@ -36,7 +36,7 @@ class benefits_premiums(Variable): "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2", "https://www.legislation.gov.uk/ssi/2012/319/schedule/1", "https://www.legislation.gov.uk/wsi/2013/3035/schedule/2", - "https://www.legislation.gov.uk/nisr/2006/406/schedule/3", + "https://www.legislation.gov.uk/nisr/2006/406/schedule/4", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3", "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2", ) From 0d7fae33a5d6e296f4b056087f76fbd9ac9c3668 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:47:54 -0400 Subject: [PATCH 55/75] Assess each joint Council Tax Reduction claim on its own family; treat a blind partner as the HB rules do Round 2 of the independent review found three wrong results the sharer claims introduce. Council Tax Reduction scheme. Where families share the rent and each claims on its part, every claim used the household's oldest family's pensioner status. A working-age sharer then claimed under a pensioner co-tenant's scheme, and a pensioner sharer lost the pension-age scheme beside a mixed-age couple on Universal Credit. Each such claim now follows the applicant's own family (SI 2012/2885 reg 3), in the national schemes, the five councils' schemes and the choice between a simulated and a reported reduction. A household with a single claim is unchanged. Non-dependant exemption. In the councils' schemes one sharer's disability benefit exempted every claim, or none. The exemption is now the applicant's own, applied to that claim's share of the deduction. Housing Benefit severe disability conditions. A partner who is blind and gets no qualifying benefit is treated as not being the claimant's partner (HB Sch 3 para 14(3); pension age Sch 3 para 6(3)), and a blind non-dependant is disregarded (para 14(4)(b)). Without this a claimant sharing the rent with a joint tenant fell to the shared accommodation rate. Property tests: a sentinel family that meets the severe disability conditions, each joint claim's scheme, the bound on the household's simulated reductions, and no change to Council Tax Reduction without sharers. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2005.added.md | 2 +- .../rent_of_sharers_boarders_and_lodgers.yaml | 114 ++++++++++ .../joint_liability.yaml | 206 ++++++++++++++++++ ...sharers_boarders_and_lodgers_properties.py | 64 +++++- .../variables/gov/dwp/council_tax_benefit.py | 2 +- ...nefit_severe_disability_premium_applies.py | 29 ++- .../council_tax_reduction/_legacy.py | 30 ++- ...duction_applicant_has_non_dep_exemption.py | 39 ++++ .../council_tax_reduction_claim_pensioner.py | 31 +++ ...il_tax_reduction_claim_scheme_supported.py | 42 ++++ .../council_tax_reduction_claimant_benunit.py | 6 +- .../council_tax_reduction_claims_are_joint.py | 27 +++ ...simulated_council_tax_reduction_benunit.py | 6 +- ...gston_upon_thames_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 3 +- .../merton_council_tax_reduction.py | 4 +- ..._reduction_individual_non_dep_deduction.py | 3 +- .../newham_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 3 +- .../oxford_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 3 +- .../westminster_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 3 +- 23 files changed, 582 insertions(+), 43 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py diff --git a/changelog.d/2005.added.md b/changelog.d/2005.added.md index 9680d4e64a..7abfb43b3f 100644 --- a/changelog.d/2005.added.md +++ b/changelog.d/2005.added.md @@ -1 +1 @@ -- Give joint tenants and other sharers their share of the household's rent, boarders and lodgers their own rent and private tenure, a Housing Benefit LHA category (with its own size criteria and shared-accommodation rule) beside the Universal Credit one, statutory non-dependant exclusions and apportionment, the Housing Benefit meals deduction, and Council Tax Reduction on the council tax divided among jointly liable people. +- Give joint tenants and other sharers their share of the household's rent, boarders and lodgers their own rent and private tenure, a Housing Benefit LHA category (with its own size criteria and shared-accommodation rule) beside the Universal Credit one, statutory non-dependant exclusions and apportionment, the Housing Benefit meals deduction, and Council Tax Reduction on the council tax divided among jointly liable people, each claim under its own family's scheme and exemption from non-dependant deductions. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml index 990077e67b..9f95c277d9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/rent_of_sharers_boarders_and_lodgers.yaml @@ -451,3 +451,117 @@ # bedroom self-contained: B. The householder's size criteria count the # lodger as an occupier (13D(12)): 2 bedrooms: C. housing_benefit_LHA_category: [C, B] + +- name: A blind partner without a qualifying benefit does not block the severe disability conditions + period: 2025 + input: + people: + claimant: + age: 30 + is_household_head: true + pip_dl: 3_988.40 + partner: + age: 30 + is_blind: true + joint_tenant: + age: 25 + benunits: + couple: + members: [claimant, partner] + joint_tenant_family: + members: [joint_tenant] + liable_for_share_of_household_rent: true + households: + home: + members: [claimant, partner, joint_tenant] + rent: 13_500 + tenure_type: RENT_PRIVATELY + output: + # The couple owes two thirds of the rent and shares the home. + benunit_rent: [9_000, 4_500] + housing_benefit_shares_accommodation: [true, true] + # Sch 3 para 14(3): the blind partner has no qualifying benefit, so is + # treated as not being the claimant's partner; the claimant has PIP daily + # living and no non-dependant (the joint tenant is not one), so para + # 14(2)(a) applies and reg 13D(2)(a)(ii) keeps the couple off the shared + # rate: one bedroom, B. The joint tenant, aged 25, gets the shared rate. + housing_benefit_severe_disability_premium_applies: [true, false] + housing_benefit_LHA_category: [B, A] + +- name: A couple with neither a qualifying benefit for the partner nor blindness does not meet them + period: 2025 + input: + people: + claimant: + age: 30 + is_household_head: true + pip_dl: 3_988.40 + partner: + age: 30 + blind_adult_son: + age: 19 + is_blind: true + is_claimant_or_partner: false + benunits: + couple: + members: [claimant, partner] + son_family: + members: [blind_adult_son] + households: + home: + members: [claimant, partner, blind_adult_son] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + # Para 14(2)(b)(ii): the partner receives no qualifying benefit and is + # not blind, so the couple does not qualify. + housing_benefit_severe_disability_premium_applies: [false, false] + +- name: A blind non-dependant is disregarded in the severe disability conditions + period: 2025 + input: + people: + claimant: + age: 40 + is_household_head: true + pip_dl: 3_988.40 + blind_brother: + age: 45 + is_blind: true + benunits: + claimant_family: + members: [claimant] + brother_family: + members: [blind_brother] + households: + home: + members: [claimant, blind_brother] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + # Para 14(4)(b): no account is taken of a non-dependant who is blind. + housing_benefit_severe_disability_premium_applies: [true, false] + +- name: A sighted non-dependant without a qualifying benefit rules the conditions out + period: 2025 + input: + people: + claimant: + age: 40 + is_household_head: true + pip_dl: 3_988.40 + brother: + age: 45 + benunits: + claimant_family: + members: [claimant] + brother_family: + members: [brother] + households: + home: + members: [claimant, brother] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + # Para 14(2)(a)(ii): a non-dependant aged 18 or over resides with them. + housing_benefit_severe_disability_premium_applies: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml index 31efa46d3f..0f46bd5af0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml @@ -259,3 +259,209 @@ council_tax_reduction_joint_liability_share: [1, 0.5] council_tax_reduction_claimant_benunit: [true, false] simulated_council_tax_reduction_benunit: [1_800, 0] + +- name: A working-age sharer does not claim under a pensioner co-tenant's scheme + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 15_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + output: + # SI 2012/2885 reg 3: each applicant is a pensioner on their own and + # their partner's circumstances. The pensioner claims on half the bill + # under the pension-age scheme. The sharer falls under Maidstone's + # working-age scheme, which the model does not simulate, so keeps the + # reported reduction (none here). + council_tax_reduction_claims_are_joint: true + council_tax_reduction_claim_pensioner: [true, false] + council_tax_reduction_claim_scheme_supported: [true, false] + simulated_council_tax_reduction_benunit: [1_000, 0] + council_tax_benefit: [1_000, 0] + +- name: A pensioner sharer keeps the pension-age scheme beside a mixed-age couple on Universal Credit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_household_head: true + state_pension: 9_000 + partner: + age: 55 + sharer: + age: 68 + state_pension: 9_000 + benunits: + couple: + members: [head, partner] + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + households: + home: + members: [head, partner, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + brma: MAIDSTONE + output: + # The couple has a Universal Credit award with a working-age member, so + # is not a pensioner (reg 3(1)(a)(ii)); the single sharer over pension + # age is, on her own circumstances. Three people are jointly liable: + # 2,000 / 3 = 666.67 for her claim. The couple falls under the + # working-age scheme, which is not simulated in Maidstone. + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_claim_pensioner: [false, true] + council_tax_reduction_joint_liability_share: [0.333333, 0.333333] + simulated_council_tax_reduction_benunit: [0, 666.67] + council_tax_benefit: [0, 666.67] + +- name: The same households in the other order give the pensioner the same claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 68 + is_household_head: true + state_pension: 9_000 + sharer: + age: 72 + is_household_head: false + state_pension: 9_000 + sharer_partner: + age: 55 + is_household_head: false + benunits: + head_family: + members: [head] + couple: + members: [sharer, sharer_partner] + liable_for_share_of_household_rent: true + households: + home: + members: [head, sharer, sharer_partner] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + brma: MAIDSTONE + output: + # The household's oldest person is in the mixed-age couple, whose + # Universal Credit award makes it working-age; that does not carry over + # to the single pensioner's claim. + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_claim_pensioner: [true, false] + simulated_council_tax_reduction_benunit: [666.67, 0] + council_tax_benefit: [666.67, 0] + +- name: A sharer on PIP is exempt from non-dependant deductions; the other sharer is not + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + sharer: + age: 35 + pip_dl: 3_988.40 + non_dependant: + age: 25 + employment_income: 20_000 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + non_dependant_family: + members: [non_dependant] + households: + home: + members: [head, sharer, non_dependant] + rent: 15_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: OXFORD + output: + # Oxford's scheme exempts an applicant who, or whose partner, gets the + # PIP daily living component (as SI 2012/2886 Sch para 30(6)); the + # exemption is the applicant's own. The non-dependant's 270.40 is + # apportioned between the two jointly liable people: 135.20 each, and + # nothing from the exempt sharer. + council_tax_reduction_applicant_has_non_dep_exemption: [false, true, false] + oxford_council_tax_reduction_non_dep_deductions: [135.20, 0, 0] + # 2,000 / 2 = 1,000 each, less the head's 135.20. + oxford_council_tax_reduction: [864.80, 1_000, 0] + council_tax_benefit: [864.80, 1_000, 0] + +- name: The head's PIP does not exempt the other sharer's claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + is_household_head: true + pip_dl: 3_988.40 + sharer: + age: 35 + non_dependant: + age: 25 + employment_income: 20_000 + benunits: + head_family: + members: [head] + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + council_tax_reduction_applicable_income: 0 + non_dependant_family: + members: [non_dependant] + households: + home: + members: [head, sharer, non_dependant] + rent: 15_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + country: ENGLAND + region: SOUTH_EAST + local_authority: OXFORD + output: + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + oxford_council_tax_reduction_non_dep_deductions: [0, 135.20, 0] + oxford_council_tax_reduction: [1_000, 864.80, 0] + council_tax_benefit: [1_000, 864.80, 0] 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 aedadee764..f9c9cbe687 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 @@ -32,20 +32,25 @@ on the meals in the rent; with a board finding it is the rent less the deduction, floored at zero. 7. Council tax: in a household whose rent is shared, the jointly liable - claim shares never exceed one in total. + claim shares never exceed one in total, each claim's scheme follows its + own family's pensioner status, and the simulated reductions of the + household's claims never exceed its eligible council tax. 8. Monotonicity: a family's rent is non-decreasing in the household's rent. 9. No-op: in a household with no sharers, boarders or lodgers, the household head's family has the whole rent and everyone else none, as before; and where the head's family claims Universal Credit, its non-dependant deductions and bedrooms equal the previous formulas (a deduction for everyone outside the family and a bedroom for each of them aged 16 or - over). + over); and Council Tax Reduction keeps the household's single claim, + scheme and simulated-or-reported choice. """ import numpy as np from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as make_period + from policyengine_uk import Simulation YEAR = 2025 @@ -67,7 +72,8 @@ money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) earnings = st.one_of(st.just(0.0), st.floats(1_000, 60_000, allow_nan=False)) adult = st.tuples(st.integers(18, 85), earnings) -# One household with every role, so that no selection is empty. +# One household with every role, and a lodger who meets the severe disability +# premium conditions, so that no selection is empty. SENTINEL = dict( head_adults=[(45, 0.0)], head_child_age=None, @@ -76,12 +82,13 @@ council_tax=1_800.0, others=[ dict(role=role, adults=[(age, pay)], child_age=None, payment=5_200.0) - | dict(meals="AT_LEAST_THREE_A_DAY", pip=False) - for role, age, pay in [ - ("sharer", 40, 0.0), - ("boarder", 50, 0.0), - ("lodger", 30, 0.0), - ("non_dependant", 25, 25_000.0), + | dict(meals="AT_LEAST_THREE_A_DAY", pip=pip) + for role, age, pay, pip in [ + ("sharer", 40, 0.0, False), + ("boarder", 50, 0.0, False), + ("lodger", 30, 0.0, False), + ("lodger", 35, 0.0, True), + ("non_dependant", 25, 25_000.0, False), ] ], shared=False, @@ -207,6 +214,12 @@ def per_household(simulation, benunit_values): return simulation.map_result(benunit_values, "benunit", "household") +def of_household(simulation, variable): + """A household variable's value for each family in the household.""" + families = simulation.populations["benunit"] + return np.asarray(families.household(variable, make_period(YEAR))) + + @PROPERTY_SETTINGS @given(population) def test_conservation_bounds_and_tenure(population): @@ -304,6 +317,7 @@ def test_lha_categories(population): assert np.all(category[selected] == "A") # The severe disability premium conditions keep a claimant off the # shared rate (reg 13D(2)(a)). + assert sdp.any() assert not np.any(category[sdp] == "A") @@ -340,6 +354,18 @@ def test_meals_and_council_tax(population): shared = per_household(a, sharer) > 0 assert shared.any() assert np.all(total[shared] <= 1 + 1e-6) + # Each claim there follows its own family's pensioner status, not the + # household's oldest family's (SI 2012/2885 reg 3). + in_shared = of_household(a, "council_tax_reduction_claims_are_joint") + assert np.array_equal( + calc(a, "council_tax_reduction_claim_pensioner")[in_shared], + calc(a, "council_tax_reduction_pensioner")[in_shared], + ) + # The simulated reductions of the household's claims never exceed its + # eligible council tax. + simulated = calc(a, "simulated_council_tax_reduction_benunit") + liability = calc(a, "council_tax_reduction_maximum_eligible_liability") + assert np.all(per_household(a, simulated)[shared] <= liability[shared] + 0.01) @PROPERTY_SETTINGS @@ -390,3 +416,23 @@ def test_no_op_without_sharers_boarders_or_lodgers(population): head_bedrooms[head_claims], (bedrooms_alone + adults_outside)[head_claims], ) + # Council Tax Reduction: one claim, on the household's scheme, simulated + # or reported as the household's scheme is. + assert not calc(sim, "council_tax_reduction_claims_are_joint").any() + assert np.array_equal( + calc(sim, "council_tax_reduction_claim_pensioner"), + of_household(sim, "council_tax_reduction_household_has_pensioner"), + ) + supported = of_household(sim, "council_tax_reduction_scheme_supported") + assert np.array_equal( + calc(sim, "council_tax_reduction_claim_scheme_supported"), supported + ) + assert np.allclose( + calc(sim, "council_tax_benefit"), + np.where( + supported, + calc(sim, "simulated_council_tax_reduction_benunit"), + calc(sim, "council_tax_benefit_reported"), + ), + atol=0.01, + ) diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index d2861ead5b..39dd37d8f8 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -9,7 +9,7 @@ class council_tax_benefit(Variable): unit = GBP def formula(benunit, period, parameters): - supported = benunit.household("council_tax_reduction_scheme_supported", period) + supported = benunit("council_tax_reduction_claim_scheme_supported", period) simulated = benunit("simulated_council_tax_reduction_benunit", period) reported = benunit("council_tax_benefit_reported", period) return where(supported, simulated, reported) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py index c8fff7fa8e..0e0ac10e22 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_severe_disability_premium_applies.py @@ -10,9 +10,11 @@ class housing_benefit_severe_disability_premium_applies(Variable): "Benefit severe disability premium: the claimant (and any partner) " "receives a qualifying disability benefit, and no non-dependant aged " "18 or over normally resides with them, other than one who receives " - "a qualifying benefit. Not modelled: who receives Carer's Allowance, " - "Carer Support Payment or the UC carer element for caring for the " - "claimant, and the treatment of a blind partner. The legacy " + "a qualifying benefit or is blind. A partner who is blind and " + "receives no qualifying benefit is treated as not being the " + "claimant's partner, so the claimant is tested as a single person. " + "Not modelled: who receives Carer's Allowance, Carer Support Payment " + "or the UC carer element for caring for the claimant. The legacy " "severe_disability_premium variable uses the tax credit definition of " "severe disability, which is narrower." ) @@ -27,17 +29,22 @@ def formula(benunit, period, parameters): person = benunit.members qualifying = add(person, period, p.qualifying_benefits) > 0 claimant_or_partner = person("is_claimant_or_partner", period) + blind = person("is_blind", period) # Sch 3 para 14(2)(a)(i), (b)(i)-(ii): every claimant or partner - # receives a qualifying benefit. - all_qualify = benunit.all(qualifying | ~claimant_or_partner) & benunit.any( - claimant_or_partner - ) + # receives a qualifying benefit. Para 14(3) (pension age: Sch 3 para + # 6(3)): a partner who does not, and is blind, is treated as not + # being the claimant's partner, so the other member is tested as a + # single claimant under (2)(a) and must receive one. + all_qualify = benunit.all( + qualifying | blind | ~claimant_or_partner + ) & benunit.any(claimant_or_partner & qualifying) # Para 14(2)(a)(ii), (b)(iii) with (4): no non-dependant aged 18 or - # over, disregarding one who receives a qualifying benefit. Within the - # family, an adult who is neither claimant nor partner nor a child or - # young person; outside it, as for housing_benefit_has_non_dependant. + # over, disregarding one who receives a qualifying benefit or is + # blind. Within the family, an adult who is neither claimant nor + # partner nor a child or young person; outside it, as for + # housing_benefit_has_non_dependant. age = person("age", period) - adult_non_dependant = (age >= 18) & ~qualifying + adult_non_dependant = (age >= 18) & ~qualifying & ~blind young_person = person("is_child_or_young_person_for_legacy_benefits", period) within = benunit.any(adult_non_dependant & ~claimant_or_partner & ~young_person) liable_family = benunit.any(person("is_liable_for_household_rent", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 966d230ff7..a970b6995b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -10,6 +10,20 @@ def is_full_time_student_non_dep(person, period): ) +def single_claim_is_pensioner(household, period): + """Whether the household's single claim is a pensioner's. + + A non-dependant brings no deduction under a council's working-age scheme + where the household's one claim is a pensioner's. Where families claim on + their shares, each claim's own scheme decides whether its award uses the + deduction (council_tax_reduction_claim_pensioner), so none is ruled out + here. + """ + return household( + "council_tax_reduction_household_has_pensioner", period + ) & ~household("council_tax_reduction_claims_are_joint", period) + + def legacy_council_tax_reduction( benunit, period, @@ -91,7 +105,17 @@ def local_non_dep_deductions( # A non-dependant of two or more jointly liable people is apportioned # equally between them (SI 2012/2885 Sch 1 para 8(5)). share = benunit("council_tax_reduction_joint_liability_share", period) - return (deductions_in_household - deduction_for_benunit) * share + # Where families claim on their shares, no deduction from an applicant + # who, or whose partner, is blind or gets a qualifying disability benefit, + # whatever the other claimants get (the councils' schemes follow + # SI 2012/2886 Sch para 30(6)). A single claim's exemption is applied to + # each non-dependant's deduction. + applicant_exempt = benunit.household( + "council_tax_reduction_claims_are_joint", period + ) & benunit("council_tax_reduction_applicant_has_non_dep_exemption", period) + return where( + applicant_exempt, 0, (deductions_in_household - deduction_for_benunit) * share + ) def normal_gross_income_non_dep_deduction( @@ -128,9 +152,11 @@ def normal_gross_income_non_dep_deduction( ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), ctr.non_dep_deduction.amount.calc(0), ) + # The exemption of a household's single claim. Joint claims are exempted + # one by one, in local_non_dep_deductions. claimant_exempt = person.household( "council_tax_reduction_household_has_non_dep_exemption", period - ) + ) & ~person.household("council_tax_reduction_claims_are_joint", period) full_time_student = is_full_time_student_non_dep(person, period) income_based_benefit = ( (person.benunit("income_support", period) > 0) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py new file mode 100644 index 0000000000..ec510f0006 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -0,0 +1,39 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_has_non_dep_exemption(Variable): + value_type = bool + entity = BenUnit + label = "No non-dependant deductions from this family's Council Tax Reduction" + documentation = ( + "Whether no deduction is made for any non-dependant from this family's " + "Council Tax Reduction because the applicant or partner is blind, or " + "receives Attendance Allowance, the care component of Disability " + "Living Allowance, the daily living component of Personal " + "Independence Payment or Armed Forces Independence Payment. The " + "exemption belongs to each applicant: where families share the rent " + "and each claims, one family's disability does not exempt another's " + "claim. The model applies it in the English working-age local " + "schemes; a household with a single claim uses " + "council_tax_reduction_household_has_non_dep_exemption." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/30", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 Sch 1 para 8(6): no deduction for non-dependants + # "if the applicant or his partner is" blind or receiving one of the + # listed benefits. + person = benunit.members + applicant_or_partner = person("is_claimant_or_partner", period) + exempting = ( + person("is_blind", period) + | (person("attendance_allowance", period) > 0) + | (person("pip_dl", period) > 0) + | (person("dla_sc", period) > 0) + | (person("armed_forces_independence_payment", period) > 0) + ) + return benunit.any(applicant_or_partner & exempting) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py new file mode 100644 index 0000000000..6e3980a8eb --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_claim_pensioner(Variable): + value_type = bool + entity = BenUnit + label = "This family's Council Tax Reduction claim is a pensioner's" + documentation = ( + "Whether this family's Council Tax Reduction claim falls under the " + "pension-age rules. A person is a pensioner on their own and their " + "partner's circumstances (SI 2012/2885 reg 3), so where families " + "share the rent and each claims on its part, each claim follows the " + "applicant's own family (council_tax_reduction_pensioner): a " + "working-age sharer is not a pensioner because another family is, " + "and a pensioner sharer stays one beside a mixed-age couple on " + "Universal Credit. Where the household has a single claim, this is " + "that claim's status (council_tax_reduction_household_has_pensioner)." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + ) + + def formula(benunit, period, parameters): + household = benunit.household + return where( + household("council_tax_reduction_claims_are_joint", period), + benunit("council_tax_reduction_pensioner", period), + household("council_tax_reduction_household_has_pensioner", period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py new file mode 100644 index 0000000000..4b6376b35e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_supported_scheme, +) + + +class council_tax_reduction_claim_scheme_supported(Variable): + value_type = bool + entity = BenUnit + label = "The model simulates this family's Council Tax Reduction" + documentation = ( + "Whether the family's Council Tax Reduction is simulated rather than " + "taken as reported (council_tax_benefit). Where the household has a " + "single claim, this is council_tax_reduction_scheme_supported. Where " + "families share the rent and each claims on its part, a claiming " + "family is simulated if the model simulates the scheme its own claim " + "falls under, so a working-age sharer in a council whose working-age " + "scheme is not modelled keeps a reported reduction beside a pensioner " + "whose reduction is simulated. A family that cannot claim there is " + "simulated, at nil, where any claim in the household is, since the " + "simulated claims cover those families' shares of the bill." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3" + + def formula(benunit, period, parameters): + household = benunit.household + own_scheme = is_supported_scheme( + household("country", period), + benunit("council_tax_reduction_claim_pensioner", period), + household("local_authority", period), + ) + claimant = benunit("council_tax_reduction_claimant_benunit", period) + person = benunit.members + any_claim_simulated = benunit.any( + person.household.any(benunit.project(claimant & own_scheme)) + ) + return where( + household("council_tax_reduction_claims_are_joint", period), + where(claimant, own_scheme, any_claim_simulated), + household("council_tax_reduction_scheme_supported", period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py index 8e5f3b9a47..dc1f0a2f2f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_benunit.py @@ -22,10 +22,8 @@ class council_tax_reduction_claimant_benunit(Variable): def formula(benunit, period, parameters): person = benunit.members - rent_is_shared = benunit.any( - person.household.any( - person.benunit("liable_for_share_of_household_rent", period) - ) + rent_is_shared = benunit.household( + "council_tax_reduction_claims_are_joint", period ) # A full-time student is excluded from entitlement (Default Scheme # Sch para 75(1)); the model takes a person in higher education as diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py new file mode 100644 index 0000000000..cb41eb3f43 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_claims_are_joint(Variable): + value_type = bool + entity = Household + label = "Families in the household claim Council Tax Reduction on their shares" + documentation = ( + "Whether the household's rent is shared between families, so that " + "each family liable for it is jointly liable for the council tax and " + "claims Council Tax Reduction on its own part. Each such claim is " + "then assessed on the applicant's own family: its scheme " + "(council_tax_reduction_claim_pensioner) and its exemption from " + "non-dependant deductions " + "(council_tax_reduction_applicant_has_non_dep_exemption)." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/14/section/6", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + ) + + def formula(household, period, parameters): + person = household.members + return household.any( + person.benunit("liable_for_share_of_household_rent", period) + ) 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 a910caf7c9..dd830ab861 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 @@ -30,9 +30,9 @@ def formula(benunit, period, parameters): wales_ctr = local_authority_parameters.wales.council_tax_reduction country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + # Each claim's scheme follows the applicant's own family where + # families claim on their shares (SI 2012/2885 reg 3). + has_pensioner = benunit("council_tax_reduction_claim_pensioner", period) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) scotland = is_scotland_scheme(country) wales = is_wales_scheme(country) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py index 7f9b1b7663..cb9d26b20c 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py @@ -23,7 +23,7 @@ def formula(benunit, period, parameters): working_age = is_kingston_upon_thames_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_claim_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py index c8ae7f4df0..72118c4ea1 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, + single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_kingston_upon_thames_working_age, @@ -23,7 +24,7 @@ def formula(person, period, parameters): working_age = is_kingston_upon_thames_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py index 03012a5f8a..28cefbb8fe 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py @@ -18,9 +18,7 @@ def formula(benunit, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction local_authority = benunit.household("local_authority", period) country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + has_pensioner = benunit("council_tax_reduction_claim_pensioner", period) working_age = is_merton_working_age(local_authority, country, has_pensioner) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py index 2907e2a700..cdcc1d683e 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, + single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_merton_working_age, @@ -21,7 +22,7 @@ def formula(person, period, parameters): working_age = is_merton_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py index c35482476f..426f9df32e 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py @@ -20,7 +20,7 @@ def formula(benunit, period, parameters): working_age = is_newham_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_claim_pensioner", period), ) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py index 0e4b9db546..836bc636a5 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, + single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_newham_working_age, @@ -21,7 +22,7 @@ def formula(person, period, parameters): working_age = is_newham_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index abeea551dc..ef2ba846ec 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -18,7 +18,7 @@ def formula(benunit, period, parameters): working_age = is_oxford_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_claim_pensioner", period), ) is_household_head_benunit = benunit( "council_tax_reduction_claimant_benunit", period diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py index 61c80cecbb..bbd2c28f6c 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, + single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_oxford_working_age, @@ -21,7 +22,7 @@ def formula(person, period, parameters): working_age = is_oxford_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py index 3c86931aee..82ad02822b 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py @@ -21,7 +21,7 @@ def formula(benunit, period, parameters): working_age = is_westminster_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_claim_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py index d5666a67a7..4326e47f16 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, + single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_westminster_working_age, @@ -21,7 +22,7 @@ def formula(person, period, parameters): working_age = is_westminster_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, From 44c27e085ee6c8d11518abecbec978e480348c21 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:58:00 -0400 Subject: [PATCH 56/75] Name the Universal Credit carer-element omission in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/hb-pension-age-severe-disability-premium.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md index f4bd3d5f3e..af80310425 100644 --- a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md +++ b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md @@ -1 +1 @@ -- Corrected pension-age Housing Benefit and Council Tax Reduction premiums by removing adult disability and adult enhanced disability premiums absent from their statutory schedules and applying the severe disability premium's qualifying-benefit, couple, Housing Benefit non-dependant and shared carer-attribution conditions. +- Corrected pension-age Housing Benefit and Council Tax Reduction premiums by removing adult disability and adult enhanced disability premiums absent from their statutory schedules and applying the severe disability premium's qualifying-benefit, couple, Housing Benefit non-dependant and shared carer-attribution conditions. The carer condition does not yet count Universal Credit carer-element awards (#2120). From 16a455bc9da06681379a3f43488c12ea2bab8f23 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 15:08:25 -0400 Subject: [PATCH 57/75] Compare the family's reported reduction in the no-change property council_tax_benefit_reported is a person variable; the property test compared it against benefit-unit values. Co-Authored-By: Claude Opus 5.5 --- .../test_rent_of_sharers_boarders_and_lodgers_properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f9c9cbe687..77881382c1 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 @@ -432,7 +432,7 @@ def test_no_op_without_sharers_boarders_or_lodgers(population): np.where( supported, calc(sim, "simulated_council_tax_reduction_benunit"), - calc(sim, "council_tax_benefit_reported"), + calc(sim, "council_tax_benefit_reported", "benunit"), ), atol=0.01, ) From a16b6775dbf6540f41829ade0fc767a9f648d88c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 15:19:02 -0400 Subject: [PATCH 58/75] Update pension-age HB awards for main's earnings-disregard fix Main's #1908 takes the Housing Benefit earnings disregards from net earnings only, so State Pension income is no longer reduced by them. The six final awards here fall by 65% of the old disregard; the premium and applicable-amount assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- ...sion_age_severe_disability_conditions.yaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml index f1382bb123..34bb2652a1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml @@ -1,11 +1,12 @@ # HB(SPC) Regs 2006 Sch 3 para 6, with the non-dependant definition in # reg 3: these cases assert final awards without a Guarantee Credit passport. # The final HB and CTR awards include the model's current income rules, -# including three known defects outside this PR: a flat, CPI-uprated couple -# earnings disregard applied to pensions (#1908), no Savings Credit in HB +# including two known defects outside this PR: no Savings Credit in HB # income for a savings-credit-only claimant (#2112), and no non-dependant # deduction exemption for a claimant on Attendance Allowance (#2007). The -# premium and applicable-amount assertions do not depend on them. +# premium and applicable-amount assertions do not depend on them. Each HB +# award is the rent less 65% of income after tax above the applicable +# amount; State Pension gets no earnings disregard (#1908). - name: One qualifying pensioner and a non-blind partner get no HB or CTR severe premium period: 2026 @@ -33,7 +34,7 @@ guarantee_credit: 0 severe_disability_minimum_guarantee_addition: 0 pension_age_severe_disability_premium: 0 - housing_benefit: 2_883.79 + housing_benefit: 2_409.03 council_tax_benefit: 821.24 - name: Both qualifying pensioners keep two rates in their final HB and CTR awards @@ -62,7 +63,7 @@ output: guarantee_credit: 0 pension_age_severe_disability_premium: 172.10 * 52 - housing_benefit: 6_240 + housing_benefit: 6_146.01 council_tax_benefit: 1_857.22 - name: A qualifying pensioner with a blind non-qualifying partner gets one HB and CTR rate @@ -91,7 +92,7 @@ output: guarantee_credit: 0 pension_age_severe_disability_premium: 86.05 * 52 - housing_benefit: 5_792.28 + housing_benefit: 5_317.52 council_tax_benefit: 1_704.27 - name: A non-ignored adult resident blocks the pensioner's HB and CTR severe premium @@ -122,7 +123,7 @@ guarantee_credit: [0, 0] has_non_dependant_for_severe_disability_premium: [true, false] pension_age_severe_disability_premium: [0, 0] - housing_benefit: [4_136.08, 0] + housing_benefit: [3_898.70, 0] council_tax_benefit: [1_279.60, 0] - name: An 18-year-old of another family blocks HB and CTR although Pension Credit ignores him @@ -162,7 +163,7 @@ has_non_dependant_for_severe_disability_premium: [true, false] pension_age_severe_disability_premium: [0, 0] housing_benefit_applicable_amount: [13_312, 0] - housing_benefit: [4_136.08, 0] + housing_benefit: [3_898.70, 0] council_tax_benefit: [1_097.91, 0] - name: A qualifying-benefit resident in another benefit unit can still be the pensioner's paid carer @@ -204,5 +205,5 @@ has_non_dependant_for_severe_disability_premium: [false, false] is_cared_for_by_carer_benefit_recipient: [true, false] pension_age_severe_disability_premium: [0, 0] - housing_benefit: [4_136.08, 0] + housing_benefit: [3_898.70, 0] council_tax_benefit: [1_279.60, 0] From 9ca5d6eb5f80f2617e540a4d777154fbf837aa75 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 15:23:30 -0400 Subject: [PATCH 59/75] Forget a supplied input's record when its holder value is deleted Holder.delete_arrays removes a stored value but leaves core's record of it as an input (policyengine-core#559). The engine could then carry an earlier loss into the emptied year, and the stale record made Class 4 count that carried value as a second loss. Simulation.calculate now drops such records for the variables the supplied-input helpers read before the engine can refill the period, and the helpers refuse any variable outside that set. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 25 +++++---- .../test_ni_class_4_profits_properties.py | 49 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 51 ++++++++++++++++++- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 2233bfb407..13444134b0 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -30,6 +30,10 @@ ) from policyengine_uk.data.dataset_sources import materialize_gcs_dataset_url from policyengine_uk.utils.dependencies import get_variable_dependencies +from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + drop_missing_supplied_inputs, +) from policyengine_uk.reforms import create_structural_reforms_from_parameters from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( canonicalize_lsr_parameter_path, @@ -236,23 +240,12 @@ def clone( def delete_arrays(self, variable: str, period: Period = None) -> None: super().delete_arrays(variable, period) - # policyengine-core 3.32.9: Simulation.delete_arrays and + # policyengine-core's Simulation.delete_arrays and # holders/holder.py::Holder.delete_arrays remove storage, but retain # provenance keys. Drop those keys before carry-over can refill storage. # Inspect storage after core's deletion to honour period containment # and the current branch, ancestor branches and default branch. - input_keys = getattr(self, "_user_input_keys", None) - if input_keys: - branch_names = set(self._get_visible_branch_names()) - holder = self.get_holder(variable) - deleted_keys = { - (name, branch_name, input_period) - for name, branch_name, input_period in input_keys - if name == variable - and branch_name in branch_names - and holder._get_array_from_storage(input_period, branch_name) is None - } - input_keys.difference_update(deleted_keys) + drop_missing_supplied_inputs(self, variable) def reset_calculations(self): for variable in self.tax_benefit_system.variables: @@ -651,6 +644,12 @@ def calculate( period = period_(period) + if variable_name in SUPPLIED_INPUT_VARIABLES: + # A value deleted straight from the holder leaves its input record + # behind. Forget it before the engine can refill the period with a + # carried-over value that the record would pass off as supplied. + drop_missing_supplied_inputs(self, variable_name) + return super().calculate( variable_name, period, map_to=map_to, decode_enums=decode_enums ) diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index a53cee1559..98a193d677 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -51,7 +51,11 @@ from policyengine_core.reforms import Reform from policyengine_uk import Simulation -from policyengine_uk.utils.supplied_inputs import supplied_input, supplied_input_periods +from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + supplied_input, + supplied_input_periods, +) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -757,6 +761,49 @@ def test_supplied_input_helpers_ignore_missing_stored_arrays(): assert supplied_input_periods(population, "trading_loss") == [period_(2025)] +@pytest.mark.parametrize("on_branch", [False, True]) +@pytest.mark.parametrize("deleted_loss", [5_000.0, 10_000.0]) +def test_class_4_does_not_count_a_loss_deleted_from_its_holder(on_branch, deleted_loss): + original = single_person( + { + "age": {2025: 40}, + "self_employment_income": {2026: 40_000, 2027: 40_000}, + "trading_loss": {2025: 10_000}, + } + ) + sim = original.get_branch("deleted_loss") if on_branch else original + sim.set_input("trading_loss", 2026, np.array([deleted_loss])) + # Holder.delete_arrays removes the stored value but not core's record of + # it. The engine then carries the 2025 loss into 2026; with the stale + # record that 10,000 would count as a second loss (profits of 20,000 and + # contributions of 445.80), also when the deleted loss was 10,000 too. + sim.get_holder("trading_loss").delete_arrays(period_(2026), sim.branch_name) + assert sim.calculate("trading_loss", 2026)[0] == 10_000 + population = sim.get_variable_population("trading_loss") + assert supplied_input(population, "trading_loss", period_(2026)) is None + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + observed = ( + float(sim.calculate("ni_class_4_profits", 2026)[0]), + round(float(sim.calculate("ni_class_4", 2026)[0]), 2), + ) + assert observed == (30_000, 1_045.80) + assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 + if on_branch: + assert original.calculate("ni_class_4_profits", 2026)[0] == 30_000 + + +def test_supplied_input_helpers_refuse_an_unregistered_variable(): + sim = single_person({"employment_income": {2026: 20_000}}) + population = sim.get_variable_population("employment_income") + # Simulation.calculate keeps input records in step with storage only for + # SUPPLIED_INPUT_VARIABLES, so the helpers answer for no other variable. + assert "employment_income" not in SUPPLIED_INPUT_VARIABLES + with pytest.raises(ValueError, match="SUPPLIED_INPUT_VARIABLES"): + supplied_input(population, "employment_income", period_(2026)) + with pytest.raises(ValueError, match="SUPPLIED_INPUT_VARIABLES"): + supplied_input_periods(population, "employment_income") + + class neutralize_trading_loss(Reform): def apply(self): self.neutralize_variable("trading_loss") diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 2caee278b0..f28a1a5163 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -8,6 +8,13 @@ ``Simulation._user_input_keys``. UK simulations isolate this provenance on cloning and remove keys when deleting arrays; the helpers also require a stored value, so missing arrays do not count as supplied inputs. + +``Holder.delete_arrays`` removes a stored value without removing its record +(policyengine-core#559). The engine could then carry an earlier value into the +empty period, and the stale record would pass it off as supplied. So +``Simulation.calculate`` forgets the records of a variable in +``SUPPLIED_INPUT_VARIABLES`` whose stored value is gone before the engine can +refill it, and the helpers only answer for those variables. """ from typing import List, Optional @@ -16,6 +23,25 @@ from policyengine_core.periods import Period +# Variables that formulas ask about through the helpers below. +SUPPLIED_INPUT_VARIABLES = frozenset( + { + "ni_class_4_losses_brought_forward", + "ni_class_4_trading_loss", + "trading_loss", + } +) + + +def _check_registered(variable_name: str) -> None: + if variable_name not in SUPPLIED_INPUT_VARIABLES: + raise ValueError( + f"{variable_name} is not in SUPPLIED_INPUT_VARIABLES, so " + "Simulation.calculate does not keep its input records in step " + "with storage. Add it there before asking whether it was supplied." + ) + + def _visible_branch_names(simulation) -> List[str]: """This branch, then the branches it was made from, then the default.""" names = [] @@ -28,9 +54,29 @@ def _visible_branch_names(simulation) -> List[str]: return names +def drop_missing_supplied_inputs(simulation, variable_name: str) -> None: + """Forget each record of ``variable_name`` as an input, on this branch + or one it was made from, whose stored value has been deleted.""" + input_keys = getattr(simulation, "_user_input_keys", None) + if not input_keys: + return + branch_names = set(_visible_branch_names(simulation)) + holder = simulation.get_holder(variable_name) + input_keys.difference_update( + { + (name, branch_name, period) + for name, branch_name, period in input_keys + if name == variable_name + and branch_name in branch_names + and holder._get_array_from_storage(period, branch_name) is None + } + ) + + def supplied_input_periods(population, variable_name: str) -> List[Period]: """Periods for which ``variable_name`` was supplied as an input, on this branch or one it was made from, earliest first.""" + _check_registered(variable_name) simulation = population.simulation branch_names = set(_visible_branch_names(simulation)) input_keys = getattr(simulation, "_user_input_keys", None) or () @@ -54,6 +100,7 @@ def supplied_input( input, on this branch or the nearest one it was made from; otherwise None. A value the engine carried over from an earlier period, or cached after a calculation, is not an input.""" + _check_registered(variable_name) simulation = population.simulation input_keys = getattr(simulation, "_user_input_keys", None) or () holder = population.get_holder(variable_name) @@ -61,8 +108,8 @@ def supplied_input( if (variable_name, branch_name, period) in input_keys: # Read the stored input itself: Holder.get_array would fall back # to other branches' values, including cached calculations. - # Core 3.32.9's Holder.delete_arrays leaves provenance keys behind; - # a missing stored array must be ignored even if its key remains. + # Holder.delete_arrays leaves provenance keys behind; a missing + # stored array must be ignored even if its key remains. value = holder._get_array_from_storage(period, branch_name) if value is not None: # A neutralized variable reads as its default, as it does From ab929c4833db89504362b420c56bc87c78631196 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 15:26:17 -0400 Subject: [PATCH 60/75] Say that carer receipt is after the overlapping-benefit adjustment Main's #1994 reduces Carer's Allowance and Carer Support Payment by overlapping benefits, so receives_carer_benefit is already the amount in payment. The documentation still said the rule was not applied. Co-Authored-By: Claude Opus 5.5 --- .../is_cared_for_by_carer_benefit_recipient.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 f321616545..6772dfa69b 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 @@ -44,10 +44,11 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "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, so it inherits " - "carers_allowance, which does not apply the overlapping-benefit rule " - "that stops Carer's Allowance being paid alongside a higher State " - "Pension." + "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." ) definition_period = YEAR reference = ( From 215ef0beac0f8997da4eb10cb822dc17c0057e94 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:25:59 -0400 Subject: [PATCH 61/75] Update State Pension docs figures to runs on current main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rerun the before/after microsimulation on main (325d585e) and this branch merged with it, after #1899 merged. Figures from 2027-28 move slightly because main now projects the flat rates from the triple lock's statutory inputs: the gap to DWP's forecast less payments abroad is -£32.5bn in 2030-31 (was -£33.1bn), and State Pension per recipient grows 17.0% from 2025-26 to 2030-31 (was 16.4%; 21.8% before the fix, was 21.2%). Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 2518c0a89b..2f0a2e2178 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -288,11 +288,11 @@ abroad. | £bn | 2024-25 | 2025-26 | 2026-27 | 2027-28 | 2028-29 | 2029-30 | 2030-31 | |---|---:|---:|---:|---:|---:|---:|---:| -| Model State Pension | 119.0 | 124.8 | 129.6 | 130.9 | 132.9 | 136.9 | 140.9 | +| Model State Pension | 119.0 | 124.8 | 129.6 | 131.6 | 133.6 | 137.5 | 141.6 | | OBR March 2026 EFO, State Pension | 138.0 | 146.2 | 154.2 | 158.9 | 164.0 | 172.2 | 180.7 | | DWP Spring Forecast 2026, State Pension | 136.6 | 146.1 | 154.2 | 158.9 | 164.0 | 172.2 | 180.7 | | of which paid abroad | 5.3 | 5.6 | 5.9 | 6.1 | 6.2 | 6.4 | 6.6 | -| Model minus DWP less paid abroad | -12.2 | -15.6 | -18.6 | -21.9 | -24.8 | -28.9 | -33.1 | +| Model minus DWP less paid abroad | -12.2 | -15.6 | -18.6 | -21.3 | -24.2 | -28.3 | -32.5 | The model also includes Northern Ireland, which DWP's figures do not, so the like-for-like gap is larger by Northern Ireland's State Pension. @@ -322,7 +322,7 @@ tracked in [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929): to 67, while DWP's caseload less those paid abroad rises 4.8%. - **Awards.** Each record keeps its reported amount, uprated by the flat rate, including records whose cohort moves from basic to new State - Pension. The model's State Pension per recipient grows 16.4% over those + Pension. The model's State Pension per recipient grows 17.0% over those years. DWP's spending per recipient, less those paid abroad, grows 18.3%, as its new State Pension spending grows from £56.1bn to £102.4bn and its basic State Pension spending falls from £66.7bn to £57.8bn. @@ -331,7 +331,7 @@ Until the fix for [#1921](https://github.com/PolicyEngine/policyengine-uk/issues `additional_state_pension` paid the band between the flat rates twice for records moving from basic to new State Pension cohorts (see Components above): £0.8bn in 2025-26, rising to £6.8bn in 2030-31. That made the -model's State Pension per recipient grow 21.2%, faster than DWP's, and hid +model's State Pension per recipient grow 21.8%, faster than DWP's, and hid part of the widening. ### What's been fixed From fdeb64703f604f80b4a306a1ac9732de1b840a09 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:49:10 -0400 Subject: [PATCH 62/75] Correct the HB income in a pension-age premium test comment Main's #1908 no longer deducts earnings disregards from State Pension income, so the case's HB income is 16,914 (18,000 less 20% tax above the personal allowance), still below the applicable amount. The expected award is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../benefit/family/housing_benefit/pension_age_premiums.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml index 7d7059d142..6a6f03994f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_premiums.yaml @@ -35,7 +35,7 @@ pension_age_severe_disability_premium: 86.05 * 52 benefits_premiums: 86.05 * 52 # The claimant reached pension age before 1 April 2021, so the older - # allowance applies. HB income 16,548.80 is below the applicable amount, + # allowance applies. HB income 16,914 is below the applicable amount, # (256.00 + 86.05) * 52 = 17,786.60, so HB and CTR are at their maximum. housing_benefit: 6_240 council_tax_benefit: 2_000 From 00fb451d6afcb40f8e3b5d2643566cc810320060 Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sat, 3 Oct 2026 21:46:57 +0000 Subject: [PATCH 63/75] Update package version --- CHANGELOG.md | 11 +++++++++++ changelog.d/1916.fixed.md | 1 - changelog.d/class-4-chapter-2-profits.fixed.md | 3 --- changelog.d/sdp-carer-attribution.fixed.md | 1 - pyproject.toml | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) delete mode 100644 changelog.d/1916.fixed.md delete mode 100644 changelog.d/class-4-chapter-2-profits.fixed.md delete mode 100644 changelog.d/sdp-carer-attribution.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da7f930da..3480165121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2.112.1] - 2026-10-03 + +### Fixed + +- The legacy severe disability premium and the Pension Credit severe disability addition now share one attribution of whom each carer benefit is paid for caring for (`is_cared_for_by_carer_benefit_recipient`), so a carer in another benefit unit of the household bars the legacy premium as it already barred the addition, even when that carer is ignored as a non-dependant. +- - Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`). + - Isolated supplied-input provenance between simulation clones and removed it for deleted inputs, preventing Class 4 from relieving cached carry-over losses again. + - Corrected the Class 4 property-test reference to use each year's trading allowance parameter, including the zero allowance in historical years. +- - Charged Class 4 NICs on the profits chargeable under ITTOIA 2005 Part 2 Chapter 2, after capital allowances and the trading allowance, less trading losses under SSCBA 1992 Sch. 2 para. 3 (new `ni_class_4_profits`). Losses reduce only trade profits for Class 4 and carry forward until used. + + ## [2.112.0] - 2026-10-03 ### Added diff --git a/changelog.d/1916.fixed.md b/changelog.d/1916.fixed.md deleted file mode 100644 index d75e06f315..0000000000 --- a/changelog.d/1916.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Charged Class 4 NICs on the profits chargeable under ITTOIA 2005 Part 2 Chapter 2, after capital allowances and the trading allowance, less trading losses under SSCBA 1992 Sch. 2 para. 3 (new `ni_class_4_profits`). Losses reduce only trade profits for Class 4 and carry forward until used. diff --git a/changelog.d/class-4-chapter-2-profits.fixed.md b/changelog.d/class-4-chapter-2-profits.fixed.md deleted file mode 100644 index e0f6bc5988..0000000000 --- a/changelog.d/class-4-chapter-2-profits.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -- Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`). -- Isolated supplied-input provenance between simulation clones and removed it for deleted inputs, preventing Class 4 from relieving cached carry-over losses again. -- Corrected the Class 4 property-test reference to use each year's trading allowance parameter, including the zero allowance in historical years. diff --git a/changelog.d/sdp-carer-attribution.fixed.md b/changelog.d/sdp-carer-attribution.fixed.md deleted file mode 100644 index ceda1cf9a4..0000000000 --- a/changelog.d/sdp-carer-attribution.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The legacy severe disability premium and the Pension Credit severe disability addition now share one attribution of whom each carer benefit is paid for caring for (`is_cared_for_by_carer_benefit_recipient`), so a carer in another benefit unit of the household bars the legacy premium as it already barred the addition, even when that carer is ignored as a non-dependant. diff --git a/pyproject.toml b/pyproject.toml index 2429ae5e18..7c0f90f99c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.112.0" +version = "2.112.1" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 4ac62cbb5bc56167adc0ed5e8a0503c6274e3e7b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 18:51:32 -0400 Subject: [PATCH 64/75] Forward all storage arguments in the read-only cache test fixture policyengine-core 3.32.16 calls InMemoryStorage.put(..., derived=...). The fixture replaced put and get with fixed signatures, so five code-health tests failed with an unexpected keyword argument on every PR, because CI installs the latest core. The fixture now forwards every argument. Co-Authored-By: Claude Opus 5.5 --- .../test_cached_arrays_not_written_in_place.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py index 321edd943d..374ae085ce 100644 --- a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py +++ b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py @@ -469,13 +469,15 @@ def read_only_cache(monkeypatch): original_put = InMemoryStorage.put original_get = InMemoryStorage.get - def put(self, value, period, branch_name="default"): + # Forward every argument: storage methods gain keywords between core + # releases (3.32.16 added ``put(..., derived=...)``). + def put(self, value, *args, **kwargs): if isinstance(value, np.ndarray): value.flags.writeable = False - return original_put(self, value, period, branch_name) + return original_put(self, value, *args, **kwargs) - def get(self, period, branch_name="default"): - value = original_get(self, period, branch_name) + def get(self, *args, **kwargs): + value = original_get(self, *args, **kwargs) if isinstance(value, np.ndarray): value.flags.writeable = False return value From ec9d22ff63792abee1c2810ee05e6bedd992b292 Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 02:05:45 +0000 Subject: [PATCH 65/75] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/2005.added.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/2005.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3480165121..4e62c5bf25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.113.0] - 2026-10-04 + +### Added + +- - Give joint tenants and other sharers their share of the household's rent, boarders and lodgers their own rent and private tenure, a Housing Benefit LHA category (with its own size criteria and shared-accommodation rule) beside the Universal Credit one, statutory non-dependant exclusions and apportionment, the Housing Benefit meals deduction, and Council Tax Reduction on the council tax divided among jointly liable people, each claim under its own family's scheme and exemption from non-dependant deductions. + + ## [2.112.1] - 2026-10-03 ### Fixed diff --git a/changelog.d/2005.added.md b/changelog.d/2005.added.md deleted file mode 100644 index 7abfb43b3f..0000000000 --- a/changelog.d/2005.added.md +++ /dev/null @@ -1 +0,0 @@ -- Give joint tenants and other sharers their share of the household's rent, boarders and lodgers their own rent and private tenure, a Housing Benefit LHA category (with its own size criteria and shared-accommodation rule) beside the Universal Credit one, statutory non-dependant exclusions and apportionment, the Housing Benefit meals deduction, and Council Tax Reduction on the council tax divided among jointly liable people, each claim under its own family's scheme and exemption from non-dependant deductions. diff --git a/pyproject.toml b/pyproject.toml index 7c0f90f99c..3f82f2e3f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.112.1" +version = "2.113.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 7fee075d05be1e335485a33421882ef828da1e39 Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 02:06:58 +0000 Subject: [PATCH 66/75] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/afcs-severe-disability.changed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/afcs-severe-disability.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e62c5bf25..50ac17449a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.113.1] - 2026-10-04 + +### Changed + +- - Documented that `afcs_reported` is FRS benefit code 8 (Armed Forces Compensation Scheme payments and war disablement pensions, not armed forces independence payment) and tested that AFCS payments never confer severe disability. + + ## [2.113.0] - 2026-10-04 ### Added diff --git a/changelog.d/afcs-severe-disability.changed.md b/changelog.d/afcs-severe-disability.changed.md deleted file mode 100644 index 0c9753835b..0000000000 --- a/changelog.d/afcs-severe-disability.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Documented that `afcs_reported` is FRS benefit code 8 (Armed Forces Compensation Scheme payments and war disablement pensions, not armed forces independence payment) and tested that AFCS payments never confer severe disability. diff --git a/pyproject.toml b/pyproject.toml index 3f82f2e3f0..1ddaa533f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.113.0" +version = "2.113.1" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From af922472e8b222da9e840614c78e2817c5898695 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 22:24:57 -0400 Subject: [PATCH 67/75] Allow axiom: queued only when the encoder is blocked; add axiom: needed Per the independent review: use queued only when the signed encoder is blocked, record the blocker in the issue, and require separate approval for each billed encoder run. The PR template and the github-prs skill also allow external contributors to write `axiom: needed`, and the template's YAML-test checkbox applies only to policy changes. Co-Authored-By: Claude Opus 5.5 --- .github/CONTRIBUTING.md | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 4 ++-- docs/engineering/skills/github-prs.md | 13 +++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cf17a663cc..77a63f217e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -52,7 +52,7 @@ Every policy change here must also be correct in [rulespec-uk](https://github.co - National modules live under `uk/`, and council schemes such as council tax reduction live under `uk-/`. Search `main` there before opening a new issue. - An `encoded-correct` claim names the module and a companion case in its `.test.yaml` that exercises the same situation as your YAML test. -- Label `queued` issues `pe-parity`. Reuse your YAML test's externally sourced expected values (legislation.gov.uk, gov.uk guidance, official calculators) as the companion tests; don't copy values computed by policyengine-uk. +- Use `queued` only when the signed encoder is blocked; record the blocker in the issue. Each billed encoder run requires separate approval. Label `queued` issues `pe-parity`. Reuse your YAML test's externally sourced expected values (legislation.gov.uk, gov.uk guidance, official calculators) as the companion tests; don't copy values computed by policyengine-uk. ## Repo-specific anti-patterns diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 453df24228..efaaeeb295 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,9 +4,9 @@ Fixes # ## Test plan -- [ ] YAML tests whose expected values come from an external source (legislation, official guidance, official calculator) +- [ ] For policy changes: YAML tests whose expected values come from an external source (legislation, official guidance, official calculator); otherwise mark not applicable. - [ ] `make format` - [ ] Changelog fragment in `changelog.d/` - + axiom: encoded-correct | encoded | queued | n/a: diff --git a/docs/engineering/skills/github-prs.md b/docs/engineering/skills/github-prs.md index eadb157457..a1ac512107 100644 --- a/docs/engineering/skills/github-prs.md +++ b/docs/engineering/skills/github-prs.md @@ -65,10 +65,15 @@ these applies in one line of the PR description: axiom: encoded-correct | encoded | queued | n/a: ``` -A `queued` rulespec-uk issue must be dispatch-ready and labelled `pe-parity`. -It needs the module path and corpus citation, the verbatim law, the required -outputs, and companion tests from the same external source as the PR's YAML -tests. Never hand-write RuleSpec. See `.github/CONTRIBUTING.md` ("Axiom parity"). +If you are an external contributor and cannot complete the Axiom work, use +`axiom: needed`; a maintainer will follow up. + +Use `queued` only when the signed encoder is blocked; record the blocker in the +issue. Each billed encoder run requires separate approval. A `queued` +rulespec-uk issue must be dispatch-ready and labelled `pe-parity`. It needs the +module path and corpus citation, the verbatim law, the required outputs, and +companion tests from the same external source as the PR's YAML tests. Never +hand-write RuleSpec. See `.github/CONTRIBUTING.md` ("Axiom parity"). ## Required Lint And Format From a4944252e3eecbcc1561fc4a813b39a4647713fb Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 03:50:27 +0000 Subject: [PATCH 68/75] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/uc-earned-income-own-tax.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/uc-earned-income-own-tax.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ac17449a..45952c3caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.113.2] - 2026-10-04 + +### Fixed + +- Deduct from each person's Universal Credit earnings only their own income tax and National Insurance on that employment or self-employment (UC Regs 2013 reg. 55(5)(b), reg. 57(2) step 3), not the whole benefit unit's tax on all income, so tax on pensions, State Pension, property, savings and dividends, the High Income Child Benefit Charge and voluntary Class 3 contributions no longer reduce earned income. + + ## [2.113.1] - 2026-10-04 ### Changed diff --git a/changelog.d/uc-earned-income-own-tax.fixed.md b/changelog.d/uc-earned-income-own-tax.fixed.md deleted file mode 100644 index 0a800b871c..0000000000 --- a/changelog.d/uc-earned-income-own-tax.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Deduct from each person's Universal Credit earnings only their own income tax and National Insurance on that employment or self-employment (UC Regs 2013 reg. 55(5)(b), reg. 57(2) step 3), not the whole benefit unit's tax on all income, so tax on pensions, State Pension, property, savings and dividends, the High Income Child Benefit Charge and voluntary Class 3 contributions no longer reduce earned income. diff --git a/pyproject.toml b/pyproject.toml index 1ddaa533f9..75d77c73ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.113.1" +version = "2.113.2" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 723524e0815989cfda1e5d1e10bab272b4becfc7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 00:58:06 -0400 Subject: [PATCH 69/75] Make the State Pension property tests deterministic; tighten docs Address the merge-gate review of 215ef0be: - Use the repo's usual Hypothesis settings (derandomize=True, too_slow health check suppressed) with 10 examples per property test, so the file runs in about 5 minutes. Both property tests still fail on main's formula. - Docs: the additional State Pension bullet now says the excess is uprated; "no component before State Pension age" is stated for a computed type; FRS tables M.6a/M.6b are for Great Britain, the model's 11.36m for the UK. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 16 +++++++++------- .../tests/test_state_pension_components.py | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 2f0a2e2178..9f2c1d8b09 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -25,8 +25,9 @@ Three variables under `gov/dwp/`: accrual that exceeds the new flat rate, folded into NSP under current law but tracked separately in PolicyEngine so the reform surface stays clean. - - Both are computed as `max(reported - flat_max_for_type, 0)` per week, - multiplied by `WEEKS_IN_YEAR`. + - Both are the reported weekly amount above the type's full rate in the + data year, `max(reported - flat_max_for_type, 0)`, uprated as described + below and multiplied by `WEEKS_IN_YEAR`. All three split the amount a person reported in the data year (the dataset's first year) by the person's `state_pension_type` in the year @@ -56,8 +57,8 @@ below). The flag-up split (`state_pension_type`) is settled by [PR #1618](https://github.com/PolicyEngine/policyengine-uk/pull/1618): classification is based on whether the person reaches State Pension age -before or on/after 6 April 2016. No component is paid before State -Pension age. +before or on/after 6 April 2016. A computed `state_pension_type` is `NONE` +below State Pension age, so no component is paid there. ## State Pension age @@ -298,9 +299,10 @@ The model also includes Northern Ireland, which DWP's figures do not, so the like-for-like gap is larger by Northern Ireland's State Pension. **The data year.** In 2024-25 the model has 11.36m State Pension recipients -averaging £201.6 a week. The FRS's own grossed figures are 11.53m recipients -averaging £212 a week, and DWP's administrative figures are 11.88m and £212 -(FRS methodology tables M.6a and M.6b). The FRS's grossed estimate for the +averaging £201.6 a week, for the UK. The FRS's own grossed figures for Great +Britain are 11.53m recipients averaging £212 a week, and DWP's administrative +figures are 11.88m and £212 (FRS methodology tables M.6a and M.6b, which +cover Great Britain). The FRS's grossed estimate for the UK, with benefit amounts linked to DWP records, is £130.6bn, against £135.1bn from administrative data. So most of the model's £16bn shortfall against administrative data for the UK arises in building the enhanced FRS, diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index eb3d5b28ce..69f7c5d49b 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -16,7 +16,7 @@ import numpy as np import pandas as pd import pytest -from hypothesis import given, settings +from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import Microsimulation @@ -206,6 +206,15 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): assert total[1] == pytest.approx(uprated_reported(sim, 2030, "NEW", 230), abs=0.01) +# Each example builds a microsimulation, so examples are slow to generate. +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + PEOPLE = st.lists( st.fixed_dictionaries( { @@ -271,7 +280,7 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.all(new <= rates["NEW"] * WEEKS_IN_YEAR * (1 + 1e-6)) -@settings(max_examples=25, deadline=None) +@PROPERTY_SETTINGS @given(people=PEOPLE) def test_components_add_up_to_uprated_reported_amount(people): """Data projected with ages held fixed, every year from 2024-25 to 2030-31.""" @@ -279,7 +288,7 @@ def test_components_add_up_to_uprated_reported_amount(people): assert_components_add_up(sim, people, YEARS) -@settings(max_examples=25, deadline=None) +@PROPERTY_SETTINGS @given( people=PEOPLE, period=st.integers(min_value=DATA_YEAR + 1, max_value=2030), From 56f9d5bfe0c6e856c101320f4596c4cfbdcc0ee7 Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 10:08:03 +0000 Subject: [PATCH 70/75] Update package version --- CHANGELOG.md | 8 ++++++++ changelog.d/1955.fixed.md | 1 - changelog.d/additional-state-pension-period-type.fixed.md | 1 - pyproject.toml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/1955.fixed.md delete mode 100644 changelog.d/additional-state-pension-period-type.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 45952c3caa..7d80e3a8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [2.113.3] - 2026-10-04 + +### Fixed + +- Split additional State Pension by each person's State Pension type in the year simulated, as basic and new State Pension already are, instead of their type in the data year. Survey ages are held fixed across projected years, so records that were on the basic State Pension in the data year stand for new State Pension cohorts later, and the part of their reported State Pension between the two flat rates was paid twice. Basic, new and additional State Pension now add up to the reported amount uprated by the flat rate. +- Council Tax Reduction income now counts Carer Support Payment, which Scottish carers receive in place of Carer's Allowance (Council Tax Reduction (Scotland) Regulations 2021 reg 57(1)(b)(iva); Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg 27(1)(j)). The Scottish Carer Supplement stays out. + + ## [2.113.2] - 2026-10-04 ### Fixed diff --git a/changelog.d/1955.fixed.md b/changelog.d/1955.fixed.md deleted file mode 100644 index 056db824ef..0000000000 --- a/changelog.d/1955.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Council Tax Reduction income now counts Carer Support Payment, which Scottish carers receive in place of Carer's Allowance (Council Tax Reduction (Scotland) Regulations 2021 reg 57(1)(b)(iva); Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg 27(1)(j)). The Scottish Carer Supplement stays out. diff --git a/changelog.d/additional-state-pension-period-type.fixed.md b/changelog.d/additional-state-pension-period-type.fixed.md deleted file mode 100644 index 8d6871c631..0000000000 --- a/changelog.d/additional-state-pension-period-type.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Split additional State Pension by each person's State Pension type in the year simulated, as basic and new State Pension already are, instead of their type in the data year. Survey ages are held fixed across projected years, so records that were on the basic State Pension in the data year stand for new State Pension cohorts later, and the part of their reported State Pension between the two flat rates was paid twice. Basic, new and additional State Pension now add up to the reported amount uprated by the flat rate. diff --git a/pyproject.toml b/pyproject.toml index 75d77c73ab..80dcbea664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.113.2" +version = "2.113.3" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 3a0301f0c78114fc97a3e4200d28f2ca599f6034 Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 10:13:26 +0000 Subject: [PATCH 71/75] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/axiom-parity-guidelines.changed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/axiom-parity-guidelines.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d80e3a8dc..d572f2810f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.113.4] - 2026-10-04 + +### Changed + +- - Documented that policy changes must be mirrored in Axiom RuleSpec (rulespec-uk), and added a pull request template with the required `axiom:` line. + + ## [2.113.3] - 2026-10-04 ### Fixed diff --git a/changelog.d/axiom-parity-guidelines.changed.md b/changelog.d/axiom-parity-guidelines.changed.md deleted file mode 100644 index 519d89dc89..0000000000 --- a/changelog.d/axiom-parity-guidelines.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Documented that policy changes must be mirrored in Axiom RuleSpec (rulespec-uk), and added a pull request template with the required `axiom:` line. diff --git a/pyproject.toml b/pyproject.toml index 80dcbea664..3382e35c6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.113.3" +version = "2.113.4" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 9d7f9d55a9a86f7b72a1b7f2aa228368cdc0357a Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 10:16:29 +0000 Subject: [PATCH 72/75] Update package version --- CHANGELOG.md | 7 +++++++ .../hb-pension-age-severe-disability-premium.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/hb-pension-age-severe-disability-premium.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d572f2810f..134b01b7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.113.5] - 2026-10-04 + +### Fixed + +- - Corrected pension-age Housing Benefit and Council Tax Reduction premiums by removing adult disability and adult enhanced disability premiums absent from their statutory schedules and applying the severe disability premium's qualifying-benefit, couple, Housing Benefit non-dependant and shared carer-attribution conditions. The carer condition does not yet count Universal Credit carer-element awards (#2120). + + ## [2.113.4] - 2026-10-04 ### Changed diff --git a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md b/changelog.d/hb-pension-age-severe-disability-premium.fixed.md deleted file mode 100644 index af80310425..0000000000 --- a/changelog.d/hb-pension-age-severe-disability-premium.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Corrected pension-age Housing Benefit and Council Tax Reduction premiums by removing adult disability and adult enhanced disability premiums absent from their statutory schedules and applying the severe disability premium's qualifying-benefit, couple, Housing Benefit non-dependant and shared carer-attribution conditions. The carer condition does not yet count Universal Credit carer-element awards (#2120). diff --git a/pyproject.toml b/pyproject.toml index 3382e35c6a..00417ed3ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.113.4" +version = "2.113.5" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 126a6a83b40b6aea786a15458560e7734b883a1d Mon Sep 17 00:00:00 2001 From: MaxGhenis Date: Sun, 4 Oct 2026 10:27:34 +0000 Subject: [PATCH 73/75] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/pc-reported-capital-followups.changed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/pc-reported-capital-followups.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 134b01b7e5..6e699b3e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.113.6] - 2026-10-04 + +### Changed + +- Document what `pension_credit_reported_capital` must contain (countable capital after the Schedule V disregards and reg. 19 valuation) and what it replaces, and restate its monotonicity property around the savings credit's intended exception. + + ## [2.113.5] - 2026-10-04 ### Fixed diff --git a/changelog.d/pc-reported-capital-followups.changed.md b/changelog.d/pc-reported-capital-followups.changed.md deleted file mode 100644 index 2573a7f887..0000000000 --- a/changelog.d/pc-reported-capital-followups.changed.md +++ /dev/null @@ -1 +0,0 @@ -Document what `pension_credit_reported_capital` must contain (countable capital after the Schedule V disregards and reg. 19 valuation) and what it replaces, and restate its monotonicity property around the savings credit's intended exception. diff --git a/pyproject.toml b/pyproject.toml index 00417ed3ed..9584bd75ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.113.5" +version = "2.113.6" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 21412b6a177cfacfb8974ecdec962e7a9a32be85 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:40:47 -0400 Subject: [PATCH 74/75] Count corporate_wealth's components in every means-test capital list The six capital sources lists (UC, HB, IS, JSA, ESA, PC) name directly_held_shares, unit_and_investment_trusts and stocks_and_shares_isa in place of corporate_wealth, plus a new formula variable, unitemised_corporate_wealth = max(0, corporate_wealth - components), for whatever a dataset does not itemise. Datasets without the components keep counting corporate_wealth in full; datasets that build corporate_wealth as the components' exact sum count the same total, now itemised, so each component can carry its own valuation rule (the sale-expense deduction stacked on #1969 exempts unit trusts and ISAs). No dataset's capital changes: a differential property test checks that every programme's assessable capital equals the same holdings entered as one corporate_wealth. Review nits: the growth-factor sentence now matches uprating_indices.yaml exactly; the variable docs cite the DMG for the legacy benefits, the expert valuation of unquoted shares and the quoted-share rule for investment trusts. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 2 +- docs/book/assumptions/growthfactors.md | 2 +- .../gov/dwp/ESA/income/capital/sources.yaml | 10 +- .../gov/dwp/JSA/income/capital/sources.yaml | 10 +- .../means_test/capital/sources.yaml | 12 +- .../means_test/capital/sources.yaml | 6 +- .../income/capital/sources.yaml | 7 +- .../means_test/capital/sources.yaml | 13 ++- .../tests/test_capital_share_components.py | 110 ++++++++++++++++++ .../wealth/unitemised_corporate_wealth.py | 34 ++++++ .../variables/input/directly_held_shares.py | 17 +-- .../input/unit_and_investment_trusts.py | 12 +- 12 files changed, 201 insertions(+), 34 deletions(-) create mode 100644 policyengine_uk/tests/test_capital_share_components.py create mode 100644 policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md index 78001b6ac6..aa4202f98b 100644 --- a/changelog.d/wealth-share-components.added.md +++ b/changelog.d/wealth-share-components.added.md @@ -1 +1 @@ -Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, and uprate them and the ISA balances with per-capita GDP. +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count them and the stocks and shares ISA in place of `corporate_wealth` in every means-test capital sources list (with `unitemised_corporate_wealth` carrying any part a dataset does not itemise), and uprate them and the ISA balances with per-capita GDP. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 5a90a2e171..626965f0d6 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 3eb35b83ef..e0bd6f429d 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -3,10 +3,9 @@ description: > Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. The current mappings are `savings` for liquid capital, `owned_land` for land - held directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential + held directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 9 + land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 9 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 9 paragraphs 28 and 29 disregard the value of the right to receive an occupational or personal @@ -33,6 +32,9 @@ values: 2008-10-27: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 6484484ab8..e6e2f55294 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -3,10 +3,9 @@ description: > Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current mappings are `savings` for liquid capital, `owned_land` for land held - directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential + directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 8 + land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 8 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 8 paragraphs 28 and 29 disregard the value of the right to receive an occupational or personal @@ -34,6 +33,9 @@ values: 1996-10-07: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index e313576158..d4522d2197 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -3,10 +3,9 @@ description: > The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings are `savings` for liquid capital, `owned_land` for land held directly, - `corporate_wealth` as the broad available proxy for share-like financial - assets such as stocks, shares, unit trusts, and investment ISAs, + the share-like sources, `other_residential_property_value` for residential property other than the - home, and `non_residential_property_value` for other land and premises. + home, and `non_residential_property_value` for other land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 6 paragraphs 31 and 32 disregard the value of the @@ -15,7 +14,7 @@ description: > pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland regulations make the same disregard). Datasets built before private pension wealth was split out of `corporate_wealth` still carry it - inside that countable bucket. Other Schedule 6 disregards within these broad + inside `unitemised_corporate_wealth`. Other Schedule 6 disregards within these broad asset classes are not yet split out separately. Because the dataset only provides household-level capital stocks, the model allocates them across benunits using an adult-share proxy. @@ -42,6 +41,9 @@ values: 2006-04-10: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 5b28814633..336efd6e3f 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,6 +3,7 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. + `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. metadata: label: Income Support capital sources period: year @@ -22,6 +23,9 @@ values: 2010-01-01: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 0761c1b0f3..d5fb5b4388 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -5,7 +5,7 @@ description: > (Part I) disregard the value of the right to receive income from an occupational or personal pension scheme or under a retirement annuity contract; the notional income rule for undrawn money purchase pots - (regulation 18) is not modelled. + (regulation 18) is not modelled. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. metadata: label: Pension Credit capital sources unit: list @@ -25,4 +25,7 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index f64474cebb..82af9db7cb 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -4,15 +4,17 @@ description: > treated as income or disregarded under regulation 48 and Schedule 10. The current mappings are `savings` for liquid capital, `other_residential_property_value` for residential property other than the - home, `non_residential_property_value` for other land and premises, and - `corporate_wealth` for shares and similar investment assets. `main_residence_value` + home, `non_residential_property_value` for other land and premises, and the + share-like sources. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 10 paragraph 1 disregards premises occupied as the person's home. `private_pension_wealth` is excluded because Schedule 10 paragraph 10 disregards the value of any right to receive a pension under an occupational or personal pension scheme (or any other scheme registered under section 153 of the Finance Act 2004; paragraph 10 of Schedule 10 to the Northern Ireland regulations makes the same disregard); pension income - actually drawn counts as unearned income instead. Other Schedule 10 disregards within these broad asset + actually drawn counts as unearned income instead; datasets built before it + was split out of `corporate_wealth` still carry it inside + `unitemised_corporate_wealth`. Other Schedule 10 disregards within these broad asset classes are not yet split out separately. Where only household-level capital is available for a multi-benunit household, the model uses a household-wide adult-share proxy for residual capital because the dataset does not identify @@ -45,4 +47,7 @@ values: - savings - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py new file mode 100644 index 0000000000..8d4a5201ce --- /dev/null +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -0,0 +1,110 @@ +"""Itemising corporate_wealth never changes a household's capital total. + +The means-test capital sources list corporate_wealth's components and +unitemised_corporate_wealth (the part a dataset does not itemise) instead of +corporate_wealth. With no valuation rule that tells the components apart, every +programme's assessable capital must equal what it is when the same holdings are +entered as one unitemised corporate_wealth. +""" + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) +SHARE_LIKE_SOURCES = (*COMPONENTS, "unitemised_corporate_wealth") +SOURCE_LISTS = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital.sources", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital.sources", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital.sources", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital.sources", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital.sources", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital.sources", +} +AGE = {"pension_credit_assessable_capital": 70} +YEAR = 2026 + +system = CountryTaxBenefitSystem() +amount = st.integers(min_value=0, max_value=60_000) + + +def _parameter(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node(f"{YEAR}-01-01") + + +def _capital(variable, household): + situation = { + "people": {"adult": {"age": {YEAR: AGE.get(variable, 30)}}}, + "benunits": {"benunit": {"members": ["adult"], "would_claim_uc": {YEAR: True}}}, + "households": { + "household": { + "members": ["adult"], + **{name: {YEAR: value} for name, value in household.items()}, + } + }, + } + return Simulation(situation=situation).calculate(variable, YEAR)[0] + + +@pytest.mark.parametrize("variable", SOURCE_LISTS) +def test_lists_name_the_components_not_corporate_wealth(variable): + sources = list(_parameter(SOURCE_LISTS[variable])) + assert "corporate_wealth" not in sources + assert set(SHARE_LIKE_SOURCES) <= set(sources) + + +@settings(max_examples=40, deadline=None) +@given( + savings=amount, + corporate_wealth=amount, + components=st.tuples(amount, amount, amount), +) +def test_unitemised_corporate_wealth_is_the_floored_remainder( + savings, corporate_wealth, components +): + household = {"savings": savings, "corporate_wealth": corporate_wealth} + household.update(dict(zip(COMPONENTS, components))) + situation = { + "people": {"adult": {"age": {YEAR: 30}}}, + "households": { + "household": { + "members": ["adult"], + **{name: {YEAR: value} for name, value in household.items()}, + } + }, + } + residual = Simulation(situation=situation).calculate( + "unitemised_corporate_wealth", YEAR + )[0] + assert residual == max(0, corporate_wealth - sum(components)) + + +@pytest.mark.parametrize("variable", SOURCE_LISTS) +@settings(max_examples=15, deadline=None) +@given( + savings=amount, + corporate_wealth=amount, + components=st.tuples(amount, amount, amount), +) +def test_itemising_never_changes_capital( + variable, savings, corporate_wealth, components +): + """Split holdings count exactly as max(corporate_wealth, their sum) does.""" + itemised = {"savings": savings, "corporate_wealth": corporate_wealth} + itemised.update(dict(zip(COMPONENTS, components))) + unitemised = { + "savings": savings, + "corporate_wealth": max(corporate_wealth, sum(components)), + } + assert _capital(variable, itemised) == pytest.approx( + _capital(variable, unitemised), abs=1e-2 + ) diff --git a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py new file mode 100644 index 0000000000..1d7fcd56a1 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class unitemised_corporate_wealth(Variable): + label = "corporate wealth not itemised into components" + documentation = ( + "The part of corporate_wealth not carried in its itemised components " + "directly_held_shares, unit_and_investment_trusts and " + "stocks_and_shares_isa, floored at nil. On datasets without the " + "components it equals corporate_wealth (including the private pension " + "wealth that datasets built before that split fold into it); on datasets " + "that build corporate_wealth as the exact sum of the components it is " + "about nil. The means-test capital sources list the components and this " + "residual instead of corporate_wealth, so each itemised asset can carry " + "its own valuation rule while datasets without the split keep counting " + "corporate_wealth as before, and no holding is counted twice." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + itemised = add( + household, + period, + [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", + ], + ) + return max_(0, household("corporate_wealth", period) - itemised) diff --git a/policyengine_uk/variables/input/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py index 71f82217f4..5fe9fd079f 100644 --- a/policyengine_uk/variables/input/directly_held_shares.py +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -5,13 +5,16 @@ class directly_held_shares(Variable): label = "directly held shares" documentation = ( "Value of shares the household holds directly, outside ISAs and pooled " - "funds: shares in UK companies, listed or not, and employee shares and " - "share options. Part of corporate_wealth, so the two must never be " - "summed. The Enhanced FRS imputes it from the Wealth and Assets Survey " - "(UK shares plus employee shares and options); overseas shares are not " - "yet imputed. Quoted shares are valued for the means tests at their " - "price less 10% for the expenses of sale (Advice for Decision Making " - "H1665)." + "funds: shares in UK companies, listed or not (the Wealth and Assets " + "Survey question), and employee shares and share options. Part of " + "corporate_wealth, so the two must never be summed. Imputed for the " + "Enhanced FRS from the Wealth and Assets Survey's UK shares and " + "employee shares and options (policyengine-uk-data#501); overseas " + "shares are not imputed. Quoted shares are valued at their price less " + "10% for the expenses of sale (Advice for Decision Making H1665 for " + "Universal Credit; Decision Makers' Guide 29671, 52671 and 84763 for " + "Income Support and JSA, ESA and Pension Credit); unquoted shares need " + "an expert valuation (ADM H1679-H1681)." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/input/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py index 07e59aaee1..2352d63f49 100644 --- a/policyengine_uk/variables/input/unit_and_investment_trusts.py +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -6,11 +6,13 @@ class unit_and_investment_trusts(Variable): documentation = ( "Value of the household's holdings of unit trusts and investment trusts " "outside ISAs. The Wealth and Assets Survey asks about both in one " - "question, so the Enhanced FRS cannot separate them. Part of " - "corporate_wealth, so the two must never be summed. Unit trusts are " - "valued for the means tests at the price the manager pays on " - "withdrawal, with no deduction for expenses of sale (Advice for " - "Decision Making H1673-H1674)." + "question, so the Enhanced FRS cannot separate them " + "(policyengine-uk-data#501). Part of corporate_wealth, so the two must " + "never be summed. Unit trusts are valued at the bid price with no " + "deduction for expenses of sale, because the holder withdraws through " + "the manager (Advice for Decision Making H1673-H1674; Decision Makers' " + "Guide 29680-29681). Investment trusts are companies whose shares are " + "quoted, so they fall under the quoted-share rule (ADM H1665)." ) entity = Household definition_period = YEAR From b1a660045033bafd67a6f45ef8a5d7e17e798bbb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:57:09 -0400 Subject: [PATCH 75/75] Exempt unit trusts and stocks and shares ISAs from the sale-expense deduction corporate_wealth is now itemised in the capital sources lists (#1974), so the sale-expense lists name directly_held_shares, which DWP values less 10% for costs of sale (ADM H1665; DMG 29671/52671/84763; HBGM W1.513), and unitemised_corporate_wealth, which keeps the previous flat treatment on datasets that do not itemise. Unit trusts have no costs of sale even when sold through a broker (ADM H1673-H1674; DMG 29680-29681, 52680-52681, 84771-84772; HBGM W1.530-W1.531), and an ISA is valued at what the holder would get by withdrawing it, with no paragraph applying the 10% (ADM H1656-H1658; DMG 29662-29664, 52662, 84748-84750). The survey bucket of unit and investment trusts is treated as having no costs of sale; investment trusts were 8.2% and 6.9% of the pooled funds in stocks and shares ISAs at 5 April 2022 and 2025 (HMRC ISA Table 9.6). The property tests keep their own legal asset set and assert each programme's list against it, and gain the split invariants: the residual, backward compatibility with every component nil, conservation at a zero rate, split effect = rate x (trusts + ISA), and no double counting. #1974's itemising-invariance test no longer holds by design and is replaced by them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sale-expense-share-split.fixed.md | 1 + .../income/capital/sale_expenses/sources.yaml | 37 ++- .../income/capital/sale_expenses/sources.yaml | 36 ++- .../capital/sale_expenses/sources.yaml | 36 ++- .../capital/sale_expenses/sources.yaml | 36 ++- .../income/capital/sale_expenses/sources.yaml | 38 ++- .../capital/sale_expenses/sources.yaml | 34 ++- .../dwp/capital_valuation_share_split.yaml | 260 ++++++++++++++++ .../tests/test_capital_share_components.py | 34 +-- .../test_capital_valuation_properties.py | 287 +++++++++++++++--- .../tests/test_stock_capital_periods.py | 36 +++ 11 files changed, 710 insertions(+), 125 deletions(-) create mode 100644 changelog.d/sale-expense-share-split.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml diff --git a/changelog.d/sale-expense-share-split.fixed.md b/changelog.d/sale-expense-share-split.fixed.md new file mode 100644 index 0000000000..1060d7977b --- /dev/null +++ b/changelog.d/sale-expense-share-split.fixed.md @@ -0,0 +1 @@ +- Apply the 10% sale-expense deduction to directly held shares only, so unit and investment trusts (ADM H1673-H1674) and stocks and shares ISAs (ADM H1656) count at full value in every means test; datasets that do not itemise `corporate_wealth` keep its previous treatment. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml index 40f6f3fb9a..0f05cea7e0 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,28 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 52620-52623 and 52685). `corporate_wealth` is listed because + sale (DMG 52620-52623 and 52685). `directly_held_shares` is listed because shares are valued less 10% for the costs of selling them (DMG 52671). - It is a mixed variable, though: it also holds unit trusts, which have no - costs of sale (DMG 52681), stocks and shares ISAs and employee share - options, and datasets built before private pension wealth was split out also - fold non-defined-benefit pension wealth into it, which is not capital at - all. The deduction reaches all of these until the data separate them. - Sources not counted as income-related ESA capital are ignored. + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 52680-52681). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 52662-52664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as income-related ESA capital are + ignored. metadata: label: Income-related ESA capital sources with sale expenses period: year @@ -27,13 +41,16 @@ metadata: href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=115 - title: Decision Makers' Guide chapter 52, para. 52623 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 + - title: Decision Makers' Guide chapter 52, paras. 52662-52664 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=124 - title: Decision Makers' Guide chapter 52, para. 52671 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=126 - - title: Decision Makers' Guide chapter 52, paras. 52681 and 52685 - href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=128 + - title: Decision Makers' Guide chapter 52, paras. 52680-52681 and 52685 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=127 values: 2008-10-27: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml index 538833841b..db4d9e0322 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 29681), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as income-based JSA capital are ignored. + sale (DMG 29620-29623 and 29685). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (DMG 29671). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 29680-29681). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 29662-29664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as income-based JSA capital are ignored. metadata: label: Income-based JSA capital sources with sale expenses period: year @@ -25,13 +38,16 @@ metadata: href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Decision Makers' Guide chapter 29, para. 29671 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + - title: Decision Makers' Guide chapter 29, paras. 29680-29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: 1996-10-07: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index f57c9f5bd7..84284223d9 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -7,14 +7,29 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (HBGM BW1 Annex F and BP1 F2.03). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (HBGM W1.513). It - is a mixed variable, though: it also holds unit trusts, which have no costs - of sale (HBGM W1.530-W1.531), stocks and shares ISAs and employee share - options, and datasets built before private pension wealth was split out also - fold non-defined-benefit pension wealth into it, which is not capital at - all. The deduction reaches all of these until the data separate them. - Sources not counted as Housing Benefit capital are ignored. + sale (HBGM BW1 Annex F and BP1 F2.03). `directly_held_shares` is listed + because shares are valued less 10% for selling costs (HBGM W1.513 and + P1.513). `unit_and_investment_trusts` is deliberately not listed: unit trust + holdings are valued at the bid price, which already takes account of the + selling expenses, and the 10% is not deducted even if they could be sold + through a bank or stockbroker (HBGM W1.530-W1.531 and P1.530-P1.531). + Investment trusts are not named in the guidance and, as listed company + shares, arguably take the 10%, but the Wealth and Assets Survey asks about + unit and investment trusts in one question, so the whole bucket is treated + as having no costs of sale. That approximation counts investment trusts at + full value; HMRC ISA statistics (table 9.6) put investment trusts at about 7% + to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed either: the HB + guidance manual has no ISA paragraph, and the DWP guidance on the parallel + Income Support and JSA rule values an ISA at what the person would get by + withdrawing it, with no ISA paragraph applying the 10% (DMG 29662-29664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Housing Benefit capital are ignored. metadata: label: Housing Benefit capital sources with sale expenses period: year @@ -40,6 +55,8 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 - title: Housing Benefit Guidance Manual BP1, paras. P1.513 and P1.530-P1.531 href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=38 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 (ISAs, parallel IS and JSA guidance) + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Housing Benefit Guidance Manual BW1, Annex F href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=93 - title: Housing Benefit Guidance Manual BP1, paras. F2.00-F2.03 @@ -49,4 +66,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml index baa3504bda..06188a12fd 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 29681), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Income Support capital are ignored. + sale (DMG 29620-29623 and 29685). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (DMG 29671). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 29680-29681). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 29662-29664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Income Support capital are ignored. metadata: label: Income Support capital sources with sale expenses period: year @@ -25,13 +38,16 @@ metadata: href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Decision Makers' Guide chapter 29, para. 29671 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + - title: Decision Makers' Guide chapter 29, paras. 29680-29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: 2010-01-01: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml index 1dbbd6816f..dae92445b4 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 84712-84715 and 84731). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 84763). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 84772), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Pension Credit capital are ignored. + sale (DMG 84712-84715 and 84731). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (DMG 84763). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 84771-84772). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 84748-84750). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Pension Credit capital are ignored. metadata: label: Pension Credit capital sources with sale expenses period: year @@ -27,13 +40,16 @@ metadata: href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 - title: Decision Makers' Guide chapter 84, para. 84731 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=102 + - title: Decision Makers' Guide chapter 84, paras. 84748-84750 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=105 - title: Decision Makers' Guide chapter 84, para. 84763 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=107 - - title: Decision Makers' Guide chapter 84, para. 84772 - href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=109 + - title: Decision Makers' Guide chapter 84, paras. 84771-84772 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=108 values: 2003-10-06: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index fe25cbe7d8..f7a760beb9 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (ADM H1604-H1607 and H1675). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (ADM H1665). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (ADM H1674), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Universal Credit capital are ignored. + sale (ADM H1604-H1607 and H1675). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (ADM H1665). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (ADM + H1673-H1674). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (ADM H1656-H1658). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Universal Credit capital are ignored. metadata: label: Universal Credit capital sources with sale expenses period: year @@ -25,7 +38,7 @@ metadata: href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 - title: Advice for Decision Making chapter H1, paras. H1604-H1607 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 - - title: Advice for Decision Making chapter H1, para. H1656 + - title: Advice for Decision Making chapter H1, paras. H1656-H1658 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 - title: Advice for Decision Making chapter H1, para. H1665 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 @@ -36,4 +49,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml new file mode 100644 index 0000000000..d1c1a17514 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml @@ -0,0 +1,260 @@ +# corporate_wealth itemised into directly_held_shares, unit_and_investment_trusts +# and stocks_and_shares_isa, with unitemised_corporate_wealth carrying whatever +# is not itemised (max(0, corporate_wealth - the three components)). +# Capital is valued at market value less 10% where a sale would incur expenses +# (UC Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA +# Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). Shares +# take the 10% (ADM H1665; DMG 29671, 52671, 84763; HBGM W1.513). Unit trusts +# have no costs of sale (ADM H1673-H1674; DMG 29680-29681, 52680-52681, +# 84771-84772; HBGM W1.530-W1.531), and the survey bucket of unit and +# investment trusts is treated the same. An ISA is valued at what the person +# would get by withdrawing it, with no 10% (ADM H1656; DMG 29662, 52662, +# 84748). Expected values are worked by hand from those rules. + +- name: UC unsplit corporate_wealth keeps the flat 10% treatment + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # No components, so the residual is all 12,000 of corporate_wealth: + # 5,000 + 90% of 12,000 = 15,800, the same as before the split. + savings: 5_000 + corporate_wealth: 12_000 + output: + unitemised_corporate_wealth: 12_000 + uc_assessable_capital: 15_800 + is_uc_eligible: true + # 9,800 above 6,000 is 40 steps of 250 (rounded up): 40 * 4.35 * 12. + uc_tariff_income: 2_088 + +- name: UC split of the same total exempts trusts and ISAs and crosses the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual max(0, 12,000 - (4,000 + 3,000 + 5,000)) = 0. Capital is + # 5,000 + 90% of 4,000 + 3,000 + 5,000 = 16,600, above the 16,000 + # limit; unsplit, the same household held 15,800 and qualified. + savings: 5_000 + corporate_wealth: 12_000 + directly_held_shares: 4_000 + unit_and_investment_trusts: 3_000 + stocks_and_shares_isa: 5_000 + output: + unitemised_corporate_wealth: 0 + uc_assessable_capital: 16_600 + is_uc_eligible: false + +- name: UC counts the components when corporate_wealth is not given + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual max(0, 0 - 15,600) = 0. Capital is + # 1,000 + 90% of 10,000 + 2,600 + 3,000 = 15,600. + savings: 1_000 + directly_held_shares: 10_000 + unit_and_investment_trusts: 2_600 + stocks_and_shares_isa: 3_000 + output: + unitemised_corporate_wealth: 0 + uc_assessable_capital: 15_600 + is_uc_eligible: true + # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up): 39 * 4.35 * 12. + uc_tariff_income: 2_035.8 + +- name: UC partial split values the unitemised residual less 10% + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Only the ISA is itemised: residual 12,000 - 5,000 = 7,000. Share-like + # capital is 90% of 7,000 + 5,000 = 11,300; with savings 15,300 + # (unsplit it would be 4,000 + 10,800 = 14,800). + savings: 4_000 + corporate_wealth: 12_000 + stocks_and_shares_isa: 5_000 + output: + unitemised_corporate_wealth: 7_000 + uc_assessable_capital: 15_300 + # 9,300 above 6,000 is 38 steps of 250 (37.2 rounded up): 38 * 4.35 * 12. + uc_tariff_income: 1_983.6 + +- name: UC components exceeding corporate_wealth leave a nil residual + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The components (7,000) exceed corporate_wealth (5,000), so the + # residual is max(0, -2,000) = 0, not a negative offset. Capital is + # 1,000 + 90% of 4,000 + 3,000 = 7,600. + savings: 1_000 + corporate_wealth: 5_000 + directly_held_shares: 4_000 + unit_and_investment_trusts: 3_000 + output: + unitemised_corporate_wealth: 0 + uc_assessable_capital: 7_600 + # 1,600 above 6,000 is 7 steps of 250 (6.4 rounded up): 7 * 4.35 * 12. + uc_tariff_income: 365.4 + +- name: Housing Benefit split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # Residual 0. 1,100 + 90% of 5,000 + 4,000 + 6,000 = 15,600 (HBGM + # W1.513, W1.530); unsplit it would be 1,100 + 13,500 = 14,600. + savings: 1_100 + corporate_wealth: 15_000 + directly_held_shares: 5_000 + unit_and_investment_trusts: 4_000 + stocks_and_shares_isa: 6_000 + output: + unitemised_corporate_wealth: 0 + housing_benefit_assessable_capital: 15_600 + # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up) at 1 a week. + housing_benefit_tariff_income: 2_028 + housing_benefit_eligible: true + +- name: Income Support split exempts unit trusts + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 90% of 6,000 + 4,000 = 9,400 (DMG 29671, 29681); + # unsplit it would be 9,000. + corporate_wealth: 10_000 + directly_held_shares: 6_000 + unit_and_investment_trusts: 4_000 + output: + income_support_assessable_capital: 9_400 + +- name: Income-based JSA split exempts stocks and shares ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 2,000 + 90% of 3,000 + 6,000 = 10,700 (DMG 29662, + # 29671); unsplit it would be 2,000 + 8,100 = 10,100. + savings: 2_000 + corporate_wealth: 9_000 + directly_held_shares: 3_000 + stocks_and_shares_isa: 6_000 + output: + jsa_income_assessable_capital: 10_700 + +- name: Income-related ESA split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 3,000 + 90% of 2,000 + 3,000 + 5,000 = 12,800 (DMG + # 52662, 52671, 52681); unsplit it would be 3,000 + 9,000 = 12,000. + savings: 3_000 + corporate_wealth: 10_000 + directly_held_shares: 2_000 + unit_and_investment_trusts: 3_000 + stocks_and_shares_isa: 5_000 + output: + esa_income_assessable_capital: 12_800 + +- name: Pension Credit split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 2,100 + 90% of 10,000 + 8,000 + 12,000 = 31,100 (DMG + # 84748, 84763, 84772); unsplit it would be 2,100 + 27,000 = 29,100. + savings: 2_100 + corporate_wealth: 30_000 + directly_held_shares: 10_000 + unit_and_investment_trusts: 8_000 + stocks_and_shares_isa: 12_000 + output: + pension_credit_assessable_capital: 31_100 + # 21,100 above 10,000 is 43 steps of 500 (42.2 rounded up) at 1 a week. + pension_credit_deemed_income: 2_236 diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 8d4a5201ce..885d66333f 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -1,10 +1,10 @@ -"""Itemising corporate_wealth never changes a household's capital total. +"""The means-test capital sources itemise corporate_wealth. -The means-test capital sources list corporate_wealth's components and -unitemised_corporate_wealth (the part a dataset does not itemise) instead of -corporate_wealth. With no valuation rule that tells the components apart, every -programme's assessable capital must equal what it is when the same holdings are -entered as one unitemised corporate_wealth. +The lists name corporate_wealth's components and unitemised_corporate_wealth +(the part a dataset does not itemise) instead of corporate_wealth. Since the +sale-expense lists tell the components apart, itemising changes capital by +exactly rate x (trusts + ISA): that, conservation at a zero rate and the other +valuation invariants are in test_capital_valuation_properties.py. """ import pytest @@ -86,25 +86,3 @@ def test_unitemised_corporate_wealth_is_the_floored_remainder( "unitemised_corporate_wealth", YEAR )[0] assert residual == max(0, corporate_wealth - sum(components)) - - -@pytest.mark.parametrize("variable", SOURCE_LISTS) -@settings(max_examples=15, deadline=None) -@given( - savings=amount, - corporate_wealth=amount, - components=st.tuples(amount, amount, amount), -) -def test_itemising_never_changes_capital( - variable, savings, corporate_wealth, components -): - """Split holdings count exactly as max(corporate_wealth, their sum) does.""" - itemised = {"savings": savings, "corporate_wealth": corporate_wealth} - itemised.update(dict(zip(COMPONENTS, components))) - unitemised = { - "savings": savings, - "corporate_wealth": max(corporate_wealth, sum(components)), - } - assert _capital(variable, itemised) == pytest.approx( - _capital(variable, unitemised), abs=1e-2 - ) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 3ccf6d7eb3..4e77f884b9 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -7,9 +7,19 @@ Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). A bank or building society account is valued at its balance (ADM H1675); land and property always have costs of sale (H1606) and shares -are valued less 10% (H1665). +are valued less 10% (H1665). Unit trusts have no costs of sale (H1673-H1674) +and an ISA is valued at what the person would get by withdrawing it (H1656). -Invariants, for any generated population of single-adult households: +corporate_wealth may be itemised into directly_held_shares, +unit_and_investment_trusts and stocks_and_shares_isa; unitemised_corporate_wealth +carries whatever is not itemised, so it is all of corporate_wealth on datasets +without the components and about nil on datasets that build corporate_wealth as +their exact sum. The survey bucket of unit and investment trusts is treated as +having no costs of sale, an approximation for its investment-trust part. + +Invariants, for any generated population of single-adult households, whose +share-like holdings are unsplit, split consistently (corporate_wealth equal to +the components' sum), partly split, given as components only, or drawn freely: 1. Non-increasing: every programme's assessable capital is at most the same household's capital with the deduction switched off (rate 0, no debt). @@ -17,14 +27,27 @@ deduction on or off. 3. Differential: capital equals a direct implementation of the regulation, savings + sum over sources of max(0, 90% of value - secured debt), where - the test itself fixes which assets take the 10% (land, property and - shares, not cash), so a wrong parameter list fails it. + the test itself fixes which assets count and which take the 10% (land, + property, directly held shares and unitemised corporate wealth; not cash, + trusts or ISAs), so a wrong parameter list fails it. 4. Monotone: a higher sale-expense rate, or more secured debt, never raises capital. 5. Isolation: debt secured on one source never takes capital below the value of the household's unencumbered sources, and capital is never negative. (Isolation between two encumbered sources is pinned by the differential and by a YAML case.) +6. Residual: unitemised_corporate_wealth is never negative and equals + max(0, corporate_wealth - sum of components). +7. Backward compatibility: with every component nil, every programme's capital + is bit-for-bit the capital under a reform restoring the source lists used + before the split. +8. Conservation: at rate 0, a consistent split leaves capital unchanged. +9. Split effect: a consistent split less the unsplit household is rate x + (trusts + ISA), which lies in [0, rate x corporate_wealth]. +10. No double counting: capital is at most the other assets plus + max(corporate_wealth, sum of components). +11. Aggregates: total_wealth, net_wealth and corporate_sector_wealth do not + change with the component inputs. """ from functools import reduce @@ -33,7 +56,7 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from policyengine_uk import Simulation +from policyengine_uk import CountryTaxBenefitSystem, Simulation YEAR = 2026 PROPERTY_SETTINGS = settings( @@ -56,45 +79,105 @@ "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", } PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) +RESIDUAL = "unitemised_corporate_wealth" +SHARE_LIKE = (*COMPONENTS, RESIDUAL) +PROPERTY = ("other_residential_property_value", "non_residential_property_value") +# The assets each programme counts, fixed here rather than read from the +# parameters. Universal Credit's list has never included owned_land; that +# predates the capital-valuation work and is outside these properties. +LEGACY_COUNTED = {"savings", "owned_land", *PROPERTY, *SHARE_LIKE} +COUNTED = { + variable: LEGACY_COUNTED + for variable in PROGRAMMES + if variable != "uc_assessable_capital" +} | {"uc_assessable_capital": {"savings", *PROPERTY, *SHARE_LIKE}} # The assets whose sale incurs expenses, fixed here from the law rather than -# read from the parameters: real property always (ADM H1606), shares (H1665); -# never a bank or building society balance (H1675). +# read from the parameters: real property always (ADM H1606) and shares +# (H1665); never a bank or building society balance (H1675), a unit trust +# (H1673-H1674) or an ISA (H1656). The unitemised residual keeps the flat +# treatment corporate_wealth had before it was itemised. SALE_EXPENSE_ASSETS = { "owned_land", - "other_residential_property_value", - "non_residential_property_value", - "corporate_wealth", + *PROPERTY, + "directly_held_shares", + RESIDUAL, } LEGAL_RATE = 0.1 -ASSETS = [ +# The source lists in force before corporate_wealth was itemised, for the +# backward-compatibility differential. +PRE_SPLIT_LEGACY_SOURCES = [ "savings", "owned_land", "corporate_wealth", - "other_residential_property_value", - "non_residential_property_value", + *PROPERTY, ] +PRE_SPLIT_SOURCES = { + "gov.dwp.universal_credit.means_test.capital": [ + "savings", + *PROPERTY, + "corporate_wealth", + ], + "gov.dwp.housing_benefit.means_test.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.income_support.means_test.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.JSA.income.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.ESA.income.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.pension_credit.income.capital": [ + "savings", + "owned_land", + *PROPERTY, + "corporate_wealth", + ], +} +PRE_SPLIT_SALE_EXPENSE_SOURCES = ["owned_land", *PROPERTY, "corporate_wealth"] +BASE_ASSETS = ["savings", "owned_land", "corporate_wealth", *PROPERTY] DEBTS = { "owned_land": "owned_land_secured_debt", "other_residential_property_value": "other_residential_property_secured_debt", "non_residential_property_value": "non_residential_property_secured_debt", } +SPLIT_MODES = ("unsplit", "consistent", "partial", "components_only", "free") amount = st.one_of(st.just(0.0), st.floats(0, 400_000, allow_nan=False)) @st.composite -def households(draw, cash_only=False): - assets = {asset: draw(amount) for asset in ASSETS} +def households(draw, mode=None, cash_only=False, dividends=False): + mode = mode or draw(st.sampled_from(SPLIT_MODES)) + assets = {asset: draw(amount) for asset in BASE_ASSETS} + components = {component: draw(amount) for component in COMPONENTS} + if mode == "unsplit": + components = {component: 0.0 for component in COMPONENTS} + elif mode == "consistent": + assets["corporate_wealth"] = sum(components.values()) + elif mode == "partial": + components = { + component: value if draw(st.booleans()) else 0.0 + for component, value in components.items() + } + assets["corporate_wealth"] = sum(components.values()) + draw(amount) + elif mode == "components_only": + assets["corporate_wealth"] = 0.0 + assets |= components debts = {debt: draw(amount) for debt in DEBTS.values()} if cash_only: - assets = {asset: 0.0 for asset in ASSETS} | {"savings": assets["savings"]} + assets = {asset: 0.0 for asset in assets} | {"savings": assets["savings"]} debts = {debt: 0.0 for debt in debts} - return dict(age=draw(st.sampled_from([30, 70])), assets=assets, debts=debts) + unit = dict(age=draw(st.sampled_from([30, 70])), assets=assets, debts=debts) + if dividends: + unit["dividend_income"] = draw(st.floats(0, 50_000, allow_nan=False)) + return unit -def situation(units, debt_scale=1.0): +def situation(units, debt_scale=1.0, dividends=True): people, benunits, homes = {}, {}, {} for i, unit in enumerate(units): people[f"p{i}"] = {"age": {YEAR: unit["age"]}} + if dividends and "dividend_income" in unit: + people[f"p{i}"]["dividend_income"] = {YEAR: unit["dividend_income"]} benunits[f"b{i}"] = {"members": [f"p{i}"]} homes[f"h{i}"] = { "members": [f"p{i}"], @@ -110,16 +193,46 @@ def rate_reform(rate): } -def calculate(units, rate=None, debt_scale=1.0): +PRE_SPLIT_REFORM = { + f"{node}.{parameter}": {str(YEAR): value} + for node, sources in PRE_SPLIT_SOURCES.items() + for parameter, value in ( + ("sources", sources), + ("sale_expenses.sources", PRE_SPLIT_SALE_EXPENSE_SOURCES), + ) +} +EXTRA_OUTPUTS = ( + "guarantee_credit", + RESIDUAL, + "uc_tariff_income", + "uc_unearned_income", + "pension_credit_deemed_income", + "total_wealth", + "net_wealth", + "corporate_sector_wealth", +) + + +def calculate(units, rate=None, debt_scale=1.0, reform=None, dividends=True): + if rate is not None: + reform = (reform or {}) | rate_reform(rate) sim = Simulation( - situation=situation(units, debt_scale), - reform=None if rate is None else rate_reform(rate), + situation=situation(units, debt_scale, dividends), + reform=reform, ) - values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} - values["guarantee_credit"] = np.asarray(sim.calculate("guarantee_credit", YEAR)) + values = { + v: np.asarray(sim.calculate(v, YEAR), dtype=float) + for v in (*PROGRAMMES, *EXTRA_OUTPUTS) + } return sim, values +def without_components(unit): + """The same household with corporate_wealth left unitemised.""" + assets = unit["assets"] | {component: 0.0 for component in COMPONENTS} + return unit | {"assets": assets} + + def parameter_node(sim, path): return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) @@ -130,21 +243,43 @@ def applies(variable, unit, guarantee_credit=0.0): return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) -def expected_capital(unit, node): +def residual(unit): + assets = unit["assets"] + return max(0.0, assets["corporate_wealth"] - sum(assets[c] for c in COMPONENTS)) + + +def share_like_total(unit): + """max(corporate_wealth, sum of components): the share-like holdings once.""" + return sum(unit["assets"][c] for c in COMPONENTS) + residual(unit) + + +def expected_capital(unit, variable, rate=LEGAL_RATE): """Reg. 49(1) applied source by source, independently of the model.""" + values = unit["assets"] | {RESIDUAL: residual(unit)} total = 0.0 - for source in node.sources: - value = unit["assets"][source] + for source in COUNTED[variable]: + value = values[source] if source in SALE_EXPENSE_ASSETS: - value *= 1 - LEGAL_RATE + value *= 1 - rate if source in DEBTS: value = max(0.0, value - unit["debts"][DEBTS[source]]) total += value return total -def close(a, b): - return abs(a - b) <= 1 + 1e-6 * abs(b) +def close(a, b, scale=0.0): + """Equal up to float32 rounding of totals as large as ``b`` or ``scale``.""" + return abs(a - b) <= 1 + 1e-6 * max(abs(b), abs(scale)) + + +def test_source_lists_match_the_legal_asset_sets(): + parameters = CountryTaxBenefitSystem().parameters(YEAR) + for variable, path in PROGRAMMES.items(): + node = reduce(getattr, path.split("."), parameters) + assert set(node.sources) == COUNTED[variable], variable + assert set(node.sale_expenses.sources) == SALE_EXPENSE_ASSETS, variable + assert "corporate_wealth" not in node.sources, variable + assert node.sale_expenses.rate == LEGAL_RATE, variable @PROPERTY_SETTINGS @@ -155,11 +290,7 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): for variable, path in PROGRAMMES.items(): node = parameter_node(sim, path) assert node.sale_expenses.rate == LEGAL_RATE - assert "savings" in node.sources - counted = set(node.sources) - assert set(node.sale_expenses.sources) & counted == ( - SALE_EXPENSE_ASSETS & counted - ), variable + assert set(node.sale_expenses.sources) == SALE_EXPENSE_ASSETS, variable for i, unit in enumerate(units): gc = valued["guarantee_credit"][i] assert valued[variable][i] >= 0, (variable, unit) @@ -168,15 +299,97 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): if gc > 0: assert valued[variable][i] == 0, unit if applies(variable, unit, gc): - expected = expected_capital(unit, node) + expected = expected_capital(unit, variable) assert close(valued[variable][i], expected), (variable, unit) # Debt secured on property never reaches the other assets. + values = unit["assets"] | {RESIDUAL: residual(unit)} floor = sum( - unit["assets"][s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) - for s in node.sources + values[s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) + for s in COUNTED[variable] if s not in DEBTS ) assert valued[variable][i] >= floor - 1, (variable, unit) + # Each share-like holding is counted at most once. + other = sum( + unit["assets"][s] for s in COUNTED[variable] if s not in SHARE_LIKE + ) + ceiling = other + share_like_total(unit) + assert valued[variable][i] <= ceiling + 1 + 1e-6 * ceiling, ( + variable, + unit, + ) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_residual_is_corporate_wealth_less_its_components_floored_at_nil(units): + _, valued = calculate(units) + for i, unit in enumerate(units): + assert valued[RESIDUAL][i] >= 0, unit + assert close(valued[RESIDUAL][i], residual(unit)), unit + if all(unit["assets"][c] == 0 for c in COMPONENTS): + assert valued[RESIDUAL][i] == np.float32(unit["assets"]["corporate_wealth"]) + + +@PROPERTY_SETTINGS +@given(st.lists(households(mode="unsplit"), min_size=20, max_size=200)) +def test_unsplit_datasets_match_the_pre_split_source_lists_exactly(units): + _, split_lists = calculate(units) + _, pre_split = calculate(units, reform=PRE_SPLIT_REFORM) + for variable in ( + *PROGRAMMES, + "guarantee_credit", + "uc_tariff_income", + "uc_unearned_income", + "pension_credit_deemed_income", + ): + assert np.array_equal(split_lists[variable], pre_split[variable]), variable + + +@PROPERTY_SETTINGS +@given( + st.lists(households(mode="consistent"), min_size=20, max_size=200), + st.one_of(st.just(LEGAL_RATE), st.floats(0, 0.5)), +) +def test_a_consistent_split_moves_capital_only_by_the_exempt_holdings(units, rate): + unsplit_units = [without_components(unit) for unit in units] + _, split0 = calculate(units, rate=0.0) + _, unsplit0 = calculate(unsplit_units, rate=0.0) + _, split = calculate(units, rate=rate) + _, unsplit = calculate(unsplit_units, rate=rate) + for variable in PROGRAMMES: + for i, unit in enumerate(units): + if not ( + applies(variable, unit, split0["guarantee_credit"][i]) + and applies(variable, unit, unsplit0["guarantee_credit"][i]) + ): + continue + # At rate 0 itemising corporate_wealth conserves capital. + assert close(split0[variable][i], unsplit0[variable][i]), (variable, unit) + if not ( + applies(variable, unit, split["guarantee_credit"][i]) + and applies(variable, unit, unsplit["guarantee_credit"][i]) + ): + continue + exempt = ( + unit["assets"]["unit_and_investment_trusts"] + + unit["assets"]["stocks_and_shares_isa"] + ) + difference = split[variable][i] - unsplit[variable][i] + scale = max(split[variable][i], unsplit[variable][i]) + assert close(difference, rate * exempt, scale), (variable, unit, rate) + cap = rate * unit["assets"]["corporate_wealth"] + slack = 1 + 1e-6 * scale + assert -slack <= difference <= cap + slack, (variable, unit, rate) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_wealth_aggregates_ignore_the_components(units): + _, itemised = calculate(units) + _, unitemised = calculate([without_components(unit) for unit in units]) + for variable in ("total_wealth", "net_wealth", "corporate_sector_wealth"): + assert np.array_equal(itemised[variable], unitemised[variable]), variable @PROPERTY_SETTINGS diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index eead7689d9..fe6f440ddf 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -57,3 +57,39 @@ def test_pension_credit_capital_stocks_are_not_prorated_monthly(): simulation.calculate("pension_credit_assessable_capital", "2026-01")[0] == 33_600 ) + + +def test_itemised_share_like_stocks_are_not_prorated_monthly(): + stocks = { + "savings": 1_000, + "corporate_wealth": 12_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 2_000, + } + for age, capital in ( + (30, "uc_assessable_capital"), + (70, "pension_credit_assessable_capital"), + ): + situation = { + "people": {"person": {"age": {"2026": age}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + **{name: {"2026": value} for name, value in stocks.items()}, + } + }, + } + simulation = Simulation(situation=situation) + + for name, value in stocks.items(): + assert simulation.calculate(name, "2026-01")[0] == value, name + # 12,000 less the 9,000 itemised. + assert simulation.calculate("unitemised_corporate_wealth", "2026-01")[0] == ( + 3_000 + ) + # Shares and the unitemised residual less 10% for the expenses of + # sale; unit trusts and the ISA at their withdrawal value: + # 1,000 + 3,600 + 3,000 + 2,000 + 2,700. + assert simulation.calculate(capital, "2026-01")[0] == 12_300, capital