From 2db324bb6f4d1e95d38607909572ec4586e24676 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 13:54:45 -0400 Subject: [PATCH 1/3] Fix Pension Credit and pension-age Housing Benefit earnings uprating --- .../pension-credit-earnings-uprating.fixed.md | 1 + .../allowances/couple/aged.yaml | 10 +- .../allowances/couple/aged_from_cutoff.yaml | 11 +- .../allowances/lone_parent/aged.yaml | 10 +- .../lone_parent/aged_from_cutoff.yaml | 11 +- .../allowances/single/aged.yaml | 10 +- .../allowances/single/aged_from_cutoff.yaml | 11 +- .../gov/dwp/pension_credit/README.md | 17 ++ .../guarantee_credit/minimum_guarantee.yaml | 20 +- .../create_savings_credit_threshold.py | 38 ++++ .../savings_credit/maximum.yaml | 18 ++ .../savings_credit/threshold.yaml | 8 +- .../gov/economic_assumptions/README.md | 39 +++- .../create_earnings_uprating.py | 94 +++++++++ .../create_economic_assumption_indices.py | 9 +- policyengine_uk/tax_benefit_system.py | 12 +- .../pension_age_earnings_uprating.yaml | 107 ++++++++++ .../standard_minimum_guarantee.yaml | 27 +++ .../savings_credit/savings_credit.yaml | 67 ++++++ .../tests/test_pension_credit_uprating.py | 195 ++++++++++++++++++ .../tests/test_september_cpi_uprating.py | 15 +- 21 files changed, 655 insertions(+), 75 deletions(-) create mode 100644 changelog.d/pension-credit-earnings-uprating.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml create mode 100644 policyengine_uk/tests/test_pension_credit_uprating.py diff --git a/changelog.d/pension-credit-earnings-uprating.fixed.md b/changelog.d/pension-credit-earnings-uprating.fixed.md new file mode 100644 index 0000000000..83d0208983 --- /dev/null +++ b/changelog.d/pension-credit-earnings-uprating.fixed.md @@ -0,0 +1 @@ +Uprate the Pension Credit standard minimum guarantee and Housing Benefit pension-age personal allowances by the previous May-July earnings increase, meeting the SSAA 1992 s150A earnings floor after the last published rates. Derive projected Savings Credit thresholds from the guarantee and a September-CPI-uprated maximum award. April 2027's single guarantee rises from £238 to £247.282 before rounding, rather than £243.47. 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 69333ab432..3bbed648fd 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 @@ -15,12 +15,10 @@ metadata: period: week unit: currency-GBP label: Housing Benefit couple pension-age personal allowance - # Not uprated by September CPI: the pension-age personal allowances rose 4.75% - # in April 2026 (£244.40 to £256.00 single), when September CPI was 3.8%, and - # 2.3% in April 2017, when it was 1.0% (Benefit and pension rates 2026 to - # 2027, 2017 to 2018). 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 + # Project pension-age allowances at the earnings rate used for the + # Pension Credit standard minimum guarantee (SSAA 1992 s150A(2)); published + # allowances are set in Schedule 3 below. + uprating: gov.economic_assumptions.indices.earnings_uprating 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 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..2eee24814d 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 @@ -10,13 +10,10 @@ metadata: period: week unit: currency-GBP label: Housing Benefit couple personal allowance, State Pension age reached from April 2021 - # 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 + # Equal to the Pension Credit standard minimum guarantee in 2021-2026; + # project at its earnings floor (SSAA 1992 s150A(2)). The HB allowance is + # set in Schedule 3 below. + uprating: gov.economic_assumptions.indices.earnings_uprating 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.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml index ec9e5906ab..d3a23d79c7 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 @@ -16,12 +16,10 @@ metadata: period: week unit: currency-GBP label: Housing Benefit lone parent pension-age personal allowance - # Not uprated by September CPI: the pension-age personal allowances rose 4.75% - # in April 2026 (£244.40 to £256.00 single), when September CPI was 3.8%, and - # 2.3% in April 2017, when it was 1.0% (Benefit and pension rates 2026 to - # 2027, 2017 to 2018). 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 + # Project pension-age allowances at the earnings rate used for the + # Pension Credit standard minimum guarantee (SSAA 1992 s150A(2)); published + # allowances are set in Schedule 3 below. + uprating: gov.economic_assumptions.indices.earnings_uprating 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/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..efc5481fac 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 @@ -10,13 +10,10 @@ metadata: period: week unit: currency-GBP label: Housing Benefit lone parent personal allowance, State Pension age reached from April 2021 - # 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 + # Equal to the Pension Credit standard minimum guarantee in 2021-2026; + # project at its earnings floor (SSAA 1992 s150A(2)). The HB allowance is + # set in Schedule 3 below. + uprating: gov.economic_assumptions.indices.earnings_uprating 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.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml index 5392ebe49a..61dd6cad54 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 @@ -15,12 +15,10 @@ metadata: period: week unit: currency-GBP label: Housing Benefit single pension-age personal allowance - # Not uprated by September CPI: the pension-age personal allowances rose 4.75% - # in April 2026 (£244.40 to £256.00 single), when September CPI was 3.8%, and - # 2.3% in April 2017, when it was 1.0% (Benefit and pension rates 2026 to - # 2027, 2017 to 2018). 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 + # Project pension-age allowances at the earnings rate used for the + # Pension Credit standard minimum guarantee (SSAA 1992 s150A(2)); published + # allowances are set in Schedule 3 below. + uprating: gov.economic_assumptions.indices.earnings_uprating 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/housing_benefit/allowances/single/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml index a996b8e945..2cc161210f 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 @@ -10,13 +10,10 @@ metadata: period: week unit: currency-GBP label: Housing Benefit single personal allowance, State Pension age reached from April 2021 - # 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 + # Equal to the Pension Credit standard minimum guarantee in 2021-2026; + # project at its earnings floor (SSAA 1992 s150A(2)). The HB allowance is + # set in Schedule 3 below. + uprating: gov.economic_assumptions.indices.earnings_uprating 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/pension_credit/README.md b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md index 04369faedd..8eb18a11b3 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md @@ -1 +1,18 @@ # Pension Credit + +After the last published rates, the standard minimum guarantee rises each +April with the previous May-July total-pay average weekly earnings increase, +using the same statutory input and published precision as the State Pension +triple lock. This models the earnings minimum in +[SSAA 1992 s150A(2)](https://www.legislation.gov.uk/ukpga/1992/5/section/150A), +with no decrease when earnings fall. There is no CPI or 2.5% floor in this +projection. The six Housing Benefit pension-age personal allowances 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). +Projected thresholds are derived after uprating: +`threshold = 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. 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 d4bdb1e6ee..9eef083e79 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 @@ -14,12 +14,9 @@ SINGLE: 2025-04-01: 227.1 2026-04-01: 238 metadata: - # Not uprated by September CPI: the standard minimum guarantee is reviewed - # against earnings under Social Security Administration Act 1992 s150A(1)(d) - # (4.8% in April 2026, written statement HCWS1101). 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 + # Project at the earnings floor in Social Security Administration Act 1992 + # s150A(2), using the same May-July AWE input as the State Pension review. + uprating: gov.economic_assumptions.indices.earnings_uprating COUPLE: values: 2015-04-06: 230.85 @@ -35,18 +32,17 @@ COUPLE: 2025-04-01: 346.6 2026-04-01: 363.25 metadata: - # Not uprated by September CPI: the standard minimum guarantee is reviewed - # against earnings under Social Security Administration Act 1992 s150A(1)(d) - # (4.8% in April 2026, written statement HCWS1101). 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 + # Project at the earnings floor in Social Security Administration Act 1992 + # s150A(2), using the same May-July AWE input as the State Pension review. + uprating: gov.economic_assumptions.indices.earnings_uprating metadata: period: week label: Pension Credit minimum guarantee unit: currency-GBP propagate_metadata_to_children: true reference: + - title: Social Security Administration Act 1992, section 150A(2) + href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A - title: The State Pension Credit Regulations 2002 reg. 6 href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/6/2021-04-12 - title: Benefit and pension rates 2026 to 2027 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 new file mode 100644 index 0000000000..fa5a4fe7db --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -0,0 +1,38 @@ +"""Project Savings Credit thresholds from the guarantee and maximum award. + +The standard minimum guarantee rises with earnings, while the Savings Credit +maximum rises with September CPI (HCWS1101). Under the 60% phase-in rule, +maximum = phase_in * (standard minimum guarantee - threshold), so the +threshold is the guarantee less maximum / phase_in. Keep the published +thresholds, including their rounding, and derive only subsequent years. + +Run after general uprating and before fiscal-year conversion, on both the +main tree and its baseline copy. Reprocessing a reform then reads its changed +guarantee, maximum and phase-in rate. +""" + +from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant + + +def project_savings_credit_threshold(parameters: ParameterNode) -> ParameterNode: + """Fill the years after each threshold's last stated value.""" + pc = parameters.gov.dwp.pension_credit + sc = pc.savings_credit + index = parameters.gov.economic_assumptions.indices.earnings_uprating + last_year = max(int(entry.instant_str[:4]) for entry in index.values_list) + for relation, threshold in sc.threshold.children.items(): + last_stated = max(entry.instant_str for entry in threshold.values_list) + for year in range(int(last_stated[:4]) + 1, last_year + 1): + sample = f"{year}-04-30" + guarantee = pc.guarantee_credit.minimum_guarantee.children[relation](sample) + maximum = sc.maximum.children[relation](sample) + phase_in = sc.rate.phase_in(sample) + value = guarantee - maximum / phase_in + threshold.values_list.append( + ParameterAtInstant(threshold.name, f"{year}-01-01", data=value) + ) + threshold.values_list.sort(key=lambda entry: entry.instant_str, reverse=True) + threshold.mark_as_modified() + sc.threshold.clear_parent_cache() + return parameters 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 new file mode 100644 index 0000000000..52b6028c9b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml @@ -0,0 +1,18 @@ +description: Maximum weekly Savings Credit, used to derive projected income thresholds. +SINGLE: + values: + 2026-04-01: 17.96 +COUPLE: + values: + 2026-04-01: 20.10 +metadata: + unit: currency-GBP + period: week + label: Pension Credit Savings Credit maximum + uprating: gov.benefit_uprating_cpi + propagate_metadata_to_children: true + reference: + - title: Benefit and pension rates 2026 to 2027 (DWP, gov.uk), Pension Credit + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027#pension-credit + - title: Statutory Review of State Pension and Benefit Rates 2026/27, HCWS1101 (Savings Credit maximum increases by September CPI) + href: https://questions-statements.parliament.uk/written-statements/detail/2025-11-26/hcws1101 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 e2d831518e..95a66f4196 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 @@ -34,11 +34,9 @@ COUPLE: metadata: unit: currency-GBP period: week - # Not uprated by September CPI: the threshold rose 4.9% in April 2026, when - # September CPI was 3.8% (Benefit and pension rates 2026 to 2027). 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 + # Projected after uprating by create_savings_credit_threshold.py: + # standard minimum guarantee - Savings Credit maximum / phase-in rate. + # Published thresholds keep their own rounding; only projections are derived. propagate_metadata_to_children: true reference: - title: The State Pension Credit Regulations 2002 reg. 7 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 5222b340c7..994c961fd1 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -195,20 +195,41 @@ Order Explanatory Memorandum since April 2011; 3.8% for April 2026, written statement HCWS1101). `gov.benefit_uprating_cpi`, which the benefit rate parameters name as their `uprating`, follows `indices.september_cpi_uprating`, and so do the Universal Credit standard allowance and health element indices -built from it. Amounts not raised by September CPI in practice that sat on -`gov.benefit_uprating_cpi` keep the lagged calendar-year CPI projection they -had, through `indices.obr.lagged_cpi`. These include the earnings-linked -Pension Credit standard minimum guarantee, savings credit threshold, Housing -Benefit pension-age allowances and Housing Benefit non-dependant deduction -income bands, as well as DfE funding rates and the council tax high value -surcharge. Lagged calendar-year CPI remains a placeholder pending an -appropriate earnings-linked model for the first group; deduction amounts -continue to follow September CPI. The savings credit threshold and Housing +built from it. Housing Benefit non-dependant deduction income bands, DfE +funding rates and the council tax high value surcharge retain their lagged +calendar-year CPI projection through `indices.obr.lagged_cpi`, pending their +own uprating models. The deduction amounts continue to follow September CPI. +The savings credit threshold and Housing Benefit pension-age allowances are within the s150 review, under [s150(1)(l) and (h)](https://www.legislation.gov.uk/ukpga/1992/5/section/150), even though their increases in practice are linked to earnings rather than September CPI. +## Pension Credit earnings uprating + +[`create_earnings_uprating.py`](./create_earnings_uprating.py) builds +`yoy_growth.earnings_uprating` from the previous May-July total-pay AWE input +used by the triple lock, rounded to the published 0.1 percentage points. +`indices.earnings_uprating` compounds it without rounding the index levels, +so rounding cannot push the increase below earnings. When earnings fall, +the projection holds amounts unchanged. + +The Pension Credit standard minimum guarantee and six Housing Benefit +pension-age personal allowances use this index after their last published +amounts. [SSAA 1992 s150A(1)(d) and (2)](https://www.legislation.gov.uk/ukpga/1992/5/section/150A) +require the guarantee to rise at least with earnings; they impose no CPI or +2.5% floor. This projection uses that earnings minimum, allowing published +amounts to reflect discretionary increases. For April 2027, May-July 2026 +earnings growth of 3.9% gives £238 × 1.039 = £247.282 a week for a single +person and £363.25 × 1.039 = £377.41675 for a couple, before benefit rounding. + +The Savings Credit maximum rises by September CPI, as stated in +[HCWS1101](https://questions-statements.parliament.uk/written-statements/detail/2025-11-26/hcws1101). +After general uprating, the projected threshold is derived as the standard +minimum guarantee minus the maximum divided by the phase-in rate (60%). +Published thresholds keep their own rounding. The 2026-27 maxima are £17.96 +and £20.10, from the [DWP rates table](https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027#pension-credit). + Income Tax Act 2007 ss21 and 57 index the basic rate limit and the personal allowance by this measure once no freeze applies. [`../hmrc/create_threshold_indexation.py`](../hmrc/create_threshold_indexation.py) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py b/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py new file mode 100644 index 0000000000..bfb7db0ef3 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py @@ -0,0 +1,94 @@ +"""Build April earnings uprating from the previous May-July AWE growth. + +Social Security Administration Act 1992 s150A(2) requires the Pension Credit +standard minimum guarantee to rise at least as much as earnings. This +projection holds amounts unchanged when earnings do not rise. Unlike the +State Pension triple lock, that minimum has no CPI or 2.5% component. + +``gov.economic_assumptions.yoy_growth.earnings_uprating`` holds each April's +rise, keyed to 1 January as the other growth series are. It reads the same +completed ``statutory_uprating_inputs.awe_total_pay_may_july`` series as the +triple lock and rounds to the 0.1 percentage points published by the ONS. + +``create_economic_assumption_indices`` compounds it into +``gov.economic_assumptions.indices.earnings_uprating``. The series starts +from a 2010 base and runs to one year past the statutory inputs. Published +benefit amounts remain explicit; the index projects later years. +""" + +from typing import Dict, Iterable + +from policyengine_core.parameters import Parameter, ParameterNode + +from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( + AWE_OBSERVATION_MONTH_DAY, + last_input_year, + round_to_published_precision, +) + +# The index starts at 1 in this year; its value holds no rise. +BASE_YEAR = 2010 +FIRST_UPRATING_YEAR = BASE_YEAR + 1 + + +def earnings_uprating_rate(earnings_growth: float) -> float: + """April rise at the published earnings rate, without a cut.""" + rate = round_to_published_precision(earnings_growth) + return rate if rate > 0 else 0.0 + + +def uprating_years(parameters: ParameterNode) -> Iterable[int]: + """Aprils covered: from April 2011 to one year past the statutory inputs.""" + return range(FIRST_UPRATING_YEAR, last_input_year(parameters) + 2) + + +def earnings_uprating_rates(parameters: ParameterNode) -> Dict[int, float]: + """The rise taking effect in April of each year, in year order.""" + earnings = parameters.gov.economic_assumptions.statutory_uprating_inputs.awe_total_pay_may_july + rates = {} + for year in uprating_years(parameters): + may_july = earnings(f"{year - 1}-{AWE_OBSERVATION_MONTH_DAY}") + if may_july is None: + raise ValueError( + f"Missing May-July {year - 1} earnings for the April {year} uprating." + ) + rates[year] = earnings_uprating_rate(may_july) + return rates + + +def add_earnings_uprating(parameters: ParameterNode) -> ParameterNode: + """Add ``gov.economic_assumptions.yoy_growth.earnings_uprating``.""" + values = {f"{BASE_YEAR}-01-01": None} + values.update( + { + f"{year}-01-01": rate + for year, rate in earnings_uprating_rates(parameters).items() + } + ) + new_parameter = Parameter( + "gov.economic_assumptions.yoy_growth.earnings_uprating", + data={ + "description": ( + "Uprating taking effect in April of each year: May-July " + "earnings growth of the previous year, and no rise when " + "earnings fall." + ), + "values": values, + "metadata": { + "unit": "/1", + "label": "Earnings uprating rate", + "reference": [ + { + "title": ( + "Social Security Administration Act 1992, section 150A(2)" + ), + "href": "https://www.legislation.gov.uk/ukpga/1992/5/section/150A", + } + ], + }, + }, + ) + parameters.gov.economic_assumptions.yoy_growth.add_child( + "earnings_uprating", new_parameter + ) + return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 8eb8e80761..90ecde8187 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py @@ -42,10 +42,11 @@ def create_economic_assumption_indices( for year in range(start_year + 1, 2040): yoy_growth = descendant(year) - indices_value = round( - values[year - 1] * (1 + yoy_growth), - 5, - ) + indices_value = values[year - 1] * (1 + yoy_growth) + # Rounding an earnings index down can put the projected + # guarantee below its statutory annual earnings floor. + if full_name != "gov.economic_assumptions.yoy_growth.earnings_uprating": + indices_value = round(indices_value, 5) values[year] = indices_value mirror_parameter = Parameter( diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 1c5565ebb5..849d85fa78 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -30,6 +30,9 @@ from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import ( add_triple_lock, ) +from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( + project_savings_credit_threshold, +) from policyengine_uk.parameters.gov.dwp.universal_credit.rebalancing.create_health_element_uprating import ( add_uc_health_element_uprating, ) @@ -39,6 +42,9 @@ from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( add_september_cpi_uprating, ) +from policyengine_uk.parameters.gov.economic_assumptions.create_earnings_uprating import ( + add_earnings_uprating, +) from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, ) @@ -122,6 +128,7 @@ def _install_parameters(system, parameters: ParameterNode) -> None: add_lagged_earnings, add_lagged_cpi, add_statutory_uprating_inputs, + add_earnings_uprating, add_triple_lock, add_september_cpi_uprating, create_economic_assumption_indices, @@ -171,7 +178,7 @@ def process_parameter_tree( - lagged earnings and CPI indices - statutory uprating inputs (September CPI, May-July earnings) - triple lock calculations for state pensions - - September CPI uprating + - earnings and September CPI uprating - economic assumption indices - income tax and NICs threshold indexation after the freeze - Universal Credit standard allowance uprating (UC Act 2025 s. 1) @@ -210,6 +217,9 @@ def process_parameter_tree( propagate_parameter_metadata(parameters) with canonical_ni_uprating_references(parameters): uprate_parameters(parameters) + project_savings_credit_threshold(parameters) + if annualise: + project_savings_credit_threshold(parameters.baseline) parameters = backdate_parameters(parameters, BACKDATED_FROM) # Prior law's projected path for every parameter a reform can change: the # gov tree, before annualisation, and the trees outside it (household), diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml new file mode 100644 index 0000000000..68f804c574 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml @@ -0,0 +1,107 @@ +# April 2027 projections use May-July 2026 earnings growth of 3.9%. +# Lower single/lone-parent: 238 * 1.039 * 52 = 12,858.664. +# Lower couple: 363.25 * 1.039 * 52 = 19,625.671. +# Higher single/lone-parent: 256 * 1.039 * 52 = 13,831.168. +# Higher couple: 383.35 * 1.039 * 52 = 20,711.6338. +# Regulation-set/cohort inputs isolate uprating from age-attainment logic. +- name: April 2027 lower single pension-age HB allowance follows earnings + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + housing_benefit_attained_pension_age_before_cutoff: false + benefits_premiums: 0 + output: + housing_benefit_applicable_amount: 12_858.664 + +- name: April 2027 higher single pension-age HB allowance follows earnings + period: 2027 + absolute_error_margin: 0.01 + input: + age: 80 + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + housing_benefit_attained_pension_age_before_cutoff: true + benefits_premiums: 0 + output: + housing_benefit_applicable_amount: 13_831.168 + +- name: April 2027 lower couple pension-age HB allowance follows earnings + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + partner: + age: 68 + benunits: + benunit: + members: [claimant, partner] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + housing_benefit_attained_pension_age_before_cutoff: false + benefits_premiums: 0 + output: + housing_benefit_applicable_amount: 19_625.671 + +- name: April 2027 higher couple pension-age HB allowance follows earnings + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + partner: + age: 70 + benunits: + benunit: + members: [claimant, partner] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + housing_benefit_attained_pension_age_before_cutoff: true + benefits_premiums: 0 + output: + housing_benefit_applicable_amount: 20_711.6338 + +- name: April 2027 lower lone-parent pension-age HB allowance follows earnings + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + child: + age: 12 + benunits: + benunit: + members: [claimant, child] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + housing_benefit_attained_pension_age_before_cutoff: false + benefits_premiums: 0 + output: + is_lone_parent: true + housing_benefit_applicable_amount: 12_858.664 + +- name: April 2027 higher lone-parent pension-age HB allowance follows earnings + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + child: + age: 12 + benunits: + benunit: + members: [claimant, child] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + housing_benefit_attained_pension_age_before_cutoff: true + benefits_premiums: 0 + output: + is_lone_parent: true + housing_benefit_applicable_amount: 13_831.168 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..841ba5b6bc 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,30 @@ members: [person1, person2] output: standard_minimum_guarantee: 18_889 + +# SSAA 1992 s150A(2): May-July 2026 AWE growth of 3.9% applies in +# April 2027. These are projections before any ministerial 5p rounding: +# single 238 * 1.039 = 247.282 weekly; couple 363.25 * 1.039 = 377.41675. +- name: April 2027 single minimum guarantee rises by earnings + period: 2027 + absolute_error_margin: 0.01 + input: + age: 81 + relation_type: SINGLE + output: + standard_minimum_guarantee: 12_858.664 # 247.282 * 52 + +- name: April 2027 couple minimum guarantee rises by earnings + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + partner: + age: 79 + benunits: + benunit: + members: [claimant, partner] + output: + standard_minimum_guarantee: 19_625.671 # 377.41675 * 52 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 cab1abeb8d..4c39f288ca 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 @@ -65,3 +65,70 @@ guarantee_credit: 123 pension_credit_entitlement: 1_094.40 pension_credit: 1_094.40 + +# April 2027 hand controls (unrounded projections): +# SMG single 238 * 1.039 = 247.282; couple 363.25 * 1.039 = 377.41675. +# Maximum single 17.96 * 1.035 = 18.5886; couple 20.10 * 1.035 = 20.8035. +# Threshold = SMG - maximum / 0.6: single 216.301; couple 342.74425. +# Historical published thresholds remain in use through April 2026. +- name: April 2027 single savings credit maximum rises by September CPI + period: 2027 + absolute_error_margin: 0.01 + input: + age: 81 + is_savings_credit_eligible: true + relation_type: SINGLE + savings_credit_income: 20_000 + pension_credit_income: 12_858.664 + output: + standard_minimum_guarantee: 12_858.664 + savings_credit: 966.6072 # 18.5886 * 52 + +- name: April 2027 couple savings credit maximum rises by September CPI + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + partner: + age: 79 + benunits: + benunit: + members: [claimant, partner] + is_savings_credit_eligible: true + savings_credit_income: 25_000 + pension_credit_income: 19_625.671 + output: + standard_minimum_guarantee: 19_625.671 + savings_credit: 1_081.782 # 20.8035 * 52 + +- name: April 2027 single savings credit phases in above the derived threshold + period: 2027 + absolute_error_margin: 0.01 + input: + age: 81 + is_savings_credit_eligible: true + relation_type: SINGLE + savings_credit_income: 12_000 + pension_credit_income: 12_000 + output: + savings_credit: 451.4088 # 0.6 * (12_000 - 216.301 * 52) + +- name: April 2027 couple savings credit phases in above the derived threshold + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 81 + partner: + age: 79 + benunits: + benunit: + members: [claimant, partner] + is_savings_credit_eligible: true + savings_credit_income: 19_000 + pension_credit_income: 19_000 + output: + savings_credit: 706.3794 # 0.6 * (19_000 - 342.74425 * 52) diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py new file mode 100644 index 0000000000..629432b4e7 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -0,0 +1,195 @@ +"""Pension Credit's statutory earnings floor and linked projected amounts. + +SSAA 1992 s150A(2) requires the standard minimum guarantee's annual increase +to be at least earnings growth. Projections use the published May-July AWE +figure, with no cut when earnings fall. The pension-age HB allowances follow +the same increase; the Savings Credit maximum follows September CPI, with +the threshold implied by the maximum and the 60% phase-in rate. +""" + +from decimal import ROUND_HALF_UP, Decimal + +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode, load_parameter_file +from policyengine_core.parameters.operations.propagate_parameter_metadata import ( + propagate_parameter_metadata, +) +from policyengine_core.parameters.operations.uprate_parameters import uprate_parameters + +from policyengine_uk.parameters.gov.economic_assumptions.create_earnings_uprating import ( + add_earnings_uprating, +) +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + create_economic_assumption_indices, +) +from policyengine_uk.system import system +from policyengine_uk.tax_benefit_system import COUNTRY_DIR + +EARNINGS_INDEX = "gov.economic_assumptions.indices.earnings_uprating" +FIRST_PROJECTION_YEAR = 2027 +LAST_INDEX_YEAR = 2039 +SMG_NAME = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" +HB_NAME = "gov.dwp.housing_benefit.allowances" +RAW_SMG = load_parameter_file( + str( + COUNTRY_DIR + / "parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml" + ), + name=SMG_NAME, +) +RAW_HB = load_parameter_file( + str(COUNTRY_DIR / "parameters/gov/dwp/housing_benefit/allowances"), name=HB_NAME +) + +# Includes negative earnings, off-grid forecasts and different CPI paths. +growth = st.integers(min_value=-600, max_value=1500).map(lambda n: n / 10_000) +paths = st.lists(st.tuples(growth, growth), min_size=1, max_size=10) + + +def published_earnings_floor(rate): + """Independent transcription of the published 0.1pp earnings figure.""" + rounded = Decimal(str(rate)).quantize(Decimal("0.001"), ROUND_HALF_UP) + return max(float(rounded), 0.0) + + +def synthetic_parameters(path): + """Uprate the production SMG and six HB leaves without a simulation. + + Earlier observations merely supply the index's 2010 base; every generated + path begins with the May-July figure for the first projected April. + """ + observations = {year: (0.02, 0.02) for year in range(2010, 2026)} + observations.update({2026 + i: values for i, values in enumerate(path)}) + parameters = ParameterNode( + data={ + "gov": { + "economic_assumptions": { + "statutory_uprating_inputs": { + "awe_total_pay_may_july": { + "values": { + f"{year}-07-01": earnings + for year, (earnings, _) in observations.items() + } + }, + "cpi_september": { + "values": { + f"{year}-09-01": cpi + for year, (_, cpi) in observations.items() + } + }, + }, + "yoy_growth": { + "obr": { + name: { + "values": { + f"{year}-01-01": values[position] + for year, values in observations.items() + } + } + for name, position in ( + ("average_earnings", 0), + ("consumer_price_index", 1), + ) + } + }, + }, + "dwp": { + "pension_credit": {"guarantee_credit": {}}, + "housing_benefit": {"allowances": {}}, + }, + } + } + ) + parameters.gov.dwp.pension_credit.guarantee_credit.add_child( + "minimum_guarantee", RAW_SMG.clone() + ) + # Only pension-age amounts belong to the earnings link. Including the + # working-age leaves would need the unrelated CPI index too. + allowances = parameters.gov.dwp.housing_benefit.allowances + for kind in ("single", "couple", "lone_parent"): + node = ParameterNode(name=f"{HB_NAME}.{kind}", data={}) + for cohort in ("aged", "aged_from_cutoff"): + node.add_child(cohort, getattr(getattr(RAW_HB, kind), cohort).clone()) + allowances.add_child(kind, node) + parameters = create_economic_assumption_indices(add_earnings_uprating(parameters)) + propagate_parameter_metadata(parameters) + uprate_parameters(parameters) + return parameters + + +def earnings_linked_amounts(parameters): + minimum = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + yield minimum.SINGLE + yield minimum.COUPLE + allowances = parameters.gov.dwp.housing_benefit.allowances + for kind in ("single", "couple", "lone_parent"): + node = getattr(allowances, kind) + yield node.aged + yield node.aged_from_cutoff + + +@settings(max_examples=60, deadline=None) +@given(path=paths) +@example(path=[(0.01, 0.10), (-0.02, 0.05), (0.08, -0.01)]) +def test_projected_smg_and_hb_never_rise_by_less_than_published_earnings(path): + """Check amounts, not just the rate helper, under diverging inputs. + + CPI cannot replace the earnings floor or add the State Pension's price + protection. Falling earnings hold these amounts rather than cutting them. + """ + parameters = synthetic_parameters(path) + for parameter in earnings_linked_amounts(parameters): + assert parameter.metadata["uprating"] == EARNINGS_INDEX + for i, (earnings, _) in enumerate(path): + year = FIRST_PROJECTION_YEAR + i + before = parameter(f"{year - 1}-04-30") + after = parameter(f"{year}-04-30") + floor = published_earnings_floor(earnings) + assert after >= before * (1 + floor) - 1e-10, (parameter.name, year) + assert after / before - 1 == pytest.approx(floor, abs=1e-12) + + +def test_baseline_smg_and_hb_respect_the_earnings_floor_in_every_projected_year(): + parameters = system.parameters + awe = parameters.gov.economic_assumptions.statutory_uprating_inputs.awe_total_pay_may_july + for parameter in earnings_linked_amounts(parameters): + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + before = parameter(f"{year - 1}-04-30") + after = parameter(f"{year}-04-30") + floor = published_earnings_floor(awe(f"{year - 1}-07-01")) + assert after >= before * (1 + floor) - 1e-10, (parameter.name, year) + assert after / before - 1 == pytest.approx(floor, abs=1e-12) + + +@pytest.mark.parametrize("relation", ["SINGLE", "COUPLE"]) +def test_savings_credit_threshold_preserves_the_cpi_uprated_maximum(relation): + parameters = system.parameters + pc = parameters.gov.dwp.pension_credit + minimum = getattr(pc.guarantee_credit.minimum_guarantee, relation) + threshold = getattr(pc.savings_credit.threshold, relation) + maximum = getattr(pc.savings_credit.maximum, relation) + phase_in = pc.savings_credit.rate.phase_in + cpi = parameters.gov.economic_assumptions.yoy_growth.september_cpi_uprating + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + before = f"{year - 1}-04-30" + assert phase_in(at) * (minimum(at) - threshold(at)) == pytest.approx( + maximum(at), abs=1e-10 + ) + # CPI indices retain their existing five-decimal-place rounding. + assert maximum(at) / maximum(before) - 1 == pytest.approx( + cpi(f"{year}-01-01"), abs=2e-5 + ) + + +def test_hb_lower_pension_age_allowances_stay_equal_to_the_standard_minimum_guarantee(): + parameters = system.parameters.gov.dwp + minimum = parameters.pension_credit.guarantee_credit.minimum_guarantee + allowances = parameters.housing_benefit.allowances + for year in range(2021, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert allowances.single.aged_from_cutoff(at) == minimum.SINGLE(at) + assert allowances.lone_parent.aged_from_cutoff(at) == minimum.SINGLE(at) + assert allowances.couple.aged_from_cutoff(at) == minimum.COUPLE(at) diff --git a/policyengine_uk/tests/test_september_cpi_uprating.py b/policyengine_uk/tests/test_september_cpi_uprating.py index f6d4413164..d089a4892e 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, including DfE funding rates and HB non-dependant income + bands, still rise by the previous calendar year's CPI after their last + value. Pension Credit and pension-age HB allowances have their own + earnings projection.""" parameters = system.parameters raw = load_parameter_file(str(COUNTRY_DIR / "parameters"), name="") calendar_cpi = ( @@ -263,8 +264,12 @@ 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 names + assert any(name.startswith("gov.dfe.childcare_funding_rate") for name in names) + assert not any(name.startswith("gov.dwp.pension_credit.") for name in names) + assert not any( + name.startswith("gov.dwp.housing_benefit.allowances.") for name in names + ) def test_the_triple_lock_reads_the_same_september_cpi(): From 93163e7fb6d3c8d48c659819588d17e20b549f66 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 22:41:50 -0400 Subject: [PATCH 2/3] Derive higher pension-age HB allowances from their parts; guard zero phase-in The three Housing Benefit allowances for those who reached State Pension age before April 2021 are the Pension Credit standard minimum guarantee plus the maximum Savings Credit (HB A3/2021, paragraphs 3-5); every published amount from 2019-20 to 2026-27 is that sum rounded up to the next 5p. Uprating the whole allowance by earnings overstated the CPI part. Keep the published amounts and derive later years as the projected guarantee plus the projected maximum, after general uprating, as the Savings Credit threshold already is. April 2027: 265.8706 a week single and lone parent, 398.22025 couple, before rounding. A reform setting the Savings Credit phase-in rate to zero divided by zero while rebuilding the system. A zero rate pays no Savings Credit at any threshold, so that year now keeps the threshold in force before it. The synthetic property test built its Pension Credit and HB nodes from empty dicts, which Core reads as parameters, so it failed with AttributeError before checking anything (CI run 39826914200, shard 3). Build the nodes explicitly, and extend it to the higher allowances and to arbitrary phase-in paths including zero. Co-Authored-By: Claude Opus 5.5 --- .../pension-credit-earnings-uprating.fixed.md | 2 +- .../allowances/couple/aged.yaml | 10 +- .../create_pension_age_allowances.py | 43 +++ .../allowances/lone_parent/aged.yaml | 10 +- .../allowances/single/aged.yaml | 10 +- .../gov/dwp/pension_credit/README.md | 14 +- .../create_savings_credit_threshold.py | 59 +++- .../gov/economic_assumptions/README.md | 20 +- policyengine_uk/tax_benefit_system.py | 5 + .../pension_age_earnings_uprating.yaml | 24 +- ...uction_pension_age_personal_allowance.yaml | 45 +++ .../tests/test_pension_credit_uprating.py | 290 ++++++++++++++++-- .../tests/test_september_cpi_uprating.py | 4 +- 13 files changed, 461 insertions(+), 75 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py diff --git a/changelog.d/pension-credit-earnings-uprating.fixed.md b/changelog.d/pension-credit-earnings-uprating.fixed.md index 83d0208983..d5a3f39534 100644 --- a/changelog.d/pension-credit-earnings-uprating.fixed.md +++ b/changelog.d/pension-credit-earnings-uprating.fixed.md @@ -1 +1 @@ -Uprate the Pension Credit standard minimum guarantee and Housing Benefit pension-age personal allowances by the previous May-July earnings increase, meeting the SSAA 1992 s150A earnings floor after the last published rates. Derive projected Savings Credit thresholds from the guarantee and a September-CPI-uprated maximum award. April 2027's single guarantee rises from £238 to £247.282 before rounding, rather than £243.47. +Uprate the Pension Credit standard minimum guarantee and the lower Housing Benefit pension-age personal allowances by the previous May-July earnings increase, meeting the SSAA 1992 s150A earnings floor after the last published rates. Derive projected Savings Credit thresholds from the guarantee and a September-CPI-uprated maximum award, keeping the threshold in force when a reform sets a zero phase-in rate, and the higher pension-age allowances as the guarantee plus that maximum. April 2027's single guarantee rises from £238 to £247.282 before rounding, rather than £243.47. 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 3bbed648fd..d1b5a78e9a 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 @@ -15,13 +15,15 @@ metadata: period: week unit: currency-GBP label: Housing Benefit couple pension-age personal allowance - # Project pension-age allowances at the earnings rate used for the - # Pension Credit standard minimum guarantee (SSAA 1992 s150A(2)); published - # allowances are set in Schedule 3 below. - uprating: gov.economic_assumptions.indices.earnings_uprating + # Not uprated as a whole: the allowance is the Pension Credit standard + # minimum guarantee plus the maximum Savings Credit (HB A3/2021), which + # rise by earnings and September CPI. create_pension_age_allowances.py + # derives the years after the published amounts as that sum. 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 + - title: DWP Housing Benefit circular A3/2021, paragraphs 3-5 (the allowance is the standard minimum guarantee plus the maximum Savings Credit) + href: 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 - 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: Benefit and pension rates 2024 to 2025 - Policy paper 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 new file mode 100644 index 0000000000..a715f291fd --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -0,0 +1,43 @@ +"""Project the higher pension-age Housing Benefit personal allowances. + +For claimants who reached State Pension age before 1 April 2021, SI 2006/214 +Schedule 3 paragraph 1(1)(b) and (2)(b) set a personal allowance made up of +the Pension Credit standard minimum guarantee plus the maximum Savings Credit +(DWP circular HB A3/2021, paragraphs 3-5). Every published amount from +2019-20 to 2026-27 is that sum rounded up to the next 5p. The guarantee rises +with earnings and the maximum with September CPI, so neither index alone +projects the allowance. Keep the published allowances and derive later years +as the guarantee plus the maximum, each already uprated by its own index. + +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 guarantee and maximum. +""" + +from policyengine_core.parameters import ParameterNode + +from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( + extend_after_last_stated, + last_projected_year, +) + +# The Pension Credit amounts each allowance is built from: a lone parent's +# are a single person's. +RELATIONS = {"single": "SINGLE", "couple": "COUPLE", "lone_parent": "SINGLE"} + + +def project_pension_age_allowances(parameters: ParameterNode) -> ParameterNode: + """Fill the years after each higher allowance's last stated value.""" + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances + last_year = last_projected_year(parameters) + for kind, relation in RELATIONS.items(): + guarantee = pc.guarantee_credit.minimum_guarantee.children[relation] + maximum = pc.savings_credit.maximum.children[relation] + + def derive(sample, previous): + return guarantee(sample) + maximum(sample) + + extend_after_last_stated(allowances.children[kind].aged, last_year, derive) + allowances.clear_parent_cache() + 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 d3a23d79c7..303d7054eb 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 @@ -16,13 +16,15 @@ metadata: period: week unit: currency-GBP label: Housing Benefit lone parent pension-age personal allowance - # Project pension-age allowances at the earnings rate used for the - # Pension Credit standard minimum guarantee (SSAA 1992 s150A(2)); published - # allowances are set in Schedule 3 below. - uprating: gov.economic_assumptions.indices.earnings_uprating + # Not uprated as a whole: the allowance is the Pension Credit standard + # minimum guarantee plus the maximum Savings Credit (HB A3/2021), which + # rise by earnings and September CPI. create_pension_age_allowances.py + # derives the years after the published amounts as that sum. 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 + - title: DWP Housing Benefit circular A3/2021, paragraphs 3-5 (the allowance is the standard minimum guarantee plus the maximum Savings Credit) + href: 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 - 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: Benefit and pension rates 2024 to 2025 - Policy paper 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 61dd6cad54..5849592a72 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 @@ -15,13 +15,15 @@ metadata: period: week unit: currency-GBP label: Housing Benefit single pension-age personal allowance - # Project pension-age allowances at the earnings rate used for the - # Pension Credit standard minimum guarantee (SSAA 1992 s150A(2)); published - # allowances are set in Schedule 3 below. - uprating: gov.economic_assumptions.indices.earnings_uprating + # Not uprated as a whole: the allowance is the Pension Credit standard + # minimum guarantee plus the maximum Savings Credit (HB A3/2021), which + # rise by earnings and September CPI. create_pension_age_allowances.py + # derives the years after the published amounts as that sum. 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 + - title: DWP Housing Benefit circular A3/2021, paragraphs 3-5 (the allowance is the standard minimum guarantee plus the maximum Savings Credit) + href: 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 - 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: Benefit and pension rates 2024 to 2025 - Policy paper diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md index 8eb18a11b3..385e422a94 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md @@ -6,8 +6,8 @@ using the same statutory input and published precision as the State Pension triple lock. This models the earnings minimum in [SSAA 1992 s150A(2)](https://www.legislation.gov.uk/ukpga/1992/5/section/150A), with no decrease when earnings fall. There is no CPI or 2.5% floor in this -projection. The six Housing Benefit pension-age personal allowances follow -the same increase. +projection. The three lower Housing Benefit pension-age personal +allowances, which equal the guarantee, 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). @@ -15,4 +15,12 @@ Projected thresholds are derived after uprating: `threshold = 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. +thresholds are retained, including their rounding. A zero phase-in rate pays +no Savings Credit at any threshold, so that year keeps the threshold in force +before it. + +The three higher Housing Benefit pension-age personal allowances, for those +who reached State Pension age before April 2021, are the guarantee plus the +maximum Savings Credit (HB A3/2021). After their published amounts they are +derived as that sum, so the guarantee part follows earnings and the maximum +follows September CPI. 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 fa5a4fe7db..854b38458d 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 @@ -6,33 +6,64 @@ threshold is the guarantee less maximum / phase_in. Keep the published thresholds, including their rounding, and derive only subsequent years. +A zero phase-in rate pays no Savings Credit at any threshold, and the +maximum fixes none, so a year with a zero rate keeps the threshold in force +before it. + Run after general uprating and before fiscal-year conversion, on both the main tree and its baseline copy. Reprocessing a reform then reads its changed guarantee, maximum and phase-in rate. """ -from policyengine_core.parameters import ParameterNode +from typing import Any, Callable + +from policyengine_core.parameters import Parameter, ParameterNode from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant +def last_projected_year(parameters: ParameterNode) -> int: + """The last year of the earnings index, which the projections run to.""" + index = parameters.gov.economic_assumptions.indices.earnings_uprating + return max(int(entry.instant_str[:4]) for entry in index.values_list) + + +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 project_savings_credit_threshold(parameters: ParameterNode) -> ParameterNode: """Fill the years after each threshold's last stated value.""" pc = parameters.gov.dwp.pension_credit sc = pc.savings_credit - index = parameters.gov.economic_assumptions.indices.earnings_uprating - last_year = max(int(entry.instant_str[:4]) for entry in index.values_list) + last_year = last_projected_year(parameters) for relation, threshold in sc.threshold.children.items(): - last_stated = max(entry.instant_str for entry in threshold.values_list) - for year in range(int(last_stated[:4]) + 1, last_year + 1): - sample = f"{year}-04-30" - guarantee = pc.guarantee_credit.minimum_guarantee.children[relation](sample) - maximum = sc.maximum.children[relation](sample) + guarantee = pc.guarantee_credit.minimum_guarantee.children[relation] + maximum = sc.maximum.children[relation] + + def derive(sample, previous): phase_in = sc.rate.phase_in(sample) - value = guarantee - maximum / phase_in - threshold.values_list.append( - ParameterAtInstant(threshold.name, f"{year}-01-01", data=value) - ) - threshold.values_list.sort(key=lambda entry: entry.instant_str, reverse=True) - threshold.mark_as_modified() + if phase_in == 0: + return previous + return guarantee(sample) - maximum(sample) / phase_in + + extend_after_last_stated(threshold, last_year, derive) sc.threshold.clear_parent_cache() return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 994c961fd1..04aed8a3a2 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -214,9 +214,9 @@ used by the triple lock, rounded to the published 0.1 percentage points. so rounding cannot push the increase below earnings. When earnings fall, the projection holds amounts unchanged. -The Pension Credit standard minimum guarantee and six Housing Benefit -pension-age personal allowances use this index after their last published -amounts. [SSAA 1992 s150A(1)(d) and (2)](https://www.legislation.gov.uk/ukpga/1992/5/section/150A) +The Pension Credit standard minimum guarantee and the three lower Housing +Benefit pension-age personal allowances, which equal it, use this index after +their last published amounts. [SSAA 1992 s150A(1)(d) and (2)](https://www.legislation.gov.uk/ukpga/1992/5/section/150A) require the guarantee to rise at least with earnings; they impose no CPI or 2.5% floor. This projection uses that earnings minimum, allowing published amounts to reflect discretionary increases. For April 2027, May-July 2026 @@ -229,6 +229,20 @@ After general uprating, the projected threshold is derived as the standard minimum guarantee minus the maximum divided by the phase-in rate (60%). Published thresholds keep their own rounding. The 2026-27 maxima are £17.96 and £20.10, from the [DWP rates table](https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027#pension-credit). +A zero phase-in rate pays no Savings Credit, so that year keeps the threshold +in force before it. + +The three higher Housing Benefit pension-age personal allowances, for those +who reached State Pension age before April 2021, are the standard minimum +guarantee plus the maximum Savings Credit +([HB A3/2021](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), +paragraphs 3-5); each published amount from 2019-20 to 2026-27 is that sum +rounded up to the next 5p. +[`../dwp/housing_benefit/allowances/create_pension_age_allowances.py`](../dwp/housing_benefit/allowances/create_pension_age_allowances.py) +derives the years after the published amounts as that sum, after general +uprating, so each part keeps its own index. For April 2027 that is +£247.282 + £17.96 × 1.035 = £265.8706 a week for a single person or lone +parent and £377.41675 + £20.10 × 1.035 = £398.22025 for a couple. Income Tax Act 2007 ss21 and 57 index the basic rate limit and the personal allowance by this measure once no freeze applies. diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 849d85fa78..f1fd874a8f 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -30,6 +30,9 @@ 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_pension_age_allowances import ( + project_pension_age_allowances, +) from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( project_savings_credit_threshold, ) @@ -218,8 +221,10 @@ def process_parameter_tree( with canonical_ni_uprating_references(parameters): uprate_parameters(parameters) project_savings_credit_threshold(parameters) + project_pension_age_allowances(parameters) if annualise: project_savings_credit_threshold(parameters.baseline) + project_pension_age_allowances(parameters.baseline) parameters = backdate_parameters(parameters, BACKDATED_FROM) # Prior law's projected path for every parameter a reform can change: the # gov tree, before annualisation, and the trees outside it (household), diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml index 68f804c574..6df50a6dd2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_earnings_uprating.yaml @@ -1,8 +1,14 @@ -# April 2027 projections use May-July 2026 earnings growth of 3.9%. +# April 2027 projections, before any ministerial rounding. The lower +# allowances equal the standard minimum guarantee, which rises by May-July +# 2026 earnings growth of 3.9%. The higher allowances are the guarantee plus +# the maximum Savings Credit (HB A3/2021), which rises by September 2026 CPI +# of 3.5%. # Lower single/lone-parent: 238 * 1.039 * 52 = 12,858.664. # Lower couple: 363.25 * 1.039 * 52 = 19,625.671. -# Higher single/lone-parent: 256 * 1.039 * 52 = 13,831.168. -# Higher couple: 383.35 * 1.039 * 52 = 20,711.6338. +# Higher single/lone-parent: (238 * 1.039 + 17.96 * 1.035) * 52 +# = (247.282 + 18.5886) * 52 = 265.8706 * 52 = 13,825.2712. +# Higher couple: (363.25 * 1.039 + 20.10 * 1.035) * 52 +# = (377.41675 + 20.8035) * 52 = 398.22025 * 52 = 20,707.453. # Regulation-set/cohort inputs isolate uprating from age-attainment logic. - name: April 2027 lower single pension-age HB allowance follows earnings period: 2027 @@ -16,7 +22,7 @@ output: housing_benefit_applicable_amount: 12_858.664 -- name: April 2027 higher single pension-age HB allowance follows earnings +- name: April 2027 higher single pension-age HB allowance is the guarantee plus the Savings Credit maximum period: 2027 absolute_error_margin: 0.01 input: @@ -26,7 +32,7 @@ housing_benefit_attained_pension_age_before_cutoff: true benefits_premiums: 0 output: - housing_benefit_applicable_amount: 13_831.168 + housing_benefit_applicable_amount: 13_825.2712 - name: April 2027 lower couple pension-age HB allowance follows earnings period: 2027 @@ -47,7 +53,7 @@ output: housing_benefit_applicable_amount: 19_625.671 -- name: April 2027 higher couple pension-age HB allowance follows earnings +- name: April 2027 higher couple pension-age HB allowance is the guarantee plus the Savings Credit maximum period: 2027 absolute_error_margin: 0.01 input: @@ -64,7 +70,7 @@ housing_benefit_attained_pension_age_before_cutoff: true benefits_premiums: 0 output: - housing_benefit_applicable_amount: 20_711.6338 + housing_benefit_applicable_amount: 20_707.453 - name: April 2027 lower lone-parent pension-age HB allowance follows earnings period: 2027 @@ -86,7 +92,7 @@ is_lone_parent: true housing_benefit_applicable_amount: 12_858.664 -- name: April 2027 higher lone-parent pension-age HB allowance follows earnings +- name: April 2027 higher lone-parent pension-age HB allowance is the guarantee plus the Savings Credit maximum period: 2027 absolute_error_margin: 0.01 input: @@ -104,4 +110,4 @@ benefits_premiums: 0 output: is_lone_parent: true - housing_benefit_applicable_amount: 13_831.168 + housing_benefit_applicable_amount: 13_825.2712 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml index 2679c0baa8..9c1c7c0f4e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml @@ -189,3 +189,48 @@ output: council_tax_reduction_attained_pension_age_before_cutoff: false council_tax_reduction_applicable_amount: 238 * 52 + +# April 2027 projections of the one Scottish pension-age rate, the higher +# Housing Benefit allowance: the standard minimum guarantee, uprated by +# May-July 2026 earnings growth of 3.9%, plus the maximum Savings Credit, +# uprated by September 2026 CPI of 3.5% (HB A3/2021). +- name: In Scotland, a single person's April 2027 pension-age rate is the projected guarantee plus the Savings Credit maximum + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 72 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: SCOTLAND + output: + council_tax_reduction_applicable_amount: 13_825.2712 # (238 * 1.039 + 17.96 * 1.035) * 52 + +- name: In Scotland, a couple's April 2027 pension-age rate is the projected guarantee plus the Savings Credit maximum + period: 2027 + absolute_error_margin: 0.01 + input: + people: + older: + age: 71 + is_male: true + younger: + age: 69 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + households: + household: + members: [older, younger] + region: SCOTLAND + output: + council_tax_reduction_applicable_amount: 20_707.453 # (363.25 * 1.039 + 20.10 * 1.035) * 52 diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index 629432b4e7..e2c5226152 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -2,14 +2,28 @@ SSAA 1992 s150A(2) requires the standard minimum guarantee's annual increase to be at least earnings growth. Projections use the published May-July AWE -figure, with no cut when earnings fall. The pension-age HB allowances follow -the same increase; the Savings Credit maximum follows September CPI, with -the threshold implied by the maximum and the 60% phase-in rate. +figure, with no cut when earnings fall. The lower pension-age HB allowances +equal the guarantee and follow the same increase. The Savings Credit maximum +follows September CPI, with the threshold implied by the maximum and the +60% phase-in rate. The higher pension-age HB allowances are the guarantee +plus that maximum (HB A3/2021), so each part keeps its own index. + +Invariants, for every earnings, CPI and phase-in path: + +1. The guarantee and lower HB allowances rise by exactly the published + earnings figure, and never fall. +2. Each higher HB allowance equals the projected guarantee plus the + projected Savings Credit maximum, after its published amounts. +3. A positive phase-in rate times the guarantee less the threshold is the + maximum; a zero rate keeps the threshold in force before it. +4. Published amounts are unchanged by the projections. """ from decimal import ROUND_HALF_UP, Decimal +from pathlib import Path import pytest +import yaml from hypothesis import example, given, settings from hypothesis import strategies as st from policyengine_core.parameters import ParameterNode, load_parameter_file @@ -18,47 +32,86 @@ ) from policyengine_core.parameters.operations.uprate_parameters import uprate_parameters +from policyengine_uk import Simulation +from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_pension_age_allowances import ( + project_pension_age_allowances, +) +from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( + project_savings_credit_threshold, +) from policyengine_uk.parameters.gov.economic_assumptions.create_earnings_uprating import ( add_earnings_uprating, ) from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, ) +from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( + add_september_cpi_uprating, +) from policyengine_uk.system import system from policyengine_uk.tax_benefit_system import COUNTRY_DIR EARNINGS_INDEX = "gov.economic_assumptions.indices.earnings_uprating" FIRST_PROJECTION_YEAR = 2027 LAST_INDEX_YEAR = 2039 +LAST_PUBLISHED_YEAR = FIRST_PROJECTION_YEAR - 1 +PARAMETERS_DIR = COUNTRY_DIR / "parameters" SMG_NAME = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" +SC_NAME = "gov.dwp.pension_credit.savings_credit" HB_NAME = "gov.dwp.housing_benefit.allowances" RAW_SMG = load_parameter_file( str( - COUNTRY_DIR - / "parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml" + PARAMETERS_DIR + / "gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml" ), name=SMG_NAME, ) +RAW_SC = load_parameter_file( + str(PARAMETERS_DIR / "gov/dwp/pension_credit/savings_credit"), name=SC_NAME +) RAW_HB = load_parameter_file( - str(COUNTRY_DIR / "parameters/gov/dwp/housing_benefit/allowances"), name=HB_NAME + str(PARAMETERS_DIR / "gov/dwp/housing_benefit/allowances"), name=HB_NAME ) +RAW_BENEFIT_CPI = load_parameter_file( + str(PARAMETERS_DIR / "gov/benefit_uprating_cpi.yaml"), + name="gov.benefit_uprating_cpi", +) +# Each higher allowance's Pension Credit relation: a lone parent's is single. +HB_RELATIONS = {"single": "SINGLE", "couple": "COUPLE", "lone_parent": "SINGLE"} +PUBLISHED = yaml.safe_load( + ( + Path(__file__).parent / "fixtures" / "dwp_benefit_and_pension_rates.yaml" + ).read_text() +)["parameters"] # Includes negative earnings, off-grid forecasts and different CPI paths. growth = st.integers(min_value=-600, max_value=1500).map(lambda n: n / 10_000) paths = st.lists(st.tuples(growth, growth), min_size=1, max_size=10) +# Zero, the 60% in force and rates either side of it, for each projected year. +phase_in_rates = st.one_of(st.just(0.0), st.integers(1, 100).map(lambda n: n / 100)) +phase_in_paths = st.lists(phase_in_rates, min_size=1, max_size=10) -def published_earnings_floor(rate): - """Independent transcription of the published 0.1pp earnings figure.""" +def published_rise(rate): + """Independent transcription of a published 0.1pp figure, never a cut.""" rounded = Decimal(str(rate)).quantize(Decimal("0.001"), ROUND_HALF_UP) return max(float(rounded), 0.0) -def synthetic_parameters(path): - """Uprate the production SMG and six HB leaves without a simulation. +def add_node(parent, key): + """Add an empty node; Core reads an empty dict as a parameter instead.""" + node = ParameterNode(name=f"{parent.name}.{key}", data={}) + parent.add_child(key, node) + return node + - Earlier observations merely supply the index's 2010 base; every generated - path begins with the May-July figure for the first projected April. +def synthetic_parameters(path, phase_ins=()): + """Uprate and project the production Pension Credit and HB amounts. + + No simulation is built. Earlier observations merely supply the indices' + 2010 base; every generated path begins with the May-July and September + figures for the first projected April. ``phase_ins`` gives the Savings + Credit phase-in rate from each projected April, in order. """ observations = {year: (0.02, 0.02) for year in range(2010, 2026)} observations.update({2026 + i: values for i, values in enumerate(path)}) @@ -95,27 +148,32 @@ def synthetic_parameters(path): } }, }, - "dwp": { - "pension_credit": {"guarantee_credit": {}}, - "housing_benefit": {"allowances": {}}, - }, } } ) - parameters.gov.dwp.pension_credit.guarantee_credit.add_child( - "minimum_guarantee", RAW_SMG.clone() - ) + parameters.gov.add_child("benefit_uprating_cpi", RAW_BENEFIT_CPI.clone()) + dwp = add_node(parameters.gov, "dwp") + pension_credit = add_node(dwp, "pension_credit") + guarantee_credit = add_node(pension_credit, "guarantee_credit") + guarantee_credit.add_child("minimum_guarantee", RAW_SMG.clone()) + savings_credit = RAW_SC.clone() + phase_in = savings_credit.rate.phase_in + for i, rate in enumerate(phase_ins): + phase_in.update(start=f"{FIRST_PROJECTION_YEAR + i}-01-01", value=rate) + pension_credit.add_child("savings_credit", savings_credit) # Only pension-age amounts belong to the earnings link. Including the # working-age leaves would need the unrelated CPI index too. - allowances = parameters.gov.dwp.housing_benefit.allowances - for kind in ("single", "couple", "lone_parent"): - node = ParameterNode(name=f"{HB_NAME}.{kind}", data={}) + allowances = add_node(add_node(dwp, "housing_benefit"), "allowances") + for kind in HB_RELATIONS: + node = add_node(allowances, kind) for cohort in ("aged", "aged_from_cutoff"): node.add_child(cohort, getattr(getattr(RAW_HB, kind), cohort).clone()) - allowances.add_child(kind, node) - parameters = create_economic_assumption_indices(add_earnings_uprating(parameters)) + parameters = add_september_cpi_uprating(add_earnings_uprating(parameters)) + parameters = create_economic_assumption_indices(parameters) propagate_parameter_metadata(parameters) uprate_parameters(parameters) + project_savings_credit_threshold(parameters) + project_pension_age_allowances(parameters) return parameters @@ -124,17 +182,45 @@ def earnings_linked_amounts(parameters): yield minimum.SINGLE yield minimum.COUPLE allowances = parameters.gov.dwp.housing_benefit.allowances - for kind in ("single", "couple", "lone_parent"): - node = getattr(allowances, kind) - yield node.aged - yield node.aged_from_cutoff + for kind in HB_RELATIONS: + yield getattr(allowances, kind).aged_from_cutoff + + +def higher_allowances(parameters): + """Each higher HB allowance with the guarantee and maximum it is built from.""" + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances + for kind, relation in HB_RELATIONS.items(): + yield ( + getattr(allowances, kind).aged, + getattr(pc.guarantee_credit.minimum_guarantee, relation), + getattr(pc.savings_credit.maximum, relation), + ) + + +def assert_published_amounts_kept(parameters): + """Invariant 4: the YAML's stated values, read where they are stated.""" + for raw, node in ( + (RAW_SMG, parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee), + (RAW_SC.threshold, parameters.gov.dwp.pension_credit.savings_credit.threshold), + (RAW_SC.maximum, parameters.gov.dwp.pension_credit.savings_credit.maximum), + ): + for relation in ("SINGLE", "COUPLE"): + for entry in getattr(raw, relation).values_list: + assert getattr(node, relation)(entry.instant_str) == entry.value + allowances = parameters.gov.dwp.housing_benefit.allowances + for kind in HB_RELATIONS: + for cohort in ("aged", "aged_from_cutoff"): + projected = getattr(getattr(allowances, kind), cohort) + for entry in getattr(getattr(RAW_HB, kind), cohort).values_list: + assert projected(entry.instant_str) == entry.value @settings(max_examples=60, deadline=None) @given(path=paths) @example(path=[(0.01, 0.10), (-0.02, 0.05), (0.08, -0.01)]) def test_projected_smg_and_hb_never_rise_by_less_than_published_earnings(path): - """Check amounts, not just the rate helper, under diverging inputs. + """Invariant 1, on amounts, not just the rate helper, under diverging inputs. CPI cannot replace the earnings floor or add the State Pension's price protection. Falling earnings hold these amounts rather than cutting them. @@ -146,11 +232,65 @@ def test_projected_smg_and_hb_never_rise_by_less_than_published_earnings(path): year = FIRST_PROJECTION_YEAR + i before = parameter(f"{year - 1}-04-30") after = parameter(f"{year}-04-30") - floor = published_earnings_floor(earnings) + floor = published_rise(earnings) assert after >= before * (1 + floor) - 1e-10, (parameter.name, year) assert after / before - 1 == pytest.approx(floor, abs=1e-12) +@settings(max_examples=60, deadline=None) +@given(path=paths) +@example(path=[(0.039, 0.035)]) +@example(path=[(0.10, -0.02), (-0.03, 0.12), (0.0, 0.0)]) +def test_higher_hb_allowances_keep_each_part_on_its_own_index(path): + """Invariants 2 and 4: earnings move the guarantee part, CPI the maximum. + + Projecting the whole allowance by either index would misstate it once + earnings and CPI diverge. + """ + parameters = synthetic_parameters(path) + assert_published_amounts_kept(parameters) + for allowance, guarantee, maximum in higher_allowances(parameters): + assert "uprating" not in allowance.metadata + for i, (earnings, cpi) in enumerate(path): + year = FIRST_PROJECTION_YEAR + i + at, before = f"{year}-04-30", f"{year - 1}-04-30" + assert allowance(at) == pytest.approx( + guarantee(at) + maximum(at), abs=1e-9 + ), (allowance.name, year) + assert guarantee(at) / guarantee(before) - 1 == pytest.approx( + published_rise(earnings), abs=1e-12 + ) + # CPI indices keep five-decimal-place levels. + assert maximum(at) / maximum(before) - 1 == pytest.approx( + published_rise(cpi), abs=2e-5 + ) + + +@settings(max_examples=60, deadline=None) +@given(path=paths, phase_ins=phase_in_paths) +@example(path=[(0.039, 0.035)], phase_ins=[0.0]) +@example(path=[(0.039, 0.035), (0.02, 0.03), (0.05, 0.01)], phase_ins=[0.0, 0.6, 0.0]) +def test_projected_threshold_keeps_the_maximum_at_every_phase_in_rate(path, phase_ins): + """Invariant 3: a zero rate never divides, and keeps the threshold.""" + parameters = synthetic_parameters(path, phase_ins) + assert_published_amounts_kept(parameters) + pc = parameters.gov.dwp.pension_credit + phase_in = pc.savings_credit.rate.phase_in + for relation in ("SINGLE", "COUPLE"): + guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relation) + threshold = getattr(pc.savings_credit.threshold, relation) + maximum = getattr(pc.savings_credit.maximum, relation) + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at, before = f"{year}-04-30", f"{year - 1}-04-30" + rate = phase_in(at) + if rate == 0: + assert threshold(at) == threshold(before), (relation, year) + else: + assert rate * (guarantee(at) - threshold(at)) == pytest.approx( + maximum(at), abs=1e-9 + ), (relation, year, rate) + + def test_baseline_smg_and_hb_respect_the_earnings_floor_in_every_projected_year(): parameters = system.parameters awe = parameters.gov.economic_assumptions.statutory_uprating_inputs.awe_total_pay_may_july @@ -158,7 +298,7 @@ def test_baseline_smg_and_hb_respect_the_earnings_floor_in_every_projected_year( for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): before = parameter(f"{year - 1}-04-30") after = parameter(f"{year}-04-30") - floor = published_earnings_floor(awe(f"{year - 1}-07-01")) + floor = published_rise(awe(f"{year - 1}-07-01")) assert after >= before * (1 + floor) - 1e-10, (parameter.name, year) assert after / before - 1 == pytest.approx(floor, abs=1e-12) @@ -193,3 +333,91 @@ def test_hb_lower_pension_age_allowances_stay_equal_to_the_standard_minimum_guar assert allowances.single.aged_from_cutoff(at) == minimum.SINGLE(at) assert allowances.lone_parent.aged_from_cutoff(at) == minimum.SINGLE(at) assert allowances.couple.aged_from_cutoff(at) == minimum.COUPLE(at) + + +@pytest.mark.parametrize("year", range(2019, LAST_PUBLISHED_YEAR + 1)) +@pytest.mark.parametrize("kind", sorted(HB_RELATIONS)) +def test_published_higher_hb_allowances_are_the_guarantee_plus_the_savings_credit_maximum( + kind, year +): + """The premise of invariant 2, in DWP's published rates. + + The maximum is 60% of the guarantee less the threshold (State Pension + Credit Regulations 2002 reg 7). Each published allowance is the + guarantee plus that maximum, rounded up to the next 5p. + """ + relation = HB_RELATIONS[kind] + + def published(name): + return Decimal(str(PUBLISHED[name]["values"][year])) + + guarantee = published(f"{SMG_NAME}.{relation}") + threshold = published(f"{SC_NAME}.threshold.{relation}") + allowance = published(f"{HB_NAME}.{kind}.aged") + maximum = Decimal("0.6") * (guarantee - threshold) + assert Decimal(0) <= allowance - (guarantee + maximum) < Decimal("0.05") + + +def test_baseline_higher_hb_allowances_are_the_projected_guarantee_plus_maximum(): + """Invariants 2 and 4 in current law, against the indices directly. + + The differential side compounds each part's published amount by its own + index, without reading the uprated guarantee or maximum. + """ + parameters = system.parameters + assert_published_amounts_kept(parameters) + earnings = parameters.gov.economic_assumptions.indices.earnings_uprating + benefit_cpi = parameters.gov.benefit_uprating_cpi + last = f"{LAST_PUBLISHED_YEAR}-04-30" + for allowance, guarantee, maximum in higher_allowances(parameters): + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert allowance(at) == pytest.approx( + guarantee(at) + maximum(at), abs=1e-9 + ), (allowance.name, year) + from_indices = guarantee(last) * earnings(at) / earnings(last) + maximum( + last + ) * benefit_cpi(at) / benefit_cpi(last) + assert allowance(at) == pytest.approx(from_indices, abs=1e-9) + + +def zero_phase_in_situation(): + """A single pensioner with income between the threshold and the guarantee.""" + years = ("2027", "2028") + return { + "people": {"claimant": {"age": {"2027": 81, "2028": 82}}}, + "benunits": { + "benunit": { + "members": ["claimant"], + "savings_credit_income": {year: 12_000 for year in years}, + "pension_credit_income": {year: 12_000 for year in years}, + } + }, + "households": {"household": {"members": ["claimant"]}}, + } + + +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 2026-27 threshold for that year, 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}} + baseline = Simulation(situation=zero_phase_in_situation()) + reformed = Simulation(situation=zero_phase_in_situation(), reform=reform) + # 0.6 * (12,000 - 216.301 * 52), as savings_credit.yaml computes. + assert baseline.calculate("savings_credit", 2027)[0] == pytest.approx( + 451.4088, abs=0.01 + ) + assert reformed.calculate("savings_credit", 2027)[0] == 0 + assert reformed.calculate("savings_credit", 2028)[0] == pytest.approx( + baseline.calculate("savings_credit", 2028)[0], abs=1e-6 + ) + assert reformed.calculate("guarantee_credit", 2027)[0] == pytest.approx( + baseline.calculate("guarantee_credit", 2027)[0], abs=1e-6 + ) + 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(208.07) + assert threshold("2028-04-30") == pytest.approx(current("2028-04-30")) diff --git a/policyengine_uk/tests/test_september_cpi_uprating.py b/policyengine_uk/tests/test_september_cpi_uprating.py index d089a4892e..28815cca8b 100644 --- a/policyengine_uk/tests/test_september_cpi_uprating.py +++ b/policyengine_uk/tests/test_september_cpi_uprating.py @@ -241,8 +241,8 @@ def test_amounts_not_raised_by_september_cpi_keep_their_lagged_calendar_projecti """Amounts that were on gov.benefit_uprating_cpi but are not raised by September CPI, including DfE funding rates and HB non-dependant income bands, still rise by the previous calendar year's CPI after their last - value. Pension Credit and pension-age HB allowances have their own - earnings projection.""" + value. Pension Credit amounts and pension-age HB allowances have their + own projections.""" parameters = system.parameters raw = load_parameter_file(str(COUNTRY_DIR / "parameters"), name="") calendar_cpi = ( From 566b0b9aa65a3b1bffe68b6e64ed81f14ea0e82e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 23:52:01 -0400 Subject: [PATCH 3/3] Test that reforms keep their own higher allowance and move the derived one A dated change to a higher pension-age HB allowance is kept for its year and prior law resumes after it. A change to the guarantee moves the allowance derived from it in that year only, and leaves the lower allowance, which has its own earnings projection, unchanged. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_pension_credit_uprating.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index e2c5226152..bea73fd5bd 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -421,3 +421,38 @@ def test_zero_phase_in_reform_builds_and_pays_no_savings_credit(): current = baseline.tax_benefit_system.parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE 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_move_the_derived_one(): + """A dated change to a higher allowance is kept, and prior law resumes. + + A change to the guarantee moves the allowance derived from it in that + year, as it moves the Savings Credit threshold; the lower allowance has + its own earnings projection and does not follow. + """ + reform = { + f"{HB_NAME}.single.aged": {"2028": 300}, + f"{SMG_NAME}.COUPLE": {"2029": 400}, + } + situation = { + "people": {"claimant": {"age": {"2028": 81}}}, + "households": {"household": {"members": ["claimant"]}}, + } + current = system.parameters + reformed = Simulation(situation=situation, reform=reform).tax_benefit_system + allowances = reformed.parameters.gov.dwp.housing_benefit.allowances + now = current.gov.dwp.housing_benefit.allowances + sc_max = reformed.parameters.gov.dwp.pension_credit.savings_credit.maximum + for year in (2027, 2029, 2030): + at = f"{year}-04-30" + assert allowances.single.aged(at) == pytest.approx(now.single.aged(at)) + assert allowances.single.aged("2028-04-30") == 300 + assert allowances.couple.aged("2029-04-30") == pytest.approx( + 400 + sc_max.COUPLE("2029-04-30") + ) + for year in (2028, 2030): + at = f"{year}-04-30" + assert allowances.couple.aged(at) == pytest.approx(now.couple.aged(at)) + assert allowances.couple.aged_from_cutoff("2029-04-30") == pytest.approx( + now.couple.aged_from_cutoff("2029-04-30") + )