From bcdee62b32a205c910e55276c6e0d2e082cfd74d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:41:13 -0400 Subject: [PATCH 1/4] Uprate additional State Pension and protected payments by September CPI The part of a reported State Pension above the type's full rate (additional pension for the basic State Pension, protected payments for the new) was uprated by the full rate's own triple-lock rise. Each Up-rating Order raises it by September CPI instead (SSAA 1992 ss.150(1)(c), 151 and 151A; SI 2026/148 arts 4(3) and 6(3)(b): 3.8% against 4.8% for the full rates; SI 2025/295: 1.7% against 4.1%). Uprate it by gov.economic_assumptions.indices.september_cpi_uprating from the data year, read from the simulation's own parameters, so a reform to the full rates or the triple lock no longer moves it and a price scenario does. Fixes #1941. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1941.fixed.md | 1 + docs/book/programs/gov/dwp/state-pension.md | 62 ++- .../tests/test_state_pension_components.py | 451 ++++++++++++++++-- .../gov/dwp/additional_state_pension.py | 44 +- 4 files changed, 484 insertions(+), 74 deletions(-) create mode 100644 changelog.d/1941.fixed.md diff --git a/changelog.d/1941.fixed.md b/changelog.d/1941.fixed.md new file mode 100644 index 0000000000..51bd32d483 --- /dev/null +++ b/changelog.d/1941.fixed.md @@ -0,0 +1 @@ +- Uprate additional State Pension and protected payments by September CPI, as the Up-rating Orders do (SI 2026/148 arts 4(3) and 6(3)(b)), not by the full rate's triple-lock rise; reforms to the full rates no longer move them. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 985576b98a..e4aafcff7c 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -26,20 +26,41 @@ Three variables under `gov/dwp/`: current law but tracked separately in PolicyEngine so the reform surface stays clean. - Both are the reported weekly amount above the type's full rate in the - data year, `max(reported - flat_max_for_type, 0)`, uprated as described - below and multiplied by `WEEKS_IN_YEAR`. + data year, `max(reported - flat_max_for_type, 0)`, uprated by September + CPI as described below and multiplied by `WEEKS_IN_YEAR`. All three split the amount a person reported in the data year (the dataset's first year) by the person's `state_pension_type` in the year -simulated. The part up to that type's full rate in the data year is basic -or new State Pension, and the part above it is additional State Pension. -Both parts are uprated by the type's full rate (the year's over the data -year's), so together they pay the reported amount uprated by the flat rate, -for anyone over State Pension age. In law, additional pensions and -protected payments rise with prices instead: the Social Security Benefits -Up-rating Order 2026 raised them by 3.8% in April 2026 (articles 4(3) and -6(3)), and the full rates by 4.8%. This is tracked in -[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941). +simulated, for anyone over State Pension age: + +- The part up to that type's full rate in the data year is basic or new + State Pension. It is uprated by the type's full rate: the year's over the + data year's. +- The part above it is additional State Pension. It is uprated by September + CPI: `gov.economic_assumptions.indices.september_cpi_uprating` in the year + over the data year. + +That follows the law. Each year's Up-rating Order raises the full rates by +the triple lock (Social Security Administration Act 1992 s.150A), but raises +additional pensions (s.150(1)(c)), increments for deferring the pension and +the amounts of new State Pension above the full rate (protected payments, +s.151A) by the September CPI of the year before. In April 2026 the full +rates rose 4.8% and the rest 3.8% +([Social Security Benefits Up-rating Order 2026](https://www.legislation.gov.uk/uksi/2026/148/made), +articles 4(2), 4(3), 6(1) and 6(3)); in April 2025, 4.1% and 1.7% +([2025 Order](https://www.legislation.gov.uk/uksi/2025/295/made), the same +articles). Graduated retirement benefit, paid with the pension, also rose by +September CPI (article 11 of each Order). Until the fix for +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941) the +model uprated the part above the full rate by the full rate too. + +So a reform to the full rates, or to the triple lock, moves only basic and +new State Pension. A reform that sets a full rate to zero leaves additional +pensions and protected payments in place; `gov.contrib.abolish_state_pension` +removes all State Pension. The September CPI index is read from the +simulation's own parameters, so a scenario applied before the data load that +changes September CPI, or the calendar-year CPI growth its forecast follows, +moves the part above the full rate. Survey ages are held fixed in the years a dataset is projected to, so a record's birth cohort moves one year later for each year projected, and its @@ -167,6 +188,19 @@ to 2026-27; later years are uprated by `gov.economic_assumptions.indices.triple_lock`, built from the yearly rates in `gov.economic_assumptions.yoy_growth.triple_lock`. +The part of a reported State Pension above the full rate is uprated by +`gov.economic_assumptions.indices.september_cpi_uprating` instead (see +Components). Its yearly rises, +`gov.economic_assumptions.yoy_growth.september_cpi_uprating`, are the +September CPI of the year before from `statutory_uprating_inputs.cpi_september` +(below), rounded to the 0.1 percentage points the ONS publishes, and zero +when prices fall (`create_september_cpi_uprating.py`). They equal the rise +each Up-rating Order gave additional pensions from April 2011 to April 2026, +including none in April 2016, when September 2015 CPI was -0.1% +(`policyengine_uk/tests/test_state_pension_components.py`). The index is +rounded to five decimal places each year, so it differs from the exact +product of the rises by less than 0.001%. + ### The triple lock The rise each April is the highest of: @@ -360,8 +394,9 @@ tracked in [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929): population keeps the data year's age structure. From 2025-26 to 2030-31 the model's State Pension recipients fall 3.0%, as State Pension age rises to 67, while DWP's caseload less those paid abroad rises 4.8%. -- **Awards.** Each record keeps its reported amount, uprated by the flat - rate, including records whose cohort moves from basic to new State +- **Awards.** Each record keeps its reported amount, the part up to the + full rate uprated by the flat rate and the part above it by September CPI, + including records whose cohort moves from basic to new State Pension. The model's State Pension per recipient grows 17.0% over those years. DWP's spending per recipient, less those paid abroad, grows 18.3%, as its new State Pension spending grows from £56.1bn to £102.4bn and its @@ -382,6 +417,7 @@ part of the widening. | `new_state_pension` returned flat max for every NEW retiree, ignoring partial NI records | Fixed | #1634 | | Protected Payment only computed for BASIC-type recipients | Fixed | #1634 (ASP now extends to NEW) | | `additional_state_pension` split the reported amount by the data year's State Pension type, paying part of it twice for records moving from basic to new cohorts | Fixed | #1921 | +| `additional_state_pension` uprated additional pensions and protected payments by the full rate (the triple lock), not September CPI | Fixed | #1941 | ### What's still open diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 439e77a639..8487b260aa 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -7,12 +7,15 @@ period can differ from its type in the data year. All three components split by the period's type, so together they split the reported amount with no overlap and no gap: the part up to the type's full rate is uprated by the full -rate, and the part above it by the add-on's uprating (see add_on_uprating). +rate, and the part above it (additional pension, protected payments) by +September CPI, as the Up-rating Orders raise it (see add_on_uprating). The YAML runner builds simulations without a dataset, where the data year is the period by construction, so these cases build simulations from data. """ +from decimal import ROUND_HALF_UP, Decimal + import numpy as np import pandas as pd import pytest @@ -21,6 +24,8 @@ from policyengine_uk import Microsimulation from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.model_api import Scenario +from policyengine_uk.system import system DATA_YEAR = 2024 YEARS = range(DATA_YEAR, 2031) @@ -84,24 +89,82 @@ def flat_uprating(sim, year: int, pension_type: str) -> float: ) -def add_on_uprating(sim, year: int, pension_type: str) -> float: +# The rise each Social Security Benefits Up-rating Order gave additional +# pensions in long-term benefits, in April of the year: September CPI of the +# year before, under the Social Security Administration Act 1992 ss.150(1)(c) +# and 151(1). Since 2017 the same Orders raise the amounts of new State +# Pension at the transitional rate above the full rate (protected payments) by +# the same percentage, under s.151A. The 2016 Order (SI 2016/230) raised no +# additional pension: September 2015 CPI was -0.1%. For example, SI 2026/148 +# art 4(3)(a) and 6(3)(b) (3.8%) and SI 2025/295 art 4(3)(a) and 6(3)(b) +# (1.7%), against 4.8% and 4.1% for the full rates (arts 4(2) and 6(1)). +UP_RATING_ORDERS = { + 2011: ("SI 2011/821", 0.031), + 2012: ("SI 2012/780", 0.052), + 2013: ("SI 2013/574", 0.022), + 2014: ("SI 2014/516", 0.027), + 2015: ("SI 2015/457", 0.012), + 2016: ("SI 2016/230", 0.0), + 2017: ("SI 2017/260", 0.010), + 2018: ("SI 2018/281", 0.030), + 2019: ("SI 2019/480", 0.024), + 2020: ("SI 2020/234", 0.017), + 2021: ("SI 2021/162", 0.005), + 2022: ("SI 2022/292", 0.031), + 2023: ("SI 2023/316", 0.101), + 2024: ("SI 2024/242", 0.067), + 2025: ("SI 2025/295", 0.017), + 2026: ("SI 2026/148", 0.038), +} +# The model compounds the yearly rises into an index rounded to five decimal +# places each year (create_economic_assumption_indices.py), so it differs from +# the exact product of the rises by under 1e-5 of the amount. +INDEX_ROUNDING = 1e-5 + + +def add_on_rise(sim, year: int) -> float: + """The April rise in additional pensions and protected payments: the + Up-rating Order's where one has been made. For later years, the forecast + September CPI of the year before, as ONS publishes it (to 0.1 percentage + points), and no rise if prices fall (an Order under SSAA 1992 s.150(2) or + s.151A(2) is made only if prices rose).""" + if year in UP_RATING_ORDERS: + return UP_RATING_ORDERS[year][1] + parameters = sim.tax_benefit_system.parameters + inputs = parameters.gov.economic_assumptions.statutory_uprating_inputs + september = inputs.cpi_september(f"{year - 1}-09-01") + published = float( + Decimal(repr(float(september))).quantize(Decimal("0.001"), ROUND_HALF_UP) + ) + return max(published, 0.0) + + +def add_on_uprating(sim, year: int) -> float: """Uprating of the part above the full rate (additional pension, protected - payments). The model uprates it by the full rate; in law these rise with - prices (Social Security Benefits Up-rating Order 2026 arts 4(3) and 6(3)), - tracked in PolicyEngine/policyengine-uk#1941. Change it here with the model.""" - return flat_uprating(sim, year, pension_type) + payments) from the data year to ``year``: the yearly rises compounded. A + reference independent of the model's index, which follows the same rises + whatever the State Pension type (PolicyEngine/policyengine-uk#1941).""" + factor = 1.0 + for later_year in range(DATA_YEAR + 1, year + 1): + factor *= 1 + add_on_rise(sim, later_year) + return factor def uprated_reported(sim, year: int, pension_type: str, weekly: float) -> float: """The data-year amount, its part up to the full rate uprated by the full - rate and the part above it by the add-on's uprating.""" + rate and the part above it by September CPI.""" full = flat_rates(sim, DATA_YEAR)[pension_type] return WEEKS_IN_YEAR * ( min(weekly, full) * flat_uprating(sim, year, pension_type) - + max(weekly - full, 0) * add_on_uprating(sim, year, pension_type) + + max(weekly - full, 0) * add_on_uprating(sim, year) ) +def approx(expected: float): + """Exact to the penny, allowing for the model index's rounding.""" + return pytest.approx(expected, rel=INDEX_ROUNDING, abs=0.01) + + def calculate(sim, variable: str, year: int) -> np.ndarray: return np.asarray(sim.calculate(variable, year)) @@ -134,7 +197,7 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): 2014 on the basic State Pension; held at 72 she stands, by 2030-31, for a woman born in 1958, on the new State Pension. Her £240 a week exceeds the new State Pension's full rate, so the excess is additional State Pension - (a protected payment), uprated as the model uprates add-ons.""" + (a protected payment), uprated by September CPI.""" person = {"age": 72, "male": False, "months": 3, "weekly": 240} sim = projected_simulation([person]) assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "BASIC" @@ -143,9 +206,8 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): assert calculate(sim, "new_state_pension", 2030)[0] == pytest.approx( rates["NEW"] * WEEKS_IN_YEAR, abs=0.01 ) - assert calculate(sim, "additional_state_pension", 2030)[0] == pytest.approx( - (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030, "NEW"), - abs=0.01, + assert calculate(sim, "additional_state_pension", 2030)[0] == approx( + (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030) ) @@ -164,8 +226,8 @@ def test_unchanged_type_splits_at_its_own_flat_rate(person, pension_type): for year in YEARS: assert calculate(sim, "state_pension_type", year)[0] == pension_type total = sum(calculate(sim, v, year)[0] for v in COMPONENTS) - assert total == pytest.approx( - uprated_reported(sim, year, pension_type, person["weekly"]), abs=0.01 + assert total == approx( + uprated_reported(sim, year, pension_type, person["weekly"]) ) @@ -200,10 +262,170 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): assert list(calculate(sim, "state_pension_type", DATA_YEAR)) == ["NEW", "NONE"] assert list(calculate(sim, "state_pension_type", 2030)) == ["BASIC", "NEW"] total = sum(calculate(sim, v, 2030) for v in COMPONENTS) - assert total[0] == pytest.approx( - uprated_reported(sim, 2030, "BASIC", 240), abs=0.01 + assert total[0] == approx(uprated_reported(sim, 2030, "BASIC", 240)) + assert total[1] == approx(uprated_reported(sim, 2030, "NEW", 230)) + + +# The add-on rises with prices (PolicyEngine/policyengine-uk#1941). Records are +# built from 2024-25 data, where the full rates are £169.50 (basic State +# Pension, Social Security Benefits Up-rating Order 2024 art 4(2)) and £221.20 +# (new State Pension, art 6(1)). + + +def test_additional_pension_rises_with_september_cpi_not_the_basic_rate(): + """A man aged 90 in 2024-25 reached State Pension age in 1999, on the basic + State Pension, as does a man aged 90 in any later year. He reports £200 a + week, £30.50 above the 2024-25 full basic rate. The Up-rating Orders raised + the basic State Pension by 4.1% and 4.8% and additional pensions by 1.7% + and 3.8% (SI 2025/295 and SI 2026/148, arts 4(2) and 4(3)(a)), so in + 2026-27 he gets £184.90 a week of basic State Pension and £30.50 x 1.017 x + 1.038 of additional State Pension.""" + person = {"age": 90, "male": True, "months": 3, "weekly": 200} + sim = projected_simulation([person]) + for year in (DATA_YEAR, 2025, 2026): + assert calculate(sim, "state_pension_type", year)[0] == "BASIC" + additional_2025 = (200 - 169.50) * WEEKS_IN_YEAR * 1.017 + additional_2026 = (200 - 169.50) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", DATA_YEAR)[0] == approx( + (200 - 169.50) * WEEKS_IN_YEAR + ) + assert calculate(sim, "additional_state_pension", 2025)[0] == approx( + additional_2025 + ) + assert calculate(sim, "additional_state_pension", 2026)[0] == approx( + additional_2026 + ) + assert calculate(sim, "basic_state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + ) + assert calculate(sim, "state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + additional_2026 + ) + + +def test_protected_payment_rises_with_september_cpi_not_the_new_rate(): + """A man aged 70 in 2024-25 reached State Pension age in 2020, on the new + State Pension, as does a man aged 70 in 2026-27. He reports £240 a week, + £18.80 above the 2024-25 full rate: a protected payment. The Up-rating + Orders raised the full rate by 4.1% and 4.8% and protected payments by + 1.7% and 3.8% (SI 2025/295 and SI 2026/148, arts 6(1) and 6(3)(b)), so in + 2026-27 he gets £241.30 a week of new State Pension and £18.80 x 1.017 x + 1.038 of protected payment.""" + person = {"age": 70, "male": True, "months": 3, "weekly": 240} + sim = projected_simulation([person]) + for year in (DATA_YEAR, 2025, 2026): + assert calculate(sim, "state_pension_type", year)[0] == "NEW" + protected_2026 = (240 - 221.20) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", 2025)[0] == approx( + (240 - 221.20) * WEEKS_IN_YEAR * 1.017 + ) + assert calculate(sim, "additional_state_pension", 2026)[0] == approx(protected_2026) + assert calculate(sim, "new_state_pension", 2026)[0] == approx( + 241.30 * WEEKS_IN_YEAR + ) + assert calculate(sim, "state_pension", 2026)[0] == approx( + 241.30 * WEEKS_IN_YEAR + protected_2026 + ) + + +@pytest.mark.parametrize( + "person", + [ + {"age": 90, "male": True, "months": 3, "weekly": 250}, + {"age": 70, "male": True, "months": 3, "weekly": 240}, + ], + ids=["additional_pension", "protected_payment"], +) +def test_add_on_grows_each_year_by_that_years_september_cpi_rise(person): + """Each April the add-on rises by the September CPI of the year before: + the Orders' 1.7% and 3.8%, then the forecast.""" + sim = projected_simulation([person]) + for year in YEARS[1:]: + growth = ( + calculate(sim, "additional_state_pension", year)[0] + / calculate(sim, "additional_state_pension", year - 1)[0] + ) + assert growth == pytest.approx(1 + add_on_rise(sim, year), abs=INDEX_ROUNDING) + + +def test_september_cpi_uprating_matches_the_up_rating_orders(): + """The index the add-on follows rises each April by the percentage the + Up-rating Order gave additional pensions that year, 0% in April 2016.""" + parameters = system.parameters + indices = parameters.gov.economic_assumptions.indices + yoy_growth = parameters.gov.economic_assumptions.yoy_growth + for year, (order, rise) in UP_RATING_ORDERS.items(): + assert yoy_growth.september_cpi_uprating(year) == pytest.approx(rise), order + growth = indices.september_cpi_uprating(year) / indices.september_cpi_uprating( + year - 1 + ) + assert growth == pytest.approx(1 + rise, abs=INDEX_ROUNDING), order + + +@pytest.mark.parametrize( + "scenario", + [ + # Full rates set by reform from 2026-27: the add-on is not scaled with + # them. + Scenario( + parameter_changes={ + "gov.dwp.state_pension.basic_state_pension.amount": { + "2026-01-01.2100-12-31": 300.0 + }, + "gov.dwp.state_pension.new_state_pension.amount": { + "2026-01-01.2100-12-31": 300.0 + }, + } + ), + # A 10% triple-lock floor from April 2027 raises the full rates, not + # the add-on. + Scenario( + parameter_changes={ + "gov.dwp.state_pension.triple_lock.minimum_rate": { + "year:2027-01-01:100": 0.1 + }, + }, + applied_before_data_load=True, + ), + ], + ids=["full_rates", "triple_lock_floor"], +) +def test_full_rate_reforms_leave_the_add_on_alone(scenario): + people = [ + {"age": 90, "male": True, "months": 3, "weekly": 250}, + {"age": 70, "male": True, "months": 3, "weekly": 240}, + ] + baseline = projected_simulation(people) + reformed = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), scenario=scenario + ) + for year in (2027, 2028, 2030): + assert calculate(reformed, "state_pension", year).sum() != pytest.approx( + calculate(baseline, "state_pension", year).sum() + ) + assert calculate(reformed, "additional_state_pension", year) == pytest.approx( + calculate(baseline, "additional_state_pension", year), abs=0.01 + ) + + +def test_a_september_cpi_scenario_moves_the_add_on(): + """The index is read from the simulation's own parameters: with September + 2027 CPI set to 5%, the add-on rises 5% in April 2028.""" + person = {"age": 90, "male": True, "months": 3, "weekly": 250} + scenario = Scenario( + parameter_changes={ + "gov.economic_assumptions.statutory_uprating_inputs.cpi_september": { + "2027": 0.05 + }, + }, + applied_before_data_load=True, ) - assert total[1] == pytest.approx(uprated_reported(sim, 2030, "NEW", 230), abs=0.01) + sim = Microsimulation(dataset=year_dataset([person], DATA_YEAR), scenario=scenario) + growth = ( + calculate(sim, "additional_state_pension", 2028)[0] + / calculate(sim, "additional_state_pension", 2027)[0] + ) + assert growth == pytest.approx(1.05, abs=INDEX_ROUNDING) # Each example builds a microsimulation, so examples are slow to generate. @@ -255,11 +477,7 @@ def assert_components_add_up(sim, people: list, years) -> None: flat_uprating(sim, year, "BASIC"), flat_uprating(sim, year, "NEW"), ) - add_up = by_type( - pension_type, - add_on_uprating(sim, year, "BASIC"), - add_on_uprating(sim, year, "NEW"), - ) + add_up = add_on_uprating(sim, year) reported = weekly * WEEKS_IN_YEAR assert np.array_equal(pension_type == "NONE", ~over_spa), year flat = basic + new @@ -267,8 +485,7 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.allclose(flat, expected_flat, rtol=1e-5, atol=0.01) deflated = np.where( over_spa, - flat / np.where(over_spa, flat_up, 1) - + additional / np.where(over_spa, add_up, 1), + flat / np.where(over_spa, flat_up, 1) + additional / add_up, 0, ) assert np.allclose(deflated, over_spa * reported, rtol=1e-5, atol=0.01) @@ -314,10 +531,132 @@ def test_components_add_up_whatever_ages_the_period_carries(people, period, data assert_components_add_up(sim, people, (DATA_YEAR, period)) +def main_components(sim, weekly: np.ndarray, year: int) -> tuple: + """basic, new and additional State Pension as policyengine-uk main computed + them before #1941 (6d89338a), with the add-on uprated by the type's full + rate. A reference for the differential test.""" + pension_type = calculate(sim, "state_pension_type", year).astype(str) + over_spa = calculate(sim, "is_SP_age", year).astype(bool) + rates, data_rates = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) + full = by_type(pension_type, data_rates["BASIC"], data_rates["NEW"]) + full_now = by_type(pension_type, rates["BASIC"], rates["NEW"]) + has_rate = full > 0 + safe_full = np.where(has_rate, full, 1) + share = np.where(has_rate, np.minimum(weekly, full) / safe_full, 0) + basic = np.where(pension_type == "BASIC", share * rates["BASIC"], 0) + new = np.where(pension_type == "NEW", share * rates["NEW"], 0) + above = np.where(pension_type != "NONE", np.maximum(weekly - full, 0), 0) + additional = over_spa * above * np.where(has_rate, full_now / safe_full, 1) + return tuple(WEEKS_IN_YEAR * x for x in (basic, new, additional)) + + +def paired(people: list, extra: list) -> list: + """Each person, then the same person reporting ``extra`` more a week.""" + return people + [ + {**person, "weekly": person["weekly"] + more} + for person, more in zip(people, extra) + ] + + +@PROPERTY_SETTINGS +@given(people=PEOPLE, data=st.data()) +def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): + """For every record and year from 2024-25 to 2030-31: + + - the add-on is never negative, and never falls as the reported amount rises; + - from one year to the next, with the type unchanged, it grows by that + year's September CPI rise; + - differential against main: the flat-rate parts are unchanged, the add-on + is unchanged in the data year, and otherwise differs only by uprating with + September CPI instead of the full rate.""" + extra = data.draw( + st.lists( + st.floats(min_value=0, max_value=300), + min_size=len(people), + max_size=len(people), + ) + ) + records = paired(people, extra) + sim = projected_simulation(records) + weekly = np.array([p["weekly"] for p in records]) + n = len(people) + previous = None + for year in YEARS: + pension_type = calculate(sim, "state_pension_type", year).astype(str) + basic, new, additional = (calculate(sim, v, year) for v in COMPONENTS) + assert np.all(additional >= 0) + assert np.all(additional[n:] >= additional[:n] - 1e-9) + main_basic, main_new, main_additional = main_components(sim, weekly, year) + assert np.allclose(basic, main_basic, rtol=1e-12, atol=1e-9) + assert np.allclose(new, main_new, rtol=1e-12, atol=1e-9) + if year == DATA_YEAR: + assert np.allclose(additional, main_additional, rtol=1e-12, atol=1e-9) + flat_up = by_type( + pension_type, + flat_uprating(sim, year, "BASIC"), + flat_uprating(sim, year, "NEW"), + ) + flat_up = np.where(pension_type == "NONE", 1, flat_up) + assert np.allclose( + additional * flat_up, + main_additional * add_on_uprating(sim, year), + rtol=INDEX_ROUNDING, + atol=0.01, + ) + if previous is not None: + previous_type, previous_additional = previous + same = (pension_type == previous_type) & (previous_additional > 0) + rise = 1 + add_on_rise(sim, year) + assert np.allclose( + additional[same], + previous_additional[same] * rise, + rtol=INDEX_ROUNDING, + atol=0, + ) + previous = (pension_type, additional) + + +RATE = st.floats(min_value=0, max_value=500) + + +@PROPERTY_SETTINGS +@given( + people=PEOPLE, + basic_rate=RATE, + new_rate=RATE, + from_year=st.sampled_from([None, 2025, 2027]), +) +def test_full_rate_reforms_never_move_the_add_on( + people, basic_rate, new_rate, from_year +): + """Differential: the same records with and without a reform to the full + rates, undated (so also in the data year) or from a later year. In law the + add-on rises with prices whatever the full rates do, and the reported amount + is still split at the legislated data-year rates, so the add-on is the same + in every year.""" + period = ( + "2000-01-01.2100-12-31" + if from_year is None + else f"{from_year}-01-01.2100-12-31" + ) + reform = {BSP: {period: basic_rate}, NSP: {period: new_rate}} + baseline = projected_simulation(people) + reformed = Microsimulation(dataset=year_dataset(people, DATA_YEAR), reform=reform) + for year in YEARS: + assert np.allclose( + calculate(reformed, "additional_state_pension", year), + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + + # Rate reforms (PolicyEngine/policyengine-uk#2122). The reported amount is # split at the legislated (baseline) data-year rate; the flat-rate part then -# follows the reformed period rate, and the part above the legislated rate is -# scaled by the reformed period rate over the legislated data-year rate. +# follows the reformed period rate. The part above the legislated rate rises +# with September CPI, whatever the full rates (#1941): a reform to the full +# rates no longer scales it. To abolish State Pension altogether, use +# gov.contrib.abolish_state_pension. NSP = "gov.dwp.state_pension.new_state_pension.amount" BSP = "gov.dwp.state_pension.basic_state_pension.amount" @@ -331,12 +670,16 @@ def test_components_add_up_whatever_ages_the_period_carries(people, period, data } -def expected_under_rate(weekly: float, legislated: float, reformed: float) -> dict: +def expected_under_rate( + weekly: float, legislated: float, reformed: float, add_on_up: float = 1 +) -> dict: """Annual flat-rate and add-on amounts for a record reporting ``weekly`` in a data year whose legislated full rate is ``legislated``, in a period whose - reformed full rate is ``reformed``.""" + reformed full rate is ``reformed`` and whose September CPI is ``add_on_up`` + times the data year's (1 without a dataset, where the data year is the + period).""" share = min(weekly, legislated) / legislated - add_on = max(weekly - legislated, 0) * reformed / legislated + add_on = max(weekly - legislated, 0) * add_on_up return { "flat": share * reformed * WEEKS_IN_YEAR, "additional": add_on * WEEKS_IN_YEAR, @@ -398,8 +741,8 @@ def test_situation_basic_state_pension_follows_a_dated_rate_reform(): def test_situation_protected_payment_stays_additional_under_a_rate_reform(): """£10 a week above the legislated full rate is a protected payment. The - flat-rate part follows the reform; the protected payment is scaled by the - reformed over the legislated rate, as add-ons are (see #1941).""" + flat-rate part follows the reform; the protected payment stays £10 a week, + as it rises with prices, not with the full rate (SI 2026/148 art 6(3)(b)).""" year = 2026 legislated = LEGISLATED[(year, "NEW")] weekly = legislated + 10 @@ -424,19 +767,26 @@ def test_situation_protected_payment_stays_additional_under_a_rate_reform(): ({"age": 90, "male": True, "months": 3, "weekly": 250}, "BASIC", BSP), ], ) -def test_undated_rate_reform_moves_state_pension_with_the_rate( +def test_undated_rate_reform_moves_only_the_flat_rate_part( person, pension_type, parameter, reformed ): """An undated reform rewrites the data year's rate too. The record is still - split at the legislated 2024-25 rate, so State Pension rises with a higher - rate, falls with a lower one and is nil at a rate of zero.""" + split at the legislated 2024-25 rate, so the flat-rate part rises with a + higher rate, falls with a lower one and is nil at a rate of zero, while the + add-on keeps rising with September CPI: at a rate of zero the record keeps + its additional pension or protected payment. This is a deliberate change + from before #1941, when the add-on was scaled with the full rate and a rate + of zero removed all State Pension.""" sim = Microsimulation( dataset=year_dataset([person], DATA_YEAR), reform={parameter: reformed} ) year = 2027 assert calculate(sim, "state_pension_type", year)[0] == pension_type expected = expected_under_rate( - person["weekly"], LEGISLATED[(DATA_YEAR, pension_type)], reformed + person["weekly"], + LEGISLATED[(DATA_YEAR, pension_type)], + reformed, + add_on_uprating(sim, year), ) flat_variable = ( "new_state_pension" if pension_type == "NEW" else "basic_state_pension" @@ -444,12 +794,27 @@ def test_undated_rate_reform_moves_state_pension_with_the_rate( assert calculate(sim, flat_variable, year)[0] == pytest.approx( expected["flat"], abs=0.01 ) - assert calculate(sim, "additional_state_pension", year)[0] == pytest.approx( - expected["additional"], abs=0.01 + assert calculate(sim, "additional_state_pension", year)[0] == approx( + expected["additional"] ) - assert calculate(sim, "state_pension", year)[0] == pytest.approx( - expected["flat"] + expected["additional"], abs=0.01 + assert calculate(sim, "state_pension", year)[0] == approx( + expected["flat"] + expected["additional"] + ) + + +def test_abolishing_state_pension_still_removes_the_add_on(): + """gov.contrib.abolish_state_pension removes the whole State Pension, + additional pension and protected payments included.""" + people = [ + {"age": 70, "male": True, "months": 3, "weekly": 240}, + {"age": 90, "male": True, "months": 3, "weekly": 250}, + ] + sim = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), + reform={"gov.contrib.abolish_state_pension": True}, ) + for year in (DATA_YEAR, 2027, 2030): + assert np.all(calculate(sim, "state_pension", year) == 0) def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): @@ -460,7 +825,9 @@ def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): dataset=year_dataset([person], DATA_YEAR), reform={NSP: {"2026-01-01.2100-12-31": 300.0}}, ) - expected = expected_under_rate(240, LEGISLATED[(DATA_YEAR, "NEW")], 300.0) - assert calculate(sim, "state_pension", 2027)[0] == pytest.approx( - expected["flat"] + expected["additional"], abs=0.01 + expected = expected_under_rate( + 240, LEGISLATED[(DATA_YEAR, "NEW")], 300.0, add_on_uprating(sim, 2027) + ) + assert calculate(sim, "state_pension", 2027)[0] == approx( + expected["flat"] + expected["additional"] ) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index 932e7ff563..7ef5d894d6 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -7,6 +7,12 @@ class additional_state_pension(Variable): definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150", + "https://www.legislation.gov.uk/ukpga/1992/5/section/151A", + "https://www.legislation.gov.uk/uksi/2026/148/article/4/made", + "https://www.legislation.gov.uk/uksi/2026/148/article/6/made", + ) def formula(person, period, parameters): simulation = person.simulation @@ -29,21 +35,18 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) types = pension_type.possible_values - bsp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount - nsp_amount = parameters.gov.dwp.state_pension.new_state_pension.amount - # Each pension type has its own flat-rate ceiling; anything above # the ceiling is an add-on: # BASIC → SERPS / S2P (pre-2016 earnings-related top-up) # NEW → Protected Payment (pre-2016 accrual exceeding the # new flat rate, folded into NSP under current law) # state_pension_reported was observed under the legislated rates, so - # split it at the baseline's data-year rate, not the reformed one. - # Otherwise a reform that also sets the data year's rate (an undated - # reform, or any reform in a simulation without a dataset, where the - # data year is the period) leaves the period-over-data-year ratio at 1, - # and state_pension does not follow the reform - # (PolicyEngine/policyengine-uk#2122). + # split it at the baseline's data-year rate, not the reformed one, as + # basic_state_pension and new_state_pension do. Otherwise a reform + # that also sets the data year's rate (an undated reform, or any + # reform in a simulation without a dataset, where the data year is + # the period) would move part of the reported amount between the + # flat-rate part and the add-on (PolicyEngine/policyengine-uk#2122). baseline = getattr(simulation, "baseline", None) legislated = ( baseline.tax_benefit_system.parameters @@ -58,22 +61,25 @@ def formula(person, period, parameters): ], default=0, ) - max_for_type_period = select( - [pension_type == types.BASIC, pension_type == types.NEW], - [bsp_amount(period), nsp_amount(period)], - default=0, - ) - amount_in_data_year = where( pension_type != types.NONE, max_(reported - max_for_type_data, 0), 0, ) - uprating = where( - max_for_type_data > 0, - max_for_type_period / max_for_type_data, - 1, + # The full rates rise with the triple lock, but the add-on rises with + # prices: each year's Up-rating Order raises additional pensions and + # the amounts of new State Pension above the full rate by September + # CPI (Social Security Administration Act 1992 ss.150(1)(c), 151(1) + # and 151A; e.g. SI 2026/148 arts 4(3) and 6(3)(b), 3.8%, against + # 4.8% for the full rates). So it is uprated from the data year by + # September CPI, not by the full rate, and a reform to the full rates + # leaves it alone (PolicyEngine/policyengine-uk#1941). The index is + # read from this simulation's parameters, so a price scenario moves + # it. + september_cpi = ( + parameters.gov.economic_assumptions.indices.september_cpi_uprating ) + uprating = september_cpi(period) / september_cpi(data_year) # No State Pension is paid before State Pension age. A computed type # is already NONE there; this also holds if the type is an input. is_sp_age = person("is_SP_age", period) From 368ebb0618e7e2e43f8280b94a3ed15c20f7eb0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:00:47 -0400 Subject: [PATCH 2/4] Compare float32 model output with a float32 tolerance; pass rate reforms as a reform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI found two test faults (run 38161065391): the differential against main's formula compared the model's float32 arrays with a float64 reference at rtol 1e-12, which fails for any reported amount float32 cannot hold exactly (e.g. £169.00 a week, aged 98); and a Scenario cannot take the reform dictionary's "2026-01-01.2100-12-31" period, so that case passes it as a reform. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_components.py | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 8487b260aa..aeba435d94 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -363,12 +363,12 @@ def test_september_cpi_uprating_matches_the_up_rating_orders(): @pytest.mark.parametrize( - "scenario", + "change", [ # Full rates set by reform from 2026-27: the add-on is not scaled with # them. - Scenario( - parameter_changes={ + { + "reform": { "gov.dwp.state_pension.basic_state_pension.amount": { "2026-01-01.2100-12-31": 300.0 }, @@ -376,29 +376,29 @@ def test_september_cpi_uprating_matches_the_up_rating_orders(): "2026-01-01.2100-12-31": 300.0 }, } - ), + }, # A 10% triple-lock floor from April 2027 raises the full rates, not # the add-on. - Scenario( - parameter_changes={ - "gov.dwp.state_pension.triple_lock.minimum_rate": { - "year:2027-01-01:100": 0.1 + { + "scenario": Scenario( + parameter_changes={ + "gov.dwp.state_pension.triple_lock.minimum_rate": { + "year:2027-01-01:100": 0.1 + }, }, - }, - applied_before_data_load=True, - ), + applied_before_data_load=True, + ) + }, ], ids=["full_rates", "triple_lock_floor"], ) -def test_full_rate_reforms_leave_the_add_on_alone(scenario): +def test_full_rate_reforms_leave_the_add_on_alone(change): people = [ {"age": 90, "male": True, "months": 3, "weekly": 250}, {"age": 70, "male": True, "months": 3, "weekly": 240}, ] baseline = projected_simulation(people) - reformed = Microsimulation( - dataset=year_dataset(people, DATA_YEAR), scenario=scenario - ) + reformed = Microsimulation(dataset=year_dataset(people, DATA_YEAR), **change) for year in (2027, 2028, 2030): assert calculate(reformed, "state_pension", year).sum() != pytest.approx( calculate(baseline, "state_pension", year).sum() @@ -550,6 +550,10 @@ def main_components(sim, weekly: np.ndarray, year: int) -> tuple: return tuple(WEEKS_IN_YEAR * x for x in (basic, new, additional)) +# The model computes in float32 and the reference in float64. +FLOAT32 = dict(rtol=1e-6, atol=0.01) + + def paired(people: list, extra: list) -> list: """Each person, then the same person reporting ``extra`` more a week.""" return people + [ @@ -587,10 +591,10 @@ def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): assert np.all(additional >= 0) assert np.all(additional[n:] >= additional[:n] - 1e-9) main_basic, main_new, main_additional = main_components(sim, weekly, year) - assert np.allclose(basic, main_basic, rtol=1e-12, atol=1e-9) - assert np.allclose(new, main_new, rtol=1e-12, atol=1e-9) + assert np.allclose(basic, main_basic, **FLOAT32) + assert np.allclose(new, main_new, **FLOAT32) if year == DATA_YEAR: - assert np.allclose(additional, main_additional, rtol=1e-12, atol=1e-9) + assert np.allclose(additional, main_additional, **FLOAT32) flat_up = by_type( pension_type, flat_uprating(sim, year, "BASIC"), @@ -611,7 +615,7 @@ def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): additional[same], previous_additional[same] * rise, rtol=INDEX_ROUNDING, - atol=0, + atol=1e-6, ) previous = (pension_type, additional) From 15bb98d4403b1fe24ca180be51d0d468ce5902c5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:14:03 -0400 Subject: [PATCH 3/4] Separate the CPI scenario test from the triple lock; note the impact in the docs At 5% September CPI the triple lock also rose 5%, so the scenario test passed on main. At 1%, below the triple lock's 2.5% floor, the add-on rises 1% and the full rates at least 2.5%. The docs record the change in the model's State Pension against DWP's forecast, from full runs on the certified dataset. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 8 ++++++++ policyengine_uk/tests/test_state_pension_components.py | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index e4aafcff7c..98a7f636ea 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -370,6 +370,14 @@ abroad. The model also includes Northern Ireland, which DWP's figures do not, so the like-for-like gap is larger by Northern Ireland's State Pension. +These runs predate the fix for +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941), which +uprates additional pensions and protected payments by September CPI instead of +the triple lock. On the enhanced FRS 2024-25 (policyengine-uk-data-private +1.58.0) it leaves 2024-25 unchanged and lowers the model's State Pension by +£0.3bn in 2025-26, £0.5bn in 2026-27 and £0.8bn in 2030-31, so the gap to DWP +widens by as much. + **The data year.** In 2024-25 the model has 11.36m State Pension recipients averaging £201.6 a week, for the UK. The FRS's own grossed figures for Great Britain are 11.53m recipients averaging £212 a week, and DWP's administrative diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index aeba435d94..7586378f28 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -410,12 +410,13 @@ def test_full_rate_reforms_leave_the_add_on_alone(change): def test_a_september_cpi_scenario_moves_the_add_on(): """The index is read from the simulation's own parameters: with September - 2027 CPI set to 5%, the add-on rises 5% in April 2028.""" + 2027 CPI set to 1%, the add-on rises 1% in April 2028, while the full + rates rise by at least the triple lock's 2.5%.""" person = {"age": 90, "male": True, "months": 3, "weekly": 250} scenario = Scenario( parameter_changes={ "gov.economic_assumptions.statutory_uprating_inputs.cpi_september": { - "2027": 0.05 + "2027": 0.01 }, }, applied_before_data_load=True, @@ -425,7 +426,10 @@ def test_a_september_cpi_scenario_moves_the_add_on(): calculate(sim, "additional_state_pension", 2028)[0] / calculate(sim, "additional_state_pension", 2027)[0] ) - assert growth == pytest.approx(1.05, abs=INDEX_ROUNDING) + assert growth == pytest.approx(1.01, abs=INDEX_ROUNDING) + assert flat_uprating(sim, 2028, "BASIC") / flat_uprating(sim, 2027, "BASIC") >= ( + 1.025 - INDEX_ROUNDING + ) # Each example builds a microsimulation, so examples are slow to generate. From 4a6b736a09076e303f3e646c85f5709805c93de4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 01:44:27 -0400 Subject: [PATCH 4/4] Clarify the statutory uprating basis and forecast comparison Co-Authored-By: GPT-6 --- docs/book/programs/gov/dwp/state-pension.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 98a7f636ea..b353ff43d5 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -40,8 +40,9 @@ simulated, for anyone over State Pension age: CPI: `gov.economic_assumptions.indices.september_cpi_uprating` in the year over the data year. -That follows the law. Each year's Up-rating Order raises the full rates by -the triple lock (Social Security Administration Act 1992 s.150A), but raises +That follows the law. Each year's Up-rating Order applies the triple lock to +the full rates; the statutory minimum is earnings growth +([Social Security Administration Act 1992 s.150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A)). The Order raises additional pensions (s.150(1)(c)), increments for deferring the pension and the amounts of new State Pension above the full rate (protected payments, s.151A) by the September CPI of the year before. In April 2026 the full @@ -373,10 +374,9 @@ like-for-like gap is larger by Northern Ireland's State Pension. These runs predate the fix for [#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941), which uprates additional pensions and protected payments by September CPI instead of -the triple lock. On the enhanced FRS 2024-25 (policyengine-uk-data-private -1.58.0) it leaves 2024-25 unchanged and lowers the model's State Pension by -£0.3bn in 2025-26, £0.5bn in 2026-27 and £0.8bn in 2030-31, so the gap to DWP -widens by as much. +the triple lock. The fix leaves the data year unchanged. In projected years, +when the full rates rise faster than prices, it lowers the model's State +Pension and widens the gap to the forecasts in this table. **The data year.** In 2024-25 the model has 11.36m State Pension recipients averaging £201.6 a week, for the UK. The FRS's own grossed figures for Great