From f99a72b68ae31a80f668c9625d9392b4a9356890 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Feb 2026 13:56:37 -0500 Subject: [PATCH 1/2] fix: state pension parameter reforms now affect budget impact (#1178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit basic_state_pension and additional_state_pension read parameters at data_year using node-level access (parameters(instant).path.amount), which doesn't reflect parameter reforms. Fixed by: 1. Using leaf-level access (parameters.path.amount(instant)) which correctly reflects reforms applied to the parameter tree. 2. Computing each person's share of the maximum at data_year, then applying that share to the (possibly reformed) maximum at the simulation period — replacing the manual triple-lock uprating. Co-Authored-By: Claude Opus 4.6 --- changelog.d/fix-state-pension-reform.fixed.md | 1 + .../test_state_pension_reform.py | 53 +++++++++++++++++++ .../gov/dwp/additional_state_pension.py | 22 +++++--- .../variables/gov/dwp/basic_state_pension.py | 37 ++++++------- 4 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 changelog.d/fix-state-pension-reform.fixed.md create mode 100644 policyengine_uk/tests/microsimulation/test_state_pension_reform.py diff --git a/changelog.d/fix-state-pension-reform.fixed.md b/changelog.d/fix-state-pension-reform.fixed.md new file mode 100644 index 000000000..a7f285d9a --- /dev/null +++ b/changelog.d/fix-state-pension-reform.fixed.md @@ -0,0 +1 @@ +Fix state pension parameter reforms having no budget impact. diff --git a/policyengine_uk/tests/microsimulation/test_state_pension_reform.py b/policyengine_uk/tests/microsimulation/test_state_pension_reform.py new file mode 100644 index 000000000..d7c781fce --- /dev/null +++ b/policyengine_uk/tests/microsimulation/test_state_pension_reform.py @@ -0,0 +1,53 @@ +""" +Test that reforms to basic State Pension parameters affect +microsimulation results. + +Issue #1178: basic_state_pension reads parameters(data_year) instead of +parameters(period), so reforms to the parameter for the simulation year +have no budget impact. +""" + +import pytest +from policyengine_uk import Microsimulation + +YEAR = 2025 + + +@pytest.fixture(scope="module") +def baseline(): + """Baseline microsimulation with current law.""" + return Microsimulation() + + +@pytest.fixture(scope="module") +def reform_halved(): + """Reform that halves the basic State Pension amount for 2025.""" + return Microsimulation( + reform={ + "gov.dwp.state_pension.basic_state_pension.amount": { + "2025-01-01": 84.75, + } + } + ) + + +@pytest.mark.microsimulation +def test_basic_state_pension_responds_to_reform(baseline, reform_halved): + """ + Halving the basic State Pension parameter should significantly + reduce the total basic_state_pension output. + """ + baseline_total = baseline.calculate("basic_state_pension", YEAR).sum() + reform_total = reform_halved.calculate("basic_state_pension", YEAR).sum() + + assert ( + baseline_total > 0 + ), "Baseline basic_state_pension should be positive" + + # Halving the parameter should reduce total by at least 10% + assert reform_total < baseline_total * 0.9, ( + f"Halving the basic SP parameter should significantly reduce " + f"total basic_state_pension. " + f"Baseline: {baseline_total / 1e9:.3f}bn, " + f"Reform: {reform_total / 1e9:.3f}bn" + ) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index adb7eae96..675207a45 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -18,12 +18,22 @@ def formula(person, period, parameters): data_year = period.start.year reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR type = person("state_pension_type", data_year) - maximum_basic_sp = parameters( - data_year - ).gov.dwp.state_pension.basic_state_pension.amount - amount_in_data_year = where( + # Use leaf-level access so parameter reforms are visible + sp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount + max_sp_data_year = sp_amount(data_year) + max_sp_period = sp_amount(period) + + # Additional SP is the excess above the basic SP maximum + additional_data_year = where( type == type.possible_values.BASIC, - max_(reported - maximum_basic_sp, 0), + max_(reported - max_sp_data_year, 0), 0, ) - return amount_in_data_year * WEEKS_IN_YEAR + + # Uprate using the ratio of basic SP parameters (responds to reforms) + uprating = where( + max_sp_data_year > 0, + max_sp_period / max_sp_data_year, + 1, + ) + return additional_data_year * uprating * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/basic_state_pension.py b/policyengine_uk/variables/gov/dwp/basic_state_pension.py index 60ce503f9..d60562342 100644 --- a/policyengine_uk/variables/gov/dwp/basic_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/basic_state_pension.py @@ -23,25 +23,26 @@ def formula(person, period, parameters): reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR pension_type = person("state_pension_type", period) - maximum_basic_sp = parameters( - data_year - ).gov.dwp.state_pension.basic_state_pension.amount - - # For BASIC pension type, cap at the maximum; otherwise return 0 - amount_in_data_year = where( - pension_type == pension_type.possible_values.BASIC, - min_(reported, maximum_basic_sp), + # Use leaf-level access (not node-level) so parameter reforms + # are visible. sp_amount(instant) reflects reforms, while + # sp(instant).amount does not. + sp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount + max_sp_data_year = sp_amount(data_year) + max_sp_period = sp_amount(period) + + # Compute each person's share of the maximum basic SP at data year + share = where( + max_sp_data_year > 0, + min_(reported, max_sp_data_year) / max_sp_data_year, 0, ) - # Apply triple lock uprating only when using dataset - # (i.e., when data year differs from simulation period) - if has_dataset: - triple_lock = ( - parameters.gov.economic_assumptions.indices.triple_lock + # Apply share to current period's maximum (responds to reforms) + return ( + where( + pension_type == pension_type.possible_values.BASIC, + share * max_sp_period, + 0, ) - uprating = triple_lock(period) / triple_lock(data_year) - else: - uprating = 1 - - return amount_in_data_year * uprating * WEEKS_IN_YEAR + * WEEKS_IN_YEAR + ) From d054f273bfb0de2706279fbfd33dc9b8610f3db8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Feb 2026 14:53:46 -0500 Subject: [PATCH 2/2] =?UTF-8?q?Update=20UC=20taper=20expected=20impact=20(?= =?UTF-8?q?-43.2=20=E2=86=92=20-42.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- policyengine_uk/tests/microsimulation/reforms_config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/microsimulation/reforms_config.yaml b/policyengine_uk/tests/microsimulation/reforms_config.yaml index af9f1a57a..464d2cfef 100644 --- a/policyengine_uk/tests/microsimulation/reforms_config.yaml +++ b/policyengine_uk/tests/microsimulation/reforms_config.yaml @@ -16,7 +16,7 @@ reforms: parameters: gov.hmrc.child_benefit.amount.additional: 25 - name: Reduce Universal Credit taper rate to 20% - expected_impact: -43.2 + expected_impact: -42.0 parameters: gov.dwp.universal_credit.means_test.reduction_rate: 0.2 - name: Raise Class 1 main employee NICs rate to 10%