Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/state-pension-weekly-rate-rounding.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Round projected State Pension weekly rates as each Up-rating Order does. Each April's full new State Pension and basic State Pension rate is now the previous year's rate raised by the triple lock and rounded to the nearest 5p (`gov.dwp.state_pension.weekly_rate_rounding`), instead of the last published rate times the index's growth, unrounded. On the October 2026 forecast the full new State Pension is £250.70, £259.20 and £265.70 a week in 2027-28 to 2029-30 (was £250.71, £259.24 and £265.72), and 52 weeks of the 2027-28 rate is the £13,036.40 of HM Treasury's Pension Tax Allowance explainer. The triple lock index is no longer rounded to five decimal places.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ metadata:
unit: currency-GBP
label: Basic State Pension amount
uprating: gov.economic_assumptions.indices.triple_lock
documentation: Projected weekly State Pension rates are not rounded to 5p; SSAA 1992 s.150A(4) permits discretionary rounding up or down (https://www.legislation.gov.uk/ukpga/1992/5/section/150A/4).
documentation: Years after the last published rate are projected each April from the previous year's rate by the triple lock and rounded to the nearest 5p (gov.dwp.state_pension.weekly_rate_rounding), as each Up-rating Order rounds them under Social Security Administration Act 1992 s.150A(4).
reference:
- title: House of Commons Library
href: https://researchbriefings.files.parliament.uk/documents/SN05649/SN05649.pdf
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Project the State Pension weekly rates year by year, rounded as DWP rounds.

Each April's Up-rating Order raises the full rate of the new State Pension and
the basic State Pension from the previous year's published weekly rate and
rounds the result. Social Security Administration Act 1992 s.150A(4) leaves
the rounding to the Secretary of State ("up or down to such extent as he
thinks appropriate"). DWP applies the review's percentage "to the current
full weekly rates of the basic and new State Pensions. The resulting amounts
are rounded to the nearest 5p" (written answer HL10770, 20 October 2025), and
HM Treasury's forecast baseline projects both rates "rounded to the nearest
5p" (Budget 2026 policy costings, Annex A). The previous year's rate,
uprated and rounded to the nearest 5p, gives every published basic rate from
April 2011 and every full new rate from April 2017
(``tests/test_triple_lock_outturn.py``). Rounding always up or always down to
5p, or to the nearest 10p, misses at least five of those years each.

A partial award rises by the same percentage as the rounded full rate
(HL10770: the rounding "results in a percentage rate which is applied to
each individual's existing pension payment"), which is what
``new_state_pension`` and ``basic_state_pension`` already pay: a share of
the full rate.

General uprating would instead multiply the last stated rate by its index's
growth since then, unrounded: £250.711 for April 2027, where HM Treasury's
Pension Tax Allowance explainer gives the full new State Pension for 2027-28
as £13,036.40, 52 weeks at £250.70. Rounding that path would not be enough,
because the Order rounds each year from the last published, already rounded,
rate: £250.70 x 1.034 = £259.2238 gives £259.20 for April 2028, where the
unrounded path, £259.2357, would round to £259.25.

So for each rate this step fills the years after its last stated value,
through the last year of its uprating index, with the previous year's rate
times the index's growth over the year, to the nearest multiple of
``gov.dwp.state_pension.weekly_rate_rounding``. No published rate has
landed exactly on a half, so the sources do not say which way a half goes;
this step rounds halves up. Each value applies from 1 January, as
uprating's do, so the April uprating sets the fiscal year,
and general uprating then has no year left to fill. The growth comes from
the index named in the rate's ``uprating`` metadata, so a reform to the
triple lock rate, its inputs or the index moves the projection. The product
is taken to 0.0001p before rounding, which drops float noise without moving
a value the inputs state exactly (a rate on the 5p grid times a rise in
tenths of a percentage point has at most five decimal places).

Runs as a generation step, after the economic assumption indices: the
baseline copy, prior law's projected path for dated reforms and the
fiscal-year annualisation all see the rounded rates.
"""

from decimal import ROUND_HALF_UP, Decimal
from typing import Optional

from policyengine_core.parameters import Parameter, ParameterNode
from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant

from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import (
uprating_instant,
)

PROJECTED_RATES = ("new_state_pension", "basic_state_pension")
# Drops float noise in the uprated amount (well under 1e-9 of a pound) and
# keeps every digit an exact product of the inputs has.
PRODUCT_PRECISION = Decimal("0.000001")


def _decimal(value: float) -> Decimal:
return Decimal(repr(float(value)))


def round_weekly_rate(amount: Decimal, unit: Optional[float]) -> float:
"""``amount`` to the nearest multiple of ``unit``, halves up.

A zero or missing unit leaves the amount unrounded.
"""
amount = amount.quantize(PRODUCT_PRECISION, ROUND_HALF_UP)
if not unit:
return float(amount)
step = _decimal(unit)
return float((amount / step).quantize(Decimal(1), ROUND_HALF_UP) * step)


def uprate_weekly_rate(
previous: float, growth: Decimal, unit: Optional[float]
) -> float:
"""The previous year's rate raised by ``growth`` (a ratio), then rounded."""
return round_weekly_rate(_decimal(previous) * growth, unit)


def uprating_index(parameters: ParameterNode, rate: Parameter) -> Parameter:
"""The index named in the rate's ``uprating`` metadata."""
meta = rate.metadata["uprating"]
name = meta["parameter"] if isinstance(meta, dict) else meta
return parameters.get_child(name)


def project_weekly_rate(rate: Parameter, index: Parameter, rounding: Parameter) -> None:
"""Fill the years after the rate's last stated value, through the index's
last year, each from the year before."""
last = max(rate.values_list, key=lambda entry: entry.instant_str)
value = last.value
level = index(last.instant_str)
if value is None or level is None:
return
for entry in sorted(index.values_list, key=lambda entry: entry.instant_str):
if entry.instant_str <= last.instant_str:
continue
new_level = index(entry.instant_str)
unit = rounding(uprating_instant(int(entry.instant_str[:4])))
value = uprate_weekly_rate(value, _decimal(new_level) / _decimal(level), unit)
rate.values_list.append(
ParameterAtInstant(rate.name, entry.instant_str, data=value)
)
level = new_level
rate.values_list.sort(key=lambda entry: entry.instant_str, reverse=True)
rate.mark_as_modified()


def project_state_pension_rates(parameters: ParameterNode) -> ParameterNode:
"""Project the full new and basic State Pension weekly rates."""
state_pension = parameters.gov.dwp.state_pension
for scheme in PROJECTED_RATES:
rate = state_pension.get_child(f"{scheme}.amount")
project_weekly_rate(
rate,
uprating_index(parameters, rate),
state_pension.weekly_rate_rounding,
)
state_pension.clear_parent_cache()
return parameters
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ metadata:
unit: currency-GBP
label: New State Pension amount
uprating: gov.economic_assumptions.indices.triple_lock
documentation: Projected weekly State Pension rates are not rounded to 5p; SSAA 1992 s.150A(4) permits discretionary rounding up or down (https://www.legislation.gov.uk/ukpga/1992/5/section/150A/4).
documentation: Years after the last published rate are projected each April from the previous year's rate by the triple lock and rounded to the nearest 5p (gov.dwp.state_pension.weekly_rate_rounding), as each Up-rating Order rounds them under Social Security Administration Act 1992 s.150A(4).
reference:
- title: House of Commons Library
href: https://researchbriefings.files.parliament.uk/documents/SN05649/SN05649.pdf
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ metadata:
include_earnings false and this guarantee true from 2030.
Each guaranteed year, the rise is the higher of the triple lock
elements still included and the rise needed to reach the earnings path,
rounded up to 0.1 percentage points. Changes take effect through
rounded up to 0.1 percentage points. The guarantee holds for those
percentages: each weekly rate is then the year before's raised by the
year's percentage and rounded to the nearest 5p
(gov.dwp.state_pension.weekly_rate_rounding), as Social Security
Administration Act 1992 s.150A(4) allows even below the minimum, so the
rate can sit a few pence below the earnings path. Changes take effect through
Scenario(parameter_changes=...), which rebuilds the uprating rates; a
reform dictionary changes this parameter but not the rates.
reference:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
description: The full new State Pension and basic State Pension weekly rates are projected each April from the previous year's rate and rounded to the nearest multiple of this amount; zero leaves them unrounded.
values:
2011-01-01: 0.05
metadata:
unit: currency-GBP
label: State Pension weekly rate rounding
documentation: |
Applies only to projected years, after the last published rate; the
published rates are stated in the amount parameters. Social Security
Administration Act 1992 s.150A(4) lets the Secretary of State round each
increase "up or down to such extent as he thinks appropriate". DWP
applies the review's percentage to the current full weekly rates and
rounds the results to the nearest 5p (HL10770), and HM Treasury's
forecast baseline projects them the same way. The previous year's
published rate, uprated and rounded to the nearest 5p, gives every
published basic State Pension rate from April 2011 and every full new
State Pension rate from April 2017; rounding always up or always down
to 5p, or to the nearest 10p, misses at least five of those years each.
No published rate has landed exactly on a half; the model rounds halves
up. Each April's rounding is read on 30 April. A reform that sets this
amount for whole fiscal years (a single value, a year key or a date
range covering 30 April), through a reform dictionary or
Scenario(parameter_changes=...), moves the projected rates for those
years.
reference:
- title: Social Security Administration Act 1992, section 150A(4)
href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A
- title: Written question HL10770, State Retirement Pensions - Uprating (DWP answer, 20 October 2025)
href: https://questions-statements.parliament.uk/written-questions/detail/2025-10-06/HL10770
- title: Explanatory Memorandum to the Social Security Benefits Up-rating Order 2020 (SI 2020/234), paragraph 7.17
href: https://www.legislation.gov.uk/uksi/2020/234/pdfs/uksiem_20200234_en.pdf
- title: HM Treasury, Budget 2026 policy costings, Annex A (indexation in the public finance forecast baseline), page 114
href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
)


UNROUNDED_INDICES = (
"gov.economic_assumptions.yoy_growth.earnings_uprating",
"gov.economic_assumptions.yoy_growth.triple_lock",
)


def create_economic_assumption_indices(
parameters: ParameterNode,
) -> ParameterNode:
Expand Down Expand Up @@ -45,7 +51,10 @@ def create_economic_assumption_indices(
indices_value = values[year - 1] * (1 + yoy_growth)
# Rounding an earnings index down can put the projected
# guarantee below its statutory annual earnings floor.
if full_name != "gov.economic_assumptions.yoy_growth.earnings_uprating":
# Rounding the triple lock index would add up to about 3e-6
# to its yearly growth, enough to tip a State Pension rate
# to the other 5p (create_rate_projection).
if full_name not in UNROUNDED_INDICES:
indices_value = round(indices_value, 5)
values[year] = indices_value

Expand Down
6 changes: 6 additions & 0 deletions policyengine_uk/tax_benefit_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
from policyengine_uk.parameters.gov.contrib.create_private_pension_uprating import (
add_private_pension_uprating_factor,
)
from policyengine_uk.parameters.gov.dwp.state_pension.create_rate_projection import (
project_state_pension_rates,
)
from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import (
add_triple_lock,
)
Expand Down Expand Up @@ -138,6 +141,7 @@ def _install_parameters(system, parameters: ParameterNode) -> None:
add_triple_lock,
add_september_cpi_uprating,
create_economic_assumption_indices,
project_state_pension_rates,
project_savings_credit_threshold,
project_pension_age_allowances,
add_threshold_indexation,
Expand Down Expand Up @@ -196,6 +200,8 @@ def process_parameter_tree(
- triple lock calculations for state pensions
- earnings and September CPI uprating
- economic assumption indices
- State Pension weekly rates, projected each April from the year before
and rounded to the nearest 5p, as each Up-rating Order rounds them
- Pension Credit threshold and protected Housing Benefit forecast indices
- income tax and NICs threshold indexation after the freeze
- Universal Credit standard allowance uprating (UC Act 2025 s. 1)
Expand Down
20 changes: 15 additions & 5 deletions policyengine_uk/tests/test_process_time_reforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
5. Monotonicity: the April 2028 State Pension is non-decreasing in the
minimum rate.
6. Baselines stay current law: ``sim.baseline`` and ``parameters.baseline``.

Each April the State Pension rates are the year before's raised by the
triple lock rate and rounded to the nearest 5p, so the checks of 1 and 3
compare them with ``uprated``, exactly.
"""

from decimal import ROUND_HALF_UP, Decimal
Expand Down Expand Up @@ -111,6 +115,12 @@ def published(rate):
return float(Decimal(repr(float(rate))).quantize(Decimal("0.001"), ROUND_HALF_UP))


def uprated(previous, rate):
"""A weekly State Pension rate raised by ``rate``, to the nearest 5p."""
amount = Decimal(repr(float(previous))) * (1 + Decimal(repr(float(rate))))
return float((amount / Decimal("0.05")).quantize(Decimal(1), ROUND_HALF_UP) / 20)


def via_reform_dict(change, situation=PENSIONER):
return Simulation(situation=situation, reform=change)

Expand Down Expand Up @@ -154,7 +164,7 @@ def test_the_minimum_rate_moves_the_triple_lock_and_the_state_pension(
# scalar applies from 2023 (or 2000) and so raises it too.
assert weekly(reformed, path, 2027) == weekly(baseline, path, 2027)
assert weekly(reformed, path, 2028) == pytest.approx(
weekly(reformed, path, 2027) * 1.1, abs=0.006
uprated(weekly(reformed, path, 2027), 0.1), abs=1e-9
)
pension = float(reformed.calculate("new_state_pension", 2028).sum())
assert pension == pytest.approx(
Expand Down Expand Up @@ -201,7 +211,7 @@ def test_earnings_growth_sets_the_next_aprils_triple_lock_by_its_rule(
baseline, TRIPLE_LOCK, f"{year}-01-01"
)
assert weekly(reformed, NEW_STATE_PENSION, year + 1) == pytest.approx(
weekly(reformed, NEW_STATE_PENSION, year) * (1 + expected), abs=0.006
uprated(weekly(reformed, NEW_STATE_PENSION, year), expected), abs=1e-9
)


Expand Down Expand Up @@ -355,15 +365,15 @@ def test_a_triple_lock_rate_set_by_reform_uprates_the_state_pension(
]
assert pensions == reference
assert pensions[2] == pytest.approx(
weekly(baseline, NEW_STATE_PENSION, 2027) * 1.05, abs=0.006
uprated(weekly(baseline, NEW_STATE_PENSION, 2027), 0.05), abs=1e-9
)


def test_reform_from_dict_sets_a_generated_series(baseline):
change = {TRIPLE_LOCK: {"2028-01-01.2028-12-31": 0.05}}
reformed = Simulation(situation=PENSIONER, reform=Reform.from_dict(change))
assert weekly(reformed, NEW_STATE_PENSION, 2028) == pytest.approx(
weekly(baseline, NEW_STATE_PENSION, 2027) * 1.05, abs=0.006
uprated(weekly(baseline, NEW_STATE_PENSION, 2027), 0.05), abs=1e-9
)


Expand Down Expand Up @@ -466,7 +476,7 @@ def test_a_reprocessing_modifier_keeps_a_generated_series_change(baseline):
reformed = Simulation(situation=PENSIONER, scenario=scenario)
assert value(reformed, TRIPLE_LOCK, "2028-01-01") == pytest.approx(0.05)
assert weekly(reformed, NEW_STATE_PENSION, 2028) == pytest.approx(
weekly(reformed, NEW_STATE_PENSION, 2027) * 1.05, abs=0.006
uprated(weekly(reformed, NEW_STATE_PENSION, 2027), 0.05), abs=1e-9
)


Expand Down
Loading
Loading