From 92d2f9257096aff4f5161c1402ad191ecf0cc621 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 12:25:10 -0400 Subject: [PATCH 1/4] Round projected State Pension weekly rates to the nearest 5p each April MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DWP applies each review's percentage to the current full weekly rates of the basic and new State Pensions and rounds the results to the nearest 5p (HL10770; EMs to SI 2017/260-2020/234), and HM Treasury's forecast baseline projects them the same way (Budget 2026 policy costings, Annex A). The model instead multiplied the last published rate by the triple lock index's growth, unrounded, so the full new State Pension was £250.71 for 2027-28 against the £250.70 behind the Pension Tax Allowance explainer's £13,036.40. A generation step now fills each rate's projected years from the year before, rounded to gov.dwp.state_pension.weekly_rate_rounding (5p). The triple lock index is no longer rounded to five decimals, which put up to about 3e-6 of noise into its yearly growth. Co-Authored-By: Claude Opus 5.5 --- ...tate-pension-weekly-rate-rounding.fixed.md | 1 + .../basic_state_pension/amount.yaml | 2 +- .../state_pension/create_rate_projection.py | 127 ++++++ .../new_state_pension/amount.yaml | 2 +- .../state_pension/weekly_rate_rounding.yaml | 29 ++ .../create_economic_assumption_indices.py | 11 +- policyengine_uk/tax_benefit_system.py | 6 + .../tests/test_process_time_reforms.py | 20 +- .../tests/test_state_pension_rate_rounding.py | 383 ++++++++++++++++++ .../test_triple_lock_statutory_inputs.py | 21 +- 10 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 changelog.d/state-pension-weekly-rate-rounding.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py create mode 100644 policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml create mode 100644 policyengine_uk/tests/test_state_pension_rate_rounding.py diff --git a/changelog.d/state-pension-weekly-rate-rounding.fixed.md b/changelog.d/state-pension-weekly-rate-rounding.fixed.md new file mode 100644 index 0000000000..8bd1864a4a --- /dev/null +++ b/changelog.d/state-pension-weekly-rate-rounding.fixed.md @@ -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. diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/basic_state_pension/amount.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/basic_state_pension/amount.yaml index 2fe6e3b7b1..f8d5406d47 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/basic_state_pension/amount.yaml +++ b/policyengine_uk/parameters/gov/dwp/state_pension/basic_state_pension/amount.yaml @@ -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 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py b/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py new file mode 100644 index 0000000000..cc870947f6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py @@ -0,0 +1,127 @@ +"""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``, 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 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/new_state_pension/amount.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/new_state_pension/amount.yaml index 6271ec4868..6fe8bc20d5 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/new_state_pension/amount.yaml +++ b/policyengine_uk/parameters/gov/dwp/state_pension/new_state_pension/amount.yaml @@ -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 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml new file mode 100644 index 0000000000..edbc7830e6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml @@ -0,0 +1,29 @@ +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. + Every reform route reprocesses the parameters, so a change to this + amount moves the projected rates. + 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 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 90ecde8187..55408e9cf2 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py @@ -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: @@ -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 diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 9a9fdb349e..bf27d7e1d8 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -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, ) @@ -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, @@ -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) diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index b53fa00b0a..cf318c4dae 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -25,6 +25,10 @@ read every key exactly as before. 5. Monotonicity: the April 2028 State Pension is non-decreasing in the minimum rate. + +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. 6. Baselines stay current law: ``sim.baseline`` and ``parameters.baseline``. """ @@ -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) @@ -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( @@ -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 ) @@ -355,7 +365,7 @@ 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 ) @@ -363,7 +373,7 @@ 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 ) @@ -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 ) diff --git a/policyengine_uk/tests/test_state_pension_rate_rounding.py b/policyengine_uk/tests/test_state_pension_rate_rounding.py new file mode 100644 index 0000000000..08c6fd5a34 --- /dev/null +++ b/policyengine_uk/tests/test_state_pension_rate_rounding.py @@ -0,0 +1,383 @@ +"""Projected State Pension weekly rates are rounded as each Up-rating Order +rounds them. + +DWP applies the review's percentage to the current full weekly rates of the +basic and new State Pensions and rounds the results to the nearest 5p +(written answer HL10770, 20 October 2025; Explanatory Memorandum to SI +2020/234, paragraph 7.17), and HM Treasury's forecast baseline projects both +the same way (Budget 2026 policy costings, Annex A, page 114). The model +projects each year after the last published rate from the year before +(``create_rate_projection``). + +Invariants, for both rates: + +1. Published rates are untouched: every stated rate is the parameter file's. +2. Grid: every projected rate is a whole number of 5p. +3. Chain: each projected year is the previous year's rate raised by that + April's triple lock rate, to the nearest 5p, so within 2.5p of the + unrounded product. +4. Differential: April 2027 gives the £13,036.40 a year of HM Treasury's + Pension Tax Allowance explainer (52 weeks at £250.70), and the £93.28 of + tax it says that income would otherwise bear. +5. A pensioner whose only income is the full new State Pension has total + income of exactly 52 weeks at the projected rate, the explainer's Pension + Tax Allowance threshold, in every projected year. +6. The rounding is a parameter: each reform route moves the projection by + it, zero gives the unrounded chain, and the baseline stays current law. +7. A changed triple lock rate or index moves the projection. +8. Properties (Hypothesis): rounding lands on the grid, within half a unit, + idempotently and monotonically; a projected path is the chained rule for + any rates; a rise never lowers the rate. +""" + +from decimal import ROUND_HALF_UP, Decimal +from pathlib import Path + +import pandas as pd +import pytest +import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKSingleYearDataset +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.dwp.state_pension.create_rate_projection import ( + project_weekly_rate, + round_weekly_rate, + uprate_weekly_rate, +) +from policyengine_uk.system import system + +PARAMETERS = Path(__file__).parents[1] / "parameters" +STATE_PENSION = "gov.dwp.state_pension" +ROUNDING = f"{STATE_PENSION}.weekly_rate_rounding" +TRIPLE_LOCK = "gov.economic_assumptions.yoy_growth.triple_lock" +TRIPLE_LOCK_INDEX = "gov.economic_assumptions.indices.triple_lock" +RATES = { + "new": f"{STATE_PENSION}.new_state_pension.amount", + "basic": f"{STATE_PENSION}.basic_state_pension.amount", +} +LAST_PUBLISHED = 2026 +# The uprating index runs to 2039; later years are held at its last value. +PROJECTED = range(LAST_PUBLISHED + 1, 2040) +FIVE_PENCE = Decimal("0.05") +WEEKS_IN_YEAR = 52 + +PROPERTY_SETTINGS = settings( + max_examples=200, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +# Each example reprocesses the parameters, so as few as the reform tests use. +SIMULATION_SETTINGS = settings(PROPERTY_SETTINGS, max_examples=10) + + +def weekly(parameters, path, year): + """A rate in force over fiscal year ``year`` (the 30 April value).""" + return float(parameters.get_child(path)(f"{year}-04-30")) + + +def triple_lock(parameters, year): + return float(parameters.get_child(TRIPLE_LOCK)(f"{year}-01-01")) + + +def nearest(amount, unit=FIVE_PENCE): + return (Decimal(amount) / unit).quantize(Decimal(1), ROUND_HALF_UP) * unit + + +def uprated(previous, rate, unit=FIVE_PENCE): + """The rule, written independently of the model's implementation.""" + return float( + nearest(Decimal(repr(float(previous))) * (1 + Decimal(repr(float(rate)))), unit) + ) + + +def on_grid(amount, unit=FIVE_PENCE): + return Decimal(repr(float(amount))) % unit == 0 + + +def stated_values(path): + file = PARAMETERS.joinpath(*path.split(".")).with_suffix(".yaml") + return yaml.safe_load(file.read_text())["values"] + + +# 1-3. Published rates, the grid and the chain. + + +@pytest.mark.parametrize("scheme", RATES) +def test_published_rates_are_the_parameter_files(scheme): + stated = stated_values(RATES[scheme]) + amount = system.parameters.get_child(RATES[scheme]) + for date, value in stated.items(): + assert float(amount(f"{date}")) == pytest.approx(value, abs=1e-12), date + + +@pytest.mark.parametrize("scheme", RATES) +def test_every_projected_rate_is_a_whole_number_of_5p(scheme): + for year in PROJECTED: + rate = weekly(system.parameters, RATES[scheme], year) + assert on_grid(rate), (year, rate) + + +@pytest.mark.parametrize("scheme", RATES) +def test_each_projected_year_is_the_year_before_uprated_to_the_nearest_5p(scheme): + parameters = system.parameters + path = RATES[scheme] + for year in PROJECTED: + previous = weekly(parameters, path, year - 1) + rise = triple_lock(parameters, year) + rate = weekly(parameters, path, year) + assert rate == pytest.approx(uprated(previous, rise), abs=1e-12), year + assert abs(rate - previous * (1 + rise)) <= 0.025 + 1e-9, year + + +@pytest.mark.parametrize( + "scheme, expected", + [ + # 2027 to 2029: £241.30 x 1.039 = £250.7107, then x 1.034 = + # £259.2238 (where the unrounded path, £259.2357, would round to + # £259.25), then x 1.025 = £265.68. + ("new", [250.70, 259.20, 265.70]), + ("basic", [192.10, 198.65, 203.60]), + ], +) +def test_rates_on_the_october_2026_forecast(scheme, expected): + """The triple lock is 3.9%, 3.4% and 2.5% for April 2027 to 2029 on the + stored forecast. For April 2027 the statutory review (HCWS427, 28 October + 2026) confirmed 3.9%, "around £490" a year on the full new State Pension + (52 x £9.40 = £488.80) and "around £375" on the basic (52 x £7.20 = + £374.40).""" + rises = [triple_lock(system.parameters, year) for year in (2027, 2028, 2029)] + assert rises == pytest.approx([0.039, 0.034, 0.025]) + rates = [weekly(system.parameters, RATES[scheme], y) for y in (2027, 2028, 2029)] + assert rates == pytest.approx(expected, abs=1e-12) + + +# 4-5. The full new State Pension against the Pension Tax Allowance +# explainer. + + +def full_new_state_pension_only(scenario=None): + """One pensioner whose only income is the full new State Pension (the + April 2026 rate in 2026-27 data).""" + person = pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [70], + "state_pension_reported": [241.30 * WEEKS_IN_YEAR], + } + ) + dataset = UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["LONDON"], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [2_000.0], + "rent": [0.0], + } + ), + fiscal_year=2026, + ) + return Simulation(dataset=dataset, scenario=scenario) + + +@pytest.fixture(scope="module") +def pensioner(): + return full_new_state_pension_only() + + +def test_april_2027_is_the_pension_tax_allowance_explainer_figure(pensioner): + """HM Treasury's Pension Tax Allowance explainer (28 October 2026): "the + full annual value of the new State Pension will increase to £13,036.40", + and Steph, whose only income it is, "would have £93.28 of income tax to + pay" without the allowance. The unrounded rate gave £13,036.97 and + £93.39.""" + assert float(pensioner.calculate("state_pension", 2027)[0]) == pytest.approx( + 13_036.40, abs=1e-6 + ) + assert float(pensioner.calculate("total_income", 2027)[0]) == pytest.approx( + 13_036.40, abs=1e-6 + ) + assert float(pensioner.calculate("income_tax", 2027)[0]) == pytest.approx( + 93.28, abs=1e-6 + ) + + +@pytest.mark.parametrize("year", [2027, 2028, 2029]) +def test_full_new_state_pension_income_is_the_pension_tax_allowance_threshold( + pensioner, year +): + """The explainer sets the allowance's threshold at the full annual value + of the new State Pension, 52 weeks at the weekly rate, and uprates it "in + line with" that value for 2028-29 and 2029-30. A pensioner with only that + pension has exactly that income, a whole number of 5p a week, so sits at + the threshold, not a few pence above it.""" + threshold = weekly(pensioner.tax_benefit_system.parameters, RATES["new"], year) + threshold *= WEEKS_IN_YEAR + income = float(pensioner.calculate("total_income", year)[0]) + assert income == pytest.approx(threshold, abs=1e-6) + assert on_grid(income / WEEKS_IN_YEAR) + assert float(pensioner.calculate("additional_state_pension", year)[0]) == 0 + + +# 6. The rounding is a parameter. + + +ROUTES = { + "reform-dict": lambda change: Simulation(situation=SITUATION, reform=change), + "parameter-changes": lambda change: Simulation( + situation=SITUATION, scenario=Scenario(parameter_changes=change) + ), + "before-data-load": lambda change: Simulation( + situation=SITUATION, + scenario=Scenario(parameter_changes=change, applied_before_data_load=True), + ), +} +SITUATION = { + "people": {"pensioner": {"age": {2025: 70}}}, + "benunits": {"benunit": {"members": ["pensioner"]}}, + "households": {"household": {"members": ["pensioner"]}}, +} + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("unit", [0, 0.01, 1]) +def test_each_reform_route_moves_the_projection_by_the_rounding(route, unit): + reformed = ROUTES[route]({ROUNDING: unit}) + parameters = reformed.tax_benefit_system.parameters + for path in RATES.values(): + previous = weekly(parameters, path, LAST_PUBLISHED) + for year in PROJECTED: + rate = weekly(parameters, path, year) + rise = triple_lock(parameters, year) + if unit: + expected = uprated(previous, rise, Decimal(repr(unit))) + else: + expected = previous * (1 + rise) + assert rate == pytest.approx(expected, abs=1e-6), (path, year) + previous = rate + # The baseline stays current law. + assert weekly(parameters.baseline, path, 2028) == weekly( + system.parameters, path, 2028 + ) + + +def test_without_rounding_the_projection_is_the_unrounded_uprating(): + """Zero rounding gives what general uprating gave: the last published + rate times the triple lock's growth since then.""" + reformed = ROUTES["parameter-changes"]({ROUNDING: 0}) + parameters = reformed.tax_benefit_system.parameters + for path in RATES.values(): + level = weekly(parameters, path, LAST_PUBLISHED) + for year in PROJECTED: + level *= 1 + triple_lock(parameters, year) + # Each year's product is kept to £0.000001. + assert weekly(parameters, path, year) == pytest.approx(level, abs=1e-5) + + +# 7. A changed triple lock rate or index moves the projection. + + +def test_a_changed_triple_lock_index_moves_the_projection(): + index = system.parameters.get_child(TRIPLE_LOCK_INDEX) + level = index("2029-01-01") * 1.1 + reformed = ROUTES["reform-dict"]({TRIPLE_LOCK_INDEX: {"2029": level}}) + parameters = reformed.tax_benefit_system.parameters + changed = parameters.get_child(TRIPLE_LOCK_INDEX) + for path in RATES.values(): + for year in (2029, 2030): + growth = changed(f"{year}-01-01") / changed(f"{year - 1}-01-01") - 1 + previous = weekly(parameters, path, year - 1) + assert weekly(parameters, path, year) == pytest.approx( + uprated(previous, growth), abs=1e-9 + ) + assert weekly(parameters, path, 2029) > weekly(system.parameters, path, 2029) + + +@SIMULATION_SETTINGS +@given(rate=st.integers(0, 150).map(lambda n: n / 1000)) +def test_a_triple_lock_rate_set_by_reform_is_rounded_into_the_chain(rate): + reformed = ROUTES["reform-dict"]({TRIPLE_LOCK: {"2028": rate}}) + parameters = reformed.tax_benefit_system.parameters + for path in RATES.values(): + rates = [weekly(parameters, path, y) for y in range(2027, 2031)] + assert all(on_grid(r) for r in rates) + assert rates[1] == pytest.approx(uprated(rates[0], rate), abs=1e-12) + assert rates[0] == weekly(system.parameters, path, 2027) + + +# 8. Properties of the rule. + +amounts = st.decimals( + min_value=0, max_value=2_000, places=6, allow_nan=False, allow_infinity=False +) +units = st.sampled_from([0.01, 0.05, 0.10, 1.0]) +grid_rates = st.integers(0, 4_000).map(lambda n: n / 20) +rises = st.integers(-50, 200).map(lambda n: n / 1000) + + +@PROPERTY_SETTINGS +@given(amount=amounts, unit=units) +def test_rounding_lands_on_the_grid_within_half_a_unit(amount, unit): + rounded = round_weekly_rate(amount, unit) + step = Decimal(repr(unit)) + assert Decimal(repr(rounded)) % step == 0 + assert abs(Decimal(repr(rounded)) - amount) <= step / 2 + assert round_weekly_rate(Decimal(repr(rounded)), unit) == rounded + + +@PROPERTY_SETTINGS +@given(a=amounts, b=amounts, unit=units) +def test_rounding_is_monotone(a, b, unit): + a, b = sorted((a, b)) + assert round_weekly_rate(a, unit) <= round_weekly_rate(b, unit) + + +@PROPERTY_SETTINGS +@given(previous=grid_rates, rise=rises) +def test_uprating_matches_the_rule_and_a_rise_never_lowers_the_rate(previous, rise): + rate = uprate_weekly_rate(previous, 1 + Decimal(repr(rise)), 0.05) + assert rate == uprated(previous, rise) + if rise >= 0: + assert rate >= previous + else: + assert rate <= previous + + +@PROPERTY_SETTINGS +@given( + start=grid_rates, + path=st.lists(st.integers(0, 150).map(lambda n: n / 1000), min_size=1, max_size=15), +) +def test_a_projected_path_is_the_chained_rule(start, path): + """For any stated rate and any yearly rises, the projection is the rule + applied year by year, keeps the stated value and is non-decreasing.""" + levels = {"2026-01-01": 1.0} + level = 1.0 + for offset, rise in enumerate(path, start=1): + level *= 1 + rise + levels[f"{2026 + offset}-01-01"] = level + index = Parameter("index", data={"values": levels}) + rounding = Parameter("rounding", data={"values": {"2011-01-01": 0.05}}) + rate = Parameter( + "rate", + data={"values": {"2026-04-01": start}, "metadata": {"uprating": "index"}}, + ) + project_weekly_rate(rate, index, rounding) + values = {entry.instant_str: entry.value for entry in rate.values_list} + assert values.pop("2026-04-01") == start + expected = start + for offset, rise in enumerate(path, start=1): + previous = expected + expected = uprated(expected, rise) + assert values[f"{2026 + offset}-01-01"] == pytest.approx(expected, abs=1e-12) + assert expected >= previous + assert len(values) == len(path) diff --git a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py index a251ccf5e9..a0ac9b4345 100644 --- a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py +++ b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py @@ -67,7 +67,8 @@ def test_april_2027_uses_published_may_july_2026_earnings(): """ONS confirmed May-July 2026 total pay growth at 3.9% on 20 October, unchanged from September. April 2027's triple lock is max(3.5% CPI, 3.9% earnings, 2.5%), so the new State Pension rises from £241.30 to - £250.71 (before rounding to 5p).""" + £250.7107, £250.70 to the nearest 5p: 52 weeks of it is the £13,036.40 + of HM Treasury's Pension Tax Allowance explainer.""" parameters = system.parameters inputs = statutory_inputs(parameters) assert inputs.cpi_september("2026-09-01") == pytest.approx(0.035) @@ -76,9 +77,7 @@ def test_april_2027_uses_published_may_july_2026_earnings(): "2027-04-30" ) == pytest.approx(0.025) assert uprating(parameters, 2027) == pytest.approx(0.039) - assert new_state_pension_weekly(parameters, 2027) == pytest.approx( - 241.30 * 1.039, abs=0.005 - ) + assert new_state_pension_weekly(parameters, 2027) == pytest.approx(250.70, abs=1e-9) def test_pension_credit_uses_the_state_pension_statutory_earnings_input(): @@ -268,14 +267,12 @@ def test_macro_scenario_moves_the_state_pension_in_a_microsimulation(): baseline_pension = float(baseline.calculate("new_state_pension", 2028)[0]) reformed_pension = float(reformed.calculate("new_state_pension", 2028)[0]) - full_rate_2027 = 241.30 * 1.039 - # Uprating indices are stored to 5 decimal places. - assert baseline_pension == pytest.approx( - full_rate_2027 * 1.034 * WEEKS_IN_YEAR, rel=1e-5 - ) - assert reformed_pension == pytest.approx( - full_rate_2027 * 1.053 * WEEKS_IN_YEAR, rel=1e-5 - ) + # Each April's rate is the year before's, uprated, to the nearest 5p: + # £241.30 x 1.039 = £250.7107 gives £250.70 for April 2027, then + # £250.70 x 1.034 = £259.2238 gives £259.20 and £250.70 x 1.053 = + # £263.9871 gives £264.00 for April 2028. + assert baseline_pension == pytest.approx(259.20 * WEEKS_IN_YEAR, abs=1e-6) + assert reformed_pension == pytest.approx(264.00 * WEEKS_IN_YEAR, abs=1e-6) def test_setting_a_statutory_input_directly_overrides_its_forecast(): From 7b17962a7db9bb10628550c99e774334df1cd677 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 12:37:54 -0400 Subject: [PATCH 2/4] Compare simulated State Pension incomes at float32 precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simulations hold float32, so 52 weeks at £250.70 comes back as £13,036.399. The tests now compare simulated incomes to half a penny, which still separates them from the unrounded rates' 57p to £1.86. The synthetic projection property builds its parameters under a node, as mark_as_modified needs a parent. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_rate_rounding.py | 48 +++++++++++-------- .../test_triple_lock_statutory_inputs.py | 7 +-- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_rate_rounding.py b/policyengine_uk/tests/test_state_pension_rate_rounding.py index 08c6fd5a34..8881c91ebf 100644 --- a/policyengine_uk/tests/test_state_pension_rate_rounding.py +++ b/policyengine_uk/tests/test_state_pension_rate_rounding.py @@ -20,8 +20,9 @@ Pension Tax Allowance explainer (52 weeks at £250.70), and the £93.28 of tax it says that income would otherwise bear. 5. A pensioner whose only income is the full new State Pension has total - income of exactly 52 weeks at the projected rate, the explainer's Pension - Tax Allowance threshold, in every projected year. + income of 52 weeks at the projected rate, the explainer's Pension Tax + Allowance threshold, in 2027-28 to 2029-30 (to the float32 precision + simulations hold). 6. The rounding is a parameter: each reform route moves the projection by it, zero gives the unrounded chain, and the baseline stays current law. 7. A changed triple lock rate or index moves the projection. @@ -38,7 +39,7 @@ import yaml from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from policyengine_core.parameters import Parameter +from policyengine_core.parameters import ParameterNode from policyengine_uk import Simulation from policyengine_uk.data.dataset_schema import UKSingleYearDataset @@ -64,6 +65,9 @@ PROJECTED = range(LAST_PUBLISHED + 1, 2040) FIVE_PENCE = Decimal("0.05") WEEKS_IN_YEAR = 52 +# Simulations hold float32, which at £13,000 resolves about a tenth of a +# penny; the unrounded rates put these incomes 57p to £1.86 off. +HALF_PENNY = 0.005 PROPERTY_SETTINGS = settings( max_examples=200, @@ -200,14 +204,13 @@ def test_april_2027_is_the_pension_tax_allowance_explainer_figure(pensioner): and Steph, whose only income it is, "would have £93.28 of income tax to pay" without the allowance. The unrounded rate gave £13,036.97 and £93.39.""" - assert float(pensioner.calculate("state_pension", 2027)[0]) == pytest.approx( - 13_036.40, abs=1e-6 - ) - assert float(pensioner.calculate("total_income", 2027)[0]) == pytest.approx( - 13_036.40, abs=1e-6 - ) + # Simulations hold float32, a thousandth of a pound apart at this size. + for variable in ("state_pension", "total_income"): + assert float(pensioner.calculate(variable, 2027)[0]) == pytest.approx( + 13_036.40, abs=HALF_PENNY + ) assert float(pensioner.calculate("income_tax", 2027)[0]) == pytest.approx( - 93.28, abs=1e-6 + 93.28, abs=0.001 ) @@ -220,11 +223,10 @@ def test_full_new_state_pension_income_is_the_pension_tax_allowance_threshold( line with" that value for 2028-29 and 2029-30. A pensioner with only that pension has exactly that income, a whole number of 5p a week, so sits at the threshold, not a few pence above it.""" - threshold = weekly(pensioner.tax_benefit_system.parameters, RATES["new"], year) - threshold *= WEEKS_IN_YEAR + rate = weekly(pensioner.tax_benefit_system.parameters, RATES["new"], year) + assert on_grid(rate) income = float(pensioner.calculate("total_income", year)[0]) - assert income == pytest.approx(threshold, abs=1e-6) - assert on_grid(income / WEEKS_IN_YEAR) + assert income == pytest.approx(rate * WEEKS_IN_YEAR, abs=HALF_PENNY) assert float(pensioner.calculate("additional_state_pension", year)[0]) == 0 @@ -365,13 +367,19 @@ def test_a_projected_path_is_the_chained_rule(start, path): for offset, rise in enumerate(path, start=1): level *= 1 + rise levels[f"{2026 + offset}-01-01"] = level - index = Parameter("index", data={"values": levels}) - rounding = Parameter("rounding", data={"values": {"2011-01-01": 0.05}}) - rate = Parameter( - "rate", - data={"values": {"2026-04-01": start}, "metadata": {"uprating": "index"}}, + root = ParameterNode( + "", + data={ + "index": {"values": levels}, + "rounding": {"values": {"2011-01-01": 0.05}}, + "rate": { + "values": {"2026-04-01": start}, + "metadata": {"uprating": "index"}, + }, + }, ) - project_weekly_rate(rate, index, rounding) + rate = root.rate + project_weekly_rate(rate, root.index, root.rounding) values = {entry.instant_str: entry.value for entry in rate.values_list} assert values.pop("2026-04-01") == start expected = start diff --git a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py index a0ac9b4345..93fc4c157d 100644 --- a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py +++ b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py @@ -270,9 +270,10 @@ def test_macro_scenario_moves_the_state_pension_in_a_microsimulation(): # Each April's rate is the year before's, uprated, to the nearest 5p: # £241.30 x 1.039 = £250.7107 gives £250.70 for April 2027, then # £250.70 x 1.034 = £259.2238 gives £259.20 and £250.70 x 1.053 = - # £263.9871 gives £264.00 for April 2028. - assert baseline_pension == pytest.approx(259.20 * WEEKS_IN_YEAR, abs=1e-6) - assert reformed_pension == pytest.approx(264.00 * WEEKS_IN_YEAR, abs=1e-6) + # £263.9871 gives £264.00 for April 2028. Simulations hold float32, so + # compare to half a penny. + assert baseline_pension == pytest.approx(259.20 * WEEKS_IN_YEAR, abs=0.005) + assert reformed_pension == pytest.approx(264.00 * WEEKS_IN_YEAR, abs=0.005) def test_setting_a_statutory_input_directly_overrides_its_forecast(): From fe92606fed41db5aa3d24f8c85459eb83d7a493f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 13:25:54 -0400 Subject: [PATCH 3/4] Test a rounding tie and state what the earnings guarantee covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 3.9% triple lock for April 2028 takes the new State Pension to £273.675 exactly in April 2030. The model gives £273.70, and an index stored to five decimal places would give £273.65, so a new test covers both the halves-up convention and the index's precision. Another test checks that a rate a reform states for a year stays as stated. The earnings-path guarantee holds for the uprating percentages; the weekly rate then rounds to the nearest 5p, which s.150A(4) allows below the minimum (April 2019: £168.6231 became £168.60). Its test now checks the percentages and the cash chain exactly, not a 5-dp tolerance. The rounding parameter's documentation now names the reform routes that move it, and both it and the projection step say halves up is a convention: no published rate has landed on a half. Co-Authored-By: Claude Opus 5.5 --- .../state_pension/create_rate_projection.py | 6 ++- .../triple_lock/earnings_path_guarantee.yaml | 7 +++- .../state_pension/weekly_rate_rounding.yaml | 7 +++- .../tests/test_process_time_reforms.py | 2 +- .../tests/test_state_pension_rate_rounding.py | 41 +++++++++++++++---- .../test_triple_lock_statutory_inputs.py | 33 +++++++++++---- 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py b/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py index cc870947f6..bc78b90e3b 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py +++ b/policyengine_uk/parameters/gov/dwp/state_pension/create_rate_projection.py @@ -31,8 +31,10 @@ 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``, halves up. Each value applies -from 1 January, as uprating's do, so the April uprating sets the fiscal year, +``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 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/earnings_path_guarantee.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/earnings_path_guarantee.yaml index ce8546216c..47227edecb 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/earnings_path_guarantee.yaml +++ b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/earnings_path_guarantee.yaml @@ -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: diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml index edbc7830e6..fae00fa54b 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml +++ b/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml @@ -16,8 +16,11 @@ metadata: 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. - Every reform route reprocesses the parameters, so a change to this - amount moves the projected rates. + No published rate has landed exactly on a half; the model rounds halves + up. Each April's rounding is read on 30 April, as the triple lock's + inputs are, so a reform to this amount (through a reform dictionary or + Scenario(parameter_changes=...)) moves the projected rates for the years + it covers, while a key for a single other day leaves them unchanged. reference: - title: Social Security Administration Act 1992, section 150A(4) href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index cf318c4dae..2020b198b8 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -25,11 +25,11 @@ read every key exactly as before. 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. -6. Baselines stay current law: ``sim.baseline`` and ``parameters.baseline``. """ from decimal import ROUND_HALF_UP, Decimal diff --git a/policyengine_uk/tests/test_state_pension_rate_rounding.py b/policyengine_uk/tests/test_state_pension_rate_rounding.py index 8881c91ebf..355ff1f3e5 100644 --- a/policyengine_uk/tests/test_state_pension_rate_rounding.py +++ b/policyengine_uk/tests/test_state_pension_rate_rounding.py @@ -25,7 +25,9 @@ simulations hold). 6. The rounding is a parameter: each reform route moves the projection by it, zero gives the unrounded chain, and the baseline stays current law. -7. A changed triple lock rate or index moves the projection. + A rate a reform states for a year stays as stated. +7. A changed triple lock rate or index moves the projection, at full + precision: a half rounds up (£273.675 to £273.70). 8. Properties (Hypothesis): rounding lands on the grid, within half a unit, idempotently and monotonically; a projected path is the chained rule for any rates; a rise never lowers the rate. @@ -75,8 +77,6 @@ derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) -# Each example reprocesses the parameters, so as few as the reform tests use. -SIMULATION_SETTINGS = settings(PROPERTY_SETTINGS, max_examples=10) def weekly(parameters, path, year): @@ -273,8 +273,9 @@ def test_each_reform_route_moves_the_projection_by_the_rounding(route, unit): def test_without_rounding_the_projection_is_the_unrounded_uprating(): - """Zero rounding gives what general uprating gave: the last published - rate times the triple lock's growth since then.""" + """Zero rounding gives the unrounded chain: the last published rate + times the triple lock's growth since then. (General uprating gave this to + within about 0.1p; it read the index stored to five decimal places.)""" reformed = ROUTES["parameter-changes"]({ROUNDING: 0}) parameters = reformed.tax_benefit_system.parameters for path in RATES.values(): @@ -304,8 +305,9 @@ def test_a_changed_triple_lock_index_moves_the_projection(): assert weekly(parameters, path, 2029) > weekly(system.parameters, path, 2029) -@SIMULATION_SETTINGS -@given(rate=st.integers(0, 150).map(lambda n: n / 1000)) +# Each case rebuilds the parameters, so a short list stands in for a +# Hypothesis search: no rise, the floor, the tie case below, and two others. +@pytest.mark.parametrize("rate", [0, 0.025, 0.039, 0.053, 0.15]) def test_a_triple_lock_rate_set_by_reform_is_rounded_into_the_chain(rate): reformed = ROUTES["reform-dict"]({TRIPLE_LOCK: {"2028": rate}}) parameters = reformed.tax_benefit_system.parameters @@ -316,6 +318,31 @@ def test_a_triple_lock_rate_set_by_reform_is_rounded_into_the_chain(rate): assert rates[0] == weekly(system.parameters, path, 2027) +def test_a_half_rounds_up_and_the_index_keeps_its_precision(): + """A 3.9% triple lock for April 2028 takes £250.70 to £260.4773, so + £260.50; 2.5% then gives £267.0125, so £267.00, and £273.675 exactly for + April 2030, which rounds up to £273.70. In floats that product is + 273.67499999999995, and an index stored to five decimal places gives + £273.65.""" + reformed = ROUTES["reform-dict"]({TRIPLE_LOCK: {"2028": 0.039}}) + parameters = reformed.tax_benefit_system.parameters + assert [triple_lock(parameters, y) for y in (2029, 2030)] == [0.025, 0.025] + rates = [weekly(parameters, RATES["new"], y) for y in range(2027, 2031)] + assert rates == pytest.approx([250.70, 260.50, 267.00, 273.70], abs=1e-12) + + +def test_a_rate_a_reform_states_is_kept(): + """A reform that states the full rate for one year keeps it, and the + years after return to prior law's rounded path, as dated reforms fill + gaps from prior law.""" + reformed = ROUTES["reform-dict"]({RATES["new"]: {"2028": 300}}) + parameters = reformed.tax_benefit_system.parameters + rates = [weekly(parameters, RATES["new"], y) for y in range(2027, 2031)] + prior = [weekly(system.parameters, RATES["new"], y) for y in range(2027, 2031)] + assert rates == [prior[0], 300, prior[2], prior[3]] + assert all(on_grid(rate) for rate in prior) + + # 8. Properties of the rule. amounts = st.decimals( diff --git a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py index 93fc4c157d..5ed125616a 100644 --- a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py +++ b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py @@ -10,6 +10,8 @@ one reading of the plan announced in September 2026 as a parameter reform. """ +from decimal import ROUND_HALF_UP, Decimal + import numpy as np import pandas as pd import pytest @@ -50,6 +52,12 @@ def earnings_uprating(parameters, year): ) +def nearest_5p(amount): + """Nearest multiple of 5p, halves up, as each Order rounds the rates.""" + pennies = Decimal(repr(float(amount))).quantize(Decimal("0.000001")) * 20 + return float(pennies.quantize(Decimal(1), ROUND_HALF_UP) / 20) + + def new_state_pension_weekly(parameters, year): return parameters.gov.dwp.state_pension.new_state_pension.amount(f"{year}-06-01") @@ -317,6 +325,13 @@ def test_plan_is_a_parameter_reform(): the triple lock pays 6.0%. - April 2033: the path grows 3.0% to 1.1137 and the pension, at 1.0821, again needs 2.92%, rounded up to 3.0%, as the triple lock pays. + + The guarantee holds for the uprating percentages. Each year's weekly rate + is then the year before's raised by that percentage to the nearest 5p, as + in any year, so the rate can sit a few pence below the earnings path: + s.150A(4) lets the rounding go below the minimum, as in April 2019, when + earnings growth of 2.6% took £164.35 to £168.6231 and the Order set + £168.60. """ growth = { f"{INPUTS}.awe_total_pay_may_july": { @@ -340,16 +355,18 @@ def test_plan_is_a_parameter_reform(): for year in range(2027, 2030): assert uprating(plan, year) == uprating(triple_lock, year) - base = new_state_pension_weekly(plan, 2029) - assert base == new_state_pension_weekly(triple_lock, 2029) - earnings_path = base + previous = new_state_pension_weekly(plan, 2029) + assert previous == new_state_pension_weekly(triple_lock, 2029) + level = earnings_path = 1.0 for year in range(2030, 2034): + rise = uprating(plan, year) + level *= 1 + rise earnings_path *= 1 + [0.01, 0.01, 0.06, 0.03][year - 2030] - # Uprating indices are stored to 5 decimal places. - assert new_state_pension_weekly(plan, year) >= earnings_path * (1 - 1e-5) - assert new_state_pension_weekly(plan, year) <= new_state_pension_weekly( - triple_lock, year - ) + assert level >= earnings_path * (1 - 1e-12) + rate = new_state_pension_weekly(plan, year) + assert rate == pytest.approx(nearest_5p(previous * (1 + rise)), abs=1e-9) + assert rate <= new_state_pension_weekly(triple_lock, year) + previous = rate def test_plan_is_off_under_current_law(): From c7b297275745d54761ef884c354996e788fd140b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 13:41:35 -0400 Subject: [PATCH 4/4] Pin halves up against halves to even, and narrow the route note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit £273.675 is 5,473.5 units of 5p, which both rules round up, so the tie test also checks £273.725 (5,474.5 units), which halves up takes to £273.75 and halves to even would take down. The rounding parameter's documentation now names only the reforms checked to move it: whole fiscal years set through a reform dictionary or parameter changes. General uprating on main sat within a third of a penny of the unrounded chain through 2039, not 0.1p. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/state_pension/weekly_rate_rounding.yaml | 9 +++++---- .../tests/test_state_pension_rate_rounding.py | 9 +++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml index fae00fa54b..79c2647591 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml +++ b/policyengine_uk/parameters/gov/dwp/state_pension/weekly_rate_rounding.yaml @@ -17,10 +17,11 @@ metadata: 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, as the triple lock's - inputs are, so a reform to this amount (through a reform dictionary or - Scenario(parameter_changes=...)) moves the projected rates for the years - it covers, while a key for a single other day leaves them unchanged. + 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 diff --git a/policyengine_uk/tests/test_state_pension_rate_rounding.py b/policyengine_uk/tests/test_state_pension_rate_rounding.py index 355ff1f3e5..6f4669db35 100644 --- a/policyengine_uk/tests/test_state_pension_rate_rounding.py +++ b/policyengine_uk/tests/test_state_pension_rate_rounding.py @@ -275,7 +275,8 @@ def test_each_reform_route_moves_the_projection_by_the_rounding(route, unit): def test_without_rounding_the_projection_is_the_unrounded_uprating(): """Zero rounding gives the unrounded chain: the last published rate times the triple lock's growth since then. (General uprating gave this to - within about 0.1p; it read the index stored to five decimal places.)""" + within a third of a penny through 2039; it read the index stored to five + decimal places.)""" reformed = ROUTES["parameter-changes"]({ROUNDING: 0}) parameters = reformed.tax_benefit_system.parameters for path in RATES.values(): @@ -323,12 +324,16 @@ def test_a_half_rounds_up_and_the_index_keeps_its_precision(): £260.50; 2.5% then gives £267.0125, so £267.00, and £273.675 exactly for April 2030, which rounds up to £273.70. In floats that product is 273.67499999999995, and an index stored to five decimal places gives - £273.65.""" + £273.65. £273.675 is 5,473.5 units of 5p, which rounding halves to even + would also take up, so the last check uses a half above an even count, + 5,474.5 units, which halves up takes to £273.75 and halves to even would + take down.""" reformed = ROUTES["reform-dict"]({TRIPLE_LOCK: {"2028": 0.039}}) parameters = reformed.tax_benefit_system.parameters assert [triple_lock(parameters, y) for y in (2029, 2030)] == [0.025, 0.025] rates = [weekly(parameters, RATES["new"], y) for y in range(2027, 2031)] assert rates == pytest.approx([250.70, 260.50, 267.00, 273.70], abs=1e-12) + assert round_weekly_rate(Decimal("273.725"), 0.05) == 273.75 def test_a_rate_a_reform_states_is_kept():