From f6bfade82c0ac3c76ff8ed2169d7b60cb75a49f7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:10:59 +0400 Subject: [PATCH 01/28] Fix 2025 Pension Credit rates and focus local tests --- changelog.d/2083.fixed.md | 1 + .../guarantee_credit/minimum_guarantee.yaml | 22 +++++++++++-- .../savings_credit/threshold.yaml | 22 +++++++++++-- .../standard_minimum_guarantee.yaml | 28 ++++++++++++++++ .../is_savings_credit_eligible.yaml | 33 +++++++++++++++++++ .../savings_credit/savings_credit.yaml | 24 ++++++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2083.fixed.md diff --git a/changelog.d/2083.fixed.md b/changelog.d/2083.fixed.md new file mode 100644 index 0000000000..39eb293089 --- /dev/null +++ b/changelog.d/2083.fixed.md @@ -0,0 +1 @@ +- Corrected the 2025-26 Pension Credit standard minimum guarantee and Savings Credit thresholds using official DWP and legislation sources. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml index a1dfb5f57b..9c23579c53 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml @@ -11,7 +11,16 @@ SINGLE: 2022-04-01: 182.6 2023-04-01: 201.05 2024-04-01: 218.15 - 2025-04-01: 227.1 + 2025-04-01: + value: 227.1 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 238 metadata: uprating: gov.benefit_uprating_cpi @@ -27,7 +36,16 @@ COUPLE: 2022-04-01: 278.7 2023-04-01: 306.85 2024-04-01: 332.95 - 2025-04-01: 346.6 + 2025-04-01: + value: 346.6 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 363.25 metadata: uprating: gov.benefit_uprating_cpi diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 568efeac00..1e46fcbb18 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -11,7 +11,16 @@ SINGLE: 2022-04-01: 158.47 2023-04-01: 174.49 2024-04-01: 189.8 - 2025-04-01: 198.27 + 2025-04-01: + value: 198.27 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 208.07 metadata: label: Pension Credit Savings Credit income threshold (single) @@ -27,7 +36,16 @@ COUPLE: 2022-04-01: 251.7 2023-04-01: 277.12 2024-04-01: 301.22 - 2025-04-01: 314.34 + 2025-04-01: + value: 314.34 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 329.75 metadata: label: Pension Credit Savings Credit income threshold (couple) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml index 03ec484e62..67f6cde11d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml @@ -25,3 +25,31 @@ members: [person1, person2] output: standard_minimum_guarantee: 18_889 + +- name: Case 3, 2025 single-person standard minimum guarantee uses the published rate. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + benunits: + benunit: + members: [person1] + output: + standard_minimum_guarantee: 11_809.2 + +- name: Case 4, 2025 couple standard minimum guarantee uses the published rate. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + person2: + age: 78 + benunits: + benunit: + members: [person1, person2] + output: + standard_minimum_guarantee: 18_023.2 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index 8e32aebaa7..a7fe1ac9a5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -99,3 +99,36 @@ output: # 12,000 > 208.07 * 52, but no claimant reached SPA before the cutoff. is_savings_credit_eligible: false + +- name: Case 6, 2025 single claimant below the published threshold is ineligible. + period: 2025 + input: + people: + person: + age: 80 + state_pension_age: 60 + benunits: + benunit: + members: [person] + relation_type: SINGLE + savings_credit_income: 10_100 + output: + is_savings_credit_eligible: false + +- name: Case 7, 2025 couple below the published threshold is ineligible. + period: 2025 + input: + people: + person: + age: 80 + state_pension_age: 60 + person2: + age: 78 + state_pension_age: 60 + benunits: + benunit: + members: [person, person2] + relation_type: COUPLE + savings_credit_income: 16_000 + output: + is_savings_credit_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index 1d613f2042..351868ff6d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -23,3 +23,27 @@ minimum_guarantee: 12_376 output: savings_credit: 684.216 + +- name: Case 3, 2025 single claimant uses the corrected coupled rates. + period: 2025 + absolute_error_margin: 0.01 + input: + is_savings_credit_eligible: true + relation_type: SINGLE + savings_credit_income: 20_000 + pension_credit_income: 20_000 + minimum_guarantee: 20_000 + output: + savings_credit: 899.496 + +- name: Case 4, 2025 couple uses the corrected coupled rates. + period: 2025 + absolute_error_margin: 0.01 + input: + is_savings_credit_eligible: true + relation_type: COUPLE + savings_credit_income: 25_000 + pension_credit_income: 25_000 + minimum_guarantee: 25_000 + output: + savings_credit: 1_006.512 From 8d8e0180bf0ed4a180515eacf6de8f8dad1f4fc5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:40:59 +0400 Subject: [PATCH 02/28] Strengthen Pension Credit savings credit tests --- .../is_savings_credit_eligible.yaml | 19 +++++++++++++++++- .../savings_credit/savings_credit.yaml | 20 +++++++++---------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index a7fe1ac9a5..0dd16c876e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -111,7 +111,8 @@ benunit: members: [person] relation_type: SINGLE - savings_credit_income: 10_100 + # One penny below 198.27 * 52. + savings_credit_income: 10_310.03 output: is_savings_credit_eligible: false @@ -132,3 +133,19 @@ savings_credit_income: 16_000 output: is_savings_credit_eligible: false + +- name: Case 8, 2025 single claimant above the published threshold is eligible. + period: 2025 + input: + people: + person: + age: 80 + state_pension_age: 60 + benunits: + benunit: + members: [person] + relation_type: SINGLE + # One penny above 198.27 * 52. + savings_credit_income: 10_310.05 + output: + is_savings_credit_eligible: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index 351868ff6d..51006f9056 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -24,26 +24,26 @@ output: savings_credit: 684.216 -- name: Case 3, 2025 single claimant uses the corrected coupled rates. +- name: Case 3, 2025 single claimant uses the corrected threshold. period: 2025 absolute_error_margin: 0.01 input: is_savings_credit_eligible: true relation_type: SINGLE - savings_credit_income: 20_000 - pension_credit_income: 20_000 - minimum_guarantee: 20_000 + savings_credit_income: 11_000 + pension_credit_income: 11_000 output: - savings_credit: 899.496 + # 0.6 * (11,000 - 198.27 * 52). + savings_credit: 413.976 -- name: Case 4, 2025 couple uses the corrected coupled rates. +- name: Case 4, 2025 couple uses the corrected threshold. period: 2025 absolute_error_margin: 0.01 input: is_savings_credit_eligible: true relation_type: COUPLE - savings_credit_income: 25_000 - pension_credit_income: 25_000 - minimum_guarantee: 25_000 + savings_credit_income: 17_000 + pension_credit_income: 17_000 output: - savings_credit: 1_006.512 + # 0.6 * (17,000 - 314.34 * 52). + savings_credit: 392.592 From ddce2b3408033f145424edc1e35889797bf081f8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:23:22 +0400 Subject: [PATCH 03/28] Correct Savings Credit threshold projection --- changelog.d/2083.fixed.md | 2 +- .../pension_credit/savings_credit/threshold.yaml | 7 ++++++- .../is_savings_credit_eligible.yaml | 16 ++++++++++++++++ .../savings_credit/savings_credit.yaml | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/changelog.d/2083.fixed.md b/changelog.d/2083.fixed.md index 39eb293089..f43d22c174 100644 --- a/changelog.d/2083.fixed.md +++ b/changelog.d/2083.fixed.md @@ -1 +1 @@ -- Corrected the 2025-26 Pension Credit standard minimum guarantee and Savings Credit thresholds using official DWP and legislation sources. +- Corrected the 2025-26 Pension Credit standard minimum guarantee and Savings Credit thresholds, and removed direct CPI projection of the thresholds. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 1e46fcbb18..3d6ff919b4 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -52,9 +52,10 @@ COUPLE: metadata: unit: currency-GBP period: week - uprating: gov.benefit_uprating_cpi propagate_metadata_to_children: true reference: + - title: State Pension Credit Act 2002 s. 3 + href: https://www.legislation.gov.uk/ukpga/2002/16/section/3 - title: The State Pension Credit Regulations 2002 reg. 7 href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/7/2021-04-12 - title: Benefit and pension rates 2025 to 2026 (DWP, gov.uk) @@ -65,3 +66,7 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: A11/2024 Housing Benefit uprating for the financial year ending March 2026 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2024/a112024-housing-benefit-uprating-for-the-financial-year-ending-march-2026 + - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index 0dd16c876e..f3d9e8d9ad 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -149,3 +149,19 @@ savings_credit_income: 10_310.05 output: is_savings_credit_eligible: true + +- name: Case 9, 2027 uses the last enacted threshold without direct CPI uprating. + period: 2027 + input: + people: + person: + age: 82 + state_pension_age: 60 + benunits: + benunit: + members: [person] + relation_type: SINGLE + savings_credit_income: 11_000 + output: + # 11,000 > 208.07 * 52. + is_savings_credit_eligible: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index 51006f9056..20ee3bb6e3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -47,3 +47,17 @@ output: # 0.6 * (17,000 - 314.34 * 52). savings_credit: 392.592 + +- name: Case 5, 2027 does not apply CPI directly to the threshold. + period: 2027 + absolute_error_margin: 0.01 + input: + is_savings_credit_eligible: true + relation_type: SINGLE + savings_credit_income: 11_000 + pension_credit_income: 11_000 + standard_minimum_guarantee: 12_376 + minimum_guarantee: 20_000 + output: + # 0.6 * (11,000 - 208.07 * 52). + savings_credit: 108.216 From a01c49be8b61b4dbb98b2a7de874f62029f750ba Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:46:31 +0400 Subject: [PATCH 04/28] Fix issues from review: align pension-age projections --- changelog.d/2072.fixed.md | 1 + changelog.d/2083.fixed.md | 2 +- changelog.d/2154.fixed.md | 1 + docs/book/programs/gov/dwp/state-pension.md | 9 + .../allowances/couple/aged.yaml | 9 +- .../create_protected_pension_age_uprating.py | 185 ++++++++++++++++++ .../allowances/lone_parent/aged.yaml | 9 +- .../allowances/single/aged.yaml | 9 +- ...ate_savings_credit_threshold_projection.py | 91 +++++++++ .../guarantee_credit/minimum_guarantee.yaml | 16 +- .../savings_credit/threshold.yaml | 14 +- .../triple_lock/create_triple_lock.py | 61 +++++- policyengine_uk/tax_benefit_system.py | 10 + .../is_savings_credit_eligible.yaml | 6 +- .../savings_credit/savings_credit.yaml | 10 +- ...ng_benefit_protected_allowance_uprating.py | 143 ++++++++++++++ ...est_savings_credit_threshold_projection.py | 79 ++++++++ .../test_triple_lock_statutory_inputs.py | 54 ++++- 18 files changed, 677 insertions(+), 32 deletions(-) create mode 100644 changelog.d/2072.fixed.md create mode 100644 changelog.d/2154.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py create mode 100644 policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py create mode 100644 policyengine_uk/tests/test_savings_credit_threshold_projection.py diff --git a/changelog.d/2072.fixed.md b/changelog.d/2072.fixed.md new file mode 100644 index 0000000000..a93ededf80 --- /dev/null +++ b/changelog.d/2072.fixed.md @@ -0,0 +1 @@ +- Corrects the Pension Credit standard minimum guarantee projection to follow the statutory May-July earnings input with a non-negative floor. diff --git a/changelog.d/2083.fixed.md b/changelog.d/2083.fixed.md index f43d22c174..d160aa9d6d 100644 --- a/changelog.d/2083.fixed.md +++ b/changelog.d/2083.fixed.md @@ -1 +1 @@ -- Corrected the 2025-26 Pension Credit standard minimum guarantee and Savings Credit thresholds, and removed direct CPI projection of the thresholds. +- Corrected the 2025-26 Pension Credit rates and derived future Savings Credit thresholds from the earnings-projected guarantee and CPI-projected maximum. diff --git a/changelog.d/2154.fixed.md b/changelog.d/2154.fixed.md new file mode 100644 index 0000000000..486ef95554 --- /dev/null +++ b/changelog.d/2154.fixed.md @@ -0,0 +1 @@ +- Correct the forecast basis for protected single, lone-parent, and couple pension-age Housing Benefit personal allowances. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 985576b98a..6caf7eaaba 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -210,6 +210,15 @@ statutory-basis forecast minus the calendar-year growth stored in OBR does not forecast the ONS AWE series; its measure is wages and salaries per employee, and Q2 is the quarter nearest May to July. +The State Pension triple lock consumes both statutory inputs. The shared +`indices.statutory_earnings_floor` series, which floors May-July earnings at +zero, also projects the Pension Credit standard minimum guarantee. The model +derives the Savings Credit threshold so that this earnings-projected guarantee +and a non-negative September CPI projection of maximum Savings Credit preserve +the statutory 60% relationship. Protected pension-age Housing Benefit uses +the same earnings and CPI components. These are forecast conventions after +the last enacted rates, not announced future benefit amounts. + After the EFO horizon the gap is zero, so the inputs follow calendar-year growth. Smooth forecasts pay the higher of earnings, CPI and 2.5% each year, so the baseline has none of the extra cost the triple lock builds up when diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml index a32bf5a7b0..3733c6e389 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml @@ -1,4 +1,7 @@ -description: Personal allowance for Housing Benefit for a couple where one or both members attained State Pension age before 1 April 2021 (and, before that date, every couple with a member over State Pension age). +description: >- + Personal allowance for Housing Benefit for a couple where one or both members attained State Pension age before 1 April 2021 (and, before that date, every couple with a member over State Pension age). + Amounts after the last enacted rate use a component-weighted forecast and + are not enacted future rates. values: # 2020-21 to 2026-27: DWP Benefit and pension rates (gov.uk), each # year's rate as listed in that year's publication. @@ -15,7 +18,7 @@ metadata: period: week unit: currency-GBP label: Housing Benefit couple pension-age personal allowance - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.couple reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(2)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 @@ -33,3 +36,5 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027, paragraphs 5, 6 and 20 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py new file mode 100644 index 0000000000..e8450b5e52 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py @@ -0,0 +1,185 @@ +"""Project protected pension-age Housing Benefit allowances by component. + +The protected allowances consist of the corresponding Pension Credit +standard minimum guarantee plus a retained Savings Credit uplift. The +guarantee component follows the shared statutory earnings index; the retained +uplift follows non-negative September CPI. Projecting either whole allowance +with one series would therefore misstate its relationship to Pension Credit. +""" + +from policyengine_core.parameters import Parameter, ParameterNode + +from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( + CPI_OBSERVATION_MONTH_DAY, + last_input_year, +) + + +BASE_YEAR = 2026 +BASE_INSTANT = f"{BASE_YEAR}-04-01" + + +def project_retained_uplift(amount: float, cpi_growth: float) -> float: + """Project the retained Savings Credit uplift without a cash reduction.""" + return amount * (1 + max(cpi_growth, 0)) + + +def protected_allowance_index_values( + base_allowance: float, + guarantee_component: float, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, +) -> dict[str, float]: + """Build a component-weighted index from the last enacted allowance.""" + retained_uplift = base_allowance - guarantee_component + if retained_uplift < 0: + raise ValueError( + "The protected Housing Benefit allowance cannot be lower than " + "the corresponding Pension Credit guarantee." + ) + + base_earnings_index = float(earnings_index(BASE_INSTANT)) + values = {BASE_INSTANT: 1.0} + for year in range(BASE_YEAR + 1, final_observation_year + 2): + observation_year = year - 1 + guarantee = guarantee_component * ( + float(earnings_index(f"{year}-04-01")) / base_earnings_index + ) + retained_uplift = project_retained_uplift( + retained_uplift, + float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")), + ) + values[f"{year}-04-01"] = (guarantee + retained_uplift) / base_allowance + return values + + +def _projection_parameter( + relationship: str, + base_allowance: float, + guarantee_component: float, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, +) -> Parameter: + retained_uplift = base_allowance - guarantee_component + relationship_label = ( + "single and lone-parent" if relationship == "single" else "couple" + ) + return Parameter( + ( + "gov.dwp.housing_benefit.allowances." + f"protected_pension_age_uprating.{relationship}" + ), + data={ + "description": ( + f"Forecast index for the protected {relationship_label} " + "pension-age Housing Benefit allowance. The enacted 2026-27 " + f"base comprises a £{guarantee_component:g} Pension Credit " + f"guarantee and a £{retained_uplift:g} retained Savings " + "Credit uplift. The components follow the shared statutory " + "earnings index and non-negative September CPI, respectively. " + "Projected values are not enacted rates." + ), + "values": protected_allowance_index_values( + base_allowance, + guarantee_component, + earnings_index, + cpi_september, + final_observation_year, + ), + "metadata": { + "unit": "/1", + "label": ( + f"Protected {relationship_label} pension-age Housing " + "Benefit allowance forecast index" + ), + "reference": [ + { + "title": ( + "Social Security Administration Act 1992 section 150" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150" + ), + }, + { + "title": ( + "Social Security Administration Act 1992 section 150A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150A" + ), + }, + { + "title": ( + "A14/2025 Housing Benefit uprating for the " + "financial year ending March 2027, paragraphs " + "5, 6 and 20" + ), + "href": ( + "https://www.gov.uk/government/publications/" + "housing-benefit-adjudication-circulars-2025/" + "a142025-housing-benefit-uprating-for-the-" + "financial-year-ending-march-2027" + ), + }, + { + "title": ( + "ONS KAC3 average weekly earnings, whole economy, total pay" + ), + "href": ( + "https://www.ons.gov.uk/" + "employmentandlabourmarket/peopleinwork/" + "earningsandworkinghours/timeseries/kac3/lms" + ), + }, + { + "title": "ONS D7G7 Consumer Prices Index annual rate", + "href": ( + "https://www.ons.gov.uk/economy/" + "inflationandpriceindices/timeseries/d7g7/mm23" + ), + }, + ], + }, + }, + ) + + +def add_protected_pension_age_uprating( + parameters: ParameterNode, +) -> ParameterNode: + """Add relationship-specific protected-allowance forecast indices.""" + allowances = parameters.gov.dwp.housing_benefit.allowances + guarantees = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + earnings_index = ( + parameters.gov.economic_assumptions.indices.statutory_earnings_floor + ) + cpi_september = ( + parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + ) + projection = ParameterNode( + name=("gov.dwp.housing_benefit.allowances.protected_pension_age_uprating"), + data={}, + ) + allowances.add_child("protected_pension_age_uprating", projection) + + relationship_parameters = { + "single": (allowances.single.aged, guarantees.SINGLE), + "couple": (allowances.couple.aged, guarantees.COUPLE), + } + final_observation_year = last_input_year(parameters) + for relationship, (allowance, guarantee) in relationship_parameters.items(): + projection.add_child( + relationship, + _projection_parameter( + relationship, + float(allowance(BASE_INSTANT)), + float(guarantee(BASE_INSTANT)), + earnings_index, + cpi_september, + final_observation_year, + ), + ) + return parameters diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml index 4f1a89f61f..9e22d785fe 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml @@ -1,4 +1,7 @@ -description: Personal allowance for Housing Benefit for a lone parent who attained State Pension age before 1 April 2021 (and, before that date, every pension-age lone parent). +description: >- + Personal allowance for Housing Benefit for a lone parent who attained State Pension age before 1 April 2021 (and, before that date, every pension-age lone parent). + Amounts after the last enacted rate use a component-weighted forecast and + are not enacted future rates. values: # 2020-21 to 2026-27: DWP Benefit and pension rates (gov.uk), each @@ -16,7 +19,7 @@ metadata: period: week unit: currency-GBP label: Housing Benefit lone parent pension-age personal allowance - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.single reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(1)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 @@ -34,3 +37,5 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027, paragraphs 5 and 20 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml index 4fdbd68a0a..37d6a7e34a 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml @@ -1,4 +1,7 @@ -description: Personal allowance for Housing Benefit for a single claimant who attained State Pension age before 1 April 2021 (and, before that date, every single pension-age claimant). +description: >- + Personal allowance for Housing Benefit for a single claimant who attained State Pension age before 1 April 2021 (and, before that date, every single pension-age claimant). + Amounts after the last enacted rate use a component-weighted forecast and + are not enacted future rates. values: # 2020-21 to 2026-27: DWP Benefit and pension rates (gov.uk), each # year's rate as listed in that year's publication. @@ -15,7 +18,7 @@ metadata: period: week unit: currency-GBP label: Housing Benefit single pension-age personal allowance - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.single reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(1)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 @@ -33,3 +36,5 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027, paragraphs 5 and 20 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py new file mode 100644 index 0000000000..2784aa7bcc --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py @@ -0,0 +1,91 @@ +"""Project Savings Credit thresholds from the two statutory uprating bases. + +The maximum Savings Credit is the phase-in rate multiplied by the difference +between the standard minimum guarantee and the Savings Credit threshold. DWP +uprates the guarantee with earnings and the maximum with CPI. The threshold is +therefore a derived residual; applying CPI to it directly, or freezing it, +does not preserve the statutory relationship. +""" + +from decimal import ROUND_HALF_UP, Decimal + +from policyengine_core.parameters import ParameterNode + +from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( + CPI_OBSERVATION_MONTH_DAY, + last_input_year, +) + + +BASE_YEAR = 2026 +BASE_INSTANT = f"{BASE_YEAR}-04-01" +CURRENCY_PRECISION = Decimal("0.01") + + +def round_currency(amount: float) -> float: + """Round a published weekly amount to the nearest penny, halves up.""" + return float( + Decimal(repr(float(amount))).quantize( + CURRENCY_PRECISION, + ROUND_HALF_UP, + ) + ) + + +def project_maximum_savings_credit(amount: float, cpi_growth: float) -> float: + """Project maximum Savings Credit without reducing its cash amount.""" + return amount * (1 + max(cpi_growth, 0)) + + +def savings_credit_threshold( + guarantee: float, + maximum_savings_credit: float, + phase_in_rate: float, +) -> float: + """Solve the statutory maximum-Savings-Credit formula for its threshold.""" + if phase_in_rate <= 0: + raise ValueError("The Savings Credit phase-in rate must be positive.") + return guarantee - maximum_savings_credit / phase_in_rate + + +def add_savings_credit_threshold_projection( + parameters: ParameterNode, +) -> ParameterNode: + """Add future thresholds consistent with projected guarantee and maximum.""" + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantees = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + earnings_index = ( + parameters.gov.economic_assumptions.indices.statutory_earnings_floor + ) + cpi_september = ( + parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + ) + base_phase_in_rate = float(savings_credit.rate.phase_in(BASE_INSTANT)) + base_earnings_index = float(earnings_index(BASE_INSTANT)) + relationship_parameters = { + "SINGLE": (guarantees.SINGLE, savings_credit.threshold.SINGLE), + "COUPLE": (guarantees.COUPLE, savings_credit.threshold.COUPLE), + } + + for guarantee_parameter, threshold_parameter in relationship_parameters.values(): + base_guarantee = float(guarantee_parameter(BASE_INSTANT)) + base_threshold = float(threshold_parameter(BASE_INSTANT)) + maximum = round_currency(base_phase_in_rate * (base_guarantee - base_threshold)) + for year in range(BASE_YEAR + 1, last_input_year(parameters) + 2): + observation_year = year - 1 + maximum = project_maximum_savings_credit( + maximum, + float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")), + ) + guarantee = base_guarantee * ( + float(earnings_index(f"{year}-04-01")) / base_earnings_index + ) + threshold_parameter.update( + period=f"year:{year}-04-01:1", + value=savings_credit_threshold( + guarantee, + maximum, + float(savings_credit.rate.phase_in(f"{year}-04-01")), + ), + ) + return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml index 9c23579c53..aa8636a178 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml @@ -1,4 +1,8 @@ -description: Minimum income guarantee (basic amount) for Pension Credit. +description: >- + Minimum income guarantee (basic amount) for Pension Credit. Values after the + last enacted rate use the non-negative May-July average weekly earnings input + from the annual statutory review as a forecast floor; they are not enacted + future rates. SINGLE: values: 2015-04-06: 151.2 @@ -23,7 +27,7 @@ SINGLE: href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 238 metadata: - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.statutory_earnings_floor COUPLE: values: 2015-04-06: 230.85 @@ -48,7 +52,7 @@ COUPLE: href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 363.25 metadata: - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.statutory_earnings_floor metadata: period: week label: Pension Credit minimum guarantee @@ -65,3 +69,9 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: Social Security Administration Act 1992 section 150A + href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A + - title: Social Security Administration (Northern Ireland) Act 1992 section 132A + href: https://www.legislation.gov.uk/ukpga/1992/8/section/132A + - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 3d6ff919b4..aa159ef246 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -1,4 +1,8 @@ -description: Income threshold at which Savings Credit is eligible. +description: >- + Income threshold at which Savings Credit is eligible. Values after the last + enacted rate are derived so that the earnings-projected standard minimum + guarantee and the CPI-projected maximum Savings Credit remain consistent + with the statutory 60% formula; they are not enacted future rates. SINGLE: values: 2015-04-06: 126.5 @@ -66,7 +70,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: Social Security Administration Act 1992 section 150A + href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A + - title: Social Security Administration (Northern Ireland) Act 1992 section 132A + href: https://www.legislation.gov.uk/ukpga/1992/8/section/132A - title: A11/2024 Housing Benefit uprating for the financial year ending March 2026 href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2024/a112024-housing-benefit-uprating-for-the-financial-year-ending-march-2026 - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027 href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 + - title: ONS KAC3 average weekly earnings, whole economy, total pay + href: https://www.ons.gov.uk/employmentandlabourmarket/peopleinwork/earningsandworkinghours/timeseries/kac3/lms + - title: ONS D7G7 Consumer Prices Index annual rate + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/d7g7/mm23 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py index 530f8b1b54..85073a6a50 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py +++ b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py @@ -1,4 +1,4 @@ -"""Build the State Pension uprating series from the statutory inputs. +"""Build statutory earnings and State Pension uprating series. Each April the basic and new State Pension rise by the highest of: @@ -29,10 +29,13 @@ earnings path started from its level in the year before the guarantee first applied. Current law leaves it off. -The output parameter ``gov.economic_assumptions.yoy_growth.triple_lock`` holds -the rate taking effect in April of each year, keyed to 1 January as the other -growth series are, and runs to one year past the last year of the -economic-assumption series. +The output parameters hold the rates taking effect in April of each year, +keyed to 1 January as the other growth series are. ``triple_lock`` applies the +State Pension rule. ``statutory_earnings_floor`` holds the non-negative +earnings component for amounts, including the Pension Credit standard minimum +guarantee, that section 150A protects without extending the triple lock to +them. Both run to one year past the last year of the economic-assumption +series. """ from dataclasses import dataclass @@ -148,6 +151,13 @@ def uprating_rates(years: Dict[int, UpratingYear]) -> Dict[int, float]: return rates +def statutory_earnings_floor_rates( + years: Dict[int, UpratingYear], +) -> Dict[int, float]: + """Return the non-negative earnings rate from each statutory review.""" + return {year: max(inputs.earnings, 0.0) for year, inputs in years.items()} + + def _flag(parameter: Parameter, instant: str) -> bool: value = parameter(instant) return bool(value) if value is not None else False @@ -193,22 +203,53 @@ def read_uprating_years(parameters: ParameterNode) -> Dict[int, UpratingYear]: def add_triple_lock(parameters: ParameterNode) -> ParameterNode: - """Add ``gov.economic_assumptions.yoy_growth.triple_lock``.""" - rates = uprating_rates(read_uprating_years(parameters)) - new_parameter = Parameter( + """Add the statutory earnings floor and State Pension triple lock.""" + years = read_uprating_years(parameters) + triple_lock_rates = uprating_rates(years) + earnings_floor_rates = statutory_earnings_floor_rates(years) + triple_lock = Parameter( "gov.economic_assumptions.yoy_growth.triple_lock", data={ "description": ( "State Pension uprating taking effect in April of each year." ), - "values": {f"{year}-01-01": rate for year, rate in rates.items()}, + "values": { + f"{year}-01-01": rate for year, rate in triple_lock_rates.items() + }, "metadata": { "unit": "/1", "label": "State Pension uprating rate", }, }, ) + parameters.gov.economic_assumptions.yoy_growth.add_child("triple_lock", triple_lock) + statutory_earnings_floor = Parameter( + "gov.economic_assumptions.yoy_growth.statutory_earnings_floor", + data={ + "description": ( + "Non-negative May-July average weekly earnings growth used " + "as the statutory uprating floor under section 150A." + ), + "values": { + f"{year}-01-01": rate for year, rate in earnings_floor_rates.items() + }, + "metadata": { + "unit": "/1", + "label": "Statutory earnings uprating floor", + "reference": [ + { + "title": ( + "Social Security Administration Act 1992 section 150A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150A" + ), + } + ], + }, + }, + ) parameters.gov.economic_assumptions.yoy_growth.add_child( - "triple_lock", new_parameter + "statutory_earnings_floor", statutory_earnings_floor ) return parameters diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 9e4175a690..0940da58f8 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -25,6 +25,12 @@ from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import ( add_triple_lock, ) +from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_protected_pension_age_uprating import ( + add_protected_pension_age_uprating, +) +from policyengine_uk.parameters.gov.dwp.pension_credit.create_savings_credit_threshold_projection import ( + add_savings_credit_threshold_projection, +) from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( add_september_cpi_uprating, ) @@ -91,6 +97,8 @@ def _add_generated_parameters(parameters: ParameterNode) -> ParameterNode: parameters = add_triple_lock(parameters) parameters = add_september_cpi_uprating(parameters) parameters = create_economic_assumption_indices(parameters) + parameters = add_savings_credit_threshold_projection(parameters) + parameters = add_protected_pension_age_uprating(parameters) parameters = add_threshold_indexation(parameters) parameters = add_lsr_deprecation_aliases(parameters) return parameters @@ -110,6 +118,8 @@ def process_parameter_tree( - triple lock calculations for state pensions - September CPI uprating - economic assumption indices + - Pension Credit Savings Credit threshold projections + - protected pension-age Housing Benefit allowance indices - income tax and NICs threshold indexation after the freeze - parameter uprating and backdating - conversion to fiscal year parameters diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index f3d9e8d9ad..08d297b002 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -150,7 +150,7 @@ output: is_savings_credit_eligible: true -- name: Case 9, 2027 uses the last enacted threshold without direct CPI uprating. +- name: Case 9, 2027 uses the threshold derived from the two projection bases. period: 2027 input: people: @@ -163,5 +163,5 @@ relation_type: SINGLE savings_credit_income: 11_000 output: - # 11,000 > 208.07 * 52. - is_savings_credit_eligible: true + # The projected weekly threshold is about 216.71465, or 11,269.16 a year. + is_savings_credit_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index 20ee3bb6e3..ee80935333 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -48,16 +48,16 @@ # 0.6 * (17,000 - 314.34 * 52). savings_credit: 392.592 -- name: Case 5, 2027 does not apply CPI directly to the threshold. +- name: Case 5, 2027 uses the threshold derived from the two projection bases. period: 2027 absolute_error_margin: 0.01 input: is_savings_credit_eligible: true relation_type: SINGLE - savings_credit_income: 11_000 - pension_credit_income: 11_000 + savings_credit_income: 12_000 + pension_credit_income: 12_000 standard_minimum_guarantee: 12_376 minimum_guarantee: 20_000 output: - # 0.6 * (11,000 - 208.07 * 52). - savings_credit: 108.216 + # 0.6 * (12,000 - 216.7146545 * 52). + savings_credit: 438.5028 diff --git a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py new file mode 100644 index 0000000000..55bbb33002 --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py @@ -0,0 +1,143 @@ +import pytest + +from policyengine_uk import Simulation, system +from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_protected_pension_age_uprating import ( + project_retained_uplift, +) + + +def _parameters(): + return system.parameters + + +@pytest.mark.parametrize( + ( + "allowance_name", + "index_name", + "base_allowance", + "expected_2027_allowance", + ), + [ + ("single", "single", 256.0, 265.6622572049), + ("lone_parent", "single", 256.0, 265.6622572049), + ("couple", "couple", 383.35, 397.9411154524), + ], +) +def test_protected_allowances_use_relationship_specific_component_projections( + allowance_name, + index_name, + base_allowance, + expected_2027_allowance, +): + """The 2026 bases are guarantee plus retained Savings Credit uplift.""" + allowances = _parameters().gov.dwp.housing_benefit.allowances + index = getattr(allowances.protected_pension_age_uprating, index_name) + allowance = getattr(allowances, allowance_name).aged + expected = base_allowance * index("2027-04-01") / index("2026-04-01") + + assert allowance("2026-04-01") == base_allowance + assert expected == pytest.approx(expected_2027_allowance) + assert allowance("2027-04-01") == pytest.approx(expected) + assert allowance("2027-04-01") != pytest.approx( + base_allowance + * _parameters().gov.benefit_uprating_cpi("2027-04-01") + / _parameters().gov.benefit_uprating_cpi("2026-04-01") + ) + + +def test_protected_allowances_cover_guarantee_and_maximum_savings_credit(): + parameters = _parameters() + allowances = parameters.gov.dwp.housing_benefit.allowances + pension_credit = parameters.gov.dwp.pension_credit + guarantee = pension_credit.guarantee_credit.minimum_guarantee + threshold = pension_credit.savings_credit.threshold + + for year in range(2027, 2040): + instant = f"{year}-04-01" + phase_in_rate = float(pension_credit.savings_credit.rate.phase_in(instant)) + for relationship, allowance_name in ( + ("SINGLE", "single"), + ("COUPLE", "couple"), + ): + weekly_guarantee = float(getattr(guarantee, relationship)(instant)) + weekly_threshold = float(getattr(threshold, relationship)(instant)) + maximum_savings_credit = phase_in_rate * ( + weekly_guarantee - weekly_threshold + ) + protected_allowance = float( + getattr(allowances, allowance_name).aged(instant) + ) + assert protected_allowance >= ( + weekly_guarantee + maximum_savings_credit - 0.01 + ), (relationship, year) + + +def test_retained_uplift_projection_does_not_reduce_the_cash_amount(): + assert project_retained_uplift(18, -0.02) == 18 + + +def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, float]: + year = 2035 + names = ["claimant", "partner"] if couple else ["claimant"] + people = { + name: { + "age": {year: 85}, + "private_pension_income": { + year: private_pension if name == "claimant" else 0 + }, + } + for name in names + } + simulation = Simulation( + situation={ + "people": people, + "benunits": {"benunit": {"members": names}}, + "households": { + "household": { + "members": names, + "country": {year: "ENGLAND"}, + "local_authority": {year: "MAIDSTONE"}, + "tenure_type": {year: "RENT_FROM_COUNCIL"}, + "rent": {year: 10_000}, + "council_tax": {year: 2_000}, + "savings": {year: 0}, + } + }, + } + ) + return { + variable: float(simulation.calculate(variable, year)[0]) + for variable in ( + "guarantee_credit", + "housing_benefit", + "council_tax_benefit", + "household_net_income", + "tv_licence", + ) + } + + +@pytest.mark.parametrize( + ("couple", "income_below", "income_above"), + [(False, 16_808, 16_809), (True, 27_133, 27_134)], +) +def test_net_income_does_not_fall_when_guarantee_credit_ends( + couple, + income_below, + income_above, +): + """Test £1 income steps across each modeled 2035 guarantee boundary. + + A normal marginal withdrawal may occur, but there must be no discontinuous + loss of Housing Benefit or Council Tax Reduction when Guarantee Credit ends. + """ + below = _boundary_calculation(couple, income_below) + above = _boundary_calculation(couple, income_above) + + assert below["guarantee_credit"] > 0 + assert above["guarantee_credit"] == 0 + assert above["housing_benefit"] >= below["housing_benefit"] - 1 + assert above["council_tax_benefit"] >= below["council_tax_benefit"] - 1 + assert above["household_net_income"] + above["tv_licence"] >= ( + below["household_net_income"] + below["tv_licence"] - 0.01 + ) diff --git a/policyengine_uk/tests/test_savings_credit_threshold_projection.py b/policyengine_uk/tests/test_savings_credit_threshold_projection.py new file mode 100644 index 0000000000..5803322dc8 --- /dev/null +++ b/policyengine_uk/tests/test_savings_credit_threshold_projection.py @@ -0,0 +1,79 @@ +import pytest + +from policyengine_uk import system +from policyengine_uk.parameters.gov.dwp.pension_credit.create_savings_credit_threshold_projection import ( + project_maximum_savings_credit, + round_currency, + savings_credit_threshold, +) + + +def _parameters(): + return system.parameters + + +@pytest.mark.parametrize( + ("relationship", "base_maximum", "expected_2027_maximum"), + [ + ("SINGLE", 17.96, 18.3400336), + ("COUPLE", 20.10, 20.525316), + ], +) +def test_2027_threshold_preserves_the_cpi_projected_maximum( + relationship, + base_maximum, + expected_2027_maximum, +): + """DWP A14/2025 sets £17.96/£20.10 and a 3.8% enacted CPI rise. + + The first forecast uses the repository's 2.116% September 2026 CPI input. + """ + parameters = _parameters() + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + phase_in_rate = float(savings_credit.rate.phase_in("2027-04-01")) + projected_guarantee = float(getattr(guarantee, relationship)("2027-04-01")) + projected_threshold = float( + getattr(savings_credit.threshold, relationship)("2027-04-01") + ) + projected_maximum = phase_in_rate * (projected_guarantee - projected_threshold) + + assert base_maximum * 1.02116 == pytest.approx(expected_2027_maximum) + assert projected_maximum == pytest.approx(expected_2027_maximum) + + +def test_projected_thresholds_preserve_the_maximum_in_every_forecast_year(): + parameters = _parameters() + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + cpi = parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + + for relationship in ("SINGLE", "COUPLE"): + guarantee_parameter = getattr(guarantee, relationship) + threshold_parameter = getattr(savings_credit.threshold, relationship) + base_phase_in_rate = float(savings_credit.rate.phase_in("2026-04-01")) + maximum = round_currency( + base_phase_in_rate + * ( + float(guarantee_parameter("2026-04-01")) + - float(threshold_parameter("2026-04-01")) + ) + ) + for year in range(2027, 2040): + maximum = project_maximum_savings_credit( + maximum, + float(cpi(f"{year - 1}-09-01")), + ) + phase_in_rate = float(savings_credit.rate.phase_in(f"{year}-04-01")) + actual = phase_in_rate * ( + float(guarantee_parameter(f"{year}-04-01")) + - float(threshold_parameter(f"{year}-04-01")) + ) + assert actual == pytest.approx(maximum), (relationship, year) + + +def test_projection_floors_cpi_and_validates_the_phase_in_rate(): + assert project_maximum_savings_credit(17.96, -0.02) == 17.96 + assert savings_credit_threshold(238, 17.96, 0.6) == pytest.approx(208.0666666667) + with pytest.raises(ValueError, match="must be positive"): + savings_credit_threshold(238, 17.96, 0) diff --git a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py index a3101905d0..8320947bc1 100644 --- a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py +++ b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py @@ -4,9 +4,10 @@ earnings growth from the previous year. These tests check that forecasts of those inputs follow the OBR where it publishes them, fall back to calendar-year growth after, respond to macro scenarios applied before the -data load, and can be set directly; and that the earnings-path guarantee -models one reading of the plan announced in September 2026 as a -parameter reform. +data load, and can be set directly. They also check that Pension Credit uses +the same May-July earnings input without inheriting the CPI and 2.5% elements +of the State Pension triple lock, and that the earnings-path guarantee models +one reading of the plan announced in September 2026 as a parameter reform. """ import numpy as np @@ -43,10 +44,21 @@ def uprating(parameters, year): return parameters.gov.economic_assumptions.yoy_growth.triple_lock(f"{year}-01-01") +def statutory_earnings_floor(parameters, year): + return parameters.gov.economic_assumptions.yoy_growth.statutory_earnings_floor( + f"{year}-01-01" + ) + + def new_state_pension_weekly(parameters, year): return parameters.gov.dwp.state_pension.new_state_pension.amount(f"{year}-06-01") +def minimum_guarantee_weekly(parameters, relation_type, year): + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + return getattr(guarantee, relation_type)(f"{year}-06-01") + + def statutory_inputs(parameters): return parameters.gov.economic_assumptions.statutory_uprating_inputs @@ -63,6 +75,42 @@ def test_april_2027_uses_published_may_july_2026_earnings(): ) +def test_pension_credit_uses_the_state_pension_statutory_earnings_input(): + parameters = system.parameters + + assert statutory_inputs(parameters).awe_total_pay_may_july( + "2026-07-01" + ) == pytest.approx(0.039) + assert statutory_earnings_floor(parameters, 2027) == pytest.approx(0.039) + assert minimum_guarantee_weekly(parameters, "SINGLE", 2027) == pytest.approx( + 238 * 1.039, rel=1e-5 + ) + assert minimum_guarantee_weekly(parameters, "COUPLE", 2027) == pytest.approx( + 363.25 * 1.039, rel=1e-5 + ) + + +def test_pension_credit_earnings_floor_rounds_and_never_reduces_the_guarantee(): + parameters = parameters_under( + { + f"{INPUTS}.awe_total_pay_may_july": { + "2027": 0.0124, + "2028": -0.004, + } + } + ) + + assert [ + statutory_earnings_floor(parameters, year) for year in (2028, 2029) + ] == pytest.approx([0.012, 0.0]) + for relation_type in ("SINGLE", "COUPLE"): + guarantee_2027 = minimum_guarantee_weekly(parameters, relation_type, 2027) + guarantee_2028 = minimum_guarantee_weekly(parameters, relation_type, 2028) + guarantee_2029 = minimum_guarantee_weekly(parameters, relation_type, 2029) + assert guarantee_2028 == pytest.approx(guarantee_2027 * 1.012, rel=1e-5) + assert guarantee_2029 == pytest.approx(guarantee_2028, rel=1e-5) + + def test_every_published_year_has_its_own_value(): """A missing year would silently carry the previous year's figure.""" inputs = statutory_inputs(system.parameters) From 38cf8319818d199df6582f89adc0775c3d4c06b9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:03:30 +0400 Subject: [PATCH 05/28] Fix Savings Credit threshold projection reforms --- changelog.d/2083.fixed.md | 2 +- .../allowances/couple/aged.yaml | 4 + .../allowances/lone_parent/aged.yaml | 4 + .../allowances/single/aged.yaml | 4 + ...ate_savings_credit_threshold_projection.py | 205 +++++++++++++++--- .../savings_credit/threshold.yaml | 6 + ...est_savings_credit_threshold_projection.py | 78 ++++++- 7 files changed, 273 insertions(+), 30 deletions(-) diff --git a/changelog.d/2083.fixed.md b/changelog.d/2083.fixed.md index d160aa9d6d..19f381e40c 100644 --- a/changelog.d/2083.fixed.md +++ b/changelog.d/2083.fixed.md @@ -1 +1 @@ -- Corrected the 2025-26 Pension Credit rates and derived future Savings Credit thresholds from the earnings-projected guarantee and CPI-projected maximum. +- Corrected the 2025-26 Pension Credit rates and derived future Savings Credit thresholds from the earnings-projected guarantee and CPI-projected maximum without replacing explicit or reformed threshold values. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml index 3733c6e389..76e54adeea 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml @@ -24,6 +24,10 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 3 paragraph 1 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/1 + - title: The Social Security Benefits Up-rating Order 2026 article 24(6) and Schedule 6 + href: https://www.legislation.gov.uk/uksi/2026/148/schedule/6/made - title: Benefit and pension rates 2024 to 2025 - Policy paper href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2024-to-2025/benefit-and-pension-rates-2024-to-2025 - title: Benefit and pension rates 2020 to 2021 (DWP, gov.uk) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml index 9e22d785fe..887d4e6eb3 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml @@ -25,6 +25,10 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 3 paragraph 1 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/1 + - title: The Social Security Benefits Up-rating Order 2026 article 24(6) and Schedule 6 + href: https://www.legislation.gov.uk/uksi/2026/148/schedule/6/made - title: Benefit and pension rates 2024 to 2025 - Policy paper href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2024-to-2025/benefit-and-pension-rates-2024-to-2025 - title: Benefit and pension rates 2020 to 2021 (DWP, gov.uk) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml index 37d6a7e34a..61f7cb9efc 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml @@ -24,6 +24,10 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 3 paragraph 1 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/paragraph/1 + - title: The Social Security Benefits Up-rating Order 2026 article 24(6) and Schedule 6 + href: https://www.legislation.gov.uk/uksi/2026/148/schedule/6/made - title: Benefit and pension rates 2024 to 2025 - Policy paper href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2024-to-2025/benefit-and-pension-rates-2024-to-2025 - title: Benefit and pension rates 2020 to 2021 (DWP, gov.uk) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py index 2784aa7bcc..b8f102eb36 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py @@ -1,15 +1,17 @@ -"""Project Savings Credit thresholds from the two statutory uprating bases. +"""Build Savings Credit threshold indices from the statutory uprating bases. The maximum Savings Credit is the phase-in rate multiplied by the difference between the standard minimum guarantee and the Savings Credit threshold. DWP uprates the guarantee with earnings and the maximum with CPI. The threshold is therefore a derived residual; applying CPI to it directly, or freezing it, -does not preserve the statutory relationship. +does not preserve the statutory relationship. Each generated index is applied +by the standard parameter-uprating operation, so explicit threshold values and +reforms remain authoritative anchors. """ from decimal import ROUND_HALF_UP, Decimal -from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters import Parameter, ParameterNode from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( CPI_OBSERVATION_MONTH_DAY, @@ -22,6 +24,11 @@ CURRENCY_PRECISION = Decimal("0.01") +def projection_instant(year: int) -> str: + """Return the date sampled by fiscal-year parameter conversion.""" + return f"{year}-04-30" + + def round_currency(amount: float) -> float: """Round a published weekly amount to the nearest penny, halves up.""" return float( @@ -48,10 +55,154 @@ def savings_credit_threshold( return guarantee - maximum_savings_credit / phase_in_rate +def threshold_index_values( + base_guarantee: float, + base_threshold: float, + phase_in_rate: Parameter, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, +) -> dict[str, float]: + """Build a forecast index from the last enacted threshold.""" + base_phase_in_rate = float(phase_in_rate(projection_instant(BASE_YEAR))) + maximum = round_currency(base_phase_in_rate * (base_guarantee - base_threshold)) + base_earnings_index = float(earnings_index(BASE_INSTANT)) + values = {BASE_INSTANT: 1.0} + + for year in range(BASE_YEAR + 1, final_observation_year + 2): + observation_year = year - 1 + maximum = project_maximum_savings_credit( + maximum, + float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")), + ) + guarantee = base_guarantee * ( + float(earnings_index(f"{year}-04-01")) / base_earnings_index + ) + threshold = savings_credit_threshold( + guarantee, + maximum, + float(phase_in_rate(projection_instant(year))), + ) + values[f"{year}-04-01"] = threshold / base_threshold + + return values + + +def _projection_parameter( + relationship: str, + base_guarantee: float, + base_threshold: float, + phase_in_rate: Parameter, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, +) -> Parameter: + relationship_label = relationship.lower() + return Parameter( + (f"gov.dwp.pension_credit.savings_credit.threshold_uprating.{relationship}"), + data={ + "description": ( + f"Forecast index for the {relationship_label} Savings Credit " + "threshold. It derives the threshold from an earnings-projected " + "standard minimum guarantee and a maximum Savings Credit amount " + "projected by non-negative September CPI. Explicit threshold " + "values remain authoritative. Projected values are not enacted " + "rates." + ), + "values": threshold_index_values( + base_guarantee, + base_threshold, + phase_in_rate, + earnings_index, + cpi_september, + final_observation_year, + ), + "metadata": { + "unit": "/1", + "label": ( + f"Pension Credit Savings Credit {relationship_label} " + "threshold forecast index" + ), + "reference": [ + { + "title": "State Pension Credit Act 2002 section 3", + "href": ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/3" + ), + }, + { + "title": ( + "Social Security Administration Act 1992 section 150" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150" + ), + }, + { + "title": ( + "Social Security Administration Act 1992 section 150A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150A" + ), + }, + { + "title": ( + "Social Security Administration (Northern Ireland) " + "Act 1992 section 132" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/8/section/132" + ), + }, + { + "title": ( + "Social Security Administration (Northern Ireland) " + "Act 1992 section 132A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/8/section/132A" + ), + }, + { + "title": ( + "A14/2025 Housing Benefit uprating for the financial " + "year ending March 2027" + ), + "href": ( + "https://www.gov.uk/government/publications/" + "housing-benefit-adjudication-circulars-2025/" + "a142025-housing-benefit-uprating-for-the-financial-" + "year-ending-march-2027" + ), + }, + { + "title": ( + "ONS KAC3 average weekly earnings, whole economy, total pay" + ), + "href": ( + "https://www.ons.gov.uk/employmentandlabourmarket/" + "peopleinwork/earningsandworkinghours/timeseries/" + "kac3/lms" + ), + }, + { + "title": "ONS D7G7 Consumer Prices Index annual rate", + "href": ( + "https://www.ons.gov.uk/economy/" + "inflationandpriceindices/timeseries/d7g7/mm23" + ), + }, + ], + }, + }, + ) + + def add_savings_credit_threshold_projection( parameters: ParameterNode, ) -> ParameterNode: - """Add future thresholds consistent with projected guarantee and maximum.""" + """Add forecast indices for each Savings Credit threshold.""" savings_credit = parameters.gov.dwp.pension_credit.savings_credit guarantees = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee earnings_index = ( @@ -60,32 +211,30 @@ def add_savings_credit_threshold_projection( cpi_september = ( parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september ) - base_phase_in_rate = float(savings_credit.rate.phase_in(BASE_INSTANT)) - base_earnings_index = float(earnings_index(BASE_INSTANT)) + projection = ParameterNode( + name=("gov.dwp.pension_credit.savings_credit.threshold_uprating"), + data={}, + ) + savings_credit.add_child("threshold_uprating", projection) relationship_parameters = { "SINGLE": (guarantees.SINGLE, savings_credit.threshold.SINGLE), "COUPLE": (guarantees.COUPLE, savings_credit.threshold.COUPLE), } - - for guarantee_parameter, threshold_parameter in relationship_parameters.values(): - base_guarantee = float(guarantee_parameter(BASE_INSTANT)) - base_threshold = float(threshold_parameter(BASE_INSTANT)) - maximum = round_currency(base_phase_in_rate * (base_guarantee - base_threshold)) - for year in range(BASE_YEAR + 1, last_input_year(parameters) + 2): - observation_year = year - 1 - maximum = project_maximum_savings_credit( - maximum, - float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")), - ) - guarantee = base_guarantee * ( - float(earnings_index(f"{year}-04-01")) / base_earnings_index - ) - threshold_parameter.update( - period=f"year:{year}-04-01:1", - value=savings_credit_threshold( - guarantee, - maximum, - float(savings_credit.rate.phase_in(f"{year}-04-01")), - ), - ) + final_observation_year = last_input_year(parameters) + for relationship, ( + guarantee_parameter, + threshold_parameter, + ) in relationship_parameters.items(): + projection.add_child( + relationship, + _projection_parameter( + relationship, + float(guarantee_parameter(projection_instant(BASE_YEAR))), + float(threshold_parameter(projection_instant(BASE_YEAR))), + savings_credit.rate.phase_in, + earnings_index, + cpi_september, + final_observation_year, + ), + ) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index aa159ef246..2babfeffd8 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -28,6 +28,7 @@ SINGLE: 2026-04-01: 208.07 metadata: label: Pension Credit Savings Credit income threshold (single) + uprating: gov.dwp.pension_credit.savings_credit.threshold_uprating.SINGLE COUPLE: values: 2015-04-06: 201.8 @@ -53,6 +54,7 @@ COUPLE: 2026-04-01: 329.75 metadata: label: Pension Credit Savings Credit income threshold (couple) + uprating: gov.dwp.pension_credit.savings_credit.threshold_uprating.COUPLE metadata: unit: currency-GBP period: week @@ -70,8 +72,12 @@ metadata: href: https://www.legislation.gov.uk/uksi/2025/295/contents/made - title: The Social Security Benefits Up-rating Order 2026 (SI 2026/148) href: https://www.legislation.gov.uk/uksi/2026/148/contents/made + - title: Social Security Administration Act 1992 section 150 + href: https://www.legislation.gov.uk/ukpga/1992/5/section/150 - title: Social Security Administration Act 1992 section 150A href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A + - title: Social Security Administration (Northern Ireland) Act 1992 section 132 + href: https://www.legislation.gov.uk/ukpga/1992/8/section/132 - title: Social Security Administration (Northern Ireland) Act 1992 section 132A href: https://www.legislation.gov.uk/ukpga/1992/8/section/132A - title: A11/2024 Housing Benefit uprating for the financial year ending March 2026 diff --git a/policyengine_uk/tests/test_savings_credit_threshold_projection.py b/policyengine_uk/tests/test_savings_credit_threshold_projection.py index 5803322dc8..06f7ac86f4 100644 --- a/policyengine_uk/tests/test_savings_credit_threshold_projection.py +++ b/policyengine_uk/tests/test_savings_credit_threshold_projection.py @@ -1,11 +1,21 @@ import pytest +from policyengine_core.periods import instant -from policyengine_uk import system +from policyengine_uk import Simulation, system +from policyengine_uk.model_api import Scenario from policyengine_uk.parameters.gov.dwp.pension_credit.create_savings_credit_threshold_projection import ( project_maximum_savings_credit, round_currency, savings_credit_threshold, ) +from policyengine_uk.tax_benefit_system import CountryTaxBenefitSystem + + +PENSIONER = { + "people": {"person": {"age": {2027: 70}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} def _parameters(): @@ -77,3 +87,69 @@ def test_projection_floors_cpi_and_validates_the_phase_in_rate(): assert savings_credit_threshold(238, 17.96, 0.6) == pytest.approx(208.0666666667) with pytest.raises(ValueError, match="must be positive"): savings_credit_threshold(238, 17.96, 0) + + +@pytest.mark.parametrize( + ("relationship", "reformed_value"), + [("SINGLE", 100), ("COUPLE", 200)], +) +def test_preprocessing_threshold_reform_is_not_replaced_by_projection( + relationship, + reformed_value, +): + """Generated indices must not overwrite scenario parameter changes.""" + path = f"gov.dwp.pension_credit.savings_credit.threshold.{relationship}" + simulation = Simulation( + situation=PENSIONER, + scenario=Scenario( + parameter_changes={path: {"2027": reformed_value}}, + applied_before_data_load=True, + ), + ) + threshold = simulation.tax_benefit_system.parameters.get_child(path) + + assert threshold("2027-06-01") == reformed_value + + +def test_postprocessing_threshold_reform_is_not_replaced_by_projection(): + """The direct reform interface must preserve its explicit value too.""" + path = "gov.dwp.pension_credit.savings_credit.threshold.SINGLE" + simulation = Simulation( + situation=PENSIONER, + reform={path: {"2027": 100}}, + ) + threshold = simulation.tax_benefit_system.parameters.get_child(path) + + assert threshold("2027-06-01") == 100 + + +def test_future_explicit_threshold_is_the_uprating_anchor(): + """A later source value remains exact and anchors subsequent forecasts.""" + tax_benefit_system = CountryTaxBenefitSystem() + tax_benefit_system.reset_parameters() + savings_credit = tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit + savings_credit.threshold.SINGLE.update( + start=instant("2027-04-01"), + stop=None, + value=100, + ) + + tax_benefit_system.process_parameters() + savings_credit = tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit + threshold = savings_credit.threshold.SINGLE + index = savings_credit.threshold_uprating.SINGLE + + assert threshold("2027-06-01") == 100 + assert threshold("2028-06-01") == pytest.approx( + 100 * index("2028-06-01") / index("2027-06-01") + ) + + +@pytest.mark.parametrize("relationship", ["SINGLE", "COUPLE"]) +def test_threshold_uses_generated_index_through_standard_uprating(relationship): + savings_credit = _parameters().gov.dwp.pension_credit.savings_credit + threshold = getattr(savings_credit.threshold, relationship) + + assert threshold.metadata["uprating"] == ( + f"gov.dwp.pension_credit.savings_credit.threshold_uprating.{relationship}" + ) From c7206b20da62e0c2c8342c8dfb14620fdee011f2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:07:21 +0400 Subject: [PATCH 06/28] Correct protected pension-age Housing Benefit rates for 2025 --- changelog.d/2154.fixed.md | 2 +- .../allowances/couple/aged.yaml | 11 +++- .../allowances/lone_parent/aged.yaml | 11 +++- .../allowances/single/aged.yaml | 11 +++- .../protected_pension_age_rates.yaml | 55 +++++++++++++++++++ ...ng_benefit_protected_allowance_uprating.py | 16 ++++++ 6 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml diff --git a/changelog.d/2154.fixed.md b/changelog.d/2154.fixed.md index 486ef95554..acdf000718 100644 --- a/changelog.d/2154.fixed.md +++ b/changelog.d/2154.fixed.md @@ -1 +1 @@ -- Correct the forecast basis for protected single, lone-parent, and couple pension-age Housing Benefit personal allowances. +- Correct the 2025-26 rates and forecast basis for protected single, lone-parent, and couple pension-age Housing Benefit personal allowances. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml index 76e54adeea..8358a4dacb 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml @@ -11,7 +11,16 @@ values: 2022-04-06: 294.9 2023-04-01: 324.7 2024-04-01: 352 - 2025-04-01: 366 + 2025-04-01: + value: 366 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025, Schedule 6 paragraph 1(2)(b) + href: https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025, Schedule 6 paragraph 1(2)(b) + href: https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made + - title: Benefit and pension rates 2025 to 2026, Housing Benefit personal allowances + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 383.35 metadata: diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml index 887d4e6eb3..8d980cc06f 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml @@ -12,7 +12,16 @@ values: 2022-04-06: 197.1 2023-04-01: 217 2024-04-01: 235.2 - 2025-04-01: 244.4 + 2025-04-01: + value: 244.4 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made + - title: Benefit and pension rates 2025 to 2026, Housing Benefit personal allowances + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 256 metadata: diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml index 61f7cb9efc..729e314b03 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml @@ -11,7 +11,16 @@ values: 2022-04-06: 197.1 2023-04-01: 217 2024-04-01: 235.2 - 2025-04-01: 244.4 + 2025-04-01: + value: 244.4 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made + - title: Benefit and pension rates 2025 to 2026, Housing Benefit personal allowances + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 256 metadata: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml new file mode 100644 index 0000000000..82e5edb3c1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml @@ -0,0 +1,55 @@ +# Protected pre-April-2021 pension-age rates: SI 2025/295 Sch 6, table +# paras 1(1)(b) and 1(2)(b); NI SR 2025/64 Sch 6 gives the same amounts. +# https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made +# https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made +# Taper: SI 2006/214 reg 51 (65% of excess applicable income). +# https://www.legislation.gov.uk/uksi/2006/214/regulation/51 +# Eligibility, assessed income, premiums and rent deductions are supplied to +# isolate the personal allowance's effect on the tapered award. + +- name: Protected single pensioner receives the enacted 2025 allowance + period: 2025 + absolute_error_margin: 0.01 + input: + age: 80 + is_claimant_or_partner: true + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + housing_benefit_applicable_income: 13_000 + benunit_rent: 10_000 + LHA_eligible: false + housing_benefit_meals_deduction: 0 + housing_benefit_non_dep_deductions: 0 + output: + housing_benefit_applicable_amount: 12_708.80 # £244.40 * 52 + housing_benefit_entitlement: 9_810.72 # £10,000 - 0.65 * (£13,000 - £12,708.80) + +- name: Protected pensioner couple receives the enacted 2025 allowance + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + partner: + age: 80 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + housing_benefit_applicable_income: 19_500 + benunit_rent: 10_000 + LHA_eligible: false + housing_benefit_meals_deduction: 0 + housing_benefit_non_dep_deductions: 0 + households: + household: + members: [claimant, partner] + output: + housing_benefit_applicable_amount: 19_032 # £366 * 52 + housing_benefit_entitlement: 9_695.80 # £10,000 - 0.65 * (£19,500 - £19,032) diff --git a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py index 55bbb33002..35bdfb603a 100644 --- a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py +++ b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py @@ -141,3 +141,19 @@ def test_net_income_does_not_fall_when_guarantee_credit_ends( assert above["household_net_income"] + above["tv_licence"] >= ( below["household_net_income"] + below["tv_licence"] - 0.01 ) + + +@pytest.mark.parametrize( + ("allowance_name", "weekly_amount"), + [("single", 244.40), ("lone_parent", 244.40), ("couple", 366.00)], +) +def test_protected_allowances_match_enacted_2025_amounts(allowance_name, weekly_amount): + """Pin the published protected-cohort rates, not a computed CPI estimate. + + SI 2025/295 Sch 6 and NI SR 2025/64 Sch 6, table paras 1(1)(b) + and 1(2)(b), prescribe these amounts independently of the model. + """ + allowance = getattr( + _parameters().gov.dwp.housing_benefit.allowances, allowance_name + ).aged + assert allowance("2025-04-30") == weekly_amount From 3c8b9bf7706b4c2fa66b23115fb14c4c45deccdb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:26:54 +0400 Subject: [PATCH 07/28] Fix issues from review: locate projected Guarantee Credit cutoffs --- ...ng_benefit_protected_allowance_uprating.py | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py index 35bdfb603a..1ec190d5f5 100644 --- a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py +++ b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py @@ -76,7 +76,7 @@ def test_retained_uplift_projection_does_not_reduce_the_cash_amount(): assert project_retained_uplift(18, -0.02) == 18 -def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, float]: +def _boundary_simulation(couple: bool, private_pension: float) -> Simulation: year = 2035 names = ["claimant", "partner"] if couple else ["claimant"] people = { @@ -88,7 +88,7 @@ def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, flo } for name in names } - simulation = Simulation( + return Simulation( situation={ "people": people, "benunits": {"benunit": {"members": names}}, @@ -105,8 +105,12 @@ def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, flo }, } ) + + +def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, float]: + simulation = _boundary_simulation(couple, private_pension) return { - variable: float(simulation.calculate(variable, year)[0]) + variable: float(simulation.calculate(variable, 2035)[0]) for variable in ( "guarantee_credit", "housing_benefit", @@ -117,20 +121,40 @@ def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, flo } -@pytest.mark.parametrize( - ("couple", "income_below", "income_above"), - [(False, 16_808, 16_809), (True, 27_133, 27_134)], -) -def test_net_income_does_not_fall_when_guarantee_credit_ends( - couple, - income_below, - income_above, -): +@pytest.mark.parametrize("couple", [False, True]) +def test_net_income_does_not_fall_when_guarantee_credit_ends(couple): """Test £1 income steps across each modeled 2035 guarantee boundary. A normal marginal withdrawal may occur, but there must be no discontinuous loss of Housing Benefit or Council Tax Reduction when Guarantee Credit ends. """ + # Locate the actual cutoff, including income-tax deductions. Forecasts and + # tax thresholds can change independently of this discontinuity regression. + income_below, income_above = 0, 100_000 + assert ( + _boundary_simulation(couple, income_below).calculate("guarantee_credit", 2035)[ + 0 + ] + > 0 + ) + assert ( + _boundary_simulation(couple, income_above).calculate("guarantee_credit", 2035)[ + 0 + ] + == 0 + ) + while income_above - income_below > 1: + midpoint = (income_below + income_above) // 2 + if ( + _boundary_simulation(couple, midpoint).calculate("guarantee_credit", 2035)[ + 0 + ] + > 0 + ): + income_below = midpoint + else: + income_above = midpoint + below = _boundary_calculation(couple, income_below) above = _boundary_calculation(couple, income_above) From e615aa8880c77575f8b7bbf7645ead0f3b9fbe25 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 03:06:11 -0400 Subject: [PATCH 08/28] Keep pension-age projections independent of guarantee reforms --- changelog.d/2092.fixed.md | 1 + .../create_protected_pension_age_uprating.py | 14 +- ...ate_savings_credit_threshold_projection.py | 13 +- policyengine_uk/tax_benefit_system.py | 23 ++- ...ion_credit_guarantee_reform_projections.py | 188 ++++++++++++++++++ 5 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 changelog.d/2092.fixed.md create mode 100644 policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py diff --git a/changelog.d/2092.fixed.md b/changelog.d/2092.fixed.md new file mode 100644 index 0000000000..9093c00b21 --- /dev/null +++ b/changelog.d/2092.fixed.md @@ -0,0 +1 @@ +Allow Pension Credit minimum-guarantee reforms above the protected Housing Benefit allowance by keeping the unreformed guarantee as the base for Housing Benefit and Savings Credit threshold projections. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py index e8450b5e52..f8b01403f1 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py @@ -149,10 +149,20 @@ def _projection_parameter( def add_protected_pension_age_uprating( parameters: ParameterNode, + unreformed: ParameterNode | None = None, ) -> ParameterNode: - """Add relationship-specific protected-allowance forecast indices.""" + """Add relationship-specific protected-allowance forecast indices. + + A guarantee reform does not re-derive the separately set HB allowance. + Use its unreformed guarantee component so the enacted-base validation + remains meaningful, even when a reform exceeds the protected allowance. + Earnings and CPI inputs still come from the reform's parameter tree. + """ allowances = parameters.gov.dwp.housing_benefit.allowances - guarantees = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + guarantee_source = parameters if unreformed is None else unreformed + guarantees = ( + guarantee_source.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) earnings_index = ( parameters.gov.economic_assumptions.indices.statutory_earnings_floor ) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py index b8f102eb36..83b75050de 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py @@ -201,10 +201,19 @@ def _projection_parameter( def add_savings_credit_threshold_projection( parameters: ParameterNode, + unreformed: ParameterNode | None = None, ) -> ParameterNode: - """Add forecast indices for each Savings Credit threshold.""" + """Add forecast indices for each Savings Credit threshold. + + The separately set threshold is not re-derived by a guarantee reform, + including a reform to the projection's base year. Other inputs and any + explicit threshold reform still come from the reform's parameter tree. + """ savings_credit = parameters.gov.dwp.pension_credit.savings_credit - guarantees = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + guarantee_source = parameters if unreformed is None else unreformed + guarantees = ( + guarantee_source.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) earnings_index = ( parameters.gov.economic_assumptions.indices.statutory_earnings_floor ) diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 9bee04f925..4d8a4fadb1 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -126,15 +126,24 @@ def _add_generated_parameters( parameters: ParameterNode, generated: Optional[List[ParameterAssignment]] = None, + unreformed: Optional[ParameterNode] = None, ) -> ParameterNode: """Run the generation steps, writing changes to generated parameters. Each change in ``generated`` is written onto its parameter just after the step that generates it, so the later steps, and uprating, read it. + ``unreformed`` supplies the guarantee base for the separately set Savings + Credit threshold and protected Housing Benefit allowance projections. """ pending = list(generated or []) for step in GENERATION_STEPS: - parameters = step(parameters) + if step in ( + add_savings_credit_threshold_projection, + add_protected_pension_age_uprating, + ): + parameters = step(parameters, unreformed=unreformed) + else: + parameters = step(parameters) if pending: defines = tree_defines(parameters) for assignment in [a for a in pending if defines(a.path)]: @@ -182,11 +191,14 @@ def process_parameter_tree( tree carrying dated reform changes passes ``unreformed``, the same tree without them, which the copy is taken from instead (#2188). ``annualise=False`` stops after the projection and adds no copy. + ``unreformed`` also keeps the Pension Credit guarantee component of the + threshold and protected HB projections independent of guarantee reforms, + including during intermediate projections with ``annualise=False``. ``generated`` holds the reform's changes to generated parameters, each written just after the step that generates it (see ``_add_generated_parameters``). """ - parameters = _add_generated_parameters(parameters, generated) + parameters = _add_generated_parameters(parameters, generated, unreformed) # Create baseline parameters for reform comparisons if annualise: @@ -708,7 +720,10 @@ def prior_law_projection( projected_timelines() if statutory and not generated else process_parameter_tree( - unreformed.clone(), annualise=False, generated=generated + unreformed.clone(), + unreformed=unreformed, + annualise=False, + generated=generated, )[1] ) environment = {path: start.get(path) for path in needing} @@ -727,7 +742,7 @@ def lookup(name: str) -> Optional[Timeline]: tree.get_child(assignment.path), assignment, lookup ) updated[path] = process_parameter_tree( - tree, annualise=False, generated=generated + tree, unreformed=unreformed, annualise=False, generated=generated )[1].get(path) if _same_timelines(updated, environment): return lookup diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py new file mode 100644 index 0000000000..65db4713be --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -0,0 +1,188 @@ +"""Guarantee reforms leave independently enacted pension-age projections intact.""" + +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation, system +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_protected_pension_age_uprating import ( + protected_allowance_index_values, +) + + +GUARANTEE = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" +HB_ALLOWANCES = "gov.dwp.housing_benefit.allowances" +SAVINGS_CREDIT = "gov.dwp.pension_credit.savings_credit" +YEARS = range(2026, 2041) +PROJECTION_PATHS = ( + *(f"{HB_ALLOWANCES}.{name}.aged" for name in ("single", "lone_parent", "couple")), + *( + f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" + for name in ("single", "couple") + ), + *( + f"{SAVINGS_CREDIT}.{name}.{relationship}" + for name in ("threshold", "threshold_uprating") + for relationship in ("SINGLE", "COUPLE") + ), +) + + +def _situation(couple, years): + names = ["claimant", "partner"] if couple else ["claimant"] + return { + "people": { + name: { + "age": {year: 70 for year in years}, + "state_pension": {year: 0 for year in years}, + "is_claimant_or_partner": {year: True for year in years}, + } + for name in names + }, + "benunits": { + "benunit": { + "members": names, + "claims_all_entitled_benefits": {year: True for year in years}, + "would_claim_pc": {year: True for year in years}, + "would_claim_uc": {year: False for year in years}, + } + }, + "households": { + "household": { + "members": names, + "country": {year: "ENGLAND" for year in years}, + "local_authority": {year: "MAIDSTONE" for year in years}, + "savings": {year: 0 for year in years}, + } + }, + } + + +def _simulation(couple, changes, years, route="reform"): + options = ( + {"reform": changes} + if route == "reform" + else { + "scenario": Scenario( + parameter_changes=changes, + applied_before_data_load=True, + ) + } + ) + return Simulation(situation=_situation(couple, years), **options) + + +def _assert_projections_unchanged(simulation): + parameters = simulation.tax_benefit_system.parameters + for path in PROJECTION_PATHS: + actual = parameters.get_child(path) + baseline = system.parameters.get_child(path) + for year in YEARS: + instant = f"{year}-04-30" + assert actual(instant) == pytest.approx(baseline(instant)), (path, year) + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize("couple,expected", [(False, 15_600), (True, 23_868)]) +def test_2026_guarantee_reform_pays_the_reformed_credit_without_rebuilding_hb( + couple, expected, route +): + changes = { + f"{GUARANTEE}.SINGLE": 300, + f"{GUARANTEE}.COUPLE": 459, + } + simulation = _simulation(couple, changes, [2026], route) + + assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx(expected) + _assert_projections_unchanged(simulation) + + +@settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + year=st.integers(2026, 2040), + weekly_guarantee=st.floats(min_value=0, allow_nan=False, allow_infinity=False), + couple=st.booleans(), + route=st.sampled_from(["reform", "scenario"]), + calendar_year=st.booleans(), +) +@example( + year=2026, weekly_guarantee=0, couple=False, route="reform", calendar_year=False +) +@example( + year=2026, + weekly_guarantee=100_000_000, + couple=True, + route="scenario", + calendar_year=True, +) +@example( + year=2040, weekly_guarantee=0, couple=True, route="scenario", calendar_year=False +) +@example( + year=2040, weekly_guarantee=1e300, couple=False, route="reform", calendar_year=True +) +def test_nonnegative_guarantee_reforms_never_break_pension_age_projections( + year, weekly_guarantee, couple, route, calendar_year +): + # Exercise the entire finite non-negative domain. Reading the weekly + # rate avoids overflowing the model's float32 annual-award variables. + relationship = "COUPLE" if couple else "SINGLE" + path = f"{GUARANTEE}.{relationship}" + period = f"year:{year}-01-01:1" if calendar_year else str(year) + simulation = _simulation( + couple, + {path: {period: weekly_guarantee}}, + [year], + route, + ) + + guarantee = simulation.tax_benefit_system.parameters.get_child(path) + assert guarantee(f"{year}-04-30") == weekly_guarantee + _assert_projections_unchanged(simulation) + + +@pytest.mark.parametrize("couple", [False, True]) +def test_earnings_path_guarantee_reform_keeps_independent_projections(couple): + """Match uk-triple-lock's annual overrides from the 2026-27 guarantee.""" + changes = {} + for relationship in ("SINGLE", "COUPLE"): + base = float( + system.parameters.get_child(f"{GUARANTEE}.{relationship}")("2026-04-30") + ) + changes[f"{GUARANTEE}.{relationship}"] = { + str(year): base * 1.05 ** (year - 2026) for year in range(2027, 2041) + } + simulation = _simulation(couple, changes, YEARS) + relationship = "COUPLE" if couple else "SINGLE" + path = f"{GUARANTEE}.{relationship}" + parameter = simulation.tax_benefit_system.parameters.get_child(path) + + for year in range(2027, 2041): + expected = changes[path][str(year)] + assert parameter(f"{year}-04-30") == pytest.approx(expected) + assert simulation.calculate("pension_credit", year)[0] == pytest.approx( + expected * 52, rel=1e-6 + ) + _assert_projections_unchanged(simulation) + + +@pytest.mark.parametrize("relationship", ["SINGLE", "COUPLE"]) +def test_invalid_enacted_protected_allowance_still_raises(relationship): + parameters = system.parameters + guarantee = float(parameters.get_child(f"{GUARANTEE}.{relationship}")("2026-04-01")) + assumptions = parameters.gov.economic_assumptions + + with pytest.raises(ValueError, match="cannot be lower than"): + protected_allowance_index_values( + guarantee - 0.01, + guarantee, + assumptions.indices.statutory_earnings_floor, + assumptions.statutory_uprating_inputs.cpi_september, + 2026, + ) From 559b7ca13093a535c971e6d7f24f1232976d3b08 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 03:44:04 -0400 Subject: [PATCH 09/28] Preserve valid joint pension guarantee and Housing Benefit reforms --- changelog.d/2092.fixed.md | 2 +- .../create_protected_pension_age_uprating.py | 32 ++++- ...ate_savings_credit_threshold_projection.py | 22 ++- policyengine_uk/tax_benefit_system.py | 31 ++++- .../tests/test_dated_reform_routes.py | 8 +- ...ion_credit_guarantee_reform_projections.py | 130 +++++++++++++++++- 6 files changed, 202 insertions(+), 23 deletions(-) diff --git a/changelog.d/2092.fixed.md b/changelog.d/2092.fixed.md index 9093c00b21..ea22f11c58 100644 --- a/changelog.d/2092.fixed.md +++ b/changelog.d/2092.fixed.md @@ -1 +1 @@ -Allow Pension Credit minimum-guarantee reforms above the protected Housing Benefit allowance by keeping the unreformed guarantee as the base for Housing Benefit and Savings Credit threshold projections. +- Allow Pension Credit guarantee-only reforms above protected Housing Benefit allowances without changing independently set HB and Savings Credit projections; preserve valid joint reforms and validation of inconsistent allowance bases. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py index f8b01403f1..4d46ed219b 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py @@ -150,18 +150,27 @@ def _projection_parameter( def add_protected_pension_age_uprating( parameters: ParameterNode, unreformed: ParameterNode | None = None, + intermediate_projection: bool = False, ) -> ParameterNode: """Add relationship-specific protected-allowance forecast indices. A guarantee reform does not re-derive the separately set HB allowance. - Use its unreformed guarantee component so the enacted-base validation - remains meaningful, even when a reform exceeds the protected allowance. - Earnings and CPI inputs still come from the reform's parameter tree. + Use the unreformed guarantee unless the corresponding HB base changes. + A changed allowance instead uses the reformed guarantee, so the guard + validates a consistent pair even for joint cuts and intermediate rebuilds. + Intermediate prior-law rebuilds can temporarily omit a guarantee cut + while retaining the HB cut. Use the enacted pair for that transient invalid + pair; the completed reform is still validated. Earnings and CPI inputs + come from the reform's parameter tree. """ allowances = parameters.gov.dwp.housing_benefit.allowances - guarantee_source = parameters if unreformed is None else unreformed + projection_base = parameters if unreformed is None else unreformed + base_allowances = projection_base.gov.dwp.housing_benefit.allowances + reformed_guarantees = ( + parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) guarantees = ( - guarantee_source.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + projection_base.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee ) earnings_index = ( parameters.gov.economic_assumptions.indices.statutory_earnings_floor @@ -176,11 +185,20 @@ def add_protected_pension_age_uprating( allowances.add_child("protected_pension_age_uprating", projection) relationship_parameters = { - "single": (allowances.single.aged, guarantees.SINGLE), - "couple": (allowances.couple.aged, guarantees.COUPLE), + "single": (base_allowances.single.aged, guarantees.SINGLE), + "couple": (base_allowances.couple.aged, guarantees.COUPLE), } final_observation_year = last_input_year(parameters) for relationship, (allowance, guarantee) in relationship_parameters.items(): + reformed_allowance = getattr(allowances, relationship).aged + if reformed_allowance(BASE_INSTANT) != allowance(BASE_INSTANT): + reformed_guarantee = getattr(reformed_guarantees, relationship.upper()) + if not ( + intermediate_projection + and reformed_allowance(BASE_INSTANT) < reformed_guarantee(BASE_INSTANT) + ): + allowance = reformed_allowance + guarantee = reformed_guarantee projection.add_child( relationship, _projection_parameter( diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py index 83b75050de..34da278013 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py @@ -206,13 +206,18 @@ def add_savings_credit_threshold_projection( """Add forecast indices for each Savings Credit threshold. The separately set threshold is not re-derived by a guarantee reform, - including a reform to the projection's base year. Other inputs and any - explicit threshold reform still come from the reform's parameter tree. + including a reform to the projection's base year. Use the unreformed + guarantee unless the corresponding threshold base changes, in which case + use the reformed pair. Other inputs come from the reform's tree. """ savings_credit = parameters.gov.dwp.pension_credit.savings_credit - guarantee_source = parameters if unreformed is None else unreformed + projection_base = parameters if unreformed is None else unreformed + base_thresholds = projection_base.gov.dwp.pension_credit.savings_credit.threshold guarantees = ( - guarantee_source.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + projection_base.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) + reformed_guarantees = ( + parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee ) earnings_index = ( parameters.gov.economic_assumptions.indices.statutory_earnings_floor @@ -226,14 +231,19 @@ def add_savings_credit_threshold_projection( ) savings_credit.add_child("threshold_uprating", projection) relationship_parameters = { - "SINGLE": (guarantees.SINGLE, savings_credit.threshold.SINGLE), - "COUPLE": (guarantees.COUPLE, savings_credit.threshold.COUPLE), + "SINGLE": (guarantees.SINGLE, base_thresholds.SINGLE), + "COUPLE": (guarantees.COUPLE, base_thresholds.COUPLE), } final_observation_year = last_input_year(parameters) for relationship, ( guarantee_parameter, threshold_parameter, ) in relationship_parameters.items(): + base_instant = projection_instant(BASE_YEAR) + reformed_threshold = getattr(savings_credit.threshold, relationship) + if reformed_threshold(base_instant) != threshold_parameter(base_instant): + threshold_parameter = reformed_threshold + guarantee_parameter = getattr(reformed_guarantees, relationship) projection.add_child( relationship, _projection_parameter( diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 4d8a4fadb1..e38f68ce99 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -127,13 +127,15 @@ def _add_generated_parameters( parameters: ParameterNode, generated: Optional[List[ParameterAssignment]] = None, unreformed: Optional[ParameterNode] = None, + intermediate_projection: bool = False, ) -> ParameterNode: """Run the generation steps, writing changes to generated parameters. Each change in ``generated`` is written onto its parameter just after the step that generates it, so the later steps, and uprating, read it. ``unreformed`` supplies the guarantee base for the separately set Savings - Credit threshold and protected Housing Benefit allowance projections. + Credit threshold and protected Housing Benefit allowance forecast indices. + A changed threshold or allowance base uses the reformed pair instead. """ pending = list(generated or []) for step in GENERATION_STEPS: @@ -141,7 +143,10 @@ def _add_generated_parameters( add_savings_credit_threshold_projection, add_protected_pension_age_uprating, ): - parameters = step(parameters, unreformed=unreformed) + options = {"unreformed": unreformed} + if step is add_protected_pension_age_uprating: + options["intermediate_projection"] = intermediate_projection + parameters = step(parameters, **options) else: parameters = step(parameters) if pending: @@ -163,6 +168,7 @@ def process_parameter_tree( unreformed: Optional[ParameterNode] = None, annualise: bool = True, generated: Optional[List[ParameterAssignment]] = None, + intermediate_projection: bool = False, ) -> Tuple[ParameterNode, Dict[str, Timeline]]: """Project and annualise a statutory parameter tree. @@ -191,14 +197,21 @@ def process_parameter_tree( tree carrying dated reform changes passes ``unreformed``, the same tree without them, which the copy is taken from instead (#2188). ``annualise=False`` stops after the projection and adds no copy. - ``unreformed`` also keeps the Pension Credit guarantee component of the - threshold and protected HB projections independent of guarantee reforms, - including during intermediate projections with ``annualise=False``. + ``unreformed`` also keeps the guarantee component of the threshold and + protected HB indices independent of guarantee-only reforms, including + during intermediate projections with ``annualise=False``. Explicit changes + to a threshold or allowance base instead use the corresponding reformed + guarantee, keeping the pair consistent. ``intermediate_projection`` permits + a transient invalid reformed HB pair to use the enacted pair while prior + law is rebuilt with one reform assignment omitted. It does not disable + validation of enacted bases or the completed reform. ``generated`` holds the reform's changes to generated parameters, each written just after the step that generates it (see ``_add_generated_parameters``). """ - parameters = _add_generated_parameters(parameters, generated, unreformed) + parameters = _add_generated_parameters( + parameters, generated, unreformed, intermediate_projection + ) # Create baseline parameters for reform comparisons if annualise: @@ -742,7 +755,11 @@ def lookup(name: str) -> Optional[Timeline]: tree.get_child(assignment.path), assignment, lookup ) updated[path] = process_parameter_tree( - tree, unreformed=unreformed, annualise=False, generated=generated + tree, + unreformed=unreformed, + annualise=False, + generated=generated, + intermediate_projection=True, )[1].get(path) if _same_timelines(updated, environment): return lookup diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index 21bdd923ec..3f24e2e0b4 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1213,7 +1213,13 @@ def test_prior_law_that_never_settles_is_an_error(monkeypatch): calls = {"n": 0} - def unsettled(tree, unreformed=None, annualise=True, generated=None): + def unsettled( + tree, + unreformed=None, + annualise=True, + generated=None, + intermediate_projection=False, + ): calls["n"] += 1 entry = ParameterAtInstant( UC_SINGLE_OLD, "2026-04-01", data={"value": float(calls["n"])} diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index 65db4713be..d1b42a9e5d 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -3,12 +3,17 @@ import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +from policyengine_core.periods import instant from policyengine_uk import Simulation, system from policyengine_uk.model_api import Scenario from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_protected_pension_age_uprating import ( protected_allowance_index_values, ) +from policyengine_uk.tax_benefit_system import ( + CountryTaxBenefitSystem, + process_parameter_tree, +) GUARANTEE = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" @@ -98,6 +103,99 @@ def test_2026_guarantee_reform_pays_the_reformed_credit_without_rebuilding_hb( _assert_projections_unchanged(simulation) +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize("couple,expected", [(False, 15_600), (True, 23_868)]) +def test_simultaneous_calendar_guarantee_reforms_keep_independent_projections( + couple, expected, route +): + """Rebuilding prior law for either dated change must tolerate the other.""" + changes = { + f"{GUARANTEE}.SINGLE": {"year:2026-01-01:1": 300}, + f"{GUARANTEE}.COUPLE": {"year:2026-01-01:1": 459}, + } + simulation = _simulation(couple, changes, [2026], route) + + assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx(expected) + _assert_projections_unchanged(simulation) + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize("calendar_year", [False, True]) +@pytest.mark.parametrize("couple", [False, True]) +def test_joint_guarantee_and_hb_cuts_use_consistent_reformed_components( + couple, calendar_year, route +): + guarantees = {"SINGLE": 200, "COUPLE": 300} + allowances = {"single": 220, "lone_parent": 220, "couple": 330} + values = { + **{f"{GUARANTEE}.{name}": value for name, value in guarantees.items()}, + **{f"{HB_ALLOWANCES}.{name}.aged": value for name, value in allowances.items()}, + } + changes = { + path: {"year:2026-01-01:1": value} if calendar_year else value + for path, value in values.items() + } + simulation = _simulation(couple, changes, [2026], route) + parameters = simulation.tax_benefit_system.parameters + expected_credit = guarantees["COUPLE" if couple else "SINGLE"] * 52 + + assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx( + expected_credit + ) + for name, amount in allowances.items(): + allowance = parameters.get_child(f"{HB_ALLOWANCES}.{name}.aged") + assert allowance("2026-04-30") == pytest.approx(amount) + + for name in ("single", "couple"): + path = f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" + index = parameters.get_child(path) + assumptions = parameters.gov.economic_assumptions + earnings = assumptions.indices.statutory_earnings_floor + earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") + cpi_growth = assumptions.statutory_uprating_inputs.cpi_september("2026-09-01") + guarantee = guarantees[name.upper()] + base = allowances[name] + expected = ( + guarantee * earnings_growth + (base - guarantee) * (1 + cpi_growth) + ) / base + assert index("2027-04-30") == pytest.approx(expected) + + +@pytest.mark.parametrize("calendar_year", [False, True]) +def test_joint_guarantee_and_savings_threshold_cuts_use_reformed_components( + calendar_year, +): + guarantees = {"SINGLE": 200, "COUPLE": 300} + thresholds = {"SINGLE": 180, "COUPLE": 280} + values = { + **{f"{GUARANTEE}.{name}": value for name, value in guarantees.items()}, + **{ + f"{SAVINGS_CREDIT}.threshold.{name}": value + for name, value in thresholds.items() + }, + } + changes = { + path: {"year:2026-01-01:1": value} if calendar_year else value + for path, value in values.items() + } + simulation = _simulation(False, changes, [2026]) + parameters = simulation.tax_benefit_system.parameters + for relationship, base_threshold in thresholds.items(): + threshold = parameters.get_child(f"{SAVINGS_CREDIT}.threshold.{relationship}") + assert threshold("2026-04-30") == pytest.approx(base_threshold) + path = f"{SAVINGS_CREDIT}.threshold_uprating.{relationship}" + index = parameters.get_child(path) + assumptions = parameters.gov.economic_assumptions + earnings = assumptions.indices.statutory_earnings_floor + earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") + cpi_growth = assumptions.statutory_uprating_inputs.cpi_september("2026-09-01") + # These reformed gaps imply exact £12 maxima at the 60% phase-in rate. + expected_threshold = guarantees[relationship] * earnings_growth - 20 * ( + 1 + cpi_growth + ) + assert index("2027-04-30") == pytest.approx(expected_threshold / base_threshold) + + @settings( max_examples=20, deadline=None, @@ -130,7 +228,7 @@ def test_2026_guarantee_reform_pays_the_reformed_credit_without_rebuilding_hb( def test_nonnegative_guarantee_reforms_never_break_pension_age_projections( year, weekly_guarantee, couple, route, calendar_year ): - # Exercise the entire finite non-negative domain. Reading the weekly + # Generate across the finite non-negative domain. Reading the weekly # rate avoids overflowing the model's float32 annual-award variables. relationship = "COUPLE" if couple else "SINGLE" path = f"{GUARANTEE}.{relationship}" @@ -186,3 +284,33 @@ def test_invalid_enacted_protected_allowance_still_raises(relationship): assumptions.statutory_uprating_inputs.cpi_september, 2026, ) + + +@pytest.mark.parametrize("allowance_name,amount", [("single", 200), ("couple", 300)]) +def test_invalid_statutory_allowance_still_raises_during_projection( + allowance_name, amount +): + tax_benefit_system = CountryTaxBenefitSystem() + tax_benefit_system.reset_parameters() + parameters = tax_benefit_system.parameters + parameters.get_child(f"{HB_ALLOWANCES}.{allowance_name}.aged").update( + start=instant("2026-04-01"), stop=None, value=amount + ) + + with pytest.raises(ValueError, match="cannot be lower than"): + process_parameter_tree(parameters, annualise=False) + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize( + "relationship,allowance_name", [("SINGLE", "single"), ("COUPLE", "couple")] +) +def test_invalid_completed_joint_reform_still_raises( + route, relationship, allowance_name +): + changes = { + f"{GUARANTEE}.{relationship}": {"year:2026-01-01:1": 230}, + f"{HB_ALLOWANCES}.{allowance_name}.aged": {"year:2026-01-01:1": 220}, + } + with pytest.raises(ValueError, match="cannot be lower than"): + _simulation(relationship == "COUPLE", changes, [2026], route) From 9f6b3c291f0a44e5f2750bae6b0131353db14d2b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 04:40:38 -0400 Subject: [PATCH 10/28] Make Savings Credit projection fixtures pension-age eligible --- .../dwp/pension_credit/savings_credit/savings_credit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index b9fddd65f9..95abc88eb7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -30,11 +30,13 @@ period: 2025 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: SINGLE savings_credit_income: 11_000 pension_credit_income: 11_000 output: + is_pension_credit_eligible: true # 0.6 * (11,000 - 198.27 * 52). savings_credit: 413.976 @@ -42,11 +44,13 @@ period: 2025 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: COUPLE savings_credit_income: 17_000 pension_credit_income: 17_000 output: + is_pension_credit_eligible: true # 0.6 * (17,000 - 314.34 * 52). savings_credit: 392.592 @@ -54,6 +58,7 @@ period: 2027 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: SINGLE savings_credit_income: 12_000 @@ -61,5 +66,6 @@ standard_minimum_guarantee: 12_376 minimum_guarantee: 20_000 output: + is_pension_credit_eligible: true # 0.6 * (12,000 - 216.7146545 * 52). savings_credit: 438.5028 From edef32cfa3901b0e187c2965a0987e7509368e24 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:16:50 -0400 Subject: [PATCH 11/28] Recognise pension-age indices in generated parameter inventory --- .../tests/test_process_time_reforms.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index 732e58f2ab..8ef14ff61f 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -387,14 +387,33 @@ def test_generated_parameters_are_the_series_processing_builds(): generates = tbs_module.generated_parameter_defines() statutory = tbs_module.statutory_parameter_defines() processed = tbs_module.processed_parameters() + protected_allowance_index = ( + "gov.dwp.housing_benefit.allowances.protected_pension_age_uprating" + ) + savings_credit_index = "gov.dwp.pension_credit.savings_credit.threshold_uprating" + # Pension-age forecast indices are generated in their program trees. + generated_prefixes = ( + f"{INDICES}.", + f"{GROWTH}.", + f"{protected_allowance_index}.", + f"{savings_credit_index}.", + ) for path in tbs_module._leaf_paths(processed): if path.startswith("baseline.") or path.startswith( "gov.simulation.labor_supply_responses." ): assert not generates(path), path continue - built = path.startswith((f"{INDICES}.", f"{GROWTH}.")) and not statutory(path) + built = path.startswith(generated_prefixes) and not statutory(path) assert generates(path) is built, path + for relationship in ("single", "couple"): + assert generates(f"{protected_allowance_index}.{relationship}") + assert not generates(f"gov.dwp.housing_benefit.allowances.{relationship}.aged") + for relationship in ("SINGLE", "COUPLE"): + assert generates(f"{savings_credit_index}.{relationship}") + assert not generates( + f"gov.dwp.pension_credit.savings_credit.threshold.{relationship}" + ) assert generates(TRIPLE_LOCK) assert generates(f"{INDICES}.uc_standard_allowance") assert not generates(MINIMUM_RATE) From 695df97c3470ca93142164975d2616f58b52287b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 04:06:30 -0400 Subject: [PATCH 12/28] Derive the pension-age projection base year and stop reforms reaching the guards Review items on #2092 (vahid-ahmadi, 8 and 13 October): - BASE_YEAR = 2026 is gone from both builders. The base year is the last year with an explicit value for the standard minimum guarantee, the Savings Credit threshold and the three protected HB allowances, read from the enacted tree; they must all reach the same year, or processing raises, so a rates update that misses one fails instead of indexing from a projection. - The protected-allowance guard was reachable: any reform that set a base-year allowance below its guarantee (or to zero) raised ValueError while the tree was built. Such an allowance keeps no retained Savings Credit, so it is now projected whole with the guarantee's earnings path, continuously with an allowance equal to its guarantee. Only an enacted allowance below its guarantee still raises. intermediate_projection is no longer needed and is removed from tax_benefit_system.py, which shrinks this PR's footprint there. - The same class of failure in the threshold builder: a zero phase-in rate raised, and a zero threshold divided by zero. The threshold now uses the enacted phase-in rate, as it already used the unreformed guarantee, and a zero base stays zero. - Lone parents get their own index, so a reform to the single allowance no longer moves the lone-parent projection. Tests: boundary cases at, 1p below and 1p above the guarantee; zero-base and phase-in reforms; base-year derivation and an incomplete rates update. Pins for 2027 move with main's published September 2026 CPI (3.5%, replacing the 2.116% forecast). Co-Authored-By: Claude Opus 5.5 --- changelog.d/2092.fixed.md | 2 +- docs/book/programs/gov/dwp/state-pension.md | 6 +- .../create_protected_pension_age_uprating.py | 96 ++++++++------ .../allowances/lone_parent/aged.yaml | 2 +- ...ate_savings_credit_threshold_projection.py | 47 ++++--- .../pension_age_projection_base.py | 47 +++++++ .../savings_credit/threshold.yaml | 4 +- policyengine_uk/tax_benefit_system.py | 29 ++--- .../savings_credit/savings_credit.yaml | 5 +- .../tests/test_dated_reform_routes.py | 8 +- ...ng_benefit_protected_allowance_uprating.py | 12 +- ...ion_credit_guarantee_reform_projections.py | 123 +++++++++++++++++- .../tests/test_process_time_reforms.py | 2 +- ...est_savings_credit_threshold_projection.py | 97 +++++++++++--- 14 files changed, 358 insertions(+), 122 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py diff --git a/changelog.d/2092.fixed.md b/changelog.d/2092.fixed.md index ea22f11c58..9db4eb084c 100644 --- a/changelog.d/2092.fixed.md +++ b/changelog.d/2092.fixed.md @@ -1 +1 @@ -- Allow Pension Credit guarantee-only reforms above protected Housing Benefit allowances without changing independently set HB and Savings Credit projections; preserve valid joint reforms and validation of inconsistent allowance bases. +- Pension Credit guarantee and Savings Credit phase-in reforms leave the separately set Savings Credit threshold and protected Housing Benefit allowance projections unchanged, and no reform to a base-year allowance or threshold stops the parameter tree building. The projections start from the last year with enacted rates, which every base parameter must reach. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index a4fd2f6ac2..000d6aa255 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -264,7 +264,11 @@ derives the Savings Credit threshold so that this earnings-projected guarantee and a non-negative September CPI projection of maximum Savings Credit preserve the statutory 60% relationship. Protected pension-age Housing Benefit uses the same earnings and CPI components. These are forecast conventions after -the last enacted rates, not announced future benefit amounts. +the last enacted rates, not announced future benefit amounts. They start from +the last year with enacted rates for the guarantee, the threshold and the +allowances, which must all reach the same year. A reform to the guarantee or +the Savings Credit phase-in rate does not re-derive the threshold or the +allowances. After the EFO horizon the gap is zero, so the inputs follow calendar-year growth. Smooth forecasts pay the higher of earnings, CPI and 2.5% each year, diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py index 4d46ed219b..3024134720 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_protected_pension_age_uprating.py @@ -5,18 +5,27 @@ guarantee component follows the shared statutory earnings index; the retained uplift follows non-negative September CPI. Projecting either whole allowance with one series would therefore misstate its relationship to Pension Credit. +The indices start from the last year with enacted rates +(pension_age_projection_base.py). """ from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + projection_base_year, +) from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( CPI_OBSERVATION_MONTH_DAY, last_input_year, ) -BASE_YEAR = 2026 -BASE_INSTANT = f"{BASE_YEAR}-04-01" +# Each protected allowance, with the standard minimum guarantee it holds. +ALLOWANCE_GUARANTEES = {"single": "SINGLE", "lone_parent": "SINGLE", "couple": "COUPLE"} + + +def base_instant(base_year: int) -> str: + return f"{base_year}-04-01" def project_retained_uplift(amount: float, cpi_growth: float) -> float: @@ -30,6 +39,8 @@ def protected_allowance_index_values( earnings_index: Parameter, cpi_september: Parameter, final_observation_year: int, + *, + base_year: int, ) -> dict[str, float]: """Build a component-weighted index from the last enacted allowance.""" retained_uplift = base_allowance - guarantee_component @@ -38,10 +49,14 @@ def protected_allowance_index_values( "The protected Housing Benefit allowance cannot be lower than " "the corresponding Pension Credit guarantee." ) - - base_earnings_index = float(earnings_index(BASE_INSTANT)) - values = {BASE_INSTANT: 1.0} - for year in range(BASE_YEAR + 1, final_observation_year + 2): + start = base_instant(base_year) + if base_allowance <= 0: + # The index scales the base, so a reform's zero allowance stays zero. + return {start: 1.0} + + base_earnings_index = float(earnings_index(start)) + values = {start: 1.0} + for year in range(base_year + 1, final_observation_year + 2): observation_year = year - 1 guarantee = guarantee_component * ( float(earnings_index(f"{year}-04-01")) / base_earnings_index @@ -55,26 +70,26 @@ def protected_allowance_index_values( def _projection_parameter( - relationship: str, + allowance: str, base_allowance: float, guarantee_component: float, earnings_index: Parameter, cpi_september: Parameter, final_observation_year: int, + base_year: int, ) -> Parameter: retained_uplift = base_allowance - guarantee_component - relationship_label = ( - "single and lone-parent" if relationship == "single" else "couple" - ) + relationship_label = allowance.replace("_", "-") + fiscal_year = f"{base_year}-{(base_year + 1) % 100:02d}" return Parameter( ( "gov.dwp.housing_benefit.allowances." - f"protected_pension_age_uprating.{relationship}" + f"protected_pension_age_uprating.{allowance}" ), data={ "description": ( f"Forecast index for the protected {relationship_label} " - "pension-age Housing Benefit allowance. The enacted 2026-27 " + f"pension-age Housing Benefit allowance. The {fiscal_year} " f"base comprises a £{guarantee_component:g} Pension Credit " f"guarantee and a £{retained_uplift:g} retained Savings " "Credit uplift. The components follow the shared statutory " @@ -87,6 +102,7 @@ def _projection_parameter( earnings_index, cpi_september, final_observation_year, + base_year=base_year, ), "metadata": { "unit": "/1", @@ -150,21 +166,22 @@ def _projection_parameter( def add_protected_pension_age_uprating( parameters: ParameterNode, unreformed: ParameterNode | None = None, - intermediate_projection: bool = False, ) -> ParameterNode: - """Add relationship-specific protected-allowance forecast indices. - - A guarantee reform does not re-derive the separately set HB allowance. - Use the unreformed guarantee unless the corresponding HB base changes. - A changed allowance instead uses the reformed guarantee, so the guard - validates a consistent pair even for joint cuts and intermediate rebuilds. - Intermediate prior-law rebuilds can temporarily omit a guarantee cut - while retaining the HB cut. Use the enacted pair for that transient invalid - pair; the completed reform is still validated. Earnings and CPI inputs - come from the reform's parameter tree. + """Add a protected-allowance forecast index for each allowance. + + A guarantee reform does not re-derive the separately set HB allowance: + each index uses the unreformed guarantee unless its own allowance base + changes, when it uses the reformed pair. A reformed allowance below its + guarantee keeps no retained Savings Credit, so it is projected whole with + the guarantee's earnings path, as an allowance equal to its guarantee is. + The enacted pair is still validated, so a rates update that puts an + allowance below its guarantee fails. Earnings and CPI inputs come from + the reform's parameter tree. """ allowances = parameters.gov.dwp.housing_benefit.allowances projection_base = parameters if unreformed is None else unreformed + base_year = projection_base_year(projection_base) + start = base_instant(base_year) base_allowances = projection_base.gov.dwp.housing_benefit.allowances reformed_guarantees = ( parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee @@ -184,30 +201,27 @@ def add_protected_pension_age_uprating( ) allowances.add_child("protected_pension_age_uprating", projection) - relationship_parameters = { - "single": (base_allowances.single.aged, guarantees.SINGLE), - "couple": (base_allowances.couple.aged, guarantees.COUPLE), - } final_observation_year = last_input_year(parameters) - for relationship, (allowance, guarantee) in relationship_parameters.items(): - reformed_allowance = getattr(allowances, relationship).aged - if reformed_allowance(BASE_INSTANT) != allowance(BASE_INSTANT): - reformed_guarantee = getattr(reformed_guarantees, relationship.upper()) - if not ( - intermediate_projection - and reformed_allowance(BASE_INSTANT) < reformed_guarantee(BASE_INSTANT) - ): - allowance = reformed_allowance - guarantee = reformed_guarantee + for allowance_name, relationship in ALLOWANCE_GUARANTEES.items(): + allowance = float(getattr(base_allowances, allowance_name).aged(start)) + guarantee = float(getattr(guarantees, relationship)(start)) + reformed_allowance = float(getattr(allowances, allowance_name).aged(start)) + if reformed_allowance != allowance: + allowance = reformed_allowance + guarantee = min( + float(getattr(reformed_guarantees, relationship)(start)), + reformed_allowance, + ) projection.add_child( - relationship, + allowance_name, _projection_parameter( - relationship, - float(allowance(BASE_INSTANT)), - float(guarantee(BASE_INSTANT)), + allowance_name, + allowance, + guarantee, earnings_index, cpi_september, final_observation_year, + base_year, ), ) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml index 77dd18f777..ec1b484ff6 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml @@ -32,7 +32,7 @@ metadata: # guarantee plus a retained Savings Credit amount, projected by component # (create_protected_pension_age_uprating.py): the guarantee by the # statutory earnings floor, the retained amount by September CPI. - uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.single + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.lone_parent reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(1)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py index 34da278013..a09e33bff3 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/create_savings_credit_threshold_projection.py @@ -6,21 +6,23 @@ therefore a derived residual; applying CPI to it directly, or freezing it, does not preserve the statutory relationship. Each generated index is applied by the standard parameter-uprating operation, so explicit threshold values and -reforms remain authoritative anchors. +reforms remain authoritative anchors. The indices start from the last year +with enacted rates (pension_age_projection_base.py). """ from decimal import ROUND_HALF_UP, Decimal from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + projection_base_year, +) from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( CPI_OBSERVATION_MONTH_DAY, last_input_year, ) -BASE_YEAR = 2026 -BASE_INSTANT = f"{BASE_YEAR}-04-01" CURRENCY_PRECISION = Decimal("0.01") @@ -62,14 +64,20 @@ def threshold_index_values( earnings_index: Parameter, cpi_september: Parameter, final_observation_year: int, + *, + base_year: int, ) -> dict[str, float]: """Build a forecast index from the last enacted threshold.""" - base_phase_in_rate = float(phase_in_rate(projection_instant(BASE_YEAR))) + base_instant = f"{base_year}-04-01" + if base_threshold <= 0: + # The index scales the base, so a reform's zero threshold stays zero. + return {base_instant: 1.0} + base_phase_in_rate = float(phase_in_rate(projection_instant(base_year))) maximum = round_currency(base_phase_in_rate * (base_guarantee - base_threshold)) - base_earnings_index = float(earnings_index(BASE_INSTANT)) - values = {BASE_INSTANT: 1.0} + base_earnings_index = float(earnings_index(base_instant)) + values = {base_instant: 1.0} - for year in range(BASE_YEAR + 1, final_observation_year + 2): + for year in range(base_year + 1, final_observation_year + 2): observation_year = year - 1 maximum = project_maximum_savings_credit( maximum, @@ -96,6 +104,7 @@ def _projection_parameter( earnings_index: Parameter, cpi_september: Parameter, final_observation_year: int, + base_year: int, ) -> Parameter: relationship_label = relationship.lower() return Parameter( @@ -116,6 +125,7 @@ def _projection_parameter( earnings_index, cpi_september, final_observation_year, + base_year=base_year, ), "metadata": { "unit": "/1", @@ -205,14 +215,19 @@ def add_savings_credit_threshold_projection( ) -> ParameterNode: """Add forecast indices for each Savings Credit threshold. - The separately set threshold is not re-derived by a guarantee reform, - including a reform to the projection's base year. Use the unreformed - guarantee unless the corresponding threshold base changes, in which case - use the reformed pair. Other inputs come from the reform's tree. + The separately set threshold is not re-derived by a guarantee or + phase-in reform, including a reform to the projection's base year. Use + the unreformed guarantee unless the corresponding threshold base changes, + in which case use the reformed pair, and the enacted phase-in rate, so a + phase-in reform (to zero, say) changes the Savings Credit paid and not the + threshold. Earnings and CPI inputs come from the reform's tree. """ savings_credit = parameters.gov.dwp.pension_credit.savings_credit projection_base = parameters if unreformed is None else unreformed - base_thresholds = projection_base.gov.dwp.pension_credit.savings_credit.threshold + base_year = projection_base_year(projection_base) + base_instant = projection_instant(base_year) + enacted_savings_credit = projection_base.gov.dwp.pension_credit.savings_credit + base_thresholds = enacted_savings_credit.threshold guarantees = ( projection_base.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee ) @@ -239,7 +254,6 @@ def add_savings_credit_threshold_projection( guarantee_parameter, threshold_parameter, ) in relationship_parameters.items(): - base_instant = projection_instant(BASE_YEAR) reformed_threshold = getattr(savings_credit.threshold, relationship) if reformed_threshold(base_instant) != threshold_parameter(base_instant): threshold_parameter = reformed_threshold @@ -248,12 +262,13 @@ def add_savings_credit_threshold_projection( relationship, _projection_parameter( relationship, - float(guarantee_parameter(projection_instant(BASE_YEAR))), - float(threshold_parameter(projection_instant(BASE_YEAR))), - savings_credit.rate.phase_in, + float(guarantee_parameter(base_instant)), + float(threshold_parameter(base_instant)), + enacted_savings_credit.rate.phase_in, earnings_index, cpi_september, final_observation_year, + base_year, ), ) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py b/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py new file mode 100644 index 0000000000..ffbada6cf3 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py @@ -0,0 +1,47 @@ +"""The enacted year the pension-age forecast indices start from. + +The Savings Credit threshold and protected pension-age Housing Benefit +allowance indices are built from the standard minimum guarantee, threshold and +allowances of the last year with enacted rates. That year is the latest one +with an explicit value, so entering the next year's rates moves the base with +them. Every one of these parameters must reach the same year: an uprating +update that misses one would otherwise build the indices from a projected +amount, so it fails here instead. +""" + +from policyengine_core.parameters import Parameter, ParameterNode + +BASE_PARAMETERS = ( + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE", + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.COUPLE", + "gov.dwp.pension_credit.savings_credit.threshold.SINGLE", + "gov.dwp.pension_credit.savings_credit.threshold.COUPLE", + "gov.dwp.housing_benefit.allowances.single.aged", + "gov.dwp.housing_benefit.allowances.lone_parent.aged", + "gov.dwp.housing_benefit.allowances.couple.aged", +) + + +def last_enacted_year(parameter: Parameter) -> int: + """The year of the parameter's latest explicit value.""" + return max(int(entry.instant_str[:4]) for entry in parameter.values_list) + + +def projection_base_year(parameters: ParameterNode) -> int: + """The last year with enacted rates for every projected parameter. + + Read it from the enacted tree, not a reformed one: a reform's later + values are anchors for uprating, not a new projection base. + """ + years = { + path: last_enacted_year(parameters.get_child(path)) for path in BASE_PARAMETERS + } + if len(set(years.values())) > 1: + listed = ", ".join(f"{path} {year}" for path, year in years.items()) + raise ValueError( + "The pension-age projections need the standard minimum guarantee, " + "the Savings Credit threshold and the pension-age Housing Benefit " + "allowances enacted to the same year. Their last explicit values " + f"are in: {listed}." + ) + return years[BASE_PARAMETERS[0]] diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 2babfeffd8..832cc70a9e 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -2,7 +2,9 @@ description: >- Income threshold at which Savings Credit is eligible. Values after the last enacted rate are derived so that the earnings-projected standard minimum guarantee and the CPI-projected maximum Savings Credit remain consistent - with the statutory 60% formula; they are not enacted future rates. + with the statutory 60% formula; they are not enacted future rates. A reform + to the guarantee or the phase-in rate does not re-derive the threshold or the + protected pension-age Housing Benefit allowances. SINGLE: values: 2015-04-06: 126.5 diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 7f21f6be45..dca92fd97b 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -144,15 +144,14 @@ def _add_generated_parameters( parameters: ParameterNode, generated: Optional[List[ParameterAssignment]] = None, unreformed: Optional[ParameterNode] = None, - intermediate_projection: bool = False, ) -> ParameterNode: """Run the generation steps, writing changes to generated parameters. Each change in ``generated`` is written onto its parameter just after the step that generates it, so the later steps, and uprating, read it. - ``unreformed`` supplies the guarantee base for the separately set Savings - Credit threshold and protected Housing Benefit allowance forecast indices. - A changed threshold or allowance base uses the reformed pair instead. + ``unreformed`` supplies the base year, guarantee and phase-in rate for the + separately set Savings Credit threshold and protected Housing Benefit + allowance forecast indices. """ pending = list(generated or []) for step in GENERATION_STEPS: @@ -160,10 +159,7 @@ def _add_generated_parameters( add_savings_credit_threshold_projection, add_protected_pension_age_uprating, ): - options = {"unreformed": unreformed} - if step is add_protected_pension_age_uprating: - options["intermediate_projection"] = intermediate_projection - parameters = step(parameters, **options) + parameters = step(parameters, unreformed=unreformed) else: parameters = step(parameters) if pending: @@ -185,7 +181,6 @@ def process_parameter_tree( unreformed: Optional[ParameterNode] = None, annualise: bool = True, generated: Optional[List[ParameterAssignment]] = None, - intermediate_projection: bool = False, ) -> Tuple[ParameterNode, Dict[str, Timeline]]: """Project and annualise a statutory parameter tree. @@ -214,22 +209,15 @@ def process_parameter_tree( tree carrying dated reform changes passes ``unreformed``, the same tree without them, which the copy is taken from instead (#2188). ``annualise=False`` stops after the projection and adds no copy. - ``unreformed`` also keeps the guarantee component of the threshold and - protected HB indices independent of guarantee-only reforms, including - during intermediate projections with ``annualise=False``. Explicit changes - to a threshold or allowance base instead use the corresponding reformed - guarantee, keeping the pair consistent. ``intermediate_projection`` permits - a transient invalid reformed HB pair to use the enacted pair while prior - law is rebuilt with one reform assignment omitted. It does not disable - validation of enacted bases or the completed reform. + ``unreformed`` also keeps the Savings Credit threshold and protected HB + indices independent of reforms to the guarantee or phase-in rate, + including during intermediate projections with ``annualise=False``. ``generated`` holds the reform's changes to generated parameters, each written just after the step that generates it (see ``_add_generated_parameters``). """ validate_ni_scale_compatibility(parameters) - parameters = _add_generated_parameters( - parameters, generated, unreformed, intermediate_projection - ) + parameters = _add_generated_parameters(parameters, generated, unreformed) # Create baseline parameters for reform comparisons if annualise: @@ -766,7 +754,6 @@ def lookup(name: str) -> Optional[Timeline]: unreformed=unreformed, annualise=False, generated=generated, - intermediate_projection=True, )[1].get(path) if _same_timelines(updated, environment): return lookup diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index 2cd34fbb97..1611cc19ff 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -67,8 +67,9 @@ minimum_guarantee: 20_000 output: is_pension_credit_eligible: true - # 0.6 * (12,000 - 216.7146545 * 52). - savings_credit: 438.5028 + # 0.6 * (12,000 - 216.3003772 * 52); the threshold's maximum Savings Credit + # rises by September 2026 CPI, 3.5%. + savings_credit: 451.4282 # Explicit SI 2019/37 art.4 saving INPUT CONTRACT, not an inference from current # receipt or proof of the separate 2016 Savings Credit continuity conditions. diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index 6f7e388282..7bfffd350d 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1213,13 +1213,7 @@ def test_prior_law_that_never_settles_is_an_error(monkeypatch): calls = {"n": 0} - def unsettled( - tree, - unreformed=None, - annualise=True, - generated=None, - intermediate_projection=False, - ): + def unsettled(tree, unreformed=None, annualise=True, generated=None): calls["n"] += 1 entry = ParameterAtInstant( UC_SINGLE_OLD, "2026-04-01", data={"value": float(calls["n"])} diff --git a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py index 1ec190d5f5..72dc9d2e9d 100644 --- a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py +++ b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py @@ -18,9 +18,9 @@ def _parameters(): "expected_2027_allowance", ), [ - ("single", "single", 256.0, 265.6622572049), - ("lone_parent", "single", 256.0, 265.6622572049), - ("couple", "couple", 383.35, 397.9411154524), + ("single", "single", 256.0, 265.9113772049), + ("lone_parent", "lone_parent", 256.0, 265.9113772049), + ("couple", "couple", 383.35, 398.2192994524), ], ) def test_protected_allowances_use_relationship_specific_component_projections( @@ -29,7 +29,11 @@ def test_protected_allowances_use_relationship_specific_component_projections( base_allowance, expected_2027_allowance, ): - """The 2026 bases are guarantee plus retained Savings Credit uplift.""" + """The 2026 bases are guarantee plus retained Savings Credit uplift. + + The retained uplift rises by September 2026 CPI, 3.5% (ONS, 21 October + 2026); the guarantee by the statutory earnings floor. + """ allowances = _parameters().gov.dwp.housing_benefit.allowances index = getattr(allowances.protected_pension_age_uprating, index_name) allowance = getattr(allowances, allowance_name).aged diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index d1b42a9e5d..1645eb9627 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -1,4 +1,17 @@ -"""Guarantee reforms leave independently enacted pension-age projections intact.""" +"""Reforms leave independently enacted pension-age projections intact. + +Invariants, for the Savings Credit threshold and protected pension-age Housing +Benefit allowance forecast indices: + +1. A reform to the standard minimum guarantee or the Savings Credit phase-in + rate leaves every index, and the projected thresholds and allowances, as + enacted. +2. No reform to a base-year allowance or threshold stops the parameter tree + being built; only an enacted allowance below its guarantee raises. +3. A reformed allowance below its guarantee is projected whole by the + guarantee's earnings path, continuously with an allowance equal to it. +4. Each allowance has its own index: a reform to one leaves the others. +""" import pytest from hypothesis import HealthCheck, example, given, settings @@ -8,6 +21,7 @@ from policyengine_uk import Simulation, system from policyengine_uk.model_api import Scenario from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_protected_pension_age_uprating import ( + ALLOWANCE_GUARANTEES, protected_allowance_index_values, ) from policyengine_uk.tax_benefit_system import ( @@ -24,7 +38,7 @@ *(f"{HB_ALLOWANCES}.{name}.aged" for name in ("single", "lone_parent", "couple")), *( f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" - for name in ("single", "couple") + for name in ALLOWANCE_GUARANTEES ), *( f"{SAVINGS_CREDIT}.{name}.{relationship}" @@ -146,14 +160,14 @@ def test_joint_guarantee_and_hb_cuts_use_consistent_reformed_components( allowance = parameters.get_child(f"{HB_ALLOWANCES}.{name}.aged") assert allowance("2026-04-30") == pytest.approx(amount) - for name in ("single", "couple"): + for name, relationship in ALLOWANCE_GUARANTEES.items(): path = f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" index = parameters.get_child(path) assumptions = parameters.gov.economic_assumptions earnings = assumptions.indices.statutory_earnings_floor earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") cpi_growth = assumptions.statutory_uprating_inputs.cpi_september("2026-09-01") - guarantee = guarantees[name.upper()] + guarantee = guarantees[relationship] base = allowances[name] expected = ( guarantee * earnings_growth + (base - guarantee) * (1 + cpi_growth) @@ -283,6 +297,7 @@ def test_invalid_enacted_protected_allowance_still_raises(relationship): assumptions.indices.statutory_earnings_floor, assumptions.statutory_uprating_inputs.cpi_september, 2026, + base_year=2026, ) @@ -301,16 +316,110 @@ def test_invalid_statutory_allowance_still_raises_during_projection( process_parameter_tree(parameters, annualise=False) +def _index_growth(parameters, name, year=2027): + index = parameters.get_child( + f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" + ) + return index(f"{year}-04-30") / index(f"{year - 1}-04-30") + + +def _earnings_growth(parameters, year=2027): + earnings = parameters.gov.economic_assumptions.indices.statutory_earnings_floor + return earnings(f"{year}-04-30") / earnings(f"{year - 1}-04-30") + + @pytest.mark.parametrize("route", ["reform", "scenario"]) @pytest.mark.parametrize( "relationship,allowance_name", [("SINGLE", "single"), ("COUPLE", "couple")] ) -def test_invalid_completed_joint_reform_still_raises( +def test_completed_joint_reform_below_the_guarantee_projects_with_the_guarantee( route, relationship, allowance_name ): + """An allowance cut below a cut guarantee no longer stops the build: with + no retained Savings Credit, it follows the guarantee's earnings path.""" changes = { f"{GUARANTEE}.{relationship}": {"year:2026-01-01:1": 230}, f"{HB_ALLOWANCES}.{allowance_name}.aged": {"year:2026-01-01:1": 220}, } - with pytest.raises(ValueError, match="cannot be lower than"): - _simulation(relationship == "COUPLE", changes, [2026], route) + simulation = _simulation(relationship == "COUPLE", changes, [2026], route) + parameters = simulation.tax_benefit_system.parameters + + allowance = parameters.get_child(f"{HB_ALLOWANCES}.{allowance_name}.aged") + assert allowance("2026-04-30") == pytest.approx(220) + assert _index_growth(parameters, allowance_name) == pytest.approx( + _earnings_growth(parameters) + ) + + +@pytest.mark.parametrize("offset", [-100, -0.01, 0, 0.01]) +def test_allowance_reform_at_the_guarantee_boundary_is_continuous(offset): + """Below, at and just above the enacted £238 guarantee, a 2026 cut to the + single allowance projects within a millionth of the guarantee's earnings + growth, exactly so at or below it, and leaves the other indices. Just + above, the penny of retained Savings Credit grows by CPI, so the growth + lies between the two.""" + guarantee = float(system.parameters.get_child(f"{GUARANTEE}.SINGLE")("2026-04-30")) + changes = { + f"{HB_ALLOWANCES}.single.aged": {"year:2026-01-01:1": guarantee + offset} + } + parameters = _simulation(False, changes, [2026]).tax_benefit_system.parameters + + growth = _index_growth(parameters, "single") + earnings_growth = _earnings_growth(parameters) + if offset <= 0: + assert growth == pytest.approx(earnings_growth, rel=1e-12) + else: + assert growth == pytest.approx(earnings_growth, rel=1e-6) + cpi = parameters.gov.economic_assumptions.statutory_uprating_inputs + cpi_growth = 1 + max(float(cpi.cpi_september("2026-09-01")), 0) + assert min(earnings_growth, cpi_growth) <= growth + assert growth <= max(earnings_growth, cpi_growth) + for name in ("lone_parent", "couple"): + assert _index_growth(parameters, name) == pytest.approx( + _index_growth(system.parameters, name) + ) + + +@pytest.mark.parametrize( + "path", + [ + f"{HB_ALLOWANCES}.single.aged", + f"{HB_ALLOWANCES}.couple.aged", + f"{SAVINGS_CREDIT}.threshold.SINGLE", + f"{SAVINGS_CREDIT}.threshold.COUPLE", + ], +) +def test_zero_base_reform_builds_and_keeps_the_zero(path): + """Zeroing a base-year allowance or threshold used to raise or divide by + zero while the tree was built; the zero now holds.""" + simulation = _simulation(False, {path: {"year:2026-01-01:1": 0}}, [2026]) + parameter = simulation.tax_benefit_system.parameters.get_child(path) + assert parameter("2026-04-30") == 0 + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +def test_phase_in_reform_changes_savings_credit_not_the_projections(route): + """A zero phase-in rate used to stop the build. The threshold is set + separately, so the reform leaves every projection and pays no Savings + Credit.""" + situation = _situation(False, [2027]) + # Savings Credit needs State Pension age before 6 April 2016. About £231 a + # week is above the projected threshold and below the guarantee. + situation["people"]["claimant"]["age"] = {2027: 85} + situation["people"]["claimant"]["state_pension"] = {2027: 12_000} + baseline = Simulation(situation=situation) + changes = {f"{SAVINGS_CREDIT}.rate.phase_in": 0} + options = ( + {"reform": changes} + if route == "reform" + else { + "scenario": Scenario( + parameter_changes=changes, applied_before_data_load=True + ) + } + ) + simulation = Simulation(situation=situation, **options) + + assert baseline.calculate("savings_credit", 2027)[0] > 0 + assert simulation.calculate("savings_credit", 2027)[0] == 0 + _assert_projections_unchanged(simulation) diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index 8ef14ff61f..ec42bbc70f 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -406,7 +406,7 @@ def test_generated_parameters_are_the_series_processing_builds(): continue built = path.startswith(generated_prefixes) and not statutory(path) assert generates(path) is built, path - for relationship in ("single", "couple"): + for relationship in ("single", "lone_parent", "couple"): assert generates(f"{protected_allowance_index}.{relationship}") assert not generates(f"gov.dwp.housing_benefit.allowances.{relationship}.aged") for relationship in ("SINGLE", "COUPLE"): diff --git a/policyengine_uk/tests/test_savings_credit_threshold_projection.py b/policyengine_uk/tests/test_savings_credit_threshold_projection.py index 06f7ac86f4..c606bbb384 100644 --- a/policyengine_uk/tests/test_savings_credit_threshold_projection.py +++ b/policyengine_uk/tests/test_savings_credit_threshold_projection.py @@ -8,6 +8,9 @@ round_currency, savings_credit_threshold, ) +from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + projection_base_year, +) from policyengine_uk.tax_benefit_system import CountryTaxBenefitSystem @@ -22,11 +25,17 @@ def _parameters(): return system.parameters +def _raw_parameters(): + tax_benefit_system = CountryTaxBenefitSystem() + tax_benefit_system.reset_parameters() + return tax_benefit_system.parameters + + @pytest.mark.parametrize( ("relationship", "base_maximum", "expected_2027_maximum"), [ - ("SINGLE", 17.96, 18.3400336), - ("COUPLE", 20.10, 20.525316), + ("SINGLE", 17.96, 18.5886), + ("COUPLE", 20.10, 20.8035), ], ) def test_2027_threshold_preserves_the_cpi_projected_maximum( @@ -36,7 +45,7 @@ def test_2027_threshold_preserves_the_cpi_projected_maximum( ): """DWP A14/2025 sets £17.96/£20.10 and a 3.8% enacted CPI rise. - The first forecast uses the repository's 2.116% September 2026 CPI input. + The first forecast uses September 2026 CPI, 3.5% (ONS, 21 October 2026). """ parameters = _parameters() savings_credit = parameters.gov.dwp.pension_credit.savings_credit @@ -48,7 +57,7 @@ def test_2027_threshold_preserves_the_cpi_projected_maximum( ) projected_maximum = phase_in_rate * (projected_guarantee - projected_threshold) - assert base_maximum * 1.02116 == pytest.approx(expected_2027_maximum) + assert base_maximum * 1.035 == pytest.approx(expected_2027_maximum) assert projected_maximum == pytest.approx(expected_2027_maximum) @@ -123,26 +132,76 @@ def test_postprocessing_threshold_reform_is_not_replaced_by_projection(): assert threshold("2027-06-01") == 100 -def test_future_explicit_threshold_is_the_uprating_anchor(): - """A later source value remains exact and anchors subsequent forecasts.""" +# A next year's rates update, entered for every parameter the pension-age +# projections start from (illustrative amounts, not enacted rates). +NEXT_YEAR_RATES = { + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE": 250, + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.COUPLE": 380, + "gov.dwp.pension_credit.savings_credit.threshold.SINGLE": 220, + "gov.dwp.pension_credit.savings_credit.threshold.COUPLE": 350, + "gov.dwp.housing_benefit.allowances.single.aged": 270, + "gov.dwp.housing_benefit.allowances.lone_parent.aged": 270, + "gov.dwp.housing_benefit.allowances.couple.aged": 405, +} + + +def _system_with_rates(rates, year=2027): tax_benefit_system = CountryTaxBenefitSystem() tax_benefit_system.reset_parameters() - savings_credit = tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit - savings_credit.threshold.SINGLE.update( - start=instant("2027-04-01"), - stop=None, - value=100, - ) - + for path, value in rates.items(): + tax_benefit_system.parameters.get_child(path).update( + start=instant(f"{year}-04-01"), stop=None, value=value + ) tax_benefit_system.process_parameters() - savings_credit = tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit - threshold = savings_credit.threshold.SINGLE - index = savings_credit.threshold_uprating.SINGLE + return tax_benefit_system - assert threshold("2027-06-01") == 100 - assert threshold("2028-06-01") == pytest.approx( - 100 * index("2028-06-01") / index("2027-06-01") + +def test_projection_base_year_is_the_last_enacted_year(): + assert projection_base_year(_raw_parameters()) == 2026 + + +def test_next_year_rates_move_the_projection_base(): + """Once 2027-28 rates are entered, the projections start from them: the + maximum Savings Credit follows CPI from the enacted 2027 maximum, and each + entered value is exact and anchors later years.""" + parameters = _system_with_rates(NEXT_YEAR_RATES).parameters + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + cpi = parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + for path, value in NEXT_YEAR_RATES.items(): + assert parameters.get_child(path)("2027-06-01") == value, path + + for relationship, maximum in (("SINGLE", 18.0), ("COUPLE", 18.0)): + # 0.6 x (250 - 220) and 0.6 x (380 - 350). + threshold = getattr(savings_credit.threshold, relationship) + index = getattr(savings_credit.threshold_uprating, relationship) + assert index("2027-04-30") == 1 + projected_maximum = maximum * (1 + max(float(cpi("2027-09-01")), 0)) + phase_in = float(savings_credit.rate.phase_in("2028-04-30")) + assert phase_in * ( + float(getattr(guarantee, relationship)("2028-04-30")) + - float(threshold("2028-04-30")) + ) == pytest.approx(projected_maximum) + protected = ( + parameters.gov.dwp.housing_benefit.allowances.protected_pension_age_uprating ) + for name in ("single", "lone_parent", "couple"): + assert getattr(protected, name)("2027-04-30") == 1 + + +@pytest.mark.parametrize( + "path", + [ + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE", + "gov.dwp.pension_credit.savings_credit.threshold.SINGLE", + "gov.dwp.housing_benefit.allowances.lone_parent.aged", + ], +) +def test_rates_update_that_misses_a_parameter_raises(path): + """A 2027 rate for some of the base parameters and not others would build + the indices from a projected amount, so the update must be completed.""" + with pytest.raises(ValueError, match="enacted to the same year"): + _system_with_rates({path: NEXT_YEAR_RATES[path]}) @pytest.mark.parametrize("relationship", ["SINGLE", "COUPLE"]) From 52c96b111efe3205feae501aafce869977dba02c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 04:39:26 -0400 Subject: [PATCH 13/28] Project the post-2021 pension-age HB allowances with the guarantee #1913's aged_from_cutoff allowances equal the Pension Credit standard minimum guarantee in every enacted year (SI 2006/214 Sch 3 para 1(1)(c) and (2)(c)). On main both were on lagged CPI, so they stayed equal; once this branch moves the guarantee to the statutory earnings floor, the allowances would have drifted below it from 2027. They now use the same index, and a test checks they are equal in every year to 2040. #2557's lagged-CPI test now checks that these amounts have left lagged CPI. Co-Authored-By: Claude Opus 5.5 --- .../allowances/couple/aged_from_cutoff.yaml | 7 +++--- .../lone_parent/aged_from_cutoff.yaml | 7 +++--- .../allowances/single/aged_from_cutoff.yaml | 7 +++--- ...ng_benefit_protected_allowance_uprating.py | 24 +++++++++++++++++++ .../tests/test_september_cpi_uprating.py | 16 +++++++++---- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml index 01ccc8d258..ec4b08c4db 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml @@ -13,10 +13,9 @@ metadata: # Not uprated by September CPI: the post-April 2021 pension-age personal # allowances equal the Pension Credit standard minimum guarantee in every year # from 2021-22 to 2026-27, which is reviewed against earnings under Social - # Security Administration Act 1992 s150A(1)(d). Keeps the lagged calendar-year - # CPI projection it had through gov.benefit_uprating_cpi until its own basis - # is modelled. - uprating: gov.economic_assumptions.indices.obr.lagged_cpi + # Security Administration Act 1992 s150A(1)(d). It is projected by the same + # non-negative statutory earnings floor as the guarantee, so the two stay equal. + uprating: gov.economic_assumptions.indices.statutory_earnings_floor reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(2)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml index f8e9cc9d6d..bb247d0b10 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml @@ -13,10 +13,9 @@ metadata: # Not uprated by September CPI: the post-April 2021 pension-age personal # allowances equal the Pension Credit standard minimum guarantee in every year # from 2021-22 to 2026-27, which is reviewed against earnings under Social - # Security Administration Act 1992 s150A(1)(d). Keeps the lagged calendar-year - # CPI projection it had through gov.benefit_uprating_cpi until its own basis - # is modelled. - uprating: gov.economic_assumptions.indices.obr.lagged_cpi + # Security Administration Act 1992 s150A(1)(d). It is projected by the same + # non-negative statutory earnings floor as the guarantee, so the two stay equal. + uprating: gov.economic_assumptions.indices.statutory_earnings_floor reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(1)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml index a996b8e945..da6d5a37d4 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml @@ -13,10 +13,9 @@ metadata: # Not uprated by September CPI: the post-April 2021 pension-age personal # allowances equal the Pension Credit standard minimum guarantee in every year # from 2021-22 to 2026-27, which is reviewed against earnings under Social - # Security Administration Act 1992 s150A(1)(d). Keeps the lagged calendar-year - # CPI projection it had through gov.benefit_uprating_cpi until its own basis - # is modelled. - uprating: gov.economic_assumptions.indices.obr.lagged_cpi + # Security Administration Act 1992 s150A(1)(d). It is projected by the same + # non-negative statutory earnings floor as the guarantee, so the two stay equal. + uprating: gov.economic_assumptions.indices.statutory_earnings_floor reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(1)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made diff --git a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py index 72dc9d2e9d..2870d882a2 100644 --- a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py +++ b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py @@ -185,3 +185,27 @@ def test_protected_allowances_match_enacted_2025_amounts(allowance_name, weekly_ _parameters().gov.dwp.housing_benefit.allowances, allowance_name ).aged assert allowance("2025-04-30") == weekly_amount + + +@pytest.mark.parametrize( + ("allowance_name", "relationship"), + [("single", "SINGLE"), ("lone_parent", "SINGLE"), ("couple", "COUPLE")], +) +def test_later_cohort_allowances_equal_the_guarantee_in_every_year( + allowance_name, relationship +): + """The allowances for those reaching State Pension age from April 2021 + equal the standard minimum guarantee in every enacted year (SI 2006/214 + Sch 3 para 1(1)(c) and (2)(c)), and project by the same earnings index, + so they stay equal.""" + parameters = _parameters() + allowance = getattr( + parameters.gov.dwp.housing_benefit.allowances, allowance_name + ).aged_from_cutoff + guarantee = getattr( + parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee, + relationship, + ) + for year in range(2021, 2041): + instant = f"{year}-04-30" + assert allowance(instant) == pytest.approx(guarantee(instant)), year diff --git a/policyengine_uk/tests/test_september_cpi_uprating.py b/policyengine_uk/tests/test_september_cpi_uprating.py index f6d4413164..6cec604a8f 100644 --- a/policyengine_uk/tests/test_september_cpi_uprating.py +++ b/policyengine_uk/tests/test_september_cpi_uprating.py @@ -239,9 +239,10 @@ def test_hb_non_dependant_income_bands_keep_lagged_cpi_while_deductions_use_sept def test_amounts_not_raised_by_september_cpi_keep_their_lagged_calendar_projection(): """Amounts that were on gov.benefit_uprating_cpi but are not raised by - September CPI (the Pension Credit standard minimum guarantee, the Housing - Benefit pension-age allowances, DfE funding rates and others) still rise - by the previous calendar year's CPI after their last value.""" + September CPI (DfE funding rates and others) still rise by the previous + calendar year's CPI after their last value. The Pension Credit standard + minimum guarantee and the pension-age Housing Benefit allowances now have + their own earnings-based projections (#2092).""" parameters = system.parameters raw = load_parameter_file(str(COUNTRY_DIR / "parameters"), name="") calendar_cpi = ( @@ -263,8 +264,13 @@ def test_amounts_not_raised_by_september_cpi_keep_their_lagged_calendar_projecti assert after / before - 1 == pytest.approx( calendar_cpi(f"{year - 1}-01-01"), abs=2e-5 ), (parameter.name, year) - assert "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE" in names - assert "gov.dwp.housing_benefit.allowances.single.aged" in names + assert "gov.dfe.childcare_funding_rate[0].amount" in names + for moved in ( + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE", + "gov.dwp.housing_benefit.allowances.single.aged", + "gov.dwp.housing_benefit.allowances.single.aged_from_cutoff", + ): + assert moved not in names def test_the_triple_lock_reads_the_same_september_cpi(): From bf041bf1eb3fb04d5cc5e991c82cf9209e453d5b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 00:53:09 -0700 Subject: [PATCH 14/28] Let maximum Savings Credit reforms reach the threshold and HB forecasts Both forecast builders now read the maximum Savings Credit from the reform's tree, uprated as general uprating will project it (Core's own rule on private copies of the maximum and its uprating chain), so a reform to the maximum from the base year or any later year moves the projected Savings Credit threshold and higher pension-age HB allowances, as it did on main. The unreformed guarantee and phase-in context and the own-base reform fallback are unchanged. Adds the hub review's zero-maximum regression, which failed at 9b0d40672. Co-Authored-By: Claude Opus 5.5 --- .../create_pension_age_allowances.py | 52 +++++++-- .../create_savings_credit_threshold.py | 104 +++++++++++++++++- .../test_pension_age_projection_invariants.py | 21 ++++ 3 files changed, 165 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py index 407999a6e0..fddbdadb29 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -2,10 +2,13 @@ Keep main's forecast identity: guarantee plus published maximum Savings Credit (DWP A3/2021 paras 3–5). Published HB amounts include independent -rounding and remain exact. Reformed allowance bases use their own retained -component; a base below its guarantee follows earnings without a retained -component. A guarantee-only reform leaves the separately set allowances -unchanged. These indices run before general uprating. +rounding and remain exact. The maximum Savings Credit part is the maximum +parameter's own projection, so a reform to the maximum, from any year, +reaches the allowances, as it did when they were derived after general +uprating. Reformed allowance bases use their own retained component; a base +below its guarantee follows earnings without a retained component. A +guarantee-only reform leaves the separately set allowances unchanged. These +indices run before general uprating. """ from policyengine_core.parameters import Parameter, ParameterNode @@ -13,6 +16,7 @@ from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( enacted_maximum, last_projected_year, + projected_maximum_values, ) from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( projection_base_year, @@ -45,8 +49,14 @@ def protected_allowance_index_values( base_year: int, retained_maximum: float | None = None, price_index: Parameter | None = None, + maxima: dict[int, float] | None = None, ) -> dict[str, float]: - """Build a component-weighted index from the last enacted allowance.""" + """Build a component-weighted index from the last enacted allowance. + + ``maxima`` gives the maximum Savings Credit in each forecast year, which + then replaces the retained component. Without it, the retained component + is projected by non-negative September CPI. + """ retained_uplift = ( base_allowance - guarantee_component if retained_maximum is None @@ -76,7 +86,11 @@ def protected_allowance_index_values( if price_index is not None else float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")) ) - retained_uplift = project_retained_uplift(retained_uplift, growth) + retained_uplift = ( + maxima[year] + if maxima is not None + else project_retained_uplift(retained_uplift, growth) + ) values[f"{year}-04-01"] = (guarantee + retained_uplift) / base_allowance return values @@ -91,6 +105,7 @@ def _projection_parameter( base_year: int, retained_maximum: float | None, price_index: Parameter, + maxima: dict[int, float] | None = None, ) -> Parameter: retained_uplift = ( base_allowance - guarantee_component @@ -99,6 +114,15 @@ def _projection_parameter( ) relationship_label = allowance.replace("_", "-") fiscal_year = f"{base_year}-{(base_year + 1) % 100:02d}" + savings_credit_component = ( + "the maximum Savings Credit, as uprated from its last explicit value. " + "The guarantee component follows the shared statutory earnings index; " + "the maximum follows non-negative September CPI." + if maxima is not None + else f"a £{retained_uplift:g} Savings Credit component. " + "The components follow the shared statutory " + "earnings index and non-negative September CPI, respectively." + ) return Parameter( ( "gov.dwp.housing_benefit.allowances." @@ -110,9 +134,7 @@ def _projection_parameter( f"pension-age Housing Benefit allowance. The £{base_allowance:g} " f"{fiscal_year} base is retained exactly. Forecasts combine a " f"£{guarantee_component:g} Pension Credit guarantee component " - f"and a £{retained_uplift:g} Savings Credit component. " - "The components follow the shared statutory " - "earnings index and non-negative September CPI, respectively. " + f"and {savings_credit_component} " "Projected values are not enacted rates." ), "values": protected_allowance_index_values( @@ -124,6 +146,7 @@ def _projection_parameter( base_year=base_year, retained_maximum=retained_maximum, price_index=price_index, + maxima=maxima, ), "metadata": { "unit": "/1", @@ -198,6 +221,12 @@ def project_pension_age_allowances( The enacted pair is still validated, so a rates update that puts an allowance below its guarantee fails. Earnings and CPI inputs come from the reform's parameter tree. + + The maximum Savings Credit part is read from the reform's tree as general + uprating will project it, so a reform to the maximum from the base year + or any later year moves each allowance whose own base is unchanged. A + reformed allowance base takes precedence: its own retained component is + projected, as before. """ allowances = parameters.gov.dwp.housing_benefit.allowances projection_base = parameters if unreformed is None else unreformed @@ -227,8 +256,12 @@ def project_pension_age_allowances( guarantee = float(getattr(guarantees, relationship)(start)) reformed_allowance = float(getattr(allowances, allowance_name).aged(start)) retained_maximum = enacted_maximum(projection_base, relationship, base_year) + maxima = projected_maximum_values( + parameters, relationship, base_year, final_observation_year + 1 + ) if reformed_allowance != allowance: retained_maximum = None + maxima = None allowance = reformed_allowance guarantee = min( float(getattr(reformed_guarantees, relationship)(start)), @@ -246,6 +279,7 @@ def project_pension_age_allowances( base_year, retained_maximum, price_index, + maxima, ), ) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 07320b1d8c..78e05526e1 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -7,13 +7,21 @@ does not preserve the statutory relationship. Each generated index is applied by the standard parameter-uprating operation, so explicit threshold values and reforms remain authoritative anchors. The indices start from the last year -with enacted rates (pension_age_projection_base.py). +with enacted rates (pension_age_projection_base.py). The maximum in each +forecast year is the maximum parameter's own projection, so a reform to it, +from any year, reaches the threshold as it did when the threshold was derived +after general uprating. """ from decimal import ROUND_HALF_UP, Decimal, localcontext from sys import float_info +from typing import Any, Iterable from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.parameters.operations.uprate_parameters import ( + get_uprating_dependency_name, + uprate_parameters, +) from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( projection_base_year, @@ -55,6 +63,75 @@ def savings_credit_threshold( return guarantee - maximum_savings_credit / phase_in_rate +def _propagated_uprating(parameter: Parameter) -> Any: + """The uprating metadata a parameter has once metadata is propagated. + + The nearest ancestor that propagates an uprating to its descendants + overrides the parameter's own (propagate_parameter_metadata). + """ + node = getattr(parameter, "parent", None) + while node is not None: + metadata = node.metadata or {} + if metadata.get("propagate_metadata_to_children") and "uprating" in metadata: + return metadata["uprating"] + node = getattr(node, "parent", None) + return parameter.metadata.get("uprating") + + +def _add_at_path(root: ParameterNode, path: str, parameter: Parameter) -> None: + node = root + *ancestors, key = path.split(".") + for name in ancestors: + if name not in node.children: + node.add_child( + name, ParameterNode(name=f"{node.name}.{name}".lstrip("."), data={}) + ) + node = node.children[name] + node.add_child(key, parameter) + + +def uprated_values( + parameter: Parameter, parameters: ParameterNode, years: Iterable[int] +) -> dict[int, float]: + """A parameter's value in each year once general uprating has run. + + Uprates private copies of the parameter and of each index its uprating + reads, by Core's own rule, before general uprating has run on the tree. + Every explicit value, a reform's included, therefore anchors the years + after it exactly as it will on the parameter itself. + """ + copies = ParameterNode(data={}) + copied = set() + path, source = parameter.name, parameter + # Stop at a repeated path: Core reports the cycle when it uprates. + while source is not None and path not in copied: + copied.add(path) + copy = source.clone() + uprating = _propagated_uprating(source) + if uprating is not None: + copy.metadata["uprating"] = uprating + _add_at_path(copies, path, copy) + path = get_uprating_dependency_name(copy) + source = parameters.get_child(path) if path is not None else None + uprate_parameters(copies) + projected = copies.get_child(parameter.name) + return {year: float(projected(projection_instant(year))) for year in years} + + +def projected_maximum_values( + parameters: ParameterNode, relationship: str, base_year: int, final_year: int +) -> dict[int, float]: + """The maximum Savings Credit in each forecast year, never below zero.""" + maximum = getattr( + parameters.gov.dwp.pension_credit.savings_credit.maximum, relationship + ) + years = range(base_year + 1, final_year + 1) + return { + year: max(0, value) + for year, value in uprated_values(maximum, parameters, years).items() + } + + def threshold_index_values( base_guarantee: float, base_threshold: float, @@ -66,8 +143,13 @@ def threshold_index_values( base_year: int, base_maximum: float | None = None, price_index: Parameter | None = None, + maxima: dict[int, float] | None = None, ) -> dict[str, float]: - """Build a forecast index from the last enacted threshold.""" + """Build a forecast index from the last enacted threshold. + + ``maxima`` gives the maximum Savings Credit in each forecast year. Without + it, the base maximum is projected by non-negative September CPI. + """ base_instant = f"{base_year}-04-01" if base_threshold <= 0: # The index scales the base, so a reform's zero threshold stays zero. @@ -90,7 +172,11 @@ def threshold_index_values( if price_index is not None else float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")) ) - maximum = project_maximum_savings_credit(maximum, growth) + maximum = ( + maxima[year] + if maxima is not None + else project_maximum_savings_credit(maximum, growth) + ) guarantee = base_guarantee * ( float(earnings_index(f"{year}-04-01")) / base_earnings_index ) @@ -121,6 +207,7 @@ def _projection_parameter( base_year: int, base_maximum: float, price_index: Parameter, + maxima: dict[int, float] | None = None, ) -> Parameter: relationship_label = relationship.lower() return Parameter( @@ -144,6 +231,7 @@ def _projection_parameter( base_year=base_year, base_maximum=base_maximum, price_index=price_index, + maxima=maxima, ), "metadata": { "unit": "/1", @@ -265,6 +353,11 @@ def project_savings_credit_threshold( in which case use the reformed pair, and the enacted phase-in rate, so a phase-in reform (to zero, say) changes the Savings Credit paid and not the threshold. Earnings and CPI inputs come from the reform's tree. + + The maximum Savings Credit is read from the reform's tree as general + uprating will project it, so a reform to the maximum from the base year + or any later year moves the projected threshold. A reformed threshold + base takes precedence: its own reformed gap is projected, as before. """ savings_credit = parameters.gov.dwp.pension_credit.savings_credit projection_base = parameters if unreformed is None else unreformed @@ -306,10 +399,14 @@ def project_savings_credit_threshold( < base_year ): maximum_parameter.update(start=f"{base_year}-04-01", value=maximum) + maxima = projected_maximum_values( + parameters, relationship, base_year, final_observation_year + 1 + ) reformed_threshold = getattr(savings_credit.threshold, relationship) if reformed_threshold(base_instant) != threshold_parameter(base_instant): threshold_parameter = reformed_threshold guarantee_parameter = getattr(reformed_guarantees, relationship) + maxima = None maximum = max( 0, round_currency( @@ -333,6 +430,7 @@ def project_savings_credit_threshold( base_year, maximum, price_index, + maxima, ), ) return parameters diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 241113371a..73414d77fa 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -121,6 +121,27 @@ def test_combined_reforms_build_and_never_project_negative_amounts(path, reforms assert parameter(f"{year}-04-30") >= 0, (name, year, reforms) +@pytest.mark.parametrize("first_year", [2026, 2028]) +def test_maximum_reforms_reach_both_dependent_forecasts(first_year): + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + parameters.get_child(f"{SC_NAME}.maximum.SINGLE").update( + start=f"{first_year}-04-01", value=0 + ) + projected = project_synthetic_parameters(parameters, unreformed) + for year in range(max(first_year, FIRST_PROJECTION_YEAR), LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + guarantee = projected.get_child(f"{SMG_NAME}.SINGLE")(at) + assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")(at) == 0 + assert projected.get_child(f"{SC_NAME}.threshold.SINGLE")(at) == pytest.approx( + guarantee + ) + for kind in ("single", "lone_parent"): + assert projected.get_child(f"{HB_NAME}.{kind}.aged")(at) == pytest.approx( + guarantee + ) + + @settings(max_examples=40, deadline=None) @given(path=paths) def test_unreformed_higher_allowances_equal_guarantee_plus_maximum_every_year(path): From 80907e79518eec059d79acff1dda24e2b77280ac Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 01:13:36 -0700 Subject: [PATCH 15/28] Test maximum Savings Credit reform paths against main's builders The combined Hypothesis property now draws a path of one to three maximum anchors for each relationship, from the year before the base onwards. A new differential property checks that any path of maximum reforms, with enacted phase-in paths and an optional reform to the maximum's uprating index, reproduces main's post-uprating builders, apart from the threshold floor. Own-base threshold and allowance reforms keep their fallback when combined with a maximum reform, while the other amounts follow it. The base-year maximum fill now also requires the enacted maximum to lack the base year, so it never overwrites a reform anchored before it. Co-Authored-By: Claude Opus 5.5 --- .../create_savings_credit_threshold.py | 12 +- .../test_pension_age_projection_invariants.py | 166 +++++++++++++++++- 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 78e05526e1..432aa45c31 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -24,6 +24,7 @@ ) from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + last_enacted_year, projection_base_year, ) from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( @@ -394,9 +395,14 @@ def project_savings_credit_threshold( ) in relationship_parameters.items(): maximum = enacted_maximum(projection_base, relationship, base_year) maximum_parameter = getattr(savings_credit.maximum, relationship) - if ( - max(int(entry.instant_str[:4]) for entry in maximum_parameter.values_list) - < base_year + # Enter the maximum a rates update without one implies, but never + # over a reform that starts before the base year. + if all( + last_enacted_year(parameter) < base_year + for parameter in ( + maximum_parameter, + getattr(enacted_savings_credit.maximum, relationship), + ) ): maximum_parameter.update(start=f"{base_year}-04-01", value=maximum) maxima = projected_maximum_values( diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 73414d77fa..00b421d80d 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -3,8 +3,10 @@ The differential reference below freezes the two builders merged in #2576 (main eb0b8087). It deliberately keeps their generation-after-uprating order. For unreformed forecasts the reconciled builders must reproduce those -amounts, including main’s earnings and CPI floors. Reform independence and -the additional threshold residual floor have their own properties. +amounts, including main’s earnings and CPI floors. Main read the maximum +Savings Credit after uprating, so for any path of maximum reforms they must +reproduce main too, apart from the additional threshold residual floor. +Reform independence and that floor have their own properties. """ from typing import Any, Callable @@ -32,6 +34,7 @@ growth, higher_allowances, paths, + phase_in_paths, project_synthetic_parameters, synthetic_parameter_tree, synthetic_parameters, @@ -57,6 +60,11 @@ f"{SC_NAME}.rate.phase_in", *(f"{HB_NAME}.{kind}.aged" for kind in HB_RELATIONS), ) +MAXIMUM_PATHS = tuple( + f"{SC_NAME}.maximum.{relation}" for relation in ("SINGLE", "COUPLE") +) +# The maximum's uprating index (maximum.yaml). +BENEFIT_UPRATING_CPI = "gov.benefit_uprating_cpi" # Cover zeros, subnormal values and large monetary/rate reforms, without # overflowing intermediate products of independently generated float64 values. nonnegative_amount = st.floats( @@ -67,9 +75,31 @@ ) +@st.composite +def maximum_path(draw): + """One to three explicit anchors, from the year before the base onwards.""" + years = draw( + st.lists( + st.integers(FIRST_PROJECTION_YEAR - 2, LAST_INDEX_YEAR), + min_size=1, + max_size=3, + unique=True, + ) + ) + return [(year, draw(nonnegative_amount)) for year in sorted(years)] + + +@st.composite +def maximum_reforms(draw): + """A path of maximum Savings Credit anchors for one or both relationships.""" + reformed = draw(st.sets(st.sampled_from(MAXIMUM_PATHS), min_size=1)) + return {path: draw(maximum_path()) for path in sorted(reformed)} + + @st.composite def combined_reforms(draw): - """Change every independent component, including the lone parent's own base.""" + """Change every independent component, including the lone parent's own base + and a path of maximum Savings Credit anchors for each relationship.""" reforms = {} for path in REFORM_PATHS: year = draw(st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR)) @@ -77,19 +107,25 @@ def combined_reforms(draw): nonnegative_rate if path.endswith("phase_in") else nonnegative_amount ) reforms[path] = (year, value) + for path in MAXIMUM_PATHS: + reforms[path] = draw(maximum_path()) return reforms def _apply_reforms(parameters, reforms): - for path, (year, value) in reforms.items(): - parameters.get_child(path).update(start=f"{year}-04-01", value=value) + """Apply each change from April of its year; a list is a path of anchors.""" + for path, change in reforms.items(): + # In year order, so each open-ended update keeps the earlier anchors. + for year, value in change if isinstance(change, list) else [change]: + parameters.get_child(path).update(start=f"{year}-04-01", value=value) @settings(max_examples=60, deadline=None) @given(path=paths, reforms=combined_reforms()) @example( path=[(0.039, 0.035)], - reforms={path: (2026, 0.0) for path in REFORM_PATHS}, + reforms={path: (2026, 0.0) for path in REFORM_PATHS} + | {path: [(2026, 0.0)] for path in MAXIMUM_PATHS}, ) @example( path=[(0.039, -0.06), (-0.06, 0.15)], @@ -102,13 +138,17 @@ def _apply_reforms(parameters, reforms): f"{HB_NAME}.single.aged": (2026, 220), f"{HB_NAME}.lone_parent.aged": (2026, 0), f"{HB_NAME}.couple.aged": (2026, 300), + f"{SC_NAME}.maximum.SINGLE": [(2025, 1e100), (2028, 0), (2031, 40)], + f"{SC_NAME}.maximum.COUPLE": [(2030, 5e-324)], }, ) def test_combined_reforms_build_and_never_project_negative_amounts(path, reforms): """Building succeeds even for zero bases and allowances below guarantees. The enacted tree supplies the base year; future reform anchors must not - turn into a mismatched set of supposedly enacted years. + turn into a mismatched set of supposedly enacted years. Maximum Savings + Credit paths, including anchors before the base year, combine with every + other reform. """ parameters = synthetic_parameter_tree(path) unreformed = parameters.clone() @@ -142,6 +182,53 @@ def test_maximum_reforms_reach_both_dependent_forecasts(first_year): ) +FOLLOWS_SINGLE_MAXIMUM = ( + f"{SC_NAME}.threshold.SINGLE", + f"{HB_NAME}.single.aged", + f"{HB_NAME}.lone_parent.aged", +) + + +@pytest.mark.parametrize("first_year", [2026, 2028]) +@pytest.mark.parametrize( + "own_base,value", + [ + (f"{SC_NAME}.threshold.SINGLE", 180), + (f"{HB_NAME}.single.aged", 250), + (f"{HB_NAME}.lone_parent.aged", 230), + ], +) +def test_a_reformed_own_base_takes_precedence_over_a_maximum_reform( + first_year, own_base, value +): + """A threshold or allowance whose own base-year value is reformed keeps + projecting its own reformed gap, with or without a maximum reform. Every + other amount built from that maximum follows the maximum reform.""" + + def project(maximum_reform): + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + parameters.get_child(own_base).update(start="2026-04-01", value=value) + if maximum_reform: + parameters.get_child(f"{SC_NAME}.maximum.SINGLE").update( + start=f"{first_year}-04-01", value=0 + ) + return project_synthetic_parameters(parameters, unreformed) + + combined, own_base_only = project(True), project(False) + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert combined.get_child(own_base)(at) == pytest.approx( + own_base_only.get_child(own_base)(at), rel=1e-12 + ), year + guarantee = combined.get_child(f"{SMG_NAME}.SINGLE")(at) + for name in FOLLOWS_SINGLE_MAXIMUM: + if name != own_base and year >= first_year: + assert combined.get_child(name)(at) == pytest.approx( + guarantee, rel=1e-12 + ), (name, year) + + @settings(max_examples=40, deadline=None) @given(path=paths) def test_unreformed_higher_allowances_equal_guarantee_plus_maximum_every_year(path): @@ -244,8 +331,10 @@ def derive(sample, previous): return parameters -def _main_parameters(path): - parameters = synthetic_parameter_tree(path) +def _main_parameters(path, phase_ins=(), reforms=None): + parameters = synthetic_parameter_tree(path, phase_ins) + # Main processed reforms written onto the statutory timeline. + _apply_reforms(parameters, reforms or {}) pc = parameters.gov.dwp.pension_credit # Main's thresholds and higher allowances were generated after uprating, # without metadata pointing at the reconciliation's new forecast indices. @@ -281,3 +370,62 @@ def test_reconciled_builders_agree_with_main_where_its_behavior_is_retained(path assert reconciled.get_child(name)(at) == pytest.approx( main.get_child(name)(at), abs=1e-9 ), (name, year, path) + + +@settings(max_examples=40, deadline=None) +@given( + path=paths, + phase_ins=phase_in_paths, + reforms=maximum_reforms(), + index_reform=st.none() + | st.tuples( + st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR), + st.floats(min_value=1, max_value=1e6), + ), +) +@example( + path=[(0.039, 0.035)], + phase_ins=[], + reforms={f"{SC_NAME}.maximum.SINGLE": [(2026, 0.0)]}, + index_reform=None, +) +@example( + path=[(0.039, 0.035)], + phase_ins=[], + reforms={f"{SC_NAME}.maximum.COUPLE": [(2028, 0.0)]}, + index_reform=None, +) +@example( + path=[(0.039, -0.06), (-0.06, 0.15)], + phase_ins=[0.6, 0.0, 0.01], + reforms={ + f"{SC_NAME}.maximum.SINGLE": [(2025, 1e100), (2027, 25), (2030, 0)], + f"{SC_NAME}.maximum.COUPLE": [(2029, 5e-324)], + }, + index_reform=(2028, 150.0), +) +def test_maximum_reforms_reproduce_main_with_a_floored_threshold( + path, phase_ins, reforms, index_reform +): + """Any path of maximum Savings Credit reforms, before, at or after the base + year, reaches every forecast as main's post-uprating builders took it, + and so does a reform to the index the maximum is uprated by. + + Main's threshold could go negative; the reconciliation floors it at zero. + """ + if index_reform is not None: + reforms = reforms | {BENEFIT_UPRATING_CPI: index_reform} + main = _main_parameters(path, phase_ins, reforms) + parameters = synthetic_parameter_tree(path, phase_ins) + unreformed = parameters.clone() + _apply_reforms(parameters, reforms) + reconciled = project_synthetic_parameters(parameters, unreformed) + for name in PROJECTED_PATHS: + for year in range(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + expected = main.get_child(name)(at) + if ".threshold." in name: + expected = max(expected, 0) + assert reconciled.get_child(name)(at) == pytest.approx( + expected, rel=1e-12, abs=1e-9 + ), (name, year, reforms) From 9d9324b6bb37b09663c5533846270b293c03720b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 01:25:41 -0700 Subject: [PATCH 16/28] Test maximum Savings Credit reforms through every public route A zero single maximum from the base year or from 2028, through the reform dict, a Scenario, a structural Reform.from_dict class and a reprocessing simulation modifier, makes the single threshold and the single and lone-parent higher allowances equal the guarantee from then on, so no Savings Credit is paid; every forecast year reads the maximum the reform leaves, and couples are unchanged. A 2026 threshold or allowance change beside the maximum reform keeps its own index. Co-Authored-By: Claude Opus 5.5 --- ...ion_credit_guarantee_reform_projections.py | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index 38b555941a..4d98be6ba0 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -11,12 +11,19 @@ 3. A reformed allowance below its guarantee is projected whole by the guarantee's earnings path, continuously with an allowance equal to it. 4. Each allowance has its own index: a reform to one leaves the others. +5. A reform to the maximum Savings Credit, from the base year or a later + year, reaches the projected threshold and higher allowances through every + reform route: each forecast year's threshold is the guarantee less the + reformed maximum / phase-in, and each allowance the guarantee plus it. + A threshold or allowance whose own base is reformed keeps projecting its + own reformed gap. """ import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_core.periods import instant +from policyengine_core.reforms import Reform from policyengine_uk import Simulation, system from policyengine_uk.model_api import Scenario @@ -431,3 +438,200 @@ def test_phase_in_reform_changes_savings_credit_not_the_projections(route): assert baseline.calculate("savings_credit", 2027)[0] > 0 assert simulation.calculate("savings_credit", 2027)[0] == 0 _assert_projections_unchanged(simulation) + + +MAXIMUM = f"{SAVINGS_CREDIT}.maximum" +FAR = "2100-12-31" +FORECAST_YEARS = range(2027, 2041) +MAXIMUM_ROUTES = ["reform", "scenario", "structural", "modifier"] +RELATIONSHIP_ALLOWANCES = {"SINGLE": ("single", "lone_parent"), "COUPLE": ("couple",)} + + +def _zero_single_maximum(start): + """A modifier that reloads the parameters, zeroes the single maximum + Savings Credit from ``start`` and processes them again.""" + + def modify(simulation): + tax_benefit_system = simulation.tax_benefit_system + tax_benefit_system.reset_parameters() + tax_benefit_system.parameters.get_child(f"{MAXIMUM}.SINGLE").update( + start=instant(start), stop=instant(FAR), value=0 + ) + tax_benefit_system.process_parameters() + + return modify + + +def _maximum_reform_simulation(route, start, situation, extra=None): + changes = {f"{MAXIMUM}.SINGLE": {f"{start}.{FAR}": 0}, **(extra or {})} + if route == "reform": + options = {"reform": changes} + elif route == "scenario": + options = { + "scenario": Scenario( + parameter_changes=changes, applied_before_data_load=True + ) + } + elif route == "structural": + options = {"reform": Reform.from_dict(changes, country_id="uk")} + else: + options = { + "scenario": Scenario( + simulation_modifier=_zero_single_maximum(start), + applied_before_data_load=True, + ) + } + return Simulation(situation=situation, **options) + + +SAVINGS_CREDIT_YEARS = [2027, 2028] + + +def _savings_credit_situation(): + """A single pensioner whose State Pension lies midway between the + projected threshold and guarantee in each year, so Savings Credit is paid. + Savings Credit needs State Pension age before 6 April 2016.""" + situation = _situation(False, SAVINGS_CREDIT_YEARS) + pc = system.parameters.gov.dwp.pension_credit + claimant = situation["people"]["claimant"] + claimant["age"] = {year: 85 + year - 2027 for year in SAVINGS_CREDIT_YEARS} + claimant["state_pension"] = { + year: 52 + * ( + pc.savings_credit.threshold.SINGLE(f"{year}-04-30") + + pc.guarantee_credit.minimum_guarantee.SINGLE(f"{year}-04-30") + ) + / 2 + for year in SAVINGS_CREDIT_YEARS + } + return situation + + +@pytest.fixture(scope="module") +def baseline_savings_credit(): + simulation = Simulation(situation=_savings_credit_situation()) + return { + year: simulation.calculate("savings_credit", year)[0] + for year in SAVINGS_CREDIT_YEARS + } + + +@pytest.mark.parametrize("route", MAXIMUM_ROUTES) +@pytest.mark.parametrize("start", ["2026-04-01", "2028-04-01"]) +def test_maximum_reform_reaches_both_forecasts_through_every_route( + route, start, baseline_savings_credit +): + """A zero single maximum from the base year, or from a later one, makes + the single threshold and the single and lone-parent higher allowances + equal the guarantee from then on, so no Savings Credit is paid. Every + forecast year reads the maximum the reform leaves; couples are unchanged. + """ + first_year = int(start[:4]) + simulation = _maximum_reform_simulation(route, start, _savings_credit_situation()) + parameters = simulation.tax_benefit_system.parameters + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances + for year in FORECAST_YEARS: + at = f"{year}-04-30" + phase_in = pc.savings_credit.rate.phase_in(at) + for relationship, kinds in RELATIONSHIP_ALLOWANCES.items(): + guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relationship)(at) + maximum = getattr(pc.savings_credit.maximum, relationship)(at) + threshold = getattr(pc.savings_credit.threshold, relationship)(at) + assert threshold == pytest.approx(max(0, guarantee - maximum / phase_in)), ( + relationship, + year, + ) + for kind in kinds: + assert getattr(allowances, kind).aged(at) == pytest.approx( + guarantee + maximum + ), (kind, year) + if relationship == "SINGLE" and year >= first_year: + assert maximum == 0 + assert threshold == pytest.approx(guarantee) + for path in PROJECTION_PATHS: + if path.endswith(("COUPLE", "couple", "couple.aged")): + for year in YEARS: + at = f"{year}-04-30" + assert parameters.get_child(path)(at) == pytest.approx( + system.parameters.get_child(path)(at) + ), (path, year) + for year in SAVINGS_CREDIT_YEARS: + assert baseline_savings_credit[year] > 0 + if year >= first_year: + assert simulation.calculate("savings_credit", year)[0] == 0, year + if route != "modifier": + # The dated routes keep prior law's projected maximum before the + # change, so everything before it is as baseline. (Uprating extends a + # parameter only from its last explicit value, so the modifier's + # direct update holds the 2026 maximum until it starts, and the + # forecasts follow that maximum instead, as checked above.) + for path in PROJECTION_PATHS: + for year in range(2026, first_year): + at = f"{year}-04-30" + assert parameters.get_child(path)(at) == pytest.approx( + system.parameters.get_child(path)(at) + ), (path, year) + for year in SAVINGS_CREDIT_YEARS: + if year < first_year: + assert simulation.calculate("savings_credit", year)[0] == pytest.approx( + baseline_savings_credit[year] + ), year + + +@pytest.mark.parametrize("route", ["reform", "structural"]) +@pytest.mark.parametrize( + "own_base,value,index", + [ + ( + f"{SAVINGS_CREDIT}.threshold.SINGLE", + 180, + f"{SAVINGS_CREDIT}.threshold_uprating.SINGLE", + ), + ( + f"{HB_ALLOWANCES}.single.aged", + 250, + f"{HB_ALLOWANCES}.protected_pension_age_uprating.single", + ), + ], +) +def test_a_reformed_own_base_keeps_its_index_beside_a_maximum_reform( + route, own_base, value, index +): + """A 2026 change to a threshold or allowance keeps its own index, which + projects its reformed gap whether or not the maximum is reformed. Once the + change ends, the dated routes restore prior law under the reform's other + changes (``prior_law_projection``), here the zero maximum, so it then + equals the guarantee, as the other amounts built from that maximum do.""" + simulation = _maximum_reform_simulation( + route, + "2026-04-01", + _situation(False, [2026]), + extra={own_base: {"year:2026-01-01:1": value}}, + ) + parameters = simulation.tax_benefit_system.parameters + assumptions = parameters.gov.economic_assumptions + earnings = assumptions.indices.earnings_uprating + cpi = assumptions.indices.september_cpi_uprating + earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") + cpi_growth = cpi("2027-04-30") / cpi("2026-04-30") - 1 + guarantee = float(system.parameters.get_child(f"{GUARANTEE}.SINGLE")("2026-04-30")) + if "threshold" in own_base: + # The reformed gap implies a £34.80 maximum at the 60% phase-in rate. + implied_maximum = round(0.6 * (guarantee - value), 2) + expected = ( + guarantee * earnings_growth - implied_maximum * (1 + cpi_growth) / 0.6 + ) + else: + expected = guarantee * earnings_growth + (value - guarantee) * (1 + cpi_growth) + assert parameters.get_child(own_base)("2026-04-30") == pytest.approx(value) + assert parameters.get_child(index)("2027-04-30") == pytest.approx(expected / value) + projected_guarantee = parameters.get_child(f"{GUARANTEE}.SINGLE")("2027-04-30") + for path in ( + f"{SAVINGS_CREDIT}.threshold.SINGLE", + f"{HB_ALLOWANCES}.single.aged", + f"{HB_ALLOWANCES}.lone_parent.aged", + ): + assert parameters.get_child(path)("2027-04-30") == pytest.approx( + projected_guarantee + ), path From 000f16be9f7f6050ebf847d53f2af3f909ea0ee6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 01:28:50 -0700 Subject: [PATCH 17/28] Publish the Pension Credit page and document maximum reforms Register docs/book/programs/gov/dwp/pension-credit.ipynb in myst.yml's Programmes block: notebooks are not pattern-matched, so the State Pension page's link to its Uprating section pointed at an unpublished page. The local docs build now builds 51 pages, including Pension Credit, and resolves that link as an internal cross-reference to its uprating heading. The notebook, the Pension Credit README and the threshold and maximum descriptions now say that a maximum Savings Credit reform moves the projected threshold and higher allowances, unless their own base-year value is reformed. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/pension-credit.ipynb | 2 +- myst.yml | 1 + .../parameters/gov/dwp/pension_credit/README.md | 7 ++++++- .../gov/dwp/pension_credit/savings_credit/maximum.yaml | 2 +- .../gov/dwp/pension_credit/savings_credit/threshold.yaml | 3 ++- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/book/programs/gov/dwp/pension-credit.ipynb b/docs/book/programs/gov/dwp/pension-credit.ipynb index 9f3bd32486..4af2614375 100644 --- a/docs/book/programs/gov/dwp/pension-credit.ipynb +++ b/docs/book/programs/gov/dwp/pension-credit.ipynb @@ -26,7 +26,7 @@ "\n", "After the last enacted rates, the standard minimum guarantee follows the previous May–July earnings increase, with no decrease when earnings fall, under [Social Security Administration Act 1992 section 150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A). Maximum Savings Credit follows September CPI with a zero floor. The model derives the Savings Credit threshold from these amounts and the enacted phase-in rate, with a zero floor on the threshold. Each unreformed higher pension-age Housing Benefit allowance equals the projected guarantee plus projected maximum Savings Credit; the post-April-2021 lower allowances follow earnings. Published amounts, including rounding, remain unchanged in their enacted years.\n", "\n", - "The projections start from the common last year with enacted guarantees, thresholds and higher Housing Benefit allowances. A reform to the guarantee or Savings Credit phase-in rate does not re-derive the separately set threshold or Housing Benefit allowances. Explicit threshold and allowance reforms anchor their own projections, and higher allowances at or below the guarantee, including zero, can still be projected. See the [Pension Credit parameter documentation](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/pension_credit/README.md) for the base-year checks and reform rules. These are forecast conventions, rather than announced future rates.\n", + "The projections start from the common last year with enacted guarantees, thresholds and higher Housing Benefit allowances. A reform to the guarantee or Savings Credit phase-in rate does not re-derive the separately set threshold or Housing Benefit allowances. A reform to the maximum Savings Credit, from the base year or a later year, does reach them: each projected threshold and higher allowance uses the reformed maximum, unless its own base-year value is also reformed. Explicit threshold and allowance reforms anchor their own projections, and higher allowances at or below the guarantee, including zero, can still be projected. See the [Pension Credit parameter documentation](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/pension_credit/README.md) for the base-year checks and reform rules. These are forecast conventions, rather than announced future rates.\n", "\n", "## Pension Credit Rate Changes\n", "\n", diff --git a/myst.yml b/myst.yml index 5d58330cbd..c3a8673b63 100644 --- a/myst.yml +++ b/myst.yml @@ -38,6 +38,7 @@ project: - title: Programmes children: - file: docs/book/programs/contrib/ubi-center/carbon-tax.ipynb + - file: docs/book/programs/gov/dwp/pension-credit.ipynb - pattern: docs/book/programs/gov/*/*.md site: options: diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md index 22ef9d5f07..d55c7750de 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md @@ -41,7 +41,12 @@ enacted year. Reform dates do not move this base. A reform to the Pension Credit guarantee or Savings Credit phase-in rate does not re-derive the separately set Savings Credit threshold or Housing Benefit -allowances. Explicit threshold and allowance reforms remain anchors for their +allowances. A reform to the maximum Savings Credit does: the projections read +the maximum as general uprating projects it, so a reform from the base year or +any later year, or to the index the maximum is uprated by, moves the projected +threshold and higher allowances as it moves the maximum. A threshold or +allowance whose own base-year value is reformed projects its own reformed gap +instead. Explicit threshold and allowance reforms remain anchors for their own later projections. Each higher allowance has its own index, including the lone-parent allowance. A base-year allowance at or below its guarantee follows earnings, so reducing an allowance below the guarantee or setting it to zero diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml index 52b6028c9b..9f5704aa51 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml @@ -1,4 +1,4 @@ -description: Maximum weekly Savings Credit, used to derive projected income thresholds. +description: Maximum weekly Savings Credit, used to derive projected income thresholds and higher pension-age Housing Benefit allowances. SINGLE: values: 2026-04-01: 17.96 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 5c142580c0..2888e818af 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -4,7 +4,8 @@ description: >- guarantee and the CPI-projected maximum Savings Credit remain consistent with the statutory 60% formula; they are not enacted future rates. A reform to the guarantee or the phase-in rate does not re-derive the threshold or the - protected pension-age Housing Benefit allowances. + protected pension-age Housing Benefit allowances. A reform to the maximum + Savings Credit moves both, unless their own base-year value is reformed. SINGLE: values: 2015-04-06: 126.5 From 6dc0a139ecf28546c8e309b8791db0650f5e6eeb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 05:46:43 -0700 Subject: [PATCH 18/28] Preserve pre-base Savings Credit reforms after rates updates --- .../create_savings_credit_threshold.py | 15 ++--- .../test_pension_age_projection_invariants.py | 59 ++++++++++++++++++- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 432aa45c31..6298365849 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -395,14 +395,15 @@ def project_savings_credit_threshold( ) in relationship_parameters.items(): maximum = enacted_maximum(projection_base, relationship, base_year) maximum_parameter = getattr(savings_credit.maximum, relationship) + enacted_maximum_parameter = getattr( + enacted_savings_credit.maximum, relationship + ) # Enter the maximum a rates update without one implies, but never - # over a reform that starts before the base year. - if all( - last_enacted_year(parameter) < base_year - for parameter in ( - maximum_parameter, - getattr(enacted_savings_credit.maximum, relationship), - ) + # over a changed maximum timeline, even if its anchors all precede + # the new base. Entries compare their names, dates and values. + if ( + last_enacted_year(maximum_parameter) < base_year + and maximum_parameter.values_list == enacted_maximum_parameter.values_list ): maximum_parameter.update(start=f"{base_year}-04-01", value=maximum) maxima = projected_maximum_values( diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 00b421d80d..99295ad8c9 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -39,6 +39,9 @@ synthetic_parameter_tree, synthetic_parameters, ) +from policyengine_uk.tests.test_savings_credit_threshold_projection import ( + NEXT_YEAR_RATES, +) PROJECTED_PATHS = ( @@ -121,11 +124,12 @@ def _apply_reforms(parameters, reforms): @settings(max_examples=60, deadline=None) -@given(path=paths, reforms=combined_reforms()) +@given(path=paths, reforms=combined_reforms(), advance_base=st.booleans()) @example( path=[(0.039, 0.035)], reforms={path: (2026, 0.0) for path in REFORM_PATHS} | {path: [(2026, 0.0)] for path in MAXIMUM_PATHS}, + advance_base=False, ) @example( path=[(0.039, -0.06), (-0.06, 0.15)], @@ -141,24 +145,73 @@ def _apply_reforms(parameters, reforms): f"{SC_NAME}.maximum.SINGLE": [(2025, 1e100), (2028, 0), (2031, 40)], f"{SC_NAME}.maximum.COUPLE": [(2030, 5e-324)], }, + advance_base=False, ) -def test_combined_reforms_build_and_never_project_negative_amounts(path, reforms): +@example( + path=[(0.039, 0.035)], + reforms={f"{SC_NAME}.maximum.SINGLE": [(2025, 0.0)]}, + advance_base=True, +) +def test_combined_reforms_build_and_never_project_negative_amounts( + path, reforms, advance_base +): """Building succeeds even for zero bases and allowances below guarantees. The enacted tree supplies the base year; future reform anchors must not turn into a mismatched set of supposedly enacted years. Maximum Savings Credit paths, including anchors before the base year, combine with every - other reform. + other reform, including after a complete rates update advances the base. + Independent component reforms must preserve the maximum's own uprating. """ parameters = synthetic_parameter_tree(path) + if advance_base: + for name, amount in NEXT_YEAR_RATES.items(): + parameters.get_child(name).update(start="2027-04-01", value=amount) unreformed = parameters.clone() _apply_reforms(parameters, reforms) projected = project_synthetic_parameters(parameters, unreformed) + main = _main_parameters( + path, reforms={name: reforms[name] for name in MAXIMUM_PATHS if name in reforms} + ) for name in PROJECTED_PATHS: parameter = projected.get_child(name) for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): assert parameter(f"{year}-04-30") >= 0, (name, year, reforms) + if name in MAXIMUM_PATHS and name in reforms: + assert parameter(f"{year}-04-30") == pytest.approx( + main.get_child(name)(f"{year}-04-30"), rel=1e-12 + ), (name, year, reforms, advance_base) + + +@pytest.mark.parametrize("first_year", [2025, 2026]) +def test_advanced_base_preserves_a_pre_base_maximum_reform(first_year): + """A complete rates update must not replace an earlier maximum reform.""" + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + for name, amount in NEXT_YEAR_RATES.items(): + parameters.get_child(name).update(start="2027-04-01", value=amount) + unreformed = parameters.clone() + parameters.get_child(f"{SC_NAME}.maximum.SINGLE").update( + start=f"{first_year}-04-01", value=0 + ) + + projected = project_synthetic_parameters(parameters, unreformed) + + for name, amount in NEXT_YEAR_RATES.items(): + assert projected.get_child(name)("2027-04-30") == amount + assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")("2027-04-30") == 0 + assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")("2028-04-30") == 0 + for name in ( + f"{SC_NAME}.threshold.SINGLE", + f"{HB_NAME}.single.aged", + f"{HB_NAME}.lone_parent.aged", + ): + assert projected.get_child(name)("2028-04-30") == pytest.approx(259.75) + for year in range(2028, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert projected.get_child(name)(at) == pytest.approx( + projected.get_child(f"{SMG_NAME}.SINGLE")(at) + ) @pytest.mark.parametrize("first_year", [2026, 2028]) From a82e405f8b29128a735d4dd9c1daea49a9923c3a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 07:25:15 -0700 Subject: [PATCH 19/28] Anchor pre-base component reforms at the projection base --- .../create_pension_age_allowances.py | 7 +++++- .../create_savings_credit_threshold.py | 9 ++++++++ .../test_pension_age_projection_invariants.py | 22 ++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py index fddbdadb29..04580c9d11 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -254,7 +254,12 @@ def project_pension_age_allowances( for allowance_name, relationship in ALLOWANCE_GUARANTEES.items(): allowance = float(getattr(base_allowances, allowance_name).aged(start)) guarantee = float(getattr(guarantees, relationship)(start)) - reformed_allowance = float(getattr(allowances, allowance_name).aged(start)) + allowance_parameter = getattr(allowances, allowance_name).aged + reformed_allowance = float(allowance_parameter(start)) + if max(entry.instant_str for entry in allowance_parameter.values_list) < start: + # Keep a pre-base reform's amount through the common base, where + # its generated forecast index begins. + allowance_parameter.update(start=start, value=reformed_allowance) retained_maximum = enacted_maximum(projection_base, relationship, base_year) maxima = projected_maximum_values( parameters, relationship, base_year, final_observation_year + 1 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 6298365849..82b78b2a8c 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -410,6 +410,15 @@ def project_savings_credit_threshold( parameters, relationship, base_year, final_observation_year + 1 ) reformed_threshold = getattr(savings_credit.threshold, relationship) + if ( + max(entry.instant_str for entry in reformed_threshold.values_list) + < base_instant + ): + # A pre-base reform can remove the newer enacted anchor. Start + # its forecast at the common base, where the index is defined. + reformed_threshold.update( + start=base_instant, value=reformed_threshold(base_instant) + ) if reformed_threshold(base_instant) != threshold_parameter(base_instant): threshold_parameter = reformed_threshold guarantee_parameter = getattr(reformed_guarantees, relationship) diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 99295ad8c9..1b45758ea4 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -131,6 +131,12 @@ def _apply_reforms(parameters, reforms): | {path: [(2026, 0.0)] for path in MAXIMUM_PATHS}, advance_base=False, ) +@example( + path=[(0.0, 0.0)], + reforms={path: (2026, 0.0) for path in REFORM_PATHS} + | {path: [(2025, 0.0)] for path in MAXIMUM_PATHS}, + advance_base=True, +) @example( path=[(0.039, -0.06), (-0.06, 0.15)], reforms={ @@ -152,6 +158,13 @@ def _apply_reforms(parameters, reforms): reforms={f"{SC_NAME}.maximum.SINGLE": [(2025, 0.0)]}, advance_base=True, ) +@example( + path=[(0.039, 0.035)], + reforms={ + f"{SC_NAME}.maximum.SINGLE": [(2026, RAW_SC.maximum.SINGLE("2026-04-30"))] + }, + advance_base=True, +) def test_combined_reforms_build_and_never_project_negative_amounts( path, reforms, advance_base ): @@ -169,6 +182,12 @@ def test_combined_reforms_build_and_never_project_negative_amounts( parameters.get_child(name).update(start="2027-04-01", value=amount) unreformed = parameters.clone() _apply_reforms(parameters, reforms) + changed_maxima = { + name + for name in MAXIMUM_PATHS + if parameters.get_child(name).values_list + != unreformed.get_child(name).values_list + } projected = project_synthetic_parameters(parameters, unreformed) main = _main_parameters( path, reforms={name: reforms[name] for name in MAXIMUM_PATHS if name in reforms} @@ -178,7 +197,8 @@ def test_combined_reforms_build_and_never_project_negative_amounts( parameter = projected.get_child(name) for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): assert parameter(f"{year}-04-30") >= 0, (name, year, reforms) - if name in MAXIMUM_PATHS and name in reforms: + # A no-op maximum update still permits the ordinary rates-update fill. + if name in changed_maxima: assert parameter(f"{year}-04-30") == pytest.approx( main.get_child(name)(f"{year}-04-30"), rel=1e-12 ), (name, year, reforms, advance_base) From 608fae47e0e166b2c747cf6f8bf2e41bed3048f6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 02:35:41 -0400 Subject: [PATCH 20/28] Preserve loaded YAML bases when reprocessing pension reforms Exercise modifier updates and projected allowance values while keeping incomplete rates updates invalid. Co-Authored-By: Claude Opus 5.5 --- .../create_pension_age_allowances.py | 7 +- .../pension_age_projection_base.py | 5 +- .../create_savings_credit_threshold.py | 7 +- policyengine_uk/tax_benefit_system.py | 24 ++++-- ...ion_credit_guarantee_reform_projections.py | 81 ++++++++++++++++--- ...est_savings_credit_threshold_projection.py | 15 +++- 6 files changed, 117 insertions(+), 22 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py index 04580c9d11..7053cab7f9 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -210,6 +210,7 @@ def _projection_parameter( def project_pension_age_allowances( parameters: ParameterNode, unreformed: ParameterNode | None = None, + enacted: ParameterNode | None = None, ) -> ParameterNode: """Add a protected-allowance forecast index for each allowance. @@ -229,7 +230,11 @@ def project_pension_age_allowances( projected, as before. """ allowances = parameters.gov.dwp.housing_benefit.allowances - projection_base = parameters if unreformed is None else unreformed + projection_base = ( + enacted + if enacted is not None + else (parameters if unreformed is None else unreformed) + ) base_year = projection_base_year(projection_base) start = base_instant(base_year) base_allowances = projection_base.gov.dwp.housing_benefit.allowances diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py b/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py index ffbada6cf3..ee5c2bf89f 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py @@ -30,8 +30,9 @@ def last_enacted_year(parameter: Parameter) -> int: def projection_base_year(parameters: ParameterNode) -> int: """The last year with enacted rates for every projected parameter. - Read it from the enacted tree, not a reformed one: a reform's later - values are anchors for uprating, not a new projection base. + Callers supply the YAML as loaded, before any modifier or dated reform: + later reform values anchor uprating, not a new projection base. The + consistency guard therefore validates YAML rates updates alone. """ years = { path: last_enacted_year(parameters.get_child(path)) for path in BASE_PARAMETERS diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 82b78b2a8c..0297e7ecd4 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -345,6 +345,7 @@ def enacted_maximum( def project_savings_credit_threshold( parameters: ParameterNode, unreformed: ParameterNode | None = None, + enacted: ParameterNode | None = None, ) -> ParameterNode: """Add forecast indices for each Savings Credit threshold. @@ -361,7 +362,11 @@ def project_savings_credit_threshold( base takes precedence: its own reformed gap is projected, as before. """ savings_credit = parameters.gov.dwp.pension_credit.savings_credit - projection_base = parameters if unreformed is None else unreformed + projection_base = ( + enacted + if enacted is not None + else (parameters if unreformed is None else unreformed) + ) base_year = projection_base_year(projection_base) base_instant = projection_instant(base_year) enacted_savings_credit = projection_base.gov.dwp.pension_credit.savings_credit diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index ba2c284c17..edc4e07180 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -148,18 +148,19 @@ def _add_generated_parameters( parameters: ParameterNode, generated: Optional[List[ParameterAssignment]] = None, unreformed: Optional[ParameterNode] = None, + enacted: Optional[ParameterNode] = None, ) -> ParameterNode: """Run the generation steps, writing changes to generated parameters. Each change in ``generated`` is written onto its parameter just after the step that generates it, so the later steps, and uprating, read it. - ``unreformed`` supplies the enacted base year and independent Pension - Credit and Housing Benefit component bases. + ``enacted`` supplies the YAML as loaded, before any modifier edits, for + the Pension Credit and Housing Benefit projection bases. """ pending = list(generated or []) for step in GENERATION_STEPS: if step in (project_savings_credit_threshold, project_pension_age_allowances): - parameters = step(parameters, unreformed=unreformed) + parameters = step(parameters, unreformed=unreformed, enacted=enacted) else: parameters = step(parameters) if pending: @@ -181,6 +182,7 @@ def process_parameter_tree( unreformed: Optional[ParameterNode] = None, annualise: bool = True, generated: Optional[List[ParameterAssignment]] = None, + enacted: Optional[ParameterNode] = None, ) -> Tuple[ParameterNode, Dict[str, Timeline]]: """Project and annualise a statutory parameter tree. @@ -211,16 +213,19 @@ def process_parameter_tree( ``generated`` holds the reform's changes to generated parameters, each written just after the step that generates it (see ``_add_generated_parameters``). + ``enacted`` retains the YAML projection bases separately from runtime + modifier edits. When omitted, the input tree supplies those bases, so + an incomplete YAML rates update is still rejected. """ validate_ni_scale_compatibility(parameters) - parameters = _add_generated_parameters(parameters, generated, unreformed) + parameters = _add_generated_parameters(parameters, generated, unreformed, enacted) # Create baseline parameters for reform comparisons if annualise: baseline = ( parameters.clone() if unreformed is None - else _add_generated_parameters(unreformed) + else _add_generated_parameters(unreformed, enacted=enacted) ) parameters.add_child("baseline", baseline) @@ -304,6 +309,8 @@ def reset_parameters(self) -> None: self._parameters_before_reset = current self.load_parameters(self.parameters_dir) _install_parameters(self, add_removed_parameter_aliases(self.parameters)) + # Keep the YAML anchors before a simulation modifier can edit them. + self._enacted_parameters = self.parameters.clone() def process_parameters(self) -> None: """Process and transform parameters with UK-specific adjustments. @@ -493,7 +500,9 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # reloaded parameters are part of it. self._raw_parameters = raw.clone() if not assignments: - parameters, self._projected_timelines = process_parameter_tree(raw) + parameters, self._projected_timelines = process_parameter_tree( + raw, enacted=self._enacted_parameters + ) _install_parameters(self, parameters) return # Changes to parameters processing generates are written as each is @@ -511,7 +520,7 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) parameters, self._projected_timelines = process_parameter_tree( - raw, unreformed, generated=generated + raw, unreformed, generated=generated, enacted=self._enacted_parameters ) _install_parameters(self, parameters) @@ -547,6 +556,7 @@ def __init__(self, capital_gains_projection: CapitalGainsProjection | None = Non global _processed_parameters_cache, _statutory_parameters_cache global _projected_timelines_cache if _processed_parameters_cache is not None: + self._enacted_parameters = _statutory_parameters_cache.clone() # Fast path: clone pre-processed parameters rather than re-running # the full pipeline (saves ~0.5s from convert_to_fiscal_year_parameters). # Reforms rebuild the parameters from the statutory tree through diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index 4d98be6ba0..3a47ead117 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -99,6 +99,55 @@ def _simulation(couple, changes, years, route="reform"): return Simulation(situation=_situation(couple, years), **options) +def _modifier_simulation(couple, changes, start="2026-04-01", stop=None): + """Reload YAML, apply open or bounded updates, then reprocess it.""" + + def modify(simulation): + tax_benefit_system = simulation.tax_benefit_system + tax_benefit_system.reset_parameters() + for path, value in changes.items(): + tax_benefit_system.parameters.get_child(path).update( + start=instant(start), stop=instant(stop) if stop else None, value=value + ) + tax_benefit_system.process_parameters() + + return Simulation( + situation=_situation(couple, range(2026, 2031)), + scenario=Scenario(simulation_modifier=modify, applied_before_data_load=True), + ) + + +@pytest.mark.parametrize( + "path,value", + [ + (f"{GUARANTEE}.SINGLE", 250), + (f"{SAVINGS_CREDIT}.threshold.SINGLE", 200), + (f"{HB_ALLOWANCES}.single.aged", 260), + ], +) +@pytest.mark.parametrize( + "start,stop", + [ + ("2026-04-01", "2100-12-31"), + ("2027-04-01", "2100-12-31"), + ("2027-04-01", None), + ], +) +def test_reprocessing_modifier_keeps_the_yaml_base_and_its_explicit_update( + path, value, start, stop +): + parameters = _modifier_simulation( + False, {path: value}, start, stop + ).tax_benefit_system.parameters + assert parameters.get_child(path)(f"{start[:4]}-04-30") == value + for index in ( + f"{SAVINGS_CREDIT}.threshold_uprating.SINGLE", + f"{HB_ALLOWANCES}.protected_pension_age_uprating.single", + ): + assert parameters.get_child(index)("2026-04-30") == 1 + assert parameters.get_child(index)("2028-04-30") > 0 + + def _assert_projections_unchanged(simulation): parameters = simulation.tax_benefit_system.parameters for path in PROJECTION_PATHS: @@ -343,20 +392,19 @@ def _earnings_growth(parameters, year=2027): return earnings(f"{year}-04-30") / earnings(f"{year - 1}-04-30") -@pytest.mark.parametrize("route", ["reform", "scenario"]) @pytest.mark.parametrize( "relationship,allowance_name", [("SINGLE", "single"), ("COUPLE", "couple")] ) def test_completed_joint_reform_below_the_guarantee_projects_with_the_guarantee( - route, relationship, allowance_name + relationship, allowance_name ): """An allowance cut below a cut guarantee no longer stops the build: with no retained Savings Credit, it follows the guarantee's earnings path.""" changes = { - f"{GUARANTEE}.{relationship}": {"year:2026-01-01:1": 230}, - f"{HB_ALLOWANCES}.{allowance_name}.aged": {"year:2026-01-01:1": 220}, + f"{GUARANTEE}.{relationship}": 230, + f"{HB_ALLOWANCES}.{allowance_name}.aged": 220, } - simulation = _simulation(relationship == "COUPLE", changes, [2026], route) + simulation = _modifier_simulation(relationship == "COUPLE", changes) parameters = simulation.tax_benefit_system.parameters allowance = parameters.get_child(f"{HB_ALLOWANCES}.{allowance_name}.aged") @@ -364,6 +412,12 @@ def test_completed_joint_reform_below_the_guarantee_projects_with_the_guarantee( assert _index_growth(parameters, allowance_name) == pytest.approx( _earnings_growth(parameters) ) + earnings = parameters.gov.economic_assumptions.indices.earnings_uprating + for year in range(2027, 2031): + at = f"{year}-04-30" + assert allowance(at) == pytest.approx( + 220 * earnings(at) / earnings("2026-04-30") + ) @pytest.mark.parametrize("offset", [-100, -0.01, 0, 0.01]) @@ -374,10 +428,8 @@ def test_allowance_reform_at_the_guarantee_boundary_is_continuous(offset): above, the penny of retained Savings Credit grows by CPI, so the growth lies between the two.""" guarantee = float(system.parameters.get_child(f"{GUARANTEE}.SINGLE")("2026-04-30")) - changes = { - f"{HB_ALLOWANCES}.single.aged": {"year:2026-01-01:1": guarantee + offset} - } - parameters = _simulation(False, changes, [2026]).tax_benefit_system.parameters + changes = {f"{HB_ALLOWANCES}.single.aged": guarantee + offset} + parameters = _modifier_simulation(False, changes).tax_benefit_system.parameters growth = _index_growth(parameters, "single") earnings_growth = _earnings_growth(parameters) @@ -393,6 +445,17 @@ def test_allowance_reform_at_the_guarantee_boundary_is_continuous(offset): assert _index_growth(parameters, name) == pytest.approx( _index_growth(system.parameters, name) ) + earnings = parameters.gov.economic_assumptions.indices.earnings_uprating + prices = parameters.gov.economic_assumptions.indices.september_cpi_uprating + for year in range(2027, 2031): + at = f"{year}-04-30" + expected = ( + min(guarantee, guarantee + offset) * earnings(at) / earnings("2026-04-30") + ) + expected += max(0, offset) * prices(at) / prices("2026-04-30") + assert parameters.gov.dwp.housing_benefit.allowances.single.aged( + at + ) == pytest.approx(expected) @pytest.mark.parametrize( diff --git a/policyengine_uk/tests/test_savings_credit_threshold_projection.py b/policyengine_uk/tests/test_savings_credit_threshold_projection.py index 896a4d19ae..274b70fd8f 100644 --- a/policyengine_uk/tests/test_savings_credit_threshold_projection.py +++ b/policyengine_uk/tests/test_savings_credit_threshold_projection.py @@ -11,7 +11,10 @@ from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( projection_base_year, ) -from policyengine_uk.tax_benefit_system import CountryTaxBenefitSystem +from policyengine_uk.tax_benefit_system import ( + CountryTaxBenefitSystem, + process_parameter_tree, +) PENSIONER = { @@ -146,13 +149,21 @@ def test_postprocessing_threshold_reform_is_not_replaced_by_projection(): def _system_with_rates(rates, year=2027): + """Process an edited raw YAML fixture, rather than a runtime modifier.""" tax_benefit_system = CountryTaxBenefitSystem() tax_benefit_system.reset_parameters() for path, value in rates.items(): tax_benefit_system.parameters.get_child(path).update( start=instant(f"{year}-04-01"), stop=None, value=value ) - tax_benefit_system.process_parameters() + raw = tax_benefit_system.parameters + # Later reforms of this fixture must retain its complete enacted base. + tax_benefit_system._enacted_parameters = raw.clone() + tax_benefit_system._raw_parameters = raw.clone() + ( + tax_benefit_system.parameters, + tax_benefit_system._projected_timelines, + ) = process_parameter_tree(raw) return tax_benefit_system From 11031d11e73603be0a0c251aae2f5e1c9164ef0b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 02:54:13 -0400 Subject: [PATCH 21/28] Distinguish derived Savings Credit eligibility from CPI uprating Co-Authored-By: Claude Opus 5.5 --- .../savings_credit/is_savings_credit_eligible.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index 008ad00233..b5f6d59b0d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -161,7 +161,8 @@ benunit: members: [person] relation_type: SINGLE - savings_credit_income: 11_000 + savings_credit_income: 11_220 output: - # The projected weekly threshold is about 216.301, or 11,247.65 a year. + # Plain CPI: 208.07 * 1.035 * 52 = 11,198.33, below this income. + # Derived: (247.282 - 18.588570 / 0.6) * 52 = 11,247.65, above it. is_savings_credit_eligible: false From 98d1dc18cc0342a0377561965c6fb53df03abd3a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 03:20:08 -0400 Subject: [PATCH 22/28] WIP: follow the reformed guarantee in the forecast threshold and HB allowances (#2092 P1) Uncommitted work from Subfleet build 20261028-004213-fix-2092-r3, which was lost (rc 1) at 07:19Z before it finished, salvaged by the UK hub. It is not yet verified or tested. A continuation build finishes it before anything reaches the PR branch. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2092.fixed.md | 2 +- .../programs/gov/dwp/pension-credit.ipynb | 4 +- .../create_pension_age_allowances.py | 49 ++++- .../gov/dwp/pension_credit/README.md | 21 +- .../create_savings_credit_threshold.py | 35 ++- .../savings_credit/threshold.yaml | 9 +- policyengine_uk/tax_benefit_system.py | 12 +- .../test_pension_age_projection_invariants.py | 41 +++- ...ion_credit_guarantee_reform_projections.py | 204 +++++++++++++++--- .../tests/test_pension_credit_uprating.py | 18 +- 10 files changed, 323 insertions(+), 72 deletions(-) diff --git a/changelog.d/2092.fixed.md b/changelog.d/2092.fixed.md index 9c0d764ae3..0751fead70 100644 --- a/changelog.d/2092.fixed.md +++ b/changelog.d/2092.fixed.md @@ -1 +1 @@ -Made Pension Credit and pension-age Housing Benefit projections non-negative and safe under reforms, validated their common enacted base year, and preserved Guarantee Credit boundary continuity. +Made Pension Credit and pension-age Housing Benefit projections non-negative and safe under reforms, validated their common enacted base year from the YAML as loaded, and preserved Guarantee Credit boundary continuity. Forecast thresholds and protected Housing Benefit allowances follow the reformed guarantee and maximum as uprated, as on main. diff --git a/docs/book/programs/gov/dwp/pension-credit.ipynb b/docs/book/programs/gov/dwp/pension-credit.ipynb index 4af2614375..92b9625acb 100644 --- a/docs/book/programs/gov/dwp/pension-credit.ipynb +++ b/docs/book/programs/gov/dwp/pension-credit.ipynb @@ -24,9 +24,9 @@ "\n", "## Uprating\n", "\n", - "After the last enacted rates, the standard minimum guarantee follows the previous May–July earnings increase, with no decrease when earnings fall, under [Social Security Administration Act 1992 section 150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A). Maximum Savings Credit follows September CPI with a zero floor. The model derives the Savings Credit threshold from these amounts and the enacted phase-in rate, with a zero floor on the threshold. Each unreformed higher pension-age Housing Benefit allowance equals the projected guarantee plus projected maximum Savings Credit; the post-April-2021 lower allowances follow earnings. Published amounts, including rounding, remain unchanged in their enacted years.\n", + "After the last enacted rates, the standard minimum guarantee follows the previous May–July earnings increase, with no decrease when earnings fall, under [Social Security Administration Act 1992 section 150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A). Maximum Savings Credit follows September CPI with a zero floor. The model derives the Savings Credit threshold from these amounts and the live phase-in rate, with a zero floor on the threshold. Each higher pension-age Housing Benefit allowance with an unreformed own base equals the projected guarantee plus projected maximum Savings Credit; the post-April-2021 lower allowances follow earnings. Published amounts, including rounding, remain unchanged in their enacted years.\n", "\n", - "The projections start from the common last year with enacted guarantees, thresholds and higher Housing Benefit allowances. A reform to the guarantee or Savings Credit phase-in rate does not re-derive the separately set threshold or Housing Benefit allowances. A reform to the maximum Savings Credit, from the base year or a later year, does reach them: each projected threshold and higher allowance uses the reformed maximum, unless its own base-year value is also reformed. Explicit threshold and allowance reforms anchor their own projections, and higher allowances at or below the guarantee, including zero, can still be projected. See the [Pension Credit parameter documentation](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/pension_credit/README.md) for the base-year checks and reform rules. These are forecast conventions, rather than announced future rates.\n", + "The projections start from the common last year with enacted guarantees, thresholds and higher Housing Benefit allowances. Forecast thresholds and protected Housing Benefit allowances follow the guarantee and maximum as general uprating projects their reformed paths, as on main, unless their own base-year value is also reformed. Threshold forecasts use the live phase-in rate; a zero rate keeps the preceding threshold and pays no Savings Credit. Explicit threshold and allowance reforms anchor their own projections, and higher allowances at or below the guarantee, including zero, can still be projected. See the [Pension Credit parameter documentation](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/pension_credit/README.md) for the base-year checks and reform rules. These are forecast conventions, rather than announced future rates.\n", "\n", "## Pension Credit Rate Changes\n", "\n", diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py index 7053cab7f9..e071672f38 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -7,16 +7,19 @@ reaches the allowances, as it did when they were derived after general uprating. Reformed allowance bases use their own retained component; a base below its guarantee follows earnings without a retained component. A -guarantee-only reform leaves the separately set allowances unchanged. These -indices run before general uprating. +guarantee reform moves the forecast allowances along its reformed uprated +path, as on main. These indices run before general uprating. """ +from sys import float_info + from policyengine_core.parameters import Parameter, ParameterNode from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( enacted_maximum, last_projected_year, projected_maximum_values, + uprated_values, ) from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( projection_base_year, @@ -50,12 +53,15 @@ def protected_allowance_index_values( retained_maximum: float | None = None, price_index: Parameter | None = None, maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, ) -> dict[str, float]: """Build a component-weighted index from the last enacted allowance. ``maxima`` gives the maximum Savings Credit in each forecast year, which then replaces the retained component. Without it, the retained component is projected by non-negative September CPI. + ``guarantees`` gives the guarantee's reformed uprated path; without it, + the own-base fallback projects the guarantee component by earnings. """ retained_uplift = ( base_allowance - guarantee_component @@ -76,8 +82,11 @@ def protected_allowance_index_values( values = {start: 1.0} for year in range(base_year + 1, final_observation_year + 2): observation_year = year - 1 - guarantee = guarantee_component * ( - float(earnings_index(f"{year}-04-01")) / base_earnings_index + guarantee = ( + guarantees[year] + if guarantees is not None + else guarantee_component + * (float(earnings_index(f"{year}-04-01")) / base_earnings_index) ) growth = ( float(price_index(f"{year}-04-01")) @@ -91,7 +100,12 @@ def protected_allowance_index_values( if maxima is not None else project_retained_uplift(retained_uplift, growth) ) - values[f"{year}-04-01"] = (guarantee + retained_uplift) / base_allowance + # Core divides by the index at later explicit anchors. A zero + # guarantee and maximum must therefore leave a positive index. + values[f"{year}-04-01"] = min( + max((guarantee + retained_uplift) / base_allowance, float_info.min), + float_info.max, + ) return values @@ -106,6 +120,7 @@ def _projection_parameter( retained_maximum: float | None, price_index: Parameter, maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, ) -> Parameter: retained_uplift = ( base_allowance - guarantee_component @@ -114,9 +129,14 @@ def _projection_parameter( ) relationship_label = allowance.replace("_", "-") fiscal_year = f"{base_year}-{(base_year + 1) % 100:02d}" + guarantee_source = ( + "the reformed Pension Credit guarantee, as uprated from its last explicit value" + if guarantees is not None + else f"a £{guarantee_component:g} Pension Credit guarantee component" + ) savings_credit_component = ( "the maximum Savings Credit, as uprated from its last explicit value. " - "The guarantee component follows the shared statutory earnings index; " + "The guarantee component follows its reformed uprated path; " "the maximum follows non-negative September CPI." if maxima is not None else f"a £{retained_uplift:g} Savings Credit component. " @@ -133,7 +153,7 @@ def _projection_parameter( f"Forecast index for the protected {relationship_label} " f"pension-age Housing Benefit allowance. The £{base_allowance:g} " f"{fiscal_year} base is retained exactly. Forecasts combine a " - f"£{guarantee_component:g} Pension Credit guarantee component " + f"{guarantee_source} " f"and {savings_credit_component} " "Projected values are not enacted rates." ), @@ -147,6 +167,7 @@ def _projection_parameter( retained_maximum=retained_maximum, price_index=price_index, maxima=maxima, + guarantees=guarantees, ), "metadata": { "unit": "/1", @@ -214,9 +235,10 @@ def project_pension_age_allowances( ) -> ParameterNode: """Add a protected-allowance forecast index for each allowance. - A guarantee reform does not re-derive the separately set HB allowance: - each index uses the unreformed guarantee unless its own allowance base - changes, when it uses the reformed pair. A reformed allowance below its + Forecasts follow the reformed guarantee and maximum as general uprating + projects them, retaining the enacted allowance as the index denominator. + If its own allowance base changes, the index uses the reformed pair. + A reformed allowance below its guarantee keeps no retained Savings Credit, so it is projected whole with the guarantee's earnings path, as an allowance equal to its guarantee is. The enacted pair is still validated, so a rates update that puts an @@ -269,9 +291,15 @@ def project_pension_age_allowances( maxima = projected_maximum_values( parameters, relationship, base_year, final_observation_year + 1 ) + guarantee_path = uprated_values( + getattr(reformed_guarantees, relationship), + parameters, + range(base_year + 1, final_observation_year + 2), + ) if reformed_allowance != allowance: retained_maximum = None maxima = None + guarantee_path = None allowance = reformed_allowance guarantee = min( float(getattr(reformed_guarantees, relationship)(start)), @@ -290,6 +318,7 @@ def project_pension_age_allowances( retained_maximum, price_index, maxima, + guarantee_path, ), ) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md index d55c7750de..9d93beb815 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md @@ -13,12 +13,12 @@ reached the qualifying age on or after 1 April 2021, follow the same increase. The Savings Credit maximum rises with September CPI, following [HCWS1101](https://questions-statements.parliament.uk/written-statements/detail/2025-11-26/hcws1101). -The maximum has a non-negative CPI floor. Unreformed projected thresholds are +The maximum has a non-negative CPI floor. Projected thresholds are derived from the component indices before general uprating: `threshold = max(0, standard minimum guarantee - maximum / phase_in)`. At the 60% phase-in rate, this ensures that the formula's maximum award follows prices even when the guarantee follows earnings. Published -thresholds are retained, including their rounding. If the enacted phase-in +thresholds are retained, including their rounding. If the live phase-in rate is zero, the projection retains the previous threshold rather than dividing by zero. @@ -27,24 +27,25 @@ who reached the qualifying age before 1 April 2021, are based on the guarantee plus maximum Savings Credit ([DWP HB A3/2021, paragraphs 3–5](https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2021/a32021-housing-benefit-person-who-have-attained-the-qualifying-age-for-state-pension-credit-amendment-regulations-2021)). Published allowances are retained exactly, including their rounding. In the -unreformed forecast, each higher allowance equals the projected guarantee plus +forecast with an unreformed own base, each higher allowance equals the projected guarantee plus the projected maximum, so its guarantee part follows earnings and its maximum part follows September CPI. ## Projection base and reforms -The projection base is the last year with explicit enacted values for both +The projection base comes from the YAML as loaded: the last year with explicit enacted values for both guarantees, both Savings Credit thresholds and the three higher Housing Benefit allowances. All seven parameters must reach the same year. If they do not, parameter loading raises an error listing each parameter and its last enacted year. Reform dates do not move this base. -A reform to the Pension Credit guarantee or Savings Credit phase-in rate does -not re-derive the separately set Savings Credit threshold or Housing Benefit -allowances. A reform to the maximum Savings Credit does: the projections read -the maximum as general uprating projects it, so a reform from the base year or -any later year, or to the index the maximum is uprated by, moves the projected -threshold and higher allowances as it moves the maximum. A threshold or +A reform to the Pension Credit guarantee moves the forecast Savings Credit +threshold and protected Housing Benefit allowances, as on main. Each index +reads the guarantee and maximum as general uprating projects their reformed +paths, including reforms from the base year or any later year and reforms to +their uprating indices. Threshold forecasts also read the live phase-in rate; +at zero they keep the preceding threshold, as on main, and pay no Savings +Credit. The enacted base-year amounts retain their own rounding. A threshold or allowance whose own base-year value is reformed projects its own reformed gap instead. Explicit threshold and allowance reforms remain anchors for their own later projections. Each higher allowance has its own index, including the diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 0297e7ecd4..b01ea0656b 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -145,11 +145,14 @@ def threshold_index_values( base_maximum: float | None = None, price_index: Parameter | None = None, maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, ) -> dict[str, float]: """Build a forecast index from the last enacted threshold. ``maxima`` gives the maximum Savings Credit in each forecast year. Without it, the base maximum is projected by non-negative September CPI. + ``guarantees`` gives the guarantee's reformed uprated path; without it, + the own-base fallback projects the base guarantee by earnings. """ base_instant = f"{base_year}-04-01" if base_threshold <= 0: @@ -178,8 +181,11 @@ def threshold_index_values( if maxima is not None else project_maximum_savings_credit(maximum, growth) ) - guarantee = base_guarantee * ( - float(earnings_index(f"{year}-04-01")) / base_earnings_index + guarantee = ( + guarantees[year] + if guarantees is not None + else base_guarantee + * (float(earnings_index(f"{year}-04-01")) / base_earnings_index) ) phase_in = float(phase_in_rate(projection_instant(year))) threshold = ( @@ -209,6 +215,7 @@ def _projection_parameter( base_maximum: float, price_index: Parameter, maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, ) -> Parameter: relationship_label = relationship.lower() return Parameter( @@ -216,7 +223,7 @@ def _projection_parameter( data={ "description": ( f"Forecast index for the {relationship_label} Savings Credit " - "threshold. It derives the threshold from an earnings-projected " + "threshold. It derives the threshold from the reformed, uprated " "standard minimum guarantee and a maximum Savings Credit amount " "projected by non-negative September CPI. Explicit threshold " "values remain authoritative. Projected values are not enacted " @@ -233,6 +240,7 @@ def _projection_parameter( base_maximum=base_maximum, price_index=price_index, maxima=maxima, + guarantees=guarantees, ), "metadata": { "unit": "/1", @@ -349,12 +357,12 @@ def project_savings_credit_threshold( ) -> ParameterNode: """Add forecast indices for each Savings Credit threshold. - The separately set threshold is not re-derived by a guarantee or - phase-in reform, including a reform to the projection's base year. Use - the unreformed guarantee unless the corresponding threshold base changes, - in which case use the reformed pair, and the enacted phase-in rate, so a - phase-in reform (to zero, say) changes the Savings Credit paid and not the - threshold. Earnings and CPI inputs come from the reform's tree. + Forecasts follow the reformed guarantee's uprated path and the live + phase-in rate, as on main. A zero phase-in rate keeps the preceding + threshold: no Savings Credit is paid, so there is no residual to solve. + The enacted threshold remains the index denominator. A reformed own + threshold base instead projects its own reformed gap. Earnings and CPI + inputs come from the reform's tree. The maximum Savings Credit is read from the reform's tree as general uprating will project it, so a reform to the maximum from the base year @@ -414,6 +422,11 @@ def project_savings_credit_threshold( maxima = projected_maximum_values( parameters, relationship, base_year, final_observation_year + 1 ) + guarantee_path = uprated_values( + getattr(reformed_guarantees, relationship), + parameters, + range(base_year + 1, final_observation_year + 2), + ) reformed_threshold = getattr(savings_credit.threshold, relationship) if ( max(entry.instant_str for entry in reformed_threshold.values_list) @@ -428,6 +441,7 @@ def project_savings_credit_threshold( threshold_parameter = reformed_threshold guarantee_parameter = getattr(reformed_guarantees, relationship) maxima = None + guarantee_path = None maximum = max( 0, round_currency( @@ -444,7 +458,7 @@ def project_savings_credit_threshold( relationship, float(guarantee_parameter(base_instant)), float(threshold_parameter(base_instant)), - enacted_savings_credit.rate.phase_in, + savings_credit.rate.phase_in, earnings_index, cpi_september, final_observation_year, @@ -452,6 +466,7 @@ def project_savings_credit_threshold( maximum, price_index, maxima, + guarantee_path, ), ) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 2888e818af..d58d6adff4 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -2,10 +2,11 @@ description: >- Income threshold at which Savings Credit is eligible. Values after the last enacted rate are derived so that the earnings-projected standard minimum guarantee and the CPI-projected maximum Savings Credit remain consistent - with the statutory 60% formula; they are not enacted future rates. A reform - to the guarantee or the phase-in rate does not re-derive the threshold or the - protected pension-age Housing Benefit allowances. A reform to the maximum - Savings Credit moves both, unless their own base-year value is reformed. + with the statutory 60% formula; they are not enacted future rates. Forecast + thresholds and protected pension-age Housing Benefit allowances follow the + reformed guarantee and maximum as uprated, as on main, unless their own + base-year value is reformed. The live phase-in rate determines the threshold; + a zero rate keeps the preceding threshold and pays no Savings Credit. SINGLE: values: 2015-04-06: 126.5 diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index edc4e07180..e78fb950ea 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -516,7 +516,9 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # Not generated either: the error the lookup gives. raw.get_child(assignment.path) unreformed = raw.clone() - projected = prior_law_projection(on_timeline, unreformed, statutory, generated) + projected = prior_law_projection( + on_timeline, unreformed, statutory, generated, enacted=self._enacted_parameters + ) for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) parameters, self._projected_timelines = process_parameter_tree( @@ -705,6 +707,7 @@ def prior_law_projection( unreformed: ParameterNode, statutory: bool = True, generated: Optional[List[ParameterAssignment]] = None, + enacted: Optional[ParameterNode] = None, ) -> Callable[[str], Optional[Timeline]]: """Prior law's projected timeline, by parameter name, for a reform. @@ -719,7 +722,8 @@ def prior_law_projection( an error. ``unreformed`` is the tree the changes are written onto; ``statutory`` says it is the YAML as loaded, whose projection without changes is cached. ``generated`` holds the reform's changes to generated - parameters, which every pass makes too. + parameters, which every pass makes too. ``enacted`` keeps the YAML bases + separate from modifier edits through these intermediate projections too. """ needing = sorted( { @@ -738,6 +742,7 @@ def prior_law_projection( unreformed=unreformed, annualise=False, generated=generated, + enacted=enacted, )[1] ) environment = {path: start.get(path) for path in needing} @@ -756,7 +761,8 @@ def lookup(name: str) -> Optional[Timeline]: tree.get_child(assignment.path), assignment, lookup ) updated[path] = process_parameter_tree( - tree, unreformed=unreformed, annualise=False, generated=generated + tree, unreformed=unreformed, annualise=False, generated=generated, + enacted=enacted, )[1].get(path) if _same_timelines(updated, environment): return lookup diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 1b45758ea4..8d4ae9f6bf 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -6,10 +6,12 @@ amounts, including main’s earnings and CPI floors. Main read the maximum Savings Credit after uprating, so for any path of maximum reforms they must reproduce main too, apart from the additional threshold residual floor. -Reform independence and that floor have their own properties. +Guarantee reforms retain main's behavior too; the residual floor has its own +properties. """ from typing import Any, Callable +from math import isfinite import pytest from hypothesis import example, given, settings @@ -193,6 +195,15 @@ def test_combined_reforms_build_and_never_project_negative_amounts( path, reforms={name: reforms[name] for name in MAXIMUM_PATHS if name in reforms} ) + for node in ( + projected.gov.dwp.pension_credit.savings_credit.threshold_uprating, + projected.gov.dwp.housing_benefit.allowances.protected_pension_age_uprating, + ): + for index in node.children.values(): + assert all( + isfinite(entry.value) and entry.value > 0 for entry in index.values_list + ) + for name in PROJECTED_PATHS: parameter = projected.get_child(name) for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): @@ -342,6 +353,34 @@ def test_forecasts_reproduce_each_enacted_value_exactly(path, enacted): assert parameters.get_child(name)(instant) == expected +@settings(max_examples=40, deadline=None) +@given( + path=paths, + increase=st.floats(min_value=1, max_value=1e100), + first_year=st.integers(2026, 2039), +) +@example(path=[(0.039, 0.035)], increase=300 / 238, first_year=2026) +def test_guarantee_increases_match_main_and_keep_protected_allowances_above_guarantee( + path, increase, first_year +): + parameters = synthetic_parameter_tree(path) + unreformed = parameters.clone() + reforms = {f"{SMG_NAME}.SINGLE": (first_year, 238 * increase)} + _apply_reforms(parameters, reforms) + projected = project_synthetic_parameters(parameters, unreformed) + main = _main_parameters(path, reforms=reforms) + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + guarantee = projected.get_child(f"{SMG_NAME}.SINGLE")(at) + for name in FOLLOWS_SINGLE_MAXIMUM: + assert projected.get_child(name)(at) == pytest.approx( + main.get_child(name)(at), rel=1e-12 + ) + for kind in ("single", "lone_parent"): + allowance = projected.get_child(f"{HB_NAME}.{kind}.aged")(at) + assert allowance >= guarantee - abs(guarantee) * 1e-12 + + def _main_last_projected_year(parameters: ParameterNode) -> int: """Frozen #2576 helper, eb0b8087: the earnings index's final year.""" index = parameters.gov.economic_assumptions.indices.earnings_uprating diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index 3a47ead117..2452455dea 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -1,11 +1,11 @@ -"""Reforms leave independently enacted pension-age projections intact. +"""Pension-age forecasts follow main's reformed guarantee and maximum. Invariants, for the Savings Credit threshold and protected pension-age Housing Benefit allowance forecast indices: -1. A reform to the standard minimum guarantee or the Savings Credit phase-in - rate leaves every index, and the projected thresholds and allowances, as - enacted. +1. Guarantee reforms move forecast thresholds and protected allowances along + the guarantee's uprated path. The live phase-in rate determines the + threshold; zero preserves the previous threshold, as on main. 2. No reform to a base-year allowance or threshold stops the parameter tree being built; only an enacted allowance below its guarantee raises. 3. A reformed allowance below its guarantee is projected whole by the @@ -19,6 +19,9 @@ own reformed gap. """ +from math import isfinite + +import numpy as np import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st @@ -99,7 +102,7 @@ def _simulation(couple, changes, years, route="reform"): return Simulation(situation=_situation(couple, years), **options) -def _modifier_simulation(couple, changes, start="2026-04-01", stop=None): +def _modifier_simulation(couple, changes, start="2026-04-01", stop=None, dated_changes=None): """Reload YAML, apply open or bounded updates, then reprocess it.""" def modify(simulation): @@ -114,6 +117,7 @@ def modify(simulation): return Simulation( situation=_situation(couple, range(2026, 2031)), scenario=Scenario(simulation_modifier=modify, applied_before_data_load=True), + reform=dated_changes, ) @@ -148,19 +152,67 @@ def test_reprocessing_modifier_keeps_the_yaml_base_and_its_explicit_update( assert parameters.get_child(index)("2028-04-30") > 0 -def _assert_projections_unchanged(simulation): +@pytest.mark.parametrize( + "path,value", + [(f"{GUARANTEE}.SINGLE", 250), (f"{SAVINGS_CREDIT}.threshold.SINGLE", 200), (f"{HB_ALLOWANCES}.single.aged", 260)], +) +def test_reprocessing_modifier_composes_with_dated_projection_reforms(path, value): + parameters = _modifier_simulation( + False, {path: value}, "2027-04-01", "2100-12-31", + dated_changes={f"{HB_ALLOWANCES}.lone_parent.aged": {"2028": 280}}, + ).tax_benefit_system.parameters + assert parameters.get_child(path)("2027-04-30") == value + assert parameters.gov.dwp.housing_benefit.allowances.lone_parent.aged("2028-04-30") == 280 + + +def _assert_projections_follow_main(simulation): + """Main's forecast algebra, preserving independently rounded enacted rates.""" parameters = simulation.tax_benefit_system.parameters + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances for path in PROJECTION_PATHS: - actual = parameters.get_child(path) - baseline = system.parameters.get_child(path) - for year in YEARS: - instant = f"{year}-04-30" - assert actual(instant) == pytest.approx(baseline(instant)), (path, year) + assert parameters.get_child(path)("2026-04-30") == system.parameters.get_child( + path + )("2026-04-30") + for year in range(2027, 2041): + at = f"{year}-04-30" + for relationship, kinds in { + "SINGLE": ("single", "lone_parent"), + "COUPLE": ("couple",), + }.items(): + guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relationship)(at) + maximum = getattr(pc.savings_credit.maximum, relationship)(at) + threshold = getattr(pc.savings_credit.threshold, relationship) + phase_in = pc.savings_credit.rate.phase_in(at) + expected_threshold = ( + max(0, guarantee - maximum / phase_in) + if phase_in > 0 + else threshold(f"{year - 1}-04-30") + ) + assert threshold(at) == pytest.approx(expected_threshold), ( + relationship, + year, + ) + base_threshold = threshold("2026-04-30") + assert getattr(pc.savings_credit.threshold_uprating, relationship)( + at + ) == pytest.approx(expected_threshold / base_threshold) + for kind in kinds: + allowance = getattr(allowances, kind).aged + expected = guarantee + maximum + assert allowance(at) == pytest.approx(expected), (kind, year) + assert allowance(at) >= guarantee - abs(guarantee) * 1e-12 + assert getattr(allowances.protected_pension_age_uprating, kind)( + at + ) == pytest.approx(expected / allowance("2026-04-30")) + for path in PROJECTION_PATHS: + if "_uprating." in path: + assert parameters.get_child(path)(at) > 0, (path, year) @pytest.mark.parametrize("route", ["reform", "scenario"]) @pytest.mark.parametrize("couple,expected", [(False, 15_600), (True, 23_868)]) -def test_2026_guarantee_reform_pays_the_reformed_credit_without_rebuilding_hb( +def test_2026_guarantee_reform_pays_the_reformed_credit_and_moves_forecasts( couple, expected, route ): changes = { @@ -170,14 +222,12 @@ def test_2026_guarantee_reform_pays_the_reformed_credit_without_rebuilding_hb( simulation = _simulation(couple, changes, [2026], route) assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx(expected) - _assert_projections_unchanged(simulation) + _assert_projections_follow_main(simulation) @pytest.mark.parametrize("route", ["reform", "scenario"]) @pytest.mark.parametrize("couple,expected", [(False, 15_600), (True, 23_868)]) -def test_simultaneous_calendar_guarantee_reforms_keep_independent_projections( - couple, expected, route -): +def test_simultaneous_calendar_guarantee_reforms_follow_main(couple, expected, route): """Rebuilding prior law for either dated change must tolerate the other.""" changes = { f"{GUARANTEE}.SINGLE": {"year:2026-01-01:1": 300}, @@ -186,7 +236,7 @@ def test_simultaneous_calendar_guarantee_reforms_keep_independent_projections( simulation = _simulation(couple, changes, [2026], route) assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx(expected) - _assert_projections_unchanged(simulation) + _assert_projections_follow_main(simulation) @pytest.mark.parametrize("route", ["reform", "scenario"]) @@ -320,11 +370,28 @@ def test_nonnegative_guarantee_reforms_never_break_pension_age_projections( guarantee = simulation.tax_benefit_system.parameters.get_child(path) assert guarantee(f"{year}-04-30") == weekly_guarantee - _assert_projections_unchanged(simulation) + parameters = simulation.tax_benefit_system.parameters + # Keep the full finite input domain for buildability. At its upper end, + # earnings uprating can overflow amounts on main too; exact algebra is + # covered separately by the bounded differential property. + for path in PROJECTION_PATHS: + parameter = parameters.get_child(path) + for forecast_year in YEARS: + value = parameter(f"{forecast_year}-04-30") + assert value >= 0, (path, forecast_year) + if "_uprating." in path: + assert isfinite(value) and value > 0, (path, forecast_year) + for name, relationship in ALLOWANCE_GUARANTEES.items(): + allowance = parameters.get_child(f"{HB_ALLOWANCES}.{name}.aged") + guarantee = parameters.get_child(f"{GUARANTEE}.{relationship}") + for forecast_year in range(2027, 2041): + at = f"{forecast_year}-04-30" + if isfinite(guarantee(at)): + assert allowance(at) >= guarantee(at) - abs(guarantee(at)) * 1e-12 @pytest.mark.parametrize("couple", [False, True]) -def test_earnings_path_guarantee_reform_keeps_independent_projections(couple): +def test_earnings_path_guarantee_reform_moves_dependent_forecasts(couple): """Match uk-triple-lock's annual overrides from the 2026-27 guarantee.""" changes = {} for relationship in ("SINGLE", "COUPLE"): @@ -345,7 +412,7 @@ def test_earnings_path_guarantee_reform_keeps_independent_projections(couple): assert simulation.calculate("pension_credit", year)[0] == pytest.approx( expected * 52, rel=1e-6 ) - _assert_projections_unchanged(simulation) + _assert_projections_follow_main(simulation) @pytest.mark.parametrize("relationship", ["SINGLE", "COUPLE"]) @@ -476,10 +543,8 @@ def test_zero_base_reform_builds_and_keeps_the_zero(path): @pytest.mark.parametrize("route", ["reform", "scenario"]) -def test_phase_in_reform_changes_savings_credit_not_the_projections(route): - """A zero phase-in rate used to stop the build. The threshold is set - separately, so the reform leaves every projection and pays no Savings - Credit.""" +def test_zero_phase_in_reform_keeps_the_previous_threshold_and_pays_no_credit(route): + """Follow main: a zero phase-in rate leaves no residual threshold to solve.""" situation = _situation(False, [2027]) # Savings Credit needs State Pension age before 6 April 2016. About £231 a # week is above the projected threshold and below the guarantee. @@ -500,7 +565,7 @@ def test_phase_in_reform_changes_savings_credit_not_the_projections(route): assert baseline.calculate("savings_credit", 2027)[0] > 0 assert simulation.calculate("savings_credit", 2027)[0] == 0 - _assert_projections_unchanged(simulation) + _assert_projections_follow_main(simulation) MAXIMUM = f"{SAVINGS_CREDIT}.maximum" @@ -510,6 +575,95 @@ def test_phase_in_reform_changes_savings_credit_not_the_projections(route): RELATIONSHIP_ALLOWANCES = {"SINGLE": ("single", "lone_parent"), "COUPLE": ("couple",)} +@pytest.mark.parametrize("route", MAXIMUM_ROUTES) +def test_guarantee_increase_reaches_forecasts_through_every_route(route): + changes = {f"{GUARANTEE}.SINGLE": {"2026-04-01.2100-12-31": 300}} + if route == "modifier": + simulation = _modifier_simulation( + False, {f"{GUARANTEE}.SINGLE": 300}, stop="2100-12-31" + ) + elif route == "structural": + simulation = Simulation( + situation=_situation(False, [2028]), + reform=Reform.from_dict(changes, country_id="uk"), + ) + else: + simulation = _simulation(False, changes, [2028], route) + _assert_projections_follow_main(simulation) + + +def test_zero_phase_in_modifier_preserves_a_reformed_own_threshold(): + parameters = _modifier_simulation( + False, + { + f"{SAVINGS_CREDIT}.threshold.SINGLE": 180, + f"{SAVINGS_CREDIT}.rate.phase_in": 0, + }, + ).tax_benefit_system.parameters + for year in range(2026, 2031): + assert ( + parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE( + f"{year}-04-30" + ) + == 180 + ) + + +@pytest.mark.parametrize( + "weekly_guarantee,expected_threshold,expected_allowance,expected_step", + [(None, 221.585186, 272.195717, 1.01), (300, 268.368418, 318.978949, 1.38)], +) +def test_guarantee_credit_boundary_matches_main( + weekly_guarantee, expected_threshold, expected_allowance, expected_step +): + """Frozen origin/main (6030cbc0b) cliff-probe values, including current law.""" + year = 2028 + situation = { + "people": {"claimant": {"age": {year: 85}}}, + "benunits": {"benunit": {"members": ["claimant"]}}, + "households": { + "household": { + "members": ["claimant"], + "country": {year: "ENGLAND"}, + "local_authority": {year: "MAIDSTONE"}, + "tenure_type": {year: "RENT_FROM_COUNCIL"}, + "rent": {year: 10_000}, + "council_tax": {year: 2_000}, + "savings": {year: 0}, + } + }, + "axes": [ + [ + { + "name": "private_pension_income", + "min": 8_000, + "max": 22_000, + "count": 2801, + "period": year, + } + ] + ], + } + changes = {f"{GUARANTEE}.SINGLE": {"2026-01-01.2100-12-31": weekly_guarantee}} + simulation = Simulation( + situation=situation, reform=changes if weekly_guarantee is not None else None + ) + parameters = simulation.tax_benefit_system.parameters + assert parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE( + "2028-04-30" + ) == pytest.approx(expected_threshold, rel=0, abs=5e-7) + assert parameters.gov.dwp.housing_benefit.allowances.single.aged( + "2028-04-30" + ) == pytest.approx(expected_allowance, rel=0, abs=5e-7) + guarantee_credit = simulation.calculate("guarantee_credit", year) + last = int(np.flatnonzero(guarantee_credit > 0)[-1]) + net = simulation.calculate("household_net_income", year) + simulation.calculate( + "tv_licence", year + ) + assert round(float(net[last + 1] - net[last]), 2) == expected_step + assert net[last + 1] >= net[last] + + def _zero_single_maximum(start): """A modifier that reloads the parameters, zeroes the single maximum Savings Credit from ``start`` and processes them again.""" diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index 4506a94cb1..380b132e85 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -414,7 +414,7 @@ def zero_phase_in_situation(): def test_zero_phase_in_reform_builds_and_pays_no_savings_credit(): """A zero Savings Credit phase-in rate for 2027-28 once divided by zero. - It now keeps the independently projected thresholds, pays no Savings + It now keeps the preceding threshold, as on main, pays no Savings Credit, and leaves 2028-29 as current law has it. """ reform = {"gov.dwp.pension_credit.savings_credit.rate.phase_in": {"2027": 0}} @@ -433,15 +433,15 @@ def test_zero_phase_in_reform_builds_and_pays_no_savings_credit(): ) threshold = reformed.tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE current = baseline.tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE - assert threshold("2027-04-30") == pytest.approx(current("2027-04-30")) + assert threshold("2027-04-30") == pytest.approx(208.07) assert threshold("2028-04-30") == pytest.approx(current("2028-04-30")) -def test_reforms_keep_their_own_allowance_and_independent_projections(): +def test_reforms_keep_their_own_allowance_and_move_the_derived_one(): """A dated change to a higher allowance is kept, and prior law resumes. - A change to the guarantee leaves the separately set HB allowances and - Savings Credit thresholds on their baseline projection paths. + A change to the guarantee moves the protected HB allowance and Savings + Credit threshold derived from it, as on main. """ reform = { f"{HB_NAME}.single.aged": {"2028": 300}, @@ -459,12 +459,18 @@ def test_reforms_keep_their_own_allowance_and_independent_projections(): at = f"{year}-04-30" assert allowances.single.aged(at) == pytest.approx(now.single.aged(at)) assert allowances.single.aged("2028-04-30") == 300 - for year in (2028, 2029, 2030): + for year in (2028, 2030): at = f"{year}-04-30" assert allowances.couple.aged(at) == pytest.approx(now.couple.aged(at)) threshold = reformed.parameters.gov.dwp.pension_credit.savings_credit.threshold baseline_threshold = current.gov.dwp.pension_credit.savings_credit.threshold assert threshold.COUPLE(at) == pytest.approx(baseline_threshold.COUPLE(at)) + pc = reformed.parameters.gov.dwp.pension_credit + maximum = pc.savings_credit.maximum.COUPLE("2029-04-30") + assert allowances.couple.aged("2029-04-30") == pytest.approx(400 + maximum) + assert pc.savings_credit.threshold.COUPLE("2029-04-30") == pytest.approx( + 400 - maximum / pc.savings_credit.rate.phase_in("2029-04-30") + ) assert allowances.couple.aged_from_cutoff("2029-04-30") == pytest.approx( now.couple.aged_from_cutoff("2029-04-30") ) From e8ffeb761d0e6bc53465f65914167df519113b37 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 03:32:05 -0400 Subject: [PATCH 23/28] Format salvaged pension projection fixes Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tax_benefit_system.py | 11 ++++++++-- ...ion_credit_guarantee_reform_projections.py | 20 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index e78fb950ea..799023129f 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -517,7 +517,11 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: raw.get_child(assignment.path) unreformed = raw.clone() projected = prior_law_projection( - on_timeline, unreformed, statutory, generated, enacted=self._enacted_parameters + on_timeline, + unreformed, + statutory, + generated, + enacted=self._enacted_parameters, ) for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) @@ -761,7 +765,10 @@ def lookup(name: str) -> Optional[Timeline]: tree.get_child(assignment.path), assignment, lookup ) updated[path] = process_parameter_tree( - tree, unreformed=unreformed, annualise=False, generated=generated, + tree, + unreformed=unreformed, + annualise=False, + generated=generated, enacted=enacted, )[1].get(path) if _same_timelines(updated, environment): diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index 2452455dea..bdd09832dd 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -102,7 +102,9 @@ def _simulation(couple, changes, years, route="reform"): return Simulation(situation=_situation(couple, years), **options) -def _modifier_simulation(couple, changes, start="2026-04-01", stop=None, dated_changes=None): +def _modifier_simulation( + couple, changes, start="2026-04-01", stop=None, dated_changes=None +): """Reload YAML, apply open or bounded updates, then reprocess it.""" def modify(simulation): @@ -154,15 +156,25 @@ def test_reprocessing_modifier_keeps_the_yaml_base_and_its_explicit_update( @pytest.mark.parametrize( "path,value", - [(f"{GUARANTEE}.SINGLE", 250), (f"{SAVINGS_CREDIT}.threshold.SINGLE", 200), (f"{HB_ALLOWANCES}.single.aged", 260)], + [ + (f"{GUARANTEE}.SINGLE", 250), + (f"{SAVINGS_CREDIT}.threshold.SINGLE", 200), + (f"{HB_ALLOWANCES}.single.aged", 260), + ], ) def test_reprocessing_modifier_composes_with_dated_projection_reforms(path, value): parameters = _modifier_simulation( - False, {path: value}, "2027-04-01", "2100-12-31", + False, + {path: value}, + "2027-04-01", + "2100-12-31", dated_changes={f"{HB_ALLOWANCES}.lone_parent.aged": {"2028": 280}}, ).tax_benefit_system.parameters assert parameters.get_child(path)("2027-04-30") == value - assert parameters.gov.dwp.housing_benefit.allowances.lone_parent.aged("2028-04-30") == 280 + assert ( + parameters.gov.dwp.housing_benefit.allowances.lone_parent.aged("2028-04-30") + == 280 + ) def _assert_projections_follow_main(simulation): From d2b12806c8c0f619c73c32b54051607d8c24e8a8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 03:36:19 -0400 Subject: [PATCH 24/28] Enforce absolute precision in the current-law main differential Disable pytest approximate comparisons relative tolerance so the current-law invariant checks equality more strictly than six decimal places. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_pension_age_projection_invariants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 8d4ae9f6bf..04e159f631 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -480,7 +480,7 @@ def test_reconciled_builders_agree_with_main_where_its_behavior_is_retained(path for year in range(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR + 1): at = f"{year}-04-30" assert reconciled.get_child(name)(at) == pytest.approx( - main.get_child(name)(at), abs=1e-9 + main.get_child(name)(at), rel=0, abs=1e-9 ), (name, year, path) From 74a7fd5da25b0cb7b3080c220286e003823bad98 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 03:44:19 -0400 Subject: [PATCH 25/28] Keep modifiers active in composed pension reform regressions Put dated changes and the reprocessing modifier in the same Scenario, since Simulation replaces scenario when reform is supplied. Reuse the existing cloned-system fixture while preserving independent simulation state. Co-Authored-By: Claude Opus 5.5 --- ...test_pension_credit_guarantee_reform_projections.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index bdd09832dd..d37f3d9ec5 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -40,6 +40,9 @@ ) +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + GUARANTEE = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" HB_ALLOWANCES = "gov.dwp.housing_benefit.allowances" SAVINGS_CREDIT = "gov.dwp.pension_credit.savings_credit" @@ -118,8 +121,11 @@ def modify(simulation): return Simulation( situation=_situation(couple, range(2026, 2031)), - scenario=Scenario(simulation_modifier=modify, applied_before_data_load=True), - reform=dated_changes, + scenario=Scenario( + parameter_changes=dated_changes, + simulation_modifier=modify, + applied_before_data_load=True, + ), ) From 2feb4750cf960e89f9693382c47ec6cb2b6254b8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 04:27:17 -0400 Subject: [PATCH 26/28] Preserve zero forecast anchors when component indices rebound Keep the index constant after the latest explicit zero anchor. This avoids Core turning zero into NaN when the index ratio overflows, while preserving earlier index history and explicit monetary values. Cover both thresholds and all three protected HB allowances. Co-Authored-By: Claude Opus 5.5 --- .../create_pension_age_allowances.py | 28 +++++----- .../create_savings_credit_threshold.py | 43 +++++++++----- .../test_pension_age_projection_invariants.py | 56 +++++++++++++++++++ 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py index e071672f38..5fccd1adba 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -18,6 +18,7 @@ from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( enacted_maximum, last_projected_year, + preserve_zero_anchor, projected_maximum_values, uprated_values, ) @@ -305,20 +306,19 @@ def project_pension_age_allowances( float(getattr(reformed_guarantees, relationship)(start)), reformed_allowance, ) - projection.add_child( + index = _projection_parameter( allowance_name, - _projection_parameter( - allowance_name, - allowance, - guarantee, - earnings_index, - cpi_september, - final_observation_year, - base_year, - retained_maximum, - price_index, - maxima, - guarantee_path, - ), + allowance, + guarantee, + earnings_index, + cpi_september, + final_observation_year, + base_year, + retained_maximum, + price_index, + maxima, + guarantee_path, ) + projection.add_child(allowance_name, index) + preserve_zero_anchor(allowance_parameter, index) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index b01ea0656b..7b6aec17a8 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -350,6 +350,20 @@ def enacted_maximum( ) +def preserve_zero_anchor(parameter: Parameter, index: Parameter) -> None: + """Keep a latest explicit zero anchor zero under multiplicative uprating. + + Core divides each future index by the index at the explicit anchor before + multiplying by its amount. Even finite positive indices can have an + infinite ratio, turning a zero anchor into ``0 * inf = NaN``. A constant + index after that anchor preserves its zero without changing earlier index + history or the parameter's explicit timeline. + """ + latest = max(parameter.values_list, key=lambda entry: entry.instant_str) + if latest.value == 0: + index.update(start=latest.instant_str, value=index(latest.instant_str)) + + def project_savings_credit_threshold( parameters: ParameterNode, unreformed: ParameterNode | None = None, @@ -452,21 +466,20 @@ def project_savings_credit_threshold( ) ), ) - projection.add_child( + index = _projection_parameter( relationship, - _projection_parameter( - relationship, - float(guarantee_parameter(base_instant)), - float(threshold_parameter(base_instant)), - savings_credit.rate.phase_in, - earnings_index, - cpi_september, - final_observation_year, - base_year, - maximum, - price_index, - maxima, - guarantee_path, - ), + float(guarantee_parameter(base_instant)), + float(threshold_parameter(base_instant)), + savings_credit.rate.phase_in, + earnings_index, + cpi_september, + final_observation_year, + base_year, + maximum, + price_index, + maxima, + guarantee_path, ) + projection.add_child(relationship, index) + preserve_zero_anchor(reformed_threshold, index) return parameters diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 04e159f631..fc45c53b26 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -215,6 +215,62 @@ def test_combined_reforms_build_and_never_project_negative_amounts( ), (name, year, reforms, advance_base) +@pytest.mark.parametrize( + "own_path,relationship,index_path", + [ + *( + ( + f"{SC_NAME}.threshold.{relationship}", + relationship, + f"{SC_NAME}.threshold_uprating.{relationship}", + ) + for relationship in ("SINGLE", "COUPLE") + ), + *( + ( + f"{HB_NAME}.{kind}.aged", + relationship, + f"{HB_NAME}.protected_pension_age_uprating.{kind}", + ) + for kind, relationship in HB_RELATIONS.items() + ), + ], +) +def test_zero_forecast_anchor_survives_an_extreme_component_rebound( + own_path, relationship, index_path +): + """A positive index floor must not turn an explicit zero into 0 * inf.""" + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + guarantee = f"{SMG_NAME}.{relationship}" + maximum = f"{SC_NAME}.maximum.{relationship}" + if ".threshold." in own_path: + reforms = { + guarantee: (2028, 1e100), + maximum: [(2026, 1e100), (2028, 0)], + } + rebound_path = guarantee + else: + reforms = { + guarantee: (2026, 0), + maximum: [(2027, 0), (2028, 1e100)], + } + rebound_path = maximum + _apply_reforms(parameters, reforms | {own_path: (2027, 0)}) + + projected = project_synthetic_parameters(parameters, unreformed) + + assert projected.get_child(own_path)("2026-04-30") == unreformed.get_child( + own_path + )("2026-04-30") + assert projected.get_child(rebound_path)("2028-04-30") == 1e100 + index = projected.get_child(index_path) + for year in range(FIRST_PROJECTION_YEAR, 2041): + at = f"{year}-04-30" + assert projected.get_child(own_path)(at) == 0 + assert isfinite(index(at)) and index(at) > 0 + + @pytest.mark.parametrize("first_year", [2025, 2026]) def test_advanced_base_preserves_a_pre_base_maximum_reform(first_year): """A complete rates update must not replace an earlier maximum reform.""" From f7d5a218446239a2d1b328bd5bbbcf8b129d568a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 05:35:58 -0400 Subject: [PATCH 27/28] Keep main's extend_after_last_stated for the Carer's Allowance projection #2699's Carer's Allowance earnings-limit projection imports extend_after_last_stated from the Savings Credit threshold module. This branch rewrote that module and dropped the helper, so the merged tree failed to import. The helper now lives, unchanged from main, in the Carer's Allowance module, its only remaining user; last_projected_year is still imported from the Savings Credit module, whose definition is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../create_earnings_limit_projection.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py index 51b3d487b3..6d54be71d6 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py @@ -23,16 +23,39 @@ """ import math +from typing import Any, Callable -from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant from policyengine_core.periods import instant from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( - extend_after_last_stated, last_projected_year, ) +def extend_after_last_stated( + parameter: Parameter, + last_year: int, + derive: Callable[[str, Any], Any], +) -> None: + """Add a value for each year after the parameter's last stated value. + + ``derive(sample, previous)`` gives the year's value from the other + parameters at its 30 April ``sample``; ``previous`` is the value in force + before it. Each value applies from 1 January, as uprating's do. + """ + last = max(parameter.values_list, key=lambda entry: entry.instant_str) + previous = last.value + for year in range(int(last.instant_str[:4]) + 1, last_year + 1): + previous = derive(f"{year}-04-30", previous) + parameter.values_list.append( + ParameterAtInstant(parameter.name, f"{year}-01-01", data=previous) + ) + parameter.values_list.sort(key=lambda entry: entry.instant_str, reverse=True) + parameter.mark_as_modified() + + def national_living_wage_limit(hours: float, national_living_wage: float) -> float: """``hours`` at the national living wage, rounded up to the next pound. From f94f9a2cb69efad458ea92a4f955f8e839d29ca5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 07:52:21 -0400 Subject: [PATCH 28/28] Fix the six tests the merge with main broke, and test explicit own anchors Bound the two forecast-algebra loops to the earnings index's last generated year, read from the pre-annualisation timeline: main and this branch both project to 2039, and annualisation only holds that year into 2040. Give the cache-API test system the enacted parameter context the constructor now sets, and accept the enacted argument in the cycle-error mock. Add a Hypothesis property that one to three explicit threshold or protected-HB amounts survive projection exactly, and that years whose latest explicit amount is zero stay zero. Drop the Carer's Allowance docstring's comparison with the Savings Credit threshold, which now runs before general uprating. Co-Authored-By: Claude Opus 5.5 --- .../create_earnings_limit_projection.py | 5 +- .../tests/test_dated_reform_routes.py | 2 +- .../tests/test_parameter_cache_apis.py | 1 + .../test_pension_age_projection_invariants.py | 59 +++++++++++++++++++ ...ion_credit_guarantee_reform_projections.py | 16 ++++- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py index 6d54be71d6..7982580f44 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py @@ -15,9 +15,8 @@ April 2026 rate and the hours at or above 16, no projected year is below £204. Run after general uprating and before fiscal-year conversion, on both the -main tree and its baseline copy, as the Savings Credit threshold projection -is. Reprocessing a reform then reads its changed national living wage or -hours. The exceptions edit the processed parameters, so they do not move the +main tree and its baseline copy. Reprocessing a reform then reads its changed +national living wage or hours. The exceptions edit the processed parameters, so they do not move the limit: ``Scenario.apply`` on an already-built simulation, and a reform-dict key naming a single day or month. """ diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index 729327bb0a..dec77f72fe 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1213,7 +1213,7 @@ def test_prior_law_that_never_settles_is_an_error(monkeypatch): calls = {"n": 0} - def unsettled(tree, unreformed=None, annualise=True, generated=None): + def unsettled(tree, unreformed=None, annualise=True, generated=None, enacted=None): calls["n"] += 1 entry = ParameterAtInstant( UC_SINGLE_OLD, "2026-04-01", data={"value": float(calls["n"])} diff --git a/policyengine_uk/tests/test_parameter_cache_apis.py b/policyengine_uk/tests/test_parameter_cache_apis.py index 08b6e5a822..786dd8ee9b 100644 --- a/policyengine_uk/tests/test_parameter_cache_apis.py +++ b/policyengine_uk/tests/test_parameter_cache_apis.py @@ -38,6 +38,7 @@ def small_system(): system.dated_assignments = [] system.legacy_overrides = [] system._projected_timelines = {} + system._enacted_parameters = tbs_module.statutory_parameters() system._raw_parameters = None system._parameters_before_reset = None system.capital_gains_projection = None diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index fc45c53b26..74f7095c75 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -271,6 +271,65 @@ def test_zero_forecast_anchor_survives_an_extreme_component_rebound( assert isfinite(index(at)) and index(at) > 0 +OWN_ANCHOR_PATHS = ( + *( + ( + f"{SC_NAME}.threshold.{relationship}", + f"{SC_NAME}.threshold_uprating.{relationship}", + ) + for relationship in ("SINGLE", "COUPLE") + ), + *( + (f"{HB_NAME}.{kind}.aged", f"{HB_NAME}.protected_pension_age_uprating.{kind}") + for kind in HB_RELATIONS + ), +) + + +@st.composite +def own_anchor_path(draw): + """One to three explicit own amounts, in year order, from the base year.""" + years = draw( + st.lists( + st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR), + min_size=1, + max_size=3, + unique=True, + ) + ) + return [(year, draw(nonnegative_amount)) for year in sorted(years)] + + +@settings(max_examples=40, deadline=None) +@given(paths=st.sampled_from(OWN_ANCHOR_PATHS), anchors=own_anchor_path()) +@example(paths=OWN_ANCHOR_PATHS[0], anchors=[(2027, 0.0), (2029, 180.0)]) +@example(paths=OWN_ANCHOR_PATHS[-1], anchors=[(2027, 0.0), (2029, 180.0)]) +@example(paths=OWN_ANCHOR_PATHS[1], anchors=[(2026, 300.0), (2030, 0.0)]) +def test_explicit_own_anchors_survive_projection(paths, anchors): + """Each explicit threshold or protected-HB amount survives exactly. + + Years whose latest explicit amount is zero stay zero, including a zero + followed by a positive anchor, and the index stays positive and finite. + """ + own_path, index_path = paths + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + _apply_reforms(parameters, {own_path: anchors}) + + projected = project_synthetic_parameters(parameters, unreformed) + + own = projected.get_child(own_path) + for year, value in anchors: + assert own(f"{year}-04-30") == value, (year, anchors) + index = projected.get_child(index_path) + for year in range(anchors[0][0], LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + in_force = [value for anchor_year, value in anchors if anchor_year <= year] + if in_force[-1] == 0: + assert own(at) == 0, (year, anchors) + assert isfinite(index(at)) and index(at) > 0, (year, anchors) + + @pytest.mark.parametrize("first_year", [2025, 2026]) def test_advanced_base_preserves_a_pre_base_maximum_reform(first_year): """A complete rates update must not replace an earlier maximum reform.""" diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index d37f3d9ec5..dbc2a04920 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -183,6 +183,18 @@ def test_reprocessing_modifier_composes_with_dated_projection_reforms(path, valu ) +def _last_forecast_year(simulation): + """The earnings index's last generated year, before annualisation. + + Annualisation holds that year's amounts and adds later annual labels (and + a 2041 boundary), so the annualised parameter's own dates overstate it. + """ + timeline = simulation.tax_benefit_system.projected_timelines[ + "gov.economic_assumptions.indices.earnings_uprating" + ] + return max(int(entry.instant_str[:4]) for entry in timeline) + + def _assert_projections_follow_main(simulation): """Main's forecast algebra, preserving independently rounded enacted rates.""" parameters = simulation.tax_benefit_system.parameters @@ -192,7 +204,7 @@ def _assert_projections_follow_main(simulation): assert parameters.get_child(path)("2026-04-30") == system.parameters.get_child( path )("2026-04-30") - for year in range(2027, 2041): + for year in range(2027, _last_forecast_year(simulation) + 1): at = f"{year}-04-30" for relationship, kinds in { "SINGLE": ("single", "lone_parent"), @@ -402,7 +414,7 @@ def test_nonnegative_guarantee_reforms_never_break_pension_age_projections( for name, relationship in ALLOWANCE_GUARANTEES.items(): allowance = parameters.get_child(f"{HB_ALLOWANCES}.{name}.aged") guarantee = parameters.get_child(f"{GUARANTEE}.{relationship}") - for forecast_year in range(2027, 2041): + for forecast_year in range(2027, _last_forecast_year(simulation) + 1): at = f"{forecast_year}-04-30" if isfinite(guarantee(at)): assert allowance(at) >= guarantee(at) - abs(guarantee(at)) * 1e-12