From bcdee62b32a205c910e55276c6e0d2e082cfd74d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:41:13 -0400 Subject: [PATCH 1/8] 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 16f87a0b2e2ab16fb17a884dea1ad29e57e22175 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:05:25 -0400 Subject: [PATCH 2/8] Model the 25p State Pension age addition at 80 SSCBA 1992 s.79(1) and (3) and Sch 4 Part III para 8 give a person over 80 who is entitled to a retirement pension under that Act 25p a week, which each Up-rating Order leaves out (SI 2026/148 art 3(2)(a)). A new State Pension does not count (s.79(1) as amended by the Pensions Act 2014 Sch 12 para 85). Add gov.dwp.state_pension.age_addition (amount, age) and state_pension_age_addition, and count it in state_pension. The survey's State Pension amount is taken to include the addition, so state_pension_reported_excluding_age_addition takes the data year's legislated addition off before basic_state_pension, new_state_pension and additional_state_pension split it: it is neither counted twice nor uprated as additional pension. It never reduces a carer benefit (SI 1979/597 reg 4(3), Sch 1 row 4), so overlapping_state_pension leaves it out. Fixes #2139. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2139.fixed.md | 1 + docs/book/programs/gov/dwp/state-pension.md | 57 +++++- .../dwp/state_pension/age_addition/age.yaml | 11 ++ .../state_pension/age_addition/amount.yaml | 12 ++ .../gov/dwp/state_pension_age_addition.yaml | 139 ++++++++++++++ .../tests/test_state_pension_components.py | 173 +++++++++++++++--- .../gov/dwp/additional_state_pension.py | 7 +- .../variables/gov/dwp/basic_state_pension.py | 7 +- .../variables/gov/dwp/new_state_pension.py | 7 +- .../gov/dwp/overlapping_state_pension.py | 23 ++- .../gov/dwp/state_pension_age_addition.py | 58 ++++++ ...pension_reported_excluding_age_addition.py | 42 +++++ .../variables/input/state_pension.py | 1 + 13 files changed, 496 insertions(+), 42 deletions(-) create mode 100644 changelog.d/2139.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml create mode 100644 policyengine_uk/variables/gov/dwp/state_pension_age_addition.py create mode 100644 policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py diff --git a/changelog.d/2139.fixed.md b/changelog.d/2139.fixed.md new file mode 100644 index 0000000000..1bd5d9bd9b --- /dev/null +++ b/changelog.d/2139.fixed.md @@ -0,0 +1 @@ +- Model the 25p a week State Pension age addition at 80 (SSCBA 1992 s.79) for basic State Pension recipients, never uprated, and take it out of the reported amount before the flat-rate and additional pension split. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index e4aafcff7c..f70c920f03 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -1,6 +1,6 @@ # State Pension -The State Pension is modelled in PolicyEngine UK as three composable +The State Pension is modelled in PolicyEngine UK as four composable person-level variables, summed into the `state_pension` aggregate that flows into household benefits. This page describes the current model and the residual aggregate gap against OBR outturn tracked under @@ -8,7 +8,7 @@ and the residual aggregate gap against OBR outturn tracked under ## Components -Three variables under `gov/dwp/`: +Four variables under `gov/dwp/`: - **`basic_state_pension`** — the pre-2016 flat-rate State Pension paid to people who reached State Pension age before 6 April 2016 @@ -28,10 +28,50 @@ Three variables under `gov/dwp/`: - Both are the reported weekly amount above the type's full rate in the 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, for anyone over State Pension age: +- **`state_pension_age_addition`** — the age addition: 25p a week + (`gov.dwp.state_pension.age_addition.amount`) for a person over the age of + 80 (`age_addition.age`) who is entitled to a retirement pension under the + Social Security Contributions and Benefits Act 1992 + ([s.79](https://www.legislation.gov.uk/ukpga/1992/4/section/79) and + [Sch 4 Part III para 8](https://www.legislation.gov.uk/ukpga/1992/4/schedule/4)), + read as a `BASIC` type with a State Pension reported in the data year. It + is never uprated: each Up-rating Order excludes it (for example + [SI 2026/148 art 3(2)(a)](https://www.legislation.gov.uk/uksi/2026/148/article/3/made)). + A new State Pension gets none, because s.79(1) covers only a retirement + pension "under this Act", words the Pensions Act 2014 inserted + ([Sch 12 para 85](https://www.legislation.gov.uk/ukpga/2014/19/schedule/12/paragraph/85)). + A person is over an age once they have attained it (SSCBA 1992 s.173). + The model reads age on 6 October, the middle of the fiscal year, as it + does for State Pension age, so a person who turns 80 by then gets the + year's £13 in full and one who turns 80 later in the year gets none until + the next. + +The survey records the State Pension in payment. For Family Resources +Survey respondents, `state_pension_reported` is the benefits table's weekly +State Pension amount (benefit code 5) times 52, and policyengine-uk-data maps +no separate code for the age addition (`datasets/frs.py`), so the model +assumes the reported amount of a basic State Pension recipient over 80 +includes it. `state_pension_reported_excluding_age_addition` takes the data +year's addition off, at the legislated 25p from 80, and the other three +components split what is left. The addition is part of `state_pension`, so +it is taxed and means-tested as the pension is: it is an increase of the +pension (s.79(1)), which income tax charges as pension income under +[ITEPA 2003 s.577](https://www.legislation.gov.uk/ukpga/2003/1/section/577) +(the pre-2003 charge in ICTA 1988 s.617(1) reached every benefit under Parts +II to IV of the 1992 Act not excepted, and the age addition was not), and +Pension Credit counts it as retirement pension income +([State Pension Credit Act 2002 s.16(1)(e)](https://www.legislation.gov.uk/ukpga/2002/16/section/16)), +as do Universal Credit (Universal Credit Regulations 2013 regs 66(1)(a) and 67) and +pension-age Housing Benefit (Housing Benefit (State Pension Credit) +Regulations 2006 reg 29(1)(c)). It never reduces Carer's Allowance: an age +addition is adjusted only by reference to another age addition +([Overlapping Benefits Regulations 1979 reg 4(3)](https://www.legislation.gov.uk/uksi/1979/597/regulation/4) +and Sch 1 row 4). + +The basic, new and additional State Pension split the amount a person +reported in the data year (the dataset's first year), less any age addition, +by the person's `state_pension_type` in the year 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 @@ -418,9 +458,14 @@ part of the widening. | 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 | +| The 25p age addition at 80 was not modelled: in the reported amount it was uprated as additional pension, and no one who turned 80 later got it | Fixed | #2139 | ### What's still open +- The Category D retirement pension, for people over 80 with no other + retirement pension or one below its rate (SSCBA 1992 s.78(3)), is not + modelled: [#2339](https://github.com/PolicyEngine/policyengine-uk/issues/2339). + - The data year's shortfall in the enhanced FRS: [policyengine-uk-data#493](https://github.com/PolicyEngine/policyengine-uk-data/issues/493). - The pension-age population and new-cohort awards in projected years: diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml new file mode 100644 index 0000000000..817db0ead0 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml @@ -0,0 +1,11 @@ +description: A person entitled to a retirement pension under the Social Security Contributions and Benefits Act 1992 gets the age addition from this age. +values: + 1992-07-01: 80 +metadata: + unit: year + label: State Pension age addition age + reference: + - title: Social Security Contributions and Benefits Act 1992 s.79(1) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/79 + - title: Social Security Contributions and Benefits Act 1992 s.173 (a person is over an age once they have attained it) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/173 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml new file mode 100644 index 0000000000..6ce0906ad2 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml @@ -0,0 +1,12 @@ +description: The Department for Work and Pensions adds this weekly amount, the age addition, to the retirement pension of a person over the age of 80. Up-rating Orders leave it out of the annual uprating, so it has stayed at 25p. +values: + 1992-07-01: 0.25 +metadata: + period: week + unit: currency-GBP + label: State Pension age addition + reference: + - title: Social Security Contributions and Benefits Act 1992 s.79(3) and Sch 4 Part III para 8 + href: https://www.legislation.gov.uk/ukpga/1992/4/schedule/4 + - title: Social Security Benefits Up-rating Order 2026 art 3(2)(a) (the age addition is not increased) + href: https://www.legislation.gov.uk/uksi/2026/148/article/3/made diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml new file mode 100644 index 0000000000..816b6927de --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml @@ -0,0 +1,139 @@ +# The age addition: 25p a week for a person over the age of 80 who is entitled +# to a retirement pension under the Social Security Contributions and Benefits +# Act 1992 (s.79(1) and (3), Sch 4 Part III para 8), never uprated (each +# Up-rating Order excludes it, e.g. SI 2026/148 art 3(2)(a)). A person is over +# an age once they have attained it (s.173). A new State Pension, under Part 1 +# of the Pensions Act 2014, is not a pension under that Act (s.79(1) as amended +# by the Pensions Act 2014 Sch 12 para 85). Annual amount: 0.25 x 52 = 13. + +- name: Case 1, aged 80 on the basic State Pension gets the age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 13 + is_SP_age: true + +- name: Case 2, aged 79 gets no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 79 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 0 + +- name: Case 3, aged 85 on the new State Pension gets no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: NEW + state_pension_reported: 12_000 + output: + state_pension_age_addition: 0 + +- name: Case 4, below State Pension age gets no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + state_pension_reported: 9_100 + output: + state_pension_type: NONE + state_pension_age_addition: 0 + +- name: Case 5, the age addition stays 25p a week in 2026-27. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 13 + +- name: Case 6, the age addition stays 25p a week in 2030-31. + period: 2030 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 13 + +- name: Case 7, no State Pension means no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 0 + output: + state_pension_age_addition: 0 + state_pension: 0 + +- name: Case 8, the computed State Pension type of a man aged 80 is basic. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + gender: MALE + state_pension_reported: 9_100 + output: + state_pension_type: BASIC + state_pension_age_addition: 13 + +- name: Case 9, a reported full basic rate plus 25p is the full rate and the addition. + # 2025-26 basic State Pension £176.45 a week (SI 2025/295 art 4(2)(b)). The + # reported £176.70 a week is the full rate and the age addition, so there is + # no additional State Pension. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 9_188.4 + output: + basic_state_pension: 9_175.4 + additional_state_pension: 0 + state_pension_age_addition: 13 + state_pension: 9_188.4 + state_pension_reported_excluding_age_addition: 9_175.4 + +- name: Case 10, the age addition does not reduce Carer's Allowance. + # Overlapping Benefits Regulations 1979 reg 4(3) and Sch 1 row 4: an age + # addition is adjusted only by reference to another age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 9_188.4 + output: + overlapping_state_pension: 9_175.4 diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 8487b260aa..946c5b4260 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -8,7 +8,10 @@ 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 (additional pension, protected payments) by -September CPI, as the Up-rating Orders raise it (see add_on_uprating). +September CPI, as the Up-rating Orders raise it (see add_on_uprating). The +age addition, 25p a week from 80 on a basic State Pension, is paid on its own +and never uprated, so the components split the reported amount without it +(see net_weekly). The YAML runner builds simulations without a dataset, where the data year is the period by construction, so these cases build simulations from data. @@ -165,6 +168,32 @@ def approx(expected: float): return pytest.approx(expected, rel=INDEX_ROUNDING, abs=0.01) +# The age addition: 25p a week for a person over the age of 80 entitled to a +# retirement pension under the Social Security Contributions and Benefits Act +# 1992, which a new State Pension is not (s.79(1) and (3), Sch 4 Part III +# para 8), never uprated (e.g. SI 2026/148 art 3(2)(a)). The reported amount +# is taken to include it. +AGE_ADDITION_WEEKLY = 0.25 +AGE_ADDITION_AGE = 80 +AGE_ADDITION = AGE_ADDITION_WEEKLY * WEEKS_IN_YEAR + + +def receives_age_addition(pension_type, age, weekly): + """Basic State Pension type, aged 80 or over and some State Pension.""" + return ( + (np.asarray(pension_type) == "BASIC") + & (np.asarray(age) >= AGE_ADDITION_AGE) + & (np.asarray(weekly) > 0) + ) + + +def net_weekly(person: dict, data_year_type: str) -> float: + """The weekly amount the components split: the reported amount less the + age addition it includes in the data year.""" + receives = receives_age_addition(data_year_type, person["age"], person["weekly"]) + return max(person["weekly"] - AGE_ADDITION_WEEKLY * bool(receives), 0) + + def calculate(sim, variable: str, year: int) -> np.ndarray: return np.asarray(sim.calculate(variable, year)) @@ -222,13 +251,16 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): ], ) def test_unchanged_type_splits_at_its_own_flat_rate(person, pension_type): + """The 90-year-old's reported amount includes the 25p age addition, which + is paid on its own; the 70-year-old, on the new State Pension, gets none.""" sim = projected_simulation([person]) + weekly = net_weekly(person, pension_type) + addition = AGE_ADDITION if pension_type == "BASIC" else 0 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 == approx( - uprated_reported(sim, year, pension_type, person["weekly"]) - ) + assert total == approx(uprated_reported(sim, year, pension_type, weekly)) + assert calculate(sim, "state_pension_age_addition", year)[0] == addition def test_nothing_is_paid_once_the_record_falls_below_state_pension_age(): @@ -275,19 +307,20 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): 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.""" + week: the 25p age addition and £30.25 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)), and left the age addition at 25p (art 3(2)(a)), so in + 2026-27 he gets £184.90 a week of basic State Pension, £30.25 x 1.017 x + 1.038 of additional State Pension and 25p.""" 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 + additional_2025 = (199.75 - 169.50) * WEEKS_IN_YEAR * 1.017 + additional_2026 = (199.75 - 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 + (199.75 - 169.50) * WEEKS_IN_YEAR ) assert calculate(sim, "additional_state_pension", 2025)[0] == approx( additional_2025 @@ -298,8 +331,9 @@ def test_additional_pension_rises_with_september_cpi_not_the_basic_rate(): assert calculate(sim, "basic_state_pension", 2026)[0] == approx( 184.90 * WEEKS_IN_YEAR ) + assert calculate(sim, "state_pension_age_addition", 2026)[0] == AGE_ADDITION assert calculate(sim, "state_pension", 2026)[0] == approx( - 184.90 * WEEKS_IN_YEAR + additional_2026 + 184.90 * WEEKS_IN_YEAR + additional_2026 + AGE_ADDITION ) @@ -443,10 +477,11 @@ def test_a_september_cpi_scenario_moves_the_add_on(): "age": st.integers(min_value=55, max_value=100), "male": st.booleans(), "months": st.floats(min_value=0, max_value=12, exclude_max=True), - # Include each data-year flat rate, where the split changes. + # Include each data-year flat rate, where the split changes, the + # age addition alone, and the basic rate with the age addition. "weekly": st.one_of( st.floats(min_value=0, max_value=600), - st.sampled_from([0, 169.5, 221.2]), + st.sampled_from([0, 0.25, 169.5, 169.75, 221.2]), ), } ), @@ -459,13 +494,30 @@ def by_type(pension_type: np.ndarray, basic, new) -> np.ndarray: return np.select([pension_type == "BASIC", pension_type == "NEW"], [basic, new], 0) +def data_year_net_weekly(sim, people: list) -> np.ndarray: + """Each record's reported weekly amount less the age addition it includes + in the data year.""" + weekly = np.array([p["weekly"] for p in people]) + included = receives_age_addition( + calculate(sim, "state_pension_type", DATA_YEAR).astype(str), + calculate(sim, "age", DATA_YEAR), + weekly, + ) + return np.maximum(weekly - AGE_ADDITION_WEEKLY * included, 0) + + def assert_components_add_up(sim, people: list, years) -> None: """For each person and year: below State Pension age, no component and type NONE. Over it, only the period type's flat-rate component, which is the - reported amount up to the type's full rate, uprated by the full rate; and, - deflating each component by its own uprating, the three components add up - to exactly the reported amount, with no overlap and no gap.""" - weekly = np.array([p["weekly"] for p in people]) + reported amount net of the age addition up to the type's full rate, + uprated by the full rate; and, deflating each component by its own + uprating, the three components add up to exactly that net amount, with no + overlap and no gap. The age addition is 25p a week exactly when the record + is on the basic State Pension, aged 80 or over and has a State Pension, in + every year; in the data year the four parts add up to the reported amount + whenever it is at least 25p a week.""" + reported_weekly = np.array([p["weekly"] for p in people]) + weekly = data_year_net_weekly(sim, people) for year in years: pension_type = calculate(sim, "state_pension_type", year).astype(str) over_spa = calculate(sim, "is_SP_age", year).astype(bool) @@ -495,6 +547,20 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.all(additional >= 0) assert np.all(basic <= rates["BASIC"] * WEEKS_IN_YEAR * (1 + 1e-6)) assert np.all(new <= rates["NEW"] * WEEKS_IN_YEAR * (1 + 1e-6)) + addition = calculate(sim, "state_pension_age_addition", year) + receives = receives_age_addition( + pension_type, calculate(sim, "age", year), reported_weekly + ) + assert np.array_equal(addition, receives * AGE_ADDITION), year + if year == DATA_YEAR: + total = basic + new + additional + addition + full_record = over_spa & (reported_weekly >= AGE_ADDITION_WEEKLY) + assert np.allclose( + total[full_record], + reported_weekly[full_record] * WEEKS_IN_YEAR, + rtol=1e-9, + atol=1e-6, + ) @PROPERTY_SETTINGS @@ -566,9 +632,10 @@ def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): - 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.""" + - differential against main, applied to the reported amount net of the age + addition: 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), @@ -578,7 +645,7 @@ def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): ) records = paired(people, extra) sim = projected_simulation(records) - weekly = np.array([p["weekly"] for p in records]) + weekly = data_year_net_weekly(sim, records) n = len(people) previous = None for year in YEARS: @@ -729,14 +796,17 @@ def test_situation_new_state_pension_follows_a_dated_rate_reform(reformed): def test_situation_basic_state_pension_follows_a_dated_rate_reform(): + """Aged 90, reporting the full basic rate and the 25p age addition.""" year = 2026 - weekly = LEGISLATED[(year, "BASIC")] + weekly = LEGISLATED[(year, "BASIC")] + AGE_ADDITION_WEEKLY sit = situation(90, "BASIC", weekly, year) reform = {BSP: {f"{year}-01-01.{year}-12-31": 170.0}} result = situation_components(sit, reform, year) assert result["basic_state_pension"] == pytest.approx(170 * WEEKS_IN_YEAR, abs=0.01) assert result["additional_state_pension"] == 0 - assert result["state_pension"] == pytest.approx(170 * WEEKS_IN_YEAR, abs=0.01) + assert result["state_pension"] == pytest.approx( + 170 * WEEKS_IN_YEAR + AGE_ADDITION, abs=0.01 + ) def test_situation_protected_payment_stays_additional_under_a_rate_reform(): @@ -783,11 +853,12 @@ def test_undated_rate_reform_moves_only_the_flat_rate_part( year = 2027 assert calculate(sim, "state_pension_type", year)[0] == pension_type expected = expected_under_rate( - person["weekly"], + net_weekly(person, pension_type), LEGISLATED[(DATA_YEAR, pension_type)], reformed, add_on_uprating(sim, year), ) + addition = AGE_ADDITION if pension_type == "BASIC" else 0 flat_variable = ( "new_state_pension" if pension_type == "NEW" else "basic_state_pension" ) @@ -798,7 +869,7 @@ def test_undated_rate_reform_moves_only_the_flat_rate_part( expected["additional"] ) assert calculate(sim, "state_pension", year)[0] == approx( - expected["flat"] + expected["additional"] + expected["flat"] + expected["additional"] + addition ) @@ -831,3 +902,51 @@ def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): assert calculate(sim, "state_pension", 2027)[0] == approx( expected["flat"] + expected["additional"] ) + + +# The age addition (PolicyEngine/policyengine-uk#2139). + + +def test_full_basic_rate_plus_age_addition_is_never_additional_pension(): + """A man aged 85 in 2024-25 reports £169.75 a week: the full basic State + Pension (£169.50, Social Security Benefits Up-rating Order 2024 art 4(2)) + and the 25p age addition. In every year he gets the full basic rate and + 25p, never uprated, and no additional State Pension. Before #2139 the 25p + was additional pension, uprated with it.""" + person = {"age": 85, "male": True, "months": 3, "weekly": 169.75} + sim = projected_simulation([person]) + for year in YEARS: + assert calculate(sim, "state_pension_type", year)[0] == "BASIC" + basic = flat_rates(sim, year)["BASIC"] * WEEKS_IN_YEAR + assert calculate(sim, "basic_state_pension", year)[0] == approx(basic) + assert calculate(sim, "additional_state_pension", year)[0] == 0 + assert calculate(sim, "state_pension_age_addition", year)[0] == AGE_ADDITION + assert calculate(sim, "state_pension", year)[0] == approx(basic + AGE_ADDITION) + + +def test_age_addition_reforms_leave_the_reported_split_alone(): + """An undated reform paying £10 a week from 75 pays it to both records in + every year, but the reported amounts were observed under the legislated + 25p from 80: the 85-year-old's is still split net of 25p, and the + 77-year-old's in full, so the other components do not change.""" + people = [ + {"age": 85, "male": True, "months": 3, "weekly": 169.75}, + {"age": 77, "male": True, "months": 3, "weekly": 169.5}, + ] + baseline = projected_simulation(people) + reformed = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), + reform={ + "gov.dwp.state_pension.age_addition.amount": 10.0, + "gov.dwp.state_pension.age_addition.age": 75, + }, + ) + for year in (DATA_YEAR, 2027, 2030): + assert ( + list(calculate(reformed, "state_pension_age_addition", year)) + == [10 * WEEKS_IN_YEAR] * 2 + ) + for variable in COMPONENTS: + assert calculate(reformed, variable, year) == pytest.approx( + calculate(baseline, variable, year), abs=1e-6 + ), (variable, year) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index 7ef5d894d6..a864fdb4ab 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -24,7 +24,12 @@ def formula(person, period, parameters): data_year = period.start.year else: data_year = period.start.year - reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + # The age addition is paid by state_pension_age_addition, so split + # the reported amount without it. + reported = ( + person("state_pension_reported_excluding_age_addition", data_year) + / WEEKS_IN_YEAR + ) # Split by the period's type, as basic_state_pension and # new_state_pension do, so the three components add up to the # reported amount. Survey ages are held fixed across years, so a diff --git a/policyengine_uk/variables/gov/dwp/basic_state_pension.py b/policyengine_uk/variables/gov/dwp/basic_state_pension.py index 23d4a7a883..84e39f7499 100644 --- a/policyengine_uk/variables/gov/dwp/basic_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/basic_state_pension.py @@ -21,7 +21,12 @@ def formula(person, period, parameters): else: data_year = period.start.year - reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + # The age addition is paid by state_pension_age_addition, so split + # the reported amount without it. + reported = ( + person("state_pension_reported_excluding_age_addition", data_year) + / WEEKS_IN_YEAR + ) pension_type = person("state_pension_type", period) # state_pension_reported was observed under the legislated rates, so # split it at the baseline's data-year rate, not the reformed one. diff --git a/policyengine_uk/variables/gov/dwp/new_state_pension.py b/policyengine_uk/variables/gov/dwp/new_state_pension.py index f26e4a9cfd..79fa7019c1 100644 --- a/policyengine_uk/variables/gov/dwp/new_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/new_state_pension.py @@ -23,7 +23,12 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) eligible = pension_type == pension_type.possible_values.NEW - reported_weekly = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + # The age addition is paid by state_pension_age_addition, so split + # the reported amount without it. + reported_weekly = ( + person("state_pension_reported_excluding_age_addition", data_year) + / WEEKS_IN_YEAR + ) # 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 diff --git a/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py b/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py index f48729090d..70b03718bc 100644 --- a/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py @@ -9,7 +9,8 @@ class overlapping_state_pension(Variable): "The State Pension by which Carer's Allowance and Carer Support Payment " "are reduced. A new State Pension under Part 1 of the Pensions Act 2014 " "overlaps in full, including any protected payment above the full rate. " - "An old State Pension overlaps without its additional pension. When " + "An old State Pension overlaps without its additional pension or its " + "age addition. When " "only a reported total is available, the model estimates its basic " "component up to the full basic rate and treats the excess as " "additional pension. This split cannot identify basic-pension " @@ -22,25 +23,35 @@ class overlapping_state_pension(Variable): reference = ( "https://www.legislation.gov.uk/uksi/1979/597/regulation/4", "https://www.legislation.gov.uk/uksi/1979/597/regulation/12", + "https://www.legislation.gov.uk/uksi/1979/597/schedule/1", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", ) def formula(person, period, parameters): state_pension = person("state_pension", period) additional = person("additional_state_pension", period) + # An age addition is adjusted only by reference to another age + # addition (SI 1979/597 reg 4(3); Sch 1 row 4, "except any age + # addition"), so it never reduces a carer benefit. + age_addition = person("state_pension_age_addition", period) components = add( person, period, - ["basic_state_pension", "new_state_pension", "additional_state_pension"], + [ + "basic_state_pension", + "new_state_pension", + "additional_state_pension", + "state_pension_age_addition", + ], ) pension_type = person("state_pension_type", period) old_system = pension_type == pension_type.possible_values.BASIC # state_pension can scale its components (a contributed reform does), - # so take the additional pension as a share of them. - additional_share = np.divide( - additional, + # so take the parts that do not overlap as a share of them. + not_overlapping_share = np.divide( + old_system * additional + age_addition, components, out=np.zeros_like(components, dtype=float), where=components > 0, ) - return state_pension * (1 - old_system * additional_share) + return state_pension * (1 - not_overlapping_share) diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py b/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py new file mode 100644 index 0000000000..08e94edcb7 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py @@ -0,0 +1,58 @@ +from policyengine_uk.model_api import * + + +def state_pension_data_year(person, period) -> int: + """The year state_pension_reported was observed: the dataset's first year, + or the period itself in a simulation without a dataset.""" + simulation = person.simulation + if simulation.dataset is not None: + try: + return min(simulation.dataset.years) + except Exception: + return period.start.year + return period.start.year + + +def receives_age_addition(person, period, age_addition_age, data_year): + """Whether a person gets the age addition in ``period``: over the age of 80 + and entitled to a retirement pension under the Social Security + Contributions and Benefits Act 1992 (s.79(1)). A person is over an age once + they have attained it (s.173). Entitlement is read as a basic State Pension + type with a State Pension reported in the data year: a new State Pension, + under Part 1 of the Pensions Act 2014, is not a pension "under this Act" + (s.79(1) as amended by the Pensions Act 2014 Sch 12 para 85).""" + pension_type = person("state_pension_type", period) + basic = pension_type == pension_type.possible_values.BASIC + has_pension = person("state_pension_reported", data_year) > 0 + over_age = person("age", period) >= age_addition_age + return basic & has_pension & over_age + + +class state_pension_age_addition(Variable): + label = "State Pension age addition" + documentation = ( + "The age addition: 25p a week added to a retirement pension under the " + "Social Security Contributions and Benefits Act 1992 (the basic State " + "Pension) once its recipient is over 80, and never uprated. A new " + "State Pension gets none. The model is annual and, as for State " + "Pension age, reads age on 6 October, the middle of the fiscal year: " + "a person who turns 80 by then gets the whole year's addition, and one " + "who turns 80 later in the year gets none until the next." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/79", + "https://www.legislation.gov.uk/ukpga/1992/4/section/173", + "https://www.legislation.gov.uk/ukpga/1992/4/schedule/4", + "https://www.legislation.gov.uk/ukpga/2014/19/schedule/12/paragraph/85", + "https://www.legislation.gov.uk/uksi/2026/148/article/3/made", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.state_pension.age_addition + data_year = state_pension_data_year(person, period) + receives = receives_age_addition(person, period, p.age, data_year) + return receives * p.amount * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py b/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py new file mode 100644 index 0000000000..297bb5e0a4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.state_pension_age_addition import ( + receives_age_addition, +) + + +class state_pension_reported_excluding_age_addition(Variable): + label = "reported State Pension excluding the age addition" + documentation = ( + "state_pension_reported less the age addition it is taken to include. " + "The survey records the State Pension a person receives, which for a " + "basic State Pension recipient over 80 includes the 25p a week age " + "addition. basic_state_pension, new_state_pension and " + "additional_state_pension split this amount, and " + "state_pension_age_addition pays the addition, so it is neither " + "counted twice nor uprated as additional pension." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/79" + + def formula(person, period, parameters): + reported = person("state_pension_reported", period) + # state_pension_reported was observed under the legislated rules, so + # take off the legislated age addition, not a reformed one, as the + # components split it at the legislated full rate + # (PolicyEngine/policyengine-uk#2122). + baseline = getattr(person.simulation, "baseline", None) + legislated = ( + baseline.tax_benefit_system.parameters + if baseline is not None + else parameters + ) + p = legislated(period).gov.dwp.state_pension.age_addition + included = ( + receives_age_addition(person, period, p.age, period) + * p.amount + * WEEKS_IN_YEAR + ) + return max_(reported - included, 0) diff --git a/policyengine_uk/variables/input/state_pension.py b/policyengine_uk/variables/input/state_pension.py index e853011939..347194740a 100644 --- a/policyengine_uk/variables/input/state_pension.py +++ b/policyengine_uk/variables/input/state_pension.py @@ -23,6 +23,7 @@ def formula(person, period, parameters): "basic_state_pension", "additional_state_pension", "new_state_pension", + "state_pension_age_addition", ], ) * uprating From 368ebb0618e7e2e43f8280b94a3ed15c20f7eb0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:00:47 -0400 Subject: [PATCH 3/8] 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 f4e18b1a5925f7c4706d283c28d303831a003714 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:01:38 -0400 Subject: [PATCH 4/8] Read age addition entitlement from the components as well; update carer overlap tests A simulation given basic or additional State Pension directly, with no reported amount, now gets the age addition at 80 too. The carer overlap tests' 80-year-olds report amounts that include the 25p: it moves from additional pension to the age addition, and in case 8 it no longer reduces Carer's Allowance (SI 1979/597 reg 4(3); Sch 1 row 4). Co-Authored-By: Claude Opus 5.5 --- ...carers_allowance_overlapping_benefits.yaml | 16 ++++++-- .../gov/dwp/state_pension_age_addition.yaml | 28 ++++++++++++++ ...t_carer_overlapping_benefits_properties.py | 15 +++++++- .../gov/dwp/state_pension_age_addition.py | 38 +++++++++++-------- ...pension_reported_excluding_age_addition.py | 9 ++--- 5 files changed, 79 insertions(+), 27 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml index 72cf06ab25..8a24135b10 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml @@ -92,7 +92,9 @@ country: ENGLAND output: state_pension_type: BASIC - state_pension: 7_000 + # Aged 80 on an old State Pension, so the 25p age addition is paid too + # (SSCBA 1992 s.79); it does not overlap (SI 1979/597 reg 4(3)). + state_pension: 7_000 + 0.25 * 52 overlapping_state_pension: 2_000 carers_allowance: 86.45 * 52 - 2_000 @@ -119,8 +121,11 @@ care_hours: 35 country: ENGLAND output: + # The reported amount of a person aged 80 includes the 25p age addition + # (SSCBA 1992 s.79), paid on its own. basic_state_pension: 184.90 * 52 - additional_state_pension: 13_000 - 184.90 * 52 + additional_state_pension: 13_000 - 0.25 * 52 - 184.90 * 52 + state_pension_age_addition: 0.25 * 52 overlapping_state_pension: 184.90 * 52 carers_allowance: 0 @@ -133,8 +138,11 @@ care_hours: 35 country: ENGLAND output: - overlapping_state_pension: 3_000 - carers_allowance: 86.45 * 52 - 3_000 + # The 25p age addition in the reported amount does not overlap: an age + # addition is adjusted only by reference to another age addition (SI + # 1979/597 reg 4(3); Sch 1 row 4, "except any age addition"). + overlapping_state_pension: 3_000 - 0.25 * 52 + carers_allowance: 86.45 * 52 - (3_000 - 0.25 * 52) - name: Case 9, a reported new State Pension below the allowance. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml index 816b6927de..40751d4452 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml @@ -137,3 +137,31 @@ state_pension_reported: 9_188.4 output: overlapping_state_pension: 9_175.4 + +- name: Case 11, State Pension components supplied directly also bring the age addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 82 + state_pension_type: BASIC + basic_state_pension: 2_000 + output: + state_pension_age_addition: 13 + state_pension: 2_013 + +- name: Case 12, a reported amount of exactly the age addition is the age addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 13 + output: + basic_state_pension: 0 + additional_state_pension: 0 + state_pension_age_addition: 13 + state_pension: 13 diff --git a/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py b/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py index 8bb3314a77..355508bd56 100644 --- a/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py +++ b/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py @@ -215,6 +215,9 @@ def rates(sim): * WEEKS, "basic": float(parameters.gov.dwp.state_pension.basic_state_pension.amount) * WEEKS, + "age_addition": float(parameters.gov.dwp.state_pension.age_addition.amount) + * WEEKS, + "age_addition_age": float(parameters.gov.dwp.state_pension.age_addition.age), "min_hours": float(parameters.gov.dwp.carers_allowance.min_hours), "carer_addition": float( parameters.gov.dwp.pension_credit.guarantee_credit.carer.addition @@ -246,9 +249,17 @@ def reference(people, sim, extra=None): # The model's split of a reported amount: an old State Pension # above the full basic rate is additional pension, which does not # overlap. A new State Pension overlaps in full, protected payment - # included, and there is none before pensionable age. + # included, and there is none before pensionable age. From 80 an + # old State Pension is taken to include the 25p age addition, + # which does not overlap either (SI 1979/597 reg 4(3)). + with_age_addition = ( + pension_type == "BASIC" + and person["age"] >= r["age_addition_age"] + and pension > 0 + ) + basic_reported = max(pension - r["age_addition"] * with_age_addition, 0) state_pension = { - "BASIC": min(pension, r["basic"]), + "BASIC": min(basic_reported, r["basic"]), "NEW": pension, "NONE": 0, }[pension_type] diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py b/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py index 08e94edcb7..c824f4053e 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py @@ -13,19 +13,16 @@ def state_pension_data_year(person, period) -> int: return period.start.year -def receives_age_addition(person, period, age_addition_age, data_year): - """Whether a person gets the age addition in ``period``: over the age of 80 - and entitled to a retirement pension under the Social Security - Contributions and Benefits Act 1992 (s.79(1)). A person is over an age once - they have attained it (s.173). Entitlement is read as a basic State Pension - type with a State Pension reported in the data year: a new State Pension, - under Part 1 of the Pensions Act 2014, is not a pension "under this Act" - (s.79(1) as amended by the Pensions Act 2014 Sch 12 para 85).""" +def over_age_on_basic_state_pension(person, period, age_addition_age): + """Over the age of 80 with a basic State Pension type. The age addition + goes to a person over 80 entitled to a retirement pension under the Social + Security Contributions and Benefits Act 1992 (s.79(1)); a person is over + an age once they have attained it (s.173). A new State Pension, under Part + 1 of the Pensions Act 2014, is not a pension "under this Act" (s.79(1) as + amended by the Pensions Act 2014 Sch 12 para 85).""" pension_type = person("state_pension_type", period) basic = pension_type == pension_type.possible_values.BASIC - has_pension = person("state_pension_reported", data_year) > 0 - over_age = person("age", period) >= age_addition_age - return basic & has_pension & over_age + return basic & (person("age", period) >= age_addition_age) class state_pension_age_addition(Variable): @@ -34,10 +31,13 @@ class state_pension_age_addition(Variable): "The age addition: 25p a week added to a retirement pension under the " "Social Security Contributions and Benefits Act 1992 (the basic State " "Pension) once its recipient is over 80, and never uprated. A new " - "State Pension gets none. The model is annual and, as for State " - "Pension age, reads age on 6 October, the middle of the fiscal year: " - "a person who turns 80 by then gets the whole year's addition, and one " - "who turns 80 later in the year gets none until the next." + "State Pension gets none. Entitlement to the pension is read from a " + "State Pension reported in the data year or, in a simulation given " + "the components directly, basic or additional State Pension in the " + "year. The model is annual and, as for State Pension age, reads age " + "on 6 October, the middle of the fiscal year: a person who turns 80 " + "by then gets the whole year's addition, and one who turns 80 later " + "in the year gets none until the next." ) entity = Person definition_period = YEAR @@ -54,5 +54,11 @@ class state_pension_age_addition(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.state_pension.age_addition data_year = state_pension_data_year(person, period) - receives = receives_age_addition(person, period, p.age, data_year) + reported = person("state_pension_reported", data_year) > 0 + components = ( + add(person, period, ["basic_state_pension", "additional_state_pension"]) > 0 + ) + receives = over_age_on_basic_state_pension(person, period, p.age) & ( + reported | components + ) return receives * p.amount * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py b/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py index 297bb5e0a4..8fd548c218 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py @@ -1,6 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp.state_pension_age_addition import ( - receives_age_addition, + over_age_on_basic_state_pension, ) @@ -34,9 +34,8 @@ def formula(person, period, parameters): else parameters ) p = legislated(period).gov.dwp.state_pension.age_addition - included = ( - receives_age_addition(person, period, p.age, period) - * p.amount - * WEEKS_IN_YEAR + receives = over_age_on_basic_state_pension(person, period, p.age) & ( + reported > 0 ) + included = receives * p.amount * WEEKS_IN_YEAR return max_(reported - included, 0) From 10272030e588b070e4c91bdbde517d5b1eae3daa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:05:36 -0400 Subject: [PATCH 5/8] Check the data-year identity with the float32 tolerance Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_state_pension_components.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 8fd84040d4..56845f4ced 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -558,8 +558,7 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.allclose( total[full_record], reported_weekly[full_record] * WEEKS_IN_YEAR, - rtol=1e-9, - atol=1e-6, + **FLOAT32, ) From 15bb98d4403b1fe24ca180be51d0d468ce5902c5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:14:03 -0400 Subject: [PATCH 6/8] 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 5234997ea042993ab398ffcba30685164e41e90d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 22:57:34 -0400 Subject: [PATCH 7/8] Compare the age addition with the reported amount the model holds; expect none on a new State Pension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 38170838155 failed three of the age addition tests. None was a model defect: - The two components-add-up properties drew a weekly amount of 8.3e-246 (and 2.2e-313). state_pension_reported is a float variable, stored in float32, so the simulation holds it as nil and rightly pays no age addition; the reference read the float64 draw and expected 25p. The reference now reads each report as the simulation holds it, and the two CI examples (and a tiny amount float32 does hold) are pinned as examples. - The age addition reform test expected £10 a week for both records in 2030-31. Held at 77, the second record is on the new State Pension by then, and the age addition belongs only to a retirement pension under the 1992 Act (s.79(1)), reformed or not. The test now asserts each year's type and expects the addition only on the basic State Pension. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_components.py | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 56845f4ced..1ab2c4dde5 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -22,7 +22,7 @@ import numpy as np import pandas as pd import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Microsimulation @@ -494,10 +494,19 @@ def by_type(pension_type: np.ndarray, basic, new) -> np.ndarray: return np.select([pension_type == "BASIC", pension_type == "NEW"], [basic, new], 0) +def stored_weekly(people: list) -> np.ndarray: + """Each record's reported weekly amount as the simulation holds it. + state_pension_reported is a float variable, which the model stores in + float32, so an amount too small for float32 (Hypothesis draws amounts down + to 5e-324 a week) is held as nil: the record reports no State Pension.""" + annual = np.array([p["weekly"] * WEEKS_IN_YEAR for p in people], np.float32) + return annual.astype(float) / WEEKS_IN_YEAR + + def data_year_net_weekly(sim, people: list) -> np.ndarray: """Each record's reported weekly amount less the age addition it includes in the data year.""" - weekly = np.array([p["weekly"] for p in people]) + weekly = stored_weekly(people) included = receives_age_addition( calculate(sim, "state_pension_type", DATA_YEAR).astype(str), calculate(sim, "age", DATA_YEAR), @@ -516,7 +525,7 @@ def assert_components_add_up(sim, people: list, years) -> None: is on the basic State Pension, aged 80 or over and has a State Pension, in every year; in the data year the four parts add up to the reported amount whenever it is at least 25p a week.""" - reported_weekly = np.array([p["weekly"] for p in people]) + reported_weekly = stored_weekly(people) weekly = data_year_net_weekly(sim, people) for year in years: pension_type = calculate(sim, "state_pension_type", year).astype(str) @@ -562,8 +571,21 @@ def assert_components_add_up(sim, people: list, years) -> None: ) +# Reported amounts too small for float32, held as nil (CI run 38170838155 +# found the first), and a tiny amount float32 still holds, at 80 on the basic +# State Pension. +TINY_REPORTS = [ + [{"age": 80, "male": False, "months": 0.0, "weekly": 8.266518416092618e-246}], + [{"age": 80, "male": False, "months": 0.0, "weekly": 2.2250738585e-313}], + [{"age": 85, "male": True, "months": 3.0, "weekly": 1e-30}], +] + + @PROPERTY_SETTINGS @given(people=PEOPLE) +@example(people=TINY_REPORTS[0]) +@example(people=TINY_REPORTS[1]) +@example(people=TINY_REPORTS[2]) def test_components_add_up_to_uprated_reported_amount(people): """Data projected with ages held fixed, every year from 2024-25 to 2030-31.""" sim = projected_simulation(people) @@ -928,10 +950,14 @@ def test_full_basic_rate_plus_age_addition_is_never_additional_pension(): def test_age_addition_reforms_leave_the_reported_split_alone(): - """An undated reform paying £10 a week from 75 pays it to both records in - every year, but the reported amounts were observed under the legislated - 25p from 80: the 85-year-old's is still split net of 25p, and the - 77-year-old's in full, so the other components do not change.""" + """An undated reform paying £10 a week from 75 pays it to both records + while they are on the basic State Pension, but the reported amounts were + observed under the legislated 25p from 80: the 85-year-old's is still + split net of 25p, and the 77-year-old's in full, so the other components + do not change. Held at 77, the second record is on the new State Pension + by 2030-31, and a new State Pension gets no age addition, reformed or not: + the addition belongs to a retirement pension under the 1992 Act + (s.79(1)).""" people = [ {"age": 85, "male": True, "months": 3, "weekly": 169.75}, {"age": 77, "male": True, "months": 3, "weekly": 169.5}, @@ -944,11 +970,16 @@ def test_age_addition_reforms_leave_the_reported_split_alone(): "gov.dwp.state_pension.age_addition.age": 75, }, ) - for year in (DATA_YEAR, 2027, 2030): - assert ( - list(calculate(reformed, "state_pension_age_addition", year)) - == [10 * WEEKS_IN_YEAR] * 2 - ) + types = { + DATA_YEAR: ["BASIC", "BASIC"], + 2027: ["BASIC", "BASIC"], + 2030: ["BASIC", "NEW"], + } + for year, year_types in types.items(): + assert list(calculate(reformed, "state_pension_type", year)) == year_types + assert list(calculate(reformed, "state_pension_age_addition", year)) == [ + 10 * WEEKS_IN_YEAR if t == "BASIC" else 0 for t in year_types + ], year for variable in COMPONENTS: assert calculate(reformed, variable, year) == pytest.approx( calculate(baseline, variable, year), abs=1e-6 From 03adbc9d8f0d3e6f0075009fe6b33f58cd05ec07 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 23:45:31 -0400 Subject: [PATCH 8/8] Say precisely why the model taxes the age addition with the pension ITEPA 2003 s.577(2) lists the SSCBA 1992 retirement pension sections but not s.79, so the docs now say the model taxes the age addition as part of the pension it increases (s.79(1)), and that s.677 does not exempt it. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 26f617c509..edd6c4fdd5 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -54,12 +54,14 @@ assumes the reported amount of a basic State Pension recipient over 80 includes it. `state_pension_reported_excluding_age_addition` takes the data year's addition off, at the legislated 25p from 80, and the other three components split what is left. The addition is part of `state_pension`, so -it is taxed and means-tested as the pension is: it is an increase of the -pension (s.79(1)), which income tax charges as pension income under +it is taxed and means-tested as the pension is. It is an increase of the +pension (s.79(1)), so the model taxes it with the pension it increases: [ITEPA 2003 s.577](https://www.legislation.gov.uk/ukpga/2003/1/section/577) -(the pre-2003 charge in ICTA 1988 s.617(1) reached every benefit under Parts -II to IV of the 1992 Act not excepted, and the age addition was not), and -Pension Credit counts it as retirement pension income +charges the retirement pensions under SSCBA 1992 ss.44, 48A to 48BB, 51 and +78 as pension income, without naming s.79, and s.677 does not exempt the age +addition (the pre-2003 charge in ICTA 1988 s.617(1) reached every benefit +under Parts II to IV of the 1992 Act not excepted, and the age addition was +not excepted). Pension Credit counts it as retirement pension income ([State Pension Credit Act 2002 s.16(1)(e)](https://www.legislation.gov.uk/ukpga/2002/16/section/16)), as do Universal Credit (Universal Credit Regulations 2013 regs 66(1)(a) and 67) and pension-age Housing Benefit (Housing Benefit (State Pension Credit)