From 9be3fc18ea3845b3dcc5ba7650f09f879618ee4e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:21 +0400 Subject: [PATCH 1/2] Fix Pension Credit guarantee projection --- changelog.d/2072.fixed.md | 1 + .../guarantee_credit/minimum_guarantee.yaml | 16 ++++-- .../lag_average_earnings.py | 7 +++ .../gov/economic_assumptions/lagged_series.py | 14 ++++- policyengine_uk/tests/test_lagged_series.py | 53 ++++++++++++++++++- 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2072.fixed.md diff --git a/changelog.d/2072.fixed.md b/changelog.d/2072.fixed.md new file mode 100644 index 0000000000..b7375abb1d --- /dev/null +++ b/changelog.d/2072.fixed.md @@ -0,0 +1 @@ +Corrects the Pension Credit standard minimum guarantee projection to follow a non-negative lagged earnings index. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml index 0c0d5c29b9..a37a6d2818 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml @@ -1,4 +1,8 @@ -description: Minimum income guarantee (basic amount) for Pension Credit. +description: >- + Minimum income guarantee (basic amount) for Pension Credit. Values after the + last enacted rate use a non-negative, one-year-lagged OBR average-earnings + index as a forecast proxy for the statutory earnings floor; they are not + enacted future rates. SINGLE: values: 2015-04-06: 151.2 @@ -14,7 +18,7 @@ SINGLE: 2025-04-01: 221.85 2026-04-01: 238 metadata: - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.obr.nonnegative_lagged_average_earnings COUPLE: values: 2015-04-06: 230.85 @@ -30,7 +34,7 @@ COUPLE: 2025-04-01: 338.61 2026-04-01: 363.25 metadata: - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.obr.nonnegative_lagged_average_earnings metadata: period: week label: Pension Credit minimum guarantee @@ -40,3 +44,9 @@ metadata: href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/6/2021-04-12 - title: Benefit and pension rates 2026 to 2027 href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 + - title: Social Security Administration Act 1992 section 150A + href: https://www.legislation.gov.uk/ukpga/1992/5/section/150A + - title: Social Security Administration (Northern Ireland) Act 1992 section 132A + href: https://www.legislation.gov.uk/ukpga/1992/8/section/132A + - title: A14/2025 Housing Benefit uprating for the financial year ending March 2027 + href: https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2025/a142025-housing-benefit-uprating-for-the-financial-year-ending-march-2027 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py b/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py index 9153fb0521..7f3e025a8b 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py @@ -15,5 +15,12 @@ def add_lagged_earnings( add_lagged_parameter( obr, "average_earnings", "lagged_average_earnings", first_year=2022 ) + add_lagged_parameter( + obr, + "average_earnings", + "nonnegative_lagged_average_earnings", + first_year=2022, + minimum_value=0, + ) return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py b/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py index cd55ef948f..d86b5db275 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py @@ -19,15 +19,25 @@ def add_lagged_parameter( source_name: str, lagged_name: str, first_year: int, + minimum_value: float | None = None, ) -> Parameter: - """Add a one-year-lagged copy of ``source_name`` as a child of ``node``.""" + """Add a one-year-lagged copy of ``source_name`` as a child of ``node``. + + If ``minimum_value`` is set, values below it are replaced with that floor. + """ source = getattr(node, source_name) + def lagged_value(year: int) -> float: + value = source(year - 1) + if minimum_value is None: + return value + return max(minimum_value, value) + lagged = Parameter( f"{node.name}.{lagged_name}", data={ "values": { - f"{year}-01-01": source(year - 1) + f"{year}-01-01": lagged_value(year) for year in lag_source_years(source, first_year) }, }, diff --git a/policyengine_uk/tests/test_lagged_series.py b/policyengine_uk/tests/test_lagged_series.py index 3e64f5b890..fc61142b8e 100644 --- a/policyengine_uk/tests/test_lagged_series.py +++ b/policyengine_uk/tests/test_lagged_series.py @@ -6,7 +6,12 @@ index well below the series it lags. """ +from policyengine_core.parameters import Parameter, ParameterNode + from policyengine_uk import system +from policyengine_uk.parameters.gov.economic_assumptions.lagged_series import ( + add_lagged_parameter, +) def _obr(node: str): @@ -29,6 +34,10 @@ def test_lagged_series_reach_the_end_of_their_source(): for source_name, lagged_name in ( ("consumer_price_index", "lagged_cpi"), ("average_earnings", "lagged_average_earnings"), + ( + "average_earnings", + "nonnegative_lagged_average_earnings", + ), ): source = getattr(growth, source_name) lagged = getattr(growth, lagged_name) @@ -48,6 +57,10 @@ def test_lagged_series_hold_the_previous_year_of_their_source(): for source_name, lagged_name in ( ("consumer_price_index", "lagged_cpi"), ("average_earnings", "lagged_average_earnings"), + ( + "average_earnings", + "nonnegative_lagged_average_earnings", + ), ): source = getattr(growth, source_name) lagged = getattr(growth, lagged_name) @@ -65,9 +78,47 @@ def test_the_lagged_earnings_index_keeps_growing_with_earnings(): assert index("2039") > index("2035") > index("2030") +def test_lagged_series_can_apply_a_nonnegative_floor(): + node = ParameterNode(name="test", data={}) + node.add_child( + "source", + Parameter( + name="test.source", + data={"values": {"2024-01-01": -0.02, "2025-01-01": 0.03}}, + ), + ) + + lagged = add_lagged_parameter( + node, + "source", + "nonnegative_lagged_source", + first_year=2025, + minimum_value=0, + ) + + assert lagged("2025") == 0 + assert lagged("2026") == 0.03 + + +def test_minimum_guarantee_uses_nonnegative_lagged_earnings(): + parameters = system.parameters + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + index = _obr("indices").nonnegative_lagged_average_earnings + + for relation_type in ("SINGLE", "COUPLE"): + branch = getattr(guarantee, relation_type) + expected_2027 = branch("2026") * index("2027") / index("2026") + + assert branch("2027") == expected_2027 + + def test_lagged_parameter_names_match_their_position_in_the_tree(): growth = _obr("yoy_growth") - for lagged_name in ("lagged_cpi", "lagged_average_earnings"): + for lagged_name in ( + "lagged_cpi", + "lagged_average_earnings", + "nonnegative_lagged_average_earnings", + ): lagged = getattr(growth, lagged_name) assert lagged.name.endswith(f"yoy_growth.obr.{lagged_name}") From 2ee46f850be779629e67bb79cb3f8377f2785d9f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:10:02 +0400 Subject: [PATCH 2/2] Fix issues from review: use statutory earnings input --- changelog.d/2072.fixed.md | 2 +- .../guarantee_credit/minimum_guarantee.yaml | 10 +-- .../triple_lock/create_triple_lock.py | 61 ++++++++++++++++--- .../lag_average_earnings.py | 7 --- .../gov/economic_assumptions/lagged_series.py | 14 +---- policyengine_uk/tests/test_lagged_series.py | 53 +--------------- .../test_triple_lock_statutory_inputs.py | 54 +++++++++++++++- 7 files changed, 111 insertions(+), 90 deletions(-) diff --git a/changelog.d/2072.fixed.md b/changelog.d/2072.fixed.md index b7375abb1d..a93ededf80 100644 --- a/changelog.d/2072.fixed.md +++ b/changelog.d/2072.fixed.md @@ -1 +1 @@ -Corrects the Pension Credit standard minimum guarantee projection to follow a non-negative lagged earnings index. +- Corrects the Pension Credit standard minimum guarantee projection to follow the statutory May-July earnings input with a non-negative floor. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml index a37a6d2818..53d53a590a 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml @@ -1,8 +1,8 @@ description: >- Minimum income guarantee (basic amount) for Pension Credit. Values after the - last enacted rate use a non-negative, one-year-lagged OBR average-earnings - index as a forecast proxy for the statutory earnings floor; they are not - enacted future rates. + last enacted rate use the non-negative May-July average weekly earnings input + from the annual statutory review as a forecast floor; they are not enacted + future rates. SINGLE: values: 2015-04-06: 151.2 @@ -18,7 +18,7 @@ SINGLE: 2025-04-01: 221.85 2026-04-01: 238 metadata: - uprating: gov.economic_assumptions.indices.obr.nonnegative_lagged_average_earnings + uprating: gov.economic_assumptions.indices.statutory_earnings_floor COUPLE: values: 2015-04-06: 230.85 @@ -34,7 +34,7 @@ COUPLE: 2025-04-01: 338.61 2026-04-01: 363.25 metadata: - uprating: gov.economic_assumptions.indices.obr.nonnegative_lagged_average_earnings + uprating: gov.economic_assumptions.indices.statutory_earnings_floor metadata: period: week label: Pension Credit minimum guarantee diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py index b977aca217..007774cb82 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py +++ b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py @@ -1,4 +1,4 @@ -"""Build the State Pension uprating series from the statutory inputs. +"""Build statutory earnings and State Pension uprating series. Each April the basic and new State Pension rise by the highest of: @@ -29,10 +29,13 @@ earnings path started from its level in the year before the guarantee first applied. Current law leaves it off. -The output parameter ``gov.economic_assumptions.yoy_growth.triple_lock`` holds -the rate taking effect in April of each year, keyed to 1 January as the other -growth series are, and runs to one year past the last year of the -economic-assumption series. +The output parameters hold the rates taking effect in April of each year, +keyed to 1 January as the other growth series are. ``triple_lock`` applies the +State Pension rule. ``statutory_earnings_floor`` holds the non-negative +earnings component for amounts, including the Pension Credit standard minimum +guarantee, that section 150A protects without extending the triple lock to +them. Both run to one year past the last year of the economic-assumption +series. """ from dataclasses import dataclass @@ -155,6 +158,13 @@ def uprating_rates(years: Dict[int, UpratingYear]) -> Dict[int, float]: return rates +def statutory_earnings_floor_rates( + years: Dict[int, UpratingYear], +) -> Dict[int, float]: + """Return the non-negative earnings rate from each statutory review.""" + return {year: max(inputs.earnings, 0.0) for year, inputs in years.items()} + + def _flag(parameter: Parameter, instant: str) -> bool: value = parameter(instant) return bool(value) if value is not None else False @@ -200,22 +210,53 @@ def read_uprating_years(parameters: ParameterNode) -> Dict[int, UpratingYear]: def add_triple_lock(parameters: ParameterNode) -> ParameterNode: - """Add ``gov.economic_assumptions.yoy_growth.triple_lock``.""" - rates = uprating_rates(read_uprating_years(parameters)) - new_parameter = Parameter( + """Add the statutory earnings floor and State Pension triple lock.""" + years = read_uprating_years(parameters) + triple_lock_rates = uprating_rates(years) + earnings_floor_rates = statutory_earnings_floor_rates(years) + triple_lock = Parameter( "gov.economic_assumptions.yoy_growth.triple_lock", data={ "description": ( "State Pension uprating taking effect in April of each year." ), - "values": {f"{year}-01-01": rate for year, rate in rates.items()}, + "values": { + f"{year}-01-01": rate for year, rate in triple_lock_rates.items() + }, "metadata": { "unit": "/1", "label": "State Pension uprating rate", }, }, ) + parameters.gov.economic_assumptions.yoy_growth.add_child("triple_lock", triple_lock) + statutory_earnings_floor = Parameter( + "gov.economic_assumptions.yoy_growth.statutory_earnings_floor", + data={ + "description": ( + "Non-negative May-July average weekly earnings growth used " + "as the statutory uprating floor under section 150A." + ), + "values": { + f"{year}-01-01": rate for year, rate in earnings_floor_rates.items() + }, + "metadata": { + "unit": "/1", + "label": "Statutory earnings uprating floor", + "reference": [ + { + "title": ( + "Social Security Administration Act 1992 section 150A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150A" + ), + } + ], + }, + }, + ) parameters.gov.economic_assumptions.yoy_growth.add_child( - "triple_lock", new_parameter + "statutory_earnings_floor", statutory_earnings_floor ) return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py b/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py index 7f3e025a8b..9153fb0521 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/lag_average_earnings.py @@ -15,12 +15,5 @@ def add_lagged_earnings( add_lagged_parameter( obr, "average_earnings", "lagged_average_earnings", first_year=2022 ) - add_lagged_parameter( - obr, - "average_earnings", - "nonnegative_lagged_average_earnings", - first_year=2022, - minimum_value=0, - ) return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py b/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py index d86b5db275..cd55ef948f 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/lagged_series.py @@ -19,25 +19,15 @@ def add_lagged_parameter( source_name: str, lagged_name: str, first_year: int, - minimum_value: float | None = None, ) -> Parameter: - """Add a one-year-lagged copy of ``source_name`` as a child of ``node``. - - If ``minimum_value`` is set, values below it are replaced with that floor. - """ + """Add a one-year-lagged copy of ``source_name`` as a child of ``node``.""" source = getattr(node, source_name) - def lagged_value(year: int) -> float: - value = source(year - 1) - if minimum_value is None: - return value - return max(minimum_value, value) - lagged = Parameter( f"{node.name}.{lagged_name}", data={ "values": { - f"{year}-01-01": lagged_value(year) + f"{year}-01-01": source(year - 1) for year in lag_source_years(source, first_year) }, }, diff --git a/policyengine_uk/tests/test_lagged_series.py b/policyengine_uk/tests/test_lagged_series.py index fc61142b8e..3e64f5b890 100644 --- a/policyengine_uk/tests/test_lagged_series.py +++ b/policyengine_uk/tests/test_lagged_series.py @@ -6,12 +6,7 @@ index well below the series it lags. """ -from policyengine_core.parameters import Parameter, ParameterNode - from policyengine_uk import system -from policyengine_uk.parameters.gov.economic_assumptions.lagged_series import ( - add_lagged_parameter, -) def _obr(node: str): @@ -34,10 +29,6 @@ def test_lagged_series_reach_the_end_of_their_source(): for source_name, lagged_name in ( ("consumer_price_index", "lagged_cpi"), ("average_earnings", "lagged_average_earnings"), - ( - "average_earnings", - "nonnegative_lagged_average_earnings", - ), ): source = getattr(growth, source_name) lagged = getattr(growth, lagged_name) @@ -57,10 +48,6 @@ def test_lagged_series_hold_the_previous_year_of_their_source(): for source_name, lagged_name in ( ("consumer_price_index", "lagged_cpi"), ("average_earnings", "lagged_average_earnings"), - ( - "average_earnings", - "nonnegative_lagged_average_earnings", - ), ): source = getattr(growth, source_name) lagged = getattr(growth, lagged_name) @@ -78,47 +65,9 @@ def test_the_lagged_earnings_index_keeps_growing_with_earnings(): assert index("2039") > index("2035") > index("2030") -def test_lagged_series_can_apply_a_nonnegative_floor(): - node = ParameterNode(name="test", data={}) - node.add_child( - "source", - Parameter( - name="test.source", - data={"values": {"2024-01-01": -0.02, "2025-01-01": 0.03}}, - ), - ) - - lagged = add_lagged_parameter( - node, - "source", - "nonnegative_lagged_source", - first_year=2025, - minimum_value=0, - ) - - assert lagged("2025") == 0 - assert lagged("2026") == 0.03 - - -def test_minimum_guarantee_uses_nonnegative_lagged_earnings(): - parameters = system.parameters - guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee - index = _obr("indices").nonnegative_lagged_average_earnings - - for relation_type in ("SINGLE", "COUPLE"): - branch = getattr(guarantee, relation_type) - expected_2027 = branch("2026") * index("2027") / index("2026") - - assert branch("2027") == expected_2027 - - def test_lagged_parameter_names_match_their_position_in_the_tree(): growth = _obr("yoy_growth") - for lagged_name in ( - "lagged_cpi", - "lagged_average_earnings", - "nonnegative_lagged_average_earnings", - ): + for lagged_name in ("lagged_cpi", "lagged_average_earnings"): lagged = getattr(growth, lagged_name) assert lagged.name.endswith(f"yoy_growth.obr.{lagged_name}") diff --git a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py index a3101905d0..8320947bc1 100644 --- a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py +++ b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py @@ -4,9 +4,10 @@ earnings growth from the previous year. These tests check that forecasts of those inputs follow the OBR where it publishes them, fall back to calendar-year growth after, respond to macro scenarios applied before the -data load, and can be set directly; and that the earnings-path guarantee -models one reading of the plan announced in September 2026 as a -parameter reform. +data load, and can be set directly. They also check that Pension Credit uses +the same May-July earnings input without inheriting the CPI and 2.5% elements +of the State Pension triple lock, and that the earnings-path guarantee models +one reading of the plan announced in September 2026 as a parameter reform. """ import numpy as np @@ -43,10 +44,21 @@ def uprating(parameters, year): return parameters.gov.economic_assumptions.yoy_growth.triple_lock(f"{year}-01-01") +def statutory_earnings_floor(parameters, year): + return parameters.gov.economic_assumptions.yoy_growth.statutory_earnings_floor( + f"{year}-01-01" + ) + + def new_state_pension_weekly(parameters, year): return parameters.gov.dwp.state_pension.new_state_pension.amount(f"{year}-06-01") +def minimum_guarantee_weekly(parameters, relation_type, year): + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + return getattr(guarantee, relation_type)(f"{year}-06-01") + + def statutory_inputs(parameters): return parameters.gov.economic_assumptions.statutory_uprating_inputs @@ -63,6 +75,42 @@ def test_april_2027_uses_published_may_july_2026_earnings(): ) +def test_pension_credit_uses_the_state_pension_statutory_earnings_input(): + parameters = system.parameters + + assert statutory_inputs(parameters).awe_total_pay_may_july( + "2026-07-01" + ) == pytest.approx(0.039) + assert statutory_earnings_floor(parameters, 2027) == pytest.approx(0.039) + assert minimum_guarantee_weekly(parameters, "SINGLE", 2027) == pytest.approx( + 238 * 1.039, rel=1e-5 + ) + assert minimum_guarantee_weekly(parameters, "COUPLE", 2027) == pytest.approx( + 363.25 * 1.039, rel=1e-5 + ) + + +def test_pension_credit_earnings_floor_rounds_and_never_reduces_the_guarantee(): + parameters = parameters_under( + { + f"{INPUTS}.awe_total_pay_may_july": { + "2027": 0.0124, + "2028": -0.004, + } + } + ) + + assert [ + statutory_earnings_floor(parameters, year) for year in (2028, 2029) + ] == pytest.approx([0.012, 0.0]) + for relation_type in ("SINGLE", "COUPLE"): + guarantee_2027 = minimum_guarantee_weekly(parameters, relation_type, 2027) + guarantee_2028 = minimum_guarantee_weekly(parameters, relation_type, 2028) + guarantee_2029 = minimum_guarantee_weekly(parameters, relation_type, 2029) + assert guarantee_2028 == pytest.approx(guarantee_2027 * 1.012, rel=1e-5) + assert guarantee_2029 == pytest.approx(guarantee_2028, rel=1e-5) + + def test_every_published_year_has_its_own_value(): """A missing year would silently carry the previous year's figure.""" inputs = statutory_inputs(system.parameters)