From bc8f00bb2bc25dd9ac43da9c5ee5fa52f4aaf564 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 06:59:27 -0400 Subject: [PATCH 1/7] Apply the Universal Credit Act 2025 standard allowance uplifts to 2029-30 standard_allowance_uplift held the s. 1 uplifts (2.3%, 3.1%, 4.0%, 4.8%) but nothing read it after the 2026-27 amounts, which include the first uplift, were written into amount.yaml, so 2027-28 to 2029-30 grew by CPI alone. The standard allowance is now uprated by its own index, the benefit uprating CPI index (floored at 0% in 2026-27 to 2029-30, s. 1(3)) times one plus the year's uplift, so it grows by CPI times the change in uplift. A legislated amount for those years is rescaled if the uplift in force differs from the statutory one, so a reform to the uplift or to rebalancing.active reaches every year. Fixes #2239 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2239.fixed.md | 1 + docs/book/policy/uc-rebalancing.md | 32 +-- .../create_standard_allowance_uprating.py | 130 +++++++++++ .../standard_allowance_uplift.yaml | 16 +- .../standard_allowance/amount.yaml | 10 +- policyengine_uk/scenarios/uc_reform.py | 5 +- policyengine_uk/tax_benefit_system.py | 5 + .../test_uc_standard_allowance_uplift.py | 209 ++++++++++++++++++ 8 files changed, 381 insertions(+), 27 deletions(-) create mode 100644 changelog.d/2239.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py create mode 100644 policyengine_uk/tests/test_uc_standard_allowance_uplift.py diff --git a/changelog.d/2239.fixed.md b/changelog.d/2239.fixed.md new file mode 100644 index 0000000000..c3d8e47f48 --- /dev/null +++ b/changelog.d/2239.fixed.md @@ -0,0 +1 @@ +Apply the Universal Credit Act 2025 s. 1 uplifts to the Universal Credit standard allowance for 2027-28 to 2029-30 (3.1%, 4.0% and 4.8% above the CPI-only path from 2025-26). The allowance is now uprated by a dedicated index, benefit uprating CPI times one plus the year's uplift, so it grows by CPI times the change in uplift each year and by CPI alone after 2029-30. A different uplift, or switching the rebalancing reforms off, also rescales the legislated 2026-27 amounts. `standard_allowance_uplift` was previously not read. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 5082996119..aa7603ace8 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -36,14 +36,16 @@ The implementation uses transition probabilities based on WPI Economics analysis ## Standard allowance uplifts -The standard allowance receives additional percentage uplifts beyond the normal inflationary increase: +Universal Credit Act 2025 s. 1 sets the minimum standard allowance for 2026-27 to 2029-30. Each year's minimum is the previous year's CPI-uprated amount (the 2025-26 amount for 2026-27), increased by September CPI (never below 0%), and then by that year's uplift: -- 2026: 2.3% additional uplift -- 2027: 3.1% additional uplift (cumulative) -- 2028: 4.0% additional uplift (cumulative) -- 2029: 4.8% additional uplift (cumulative) +- 2026-27: 2.3% +- 2027-28: 3.1% +- 2028-29: 4.0% +- 2029-30: 4.8% -These uplifts are applied to the CPI-uprated standard allowance for each year. In other words, the model first applies the usual CPI uprating and then applies the rebalancing uplift on top. +Each uplift is measured against the CPI-only path from 2025-26, not compounded on the previous one, so from one year to the next the allowance grows by CPI times (1 + this year's uplift) / (1 + last year's uplift). After 2029-30 the uplift stays at 4.8% and the allowance grows with CPI alone. + +The 2026-27 amounts are the legislated rates in `standard_allowance/amount.yaml`. Later years are uprated by `gov.dwp.universal_credit.standard_allowance.uprating`, the benefit uprating CPI index times (1 + the year's uplift), which `rebalancing/create_standard_allowance_uprating.py` builds when parameters are processed. A legislated rate for one of these years includes that year's statutory uplift; if the uplift in force differs, through a reform or with `rebalancing.active` false (no uplift), the rate is rescaled by (1 + uplift in force) / (1 + statutory uplift). ## Implementation @@ -51,7 +53,7 @@ These uplifts are applied to the CPI-uprated standard allowance for each year. I The reforms are implemented through parameters, scenario modifiers, and scenarios that work together to enable policy analysis. ``` -- **Parameters**: Three YAML files define the reform's activation status, health element amount for new claimants, and standard allowance uplift rates. +- **Parameters**: Three YAML files define the reform's activation status, health element amount for new claimants, and standard allowance uplift rates. The uplift reaches the standard allowance through its uprating index. - **Scenario modifier**: The `add_universal_credit_reform` function applies the protected existing-claimant health-element path during microsimulation. - **Scenario**: The `universal_credit_july_2025_reform` scenario enables the reforms in policy analysis. @@ -79,14 +81,15 @@ sim = Simulation(scenario=scenario) ```python from policyengine_uk import Simulation, Scenario -# Set different uplift rates - e.g. 5% in 2026, 7% in 2027 +# Set different uplift rates - e.g. 5% in 2026-27, 7% from 2027-28. +# A bare year names the fiscal year from 6 April. scenario = Scenario( parameter_changes={ "gov.dwp.universal_credit.rebalancing.standard_allowance_uplift": { - "2026-01-01": 0.05, - "2027-01-01": 0.07, - "2028-01-01": 0.07, - "2029-01-01": 0.07, + "2026": 0.05, + "2027": 0.07, + "2028": 0.07, + "2029": 0.07, } } ) @@ -94,6 +97,8 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +The uplift reaches the allowance through parameter processing, which `Scenario(parameter_changes=...)` re-runs. A `reform=` dictionary is applied after processing, so to change the allowance that way set `gov.dwp.universal_credit.standard_allowance.amount` directly. + ### Changing the health element amount for new claimants ```python @@ -113,8 +118,9 @@ sim = Simulation(scenario=scenario) ## Legislative reference -The reforms are based on the Universal Credit Bill and its impact assessment: +The reforms are based on the Universal Credit Act 2025, the Bill and its impact assessment: +- https://www.legislation.gov.uk/ukpga/2025/22/section/1 - https://bills.parliament.uk/publications/62123/documents/6889 - https://bills.parliament.uk/publications/62124/documents/6892 - https://assets.publishing.service.gov.uk/media/689ca49e1c63de6de5bb1298/withdrawn-universal-credit-bill-uc-rebalancing-impact-assessment.pdf diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py new file mode 100644 index 0000000000..feb46f846e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -0,0 +1,130 @@ +"""Build the uprating index for the Universal Credit standard allowance. + +Universal Credit Act 2025 s. 1 sets minimum standard allowance amounts for +tax years 2026-27 to 2029-30 in three steps: + +1. Take the 2025-26 amounts for 2026-27, and the previous year's Step 2 + amounts for each later year. +2. Increase them by the relevant CPI percentage, never below 0% (s. 1(3)). +3. Increase the result by the relevant uplift percentage for the year: 2.3%, + 3.1%, 4.0% and 4.8% (s. 1(4)). + +Each uplift is measured against the CPI-only path from 2025-26, not compounded +on the previous uplift, because Step 1 takes the previous year's amount before +its Step 3. From one year to the next the allowance therefore grows by CPI +times (1 + this year's uplift) / (1 + last year's uplift). + +The 2026-27 amounts are written into ``standard_allowance/amount.yaml`` as +legislated. That file is uprated by the index built here, +``gov.dwp.universal_credit.standard_allowance.uprating``: the benefit uprating +CPI index, with s. 1(3)'s 0% floor in 2026-27 to 2029-30, times (1 + that +year's uplift). Uprating the 2026-27 amounts by it gives each later year's +amount the CPI growth and the change in uplift. After 2029-30 the uplift stays +at 4.8%, so the allowance grows with CPI alone. The CPI part uses the same +index as ``gov.benefit_uprating_cpi``, so the difference from a CPI-only path +is exactly the uplift. + +A legislated amount written into ``amount.yaml`` for 2026-27 to 2029-30 +includes that year's uplift from the s. 1(4) table. If the uplift in force +differs (a reform, or ``rebalancing.active`` false, which means no uplift), +the amount is rescaled by (1 + uplift in force) / (1 + legislated uplift), so +every year's allowance carries the uplift in force and keeps the CPI growth +already in the legislated rate. + +This runs in ``process_parameters`` before uprating, so a parameter change +made through ``Scenario(parameter_changes=...)``, which re-runs that pipeline, +reaches the allowance. A ``reform=`` dict applied after processing does not +re-run uprating; set ``standard_allowance.amount`` directly instead. +""" + +from policyengine_core.parameters import Parameter, ParameterNode, get_parameter + +INDEX_NAME = "gov.dwp.universal_credit.standard_allowance.uprating" +# Universal Credit Act 2025 s. 1(1): tax years 2026-27 to 2029-30. +FIRST_ACT_YEAR = 2026 +LAST_ACT_YEAR = 2029 +# s. 1(4): the uplift included in a legislated amount for each tax year. +LEGISLATED_UPLIFT = {2026: 0.023, 2027: 0.031, 2028: 0.040, 2029: 0.048} +RESCALED = "_rescaled_to_uplift_in_force" + + +def tax_year_instant(year: int) -> str: + """Date at which policy parameters are read for the tax year from April + ``year``: 30 April, the date fiscal-year conversion samples.""" + return f"{year}-04-30" + + +def uplift_in_force(rebalancing: ParameterNode, year: int) -> float: + instant = tax_year_instant(year) + if not rebalancing.active(instant): + return 0.0 + return rebalancing.standard_allowance_uplift(instant) + + +def legislated_tax_year(instant_str: str) -> int: + """Tax year of a legislated amount. Rates are dated from the start of + April, before the 6 April start of the tax year they apply to.""" + year, month = int(instant_str[:4]), int(instant_str[5:7]) + return year if month >= 4 else year - 1 + + +def add_uc_standard_allowance_uprating(parameters: ParameterNode) -> ParameterNode: + universal_credit = parameters.gov.dwp.universal_credit + rebalancing = universal_credit.rebalancing + standard_allowance = universal_credit.standard_allowance + + for amount in standard_allowance.amount.get_descendants(): + # Some scenarios re-run parameter processing without reloading, so + # rescale each loaded parameter once. + if not isinstance(amount, Parameter) or getattr(amount, RESCALED, False): + continue + setattr(amount, RESCALED, True) + for value in amount.values_list: + year = legislated_tax_year(value.instant_str) + if year in LEGISLATED_UPLIFT and value.value is not None: + value.value *= (1 + uplift_in_force(rebalancing, year)) / ( + 1 + LEGISLATED_UPLIFT[year] + ) + + cpi_index = get_parameter( + parameters, parameters.gov.benefit_uprating_cpi.metadata["uprating"] + ) + entries = sorted(cpi_index.values_list, key=lambda entry: entry.instant_str) + values = {} + cpi_path = 1.0 + previous_cpi = None + for entry in entries: + year = int(entry.instant_str[:4]) + if previous_cpi is not None: + growth = entry.value / previous_cpi + if FIRST_ACT_YEAR <= year <= LAST_ACT_YEAR: + growth = max(1.0, growth) + cpi_path *= growth + previous_cpi = entry.value + values[entry.instant_str] = cpi_path * (1 + uplift_in_force(rebalancing, year)) + + standard_allowance.add_child( + "uprating", + Parameter( + name=INDEX_NAME, + data={ + "description": ( + "Uprating index for the Universal Credit standard " + "allowance: benefit uprating CPI times one plus the " + "Universal Credit Act 2025 uplift." + ), + "values": values, + "metadata": { + "unit": "/1", + "label": "Universal Credit standard allowance uprating index", + "reference": [ + { + "title": "Universal Credit Act 2025 s. 1", + "href": "https://www.legislation.gov.uk/ukpga/2025/22/section/1", + } + ], + }, + }, + ), + ) + return parameters diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml index 1d10817db2..7dbe9ff585 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml @@ -1,13 +1,13 @@ -description: Uplift in the Standard Allowance for Universal Credit, in addition to the annual inflationary increase, compared to 2025-26 levels. +description: Uplift to the Universal Credit standard allowance above CPI-only uprating from its 2025-26 level (the relevant uplift percentage). It is not compounded on the previous year's uplift. values: - 2025-01-01: 0.000 - 2026-01-01: 0.023 - 2027-01-01: 0.031 - 2028-01-01: 0.040 - 2029-01-01: 0.048 + 2025-04-06: 0.000 + 2026-04-06: 0.023 + 2027-04-06: 0.031 + 2028-04-06: 0.040 + 2029-04-06: 0.048 metadata: unit: /1 label: Universal Credit rebalancing standard allowance uplift reference: - - title: Universal Credit Bill - href: https://bills.parliament.uk/publications/62123/documents/6889 + - title: Universal Credit Act 2025 s. 1(4) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/1 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/standard_allowance/amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/standard_allowance/amount.yaml index 62774cdd96..bb35ea403c 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/standard_allowance/amount.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/standard_allowance/amount.yaml @@ -16,7 +16,7 @@ SINGLE_YOUNG: 2026-04-01: 338.58 metadata: label: Universal Credit single amount (under 25) - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.universal_credit.standard_allowance.uprating unit: currency-GBP period: month SINGLE_OLD: @@ -36,7 +36,7 @@ SINGLE_OLD: 2026-04-01: 424.9 metadata: label: Universal Credit single amount (over 25) - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.universal_credit.standard_allowance.uprating unit: currency-GBP period: month COUPLE_YOUNG: @@ -56,7 +56,7 @@ COUPLE_YOUNG: 2026-04-01: 528.34 metadata: label: Universal Credit couple amount (both under 25) - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.universal_credit.standard_allowance.uprating unit: currency-GBP period: month COUPLE_OLD: @@ -81,7 +81,7 @@ COUPLE_OLD: period: month metadata: unit: currency-GBP - uprating: gov.benefit_uprating_cpi + uprating: gov.dwp.universal_credit.standard_allowance.uprating propagate_metadata_to_children: true reference: # Values. @@ -89,3 +89,5 @@ metadata: # As-of date. - https://www.legislation.gov.uk/uksi/2013/376/regulation/1 - https://www.gov.uk/universal-credit/what-youll-get + # Uprating from 2027-28: CPI and the uplifts to 2029-30. + - https://www.legislation.gov.uk/ukpga/2025/22/section/1 diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 4252775847..ca04307fd8 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -94,8 +94,9 @@ def add_universal_credit_reform(sim: Microsimulation): ) # Monthly amount * 12 sim.set_input("uc_LCWRA_element", year, current_health_element) - # Standard allowance uplift is handled in the formula itself so user - # reforms to the base amount are applied before the uplift. + # The standard allowance uplift is applied through the allowance's + # uprating index (rebalancing/create_standard_allowance_uprating.py), so a + # reform to the amount itself is used as given. universal_credit_july_2025_reform = Scenario( diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index c8264232cc..482ef6bb3d 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -21,6 +21,9 @@ from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import ( add_triple_lock, ) +from policyengine_uk.parameters.gov.dwp.universal_credit.rebalancing.create_standard_allowance_uprating import ( + add_uc_standard_allowance_uprating, +) from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, ) @@ -92,6 +95,7 @@ def process_parameters(self) -> None: - Statutory uprating inputs (September CPI, May-July earnings) - Triple lock calculations for state pensions - Economic assumption indices + - Universal Credit standard allowance uprating (UC Act 2025 s. 1) - Parameter uprating and backdating - Conversion to fiscal year parameters """ @@ -103,6 +107,7 @@ def process_parameters(self) -> None: self.parameters = add_statutory_uprating_inputs(self.parameters) self.parameters = add_triple_lock(self.parameters) self.parameters = create_economic_assumption_indices(self.parameters) + self.parameters = add_uc_standard_allowance_uprating(self.parameters) self.parameters = add_lsr_deprecation_aliases(self.parameters) # Create baseline parameters for reform comparisons diff --git a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py new file mode 100644 index 0000000000..3f953609cd --- /dev/null +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -0,0 +1,209 @@ +"""Universal Credit standard allowance uplifts, Universal Credit Act 2025 s. 1 +(issue #2239). + +The Act sets each year's minimum standard allowance for 2026-27 to 2029-30 +by three steps: take the previous year's CPI-uprated (Step 2) amount, or the +2025-26 amount for 2026-27; increase it by CPI (never below 0%); then by that +year's uplift (2.3%, 3.1%, 4.0%, 4.8%). The 2026-27 amounts are legislated +in amount.yaml; later years are uprated by CPI times the change in uplift. + +Invariants: + +1. Growth: in every year, each amount grows by the benefit CPI index ratio + times (1 + this year's uplift) / (1 + last year's uplift), and by CPI + alone after 2029-30. +2. Differential: the amounts equal an independent step-by-step + implementation of s. 1(2), started from the 2026-27 Step 2 amounts (the + legislated amounts less the 2.3% uplift). +3. The uplift in force: a reform to any year's uplift, or switching + rebalancing off (no uplift), scales that year's amount by + (1 + uplift) / (1 + statutory uplift), including the legislated 2026-27 + amounts, and leaves the CPI path alone. +""" + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +CLAIMANT_TYPES = ["SINGLE_YOUNG", "SINGLE_OLD", "COUPLE_YOUNG", "COUPLE_OLD"] +# Universal Credit Act 2025 s. 1(4). +ACT_UPLIFT = {2026: 0.023, 2027: 0.031, 2028: 0.040, 2029: 0.048} +YEARS = range(2026, 2033) +UPLIFT = "gov.dwp.universal_credit.rebalancing.standard_allowance_uplift" +ACTIVE = "gov.dwp.universal_credit.rebalancing.active" +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +def _amounts(scenario=None): + """Monthly standard allowance by claimant type and year, and the benefit + CPI index, read through processed parameters.""" + sim = Simulation( + situation={ + "people": {"p": {"age": {2026: 30}}}, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + }, + scenario=scenario, + ) + parameters = sim.tax_benefit_system.parameters + amounts = { + year: { + t: float( + parameters( + f"{year}-06-01" + ).gov.dwp.universal_credit.standard_allowance.amount[t] + ) + for t in CLAIMANT_TYPES + } + for year in [2025, *YEARS] + } + cpi = { + year: float(parameters(f"{year}-06-01").gov.benefit_uprating_cpi) + for year in [2025, *YEARS] + } + return sim, amounts, cpi + + +def _uplift(year, schedule=ACT_UPLIFT): + return schedule[min(year, 2029)] if year >= 2026 else 0.0 + + +def test_single_claimant_aged_30_matches_the_act(): + """The issue's reproducer: annual uc_standard_allowance for a single + claimant aged 30 now carries the 2027-28 to 2029-30 uplifts.""" + sim, amounts, cpi = _amounts() + annual = { + year: float(sim.calculate("uc_standard_allowance", year)[0]) + for year in range(2025, 2031) + } + assert annual[2025] == pytest.approx(400.14 * 12) + assert annual[2026] == pytest.approx(424.90 * 12) + for year in range(2027, 2031): + cpi_growth = cpi[year] / cpi[year - 1] + uplift_change = (1 + _uplift(year)) / (1 + _uplift(year - 1)) + # Simulated values are float32, so compare to the penny. + assert annual[year] == pytest.approx( + annual[year - 1] * cpi_growth * uplift_change, abs=0.01 + ) + # Before the fix 2027 grew by CPI alone (+2.3%); the 3.1% uplift adds a + # further (1.031 / 1.023 - 1) = 0.78%. + assert annual[2027] / annual[2026] == pytest.approx( + cpi[2027] / cpi[2026] * 1.031 / 1.023, rel=1e-6 + ) + + +def test_amounts_match_the_act_step_by_step(): + _, amounts, cpi = _amounts() + for t in CLAIMANT_TYPES: + # Step 2 amount for 2026-27: the legislated amount less its uplift. + step_2 = amounts[2026][t] / (1 + ACT_UPLIFT[2026]) + for year in range(2027, 2030): + step_2 *= max(1.0, cpi[year] / cpi[year - 1]) # Steps 1 and 2 + minimum = step_2 * (1 + ACT_UPLIFT[year]) # Step 3 + assert amounts[year][t] == pytest.approx(minimum, rel=1e-9) + # After 2029-30 the Act no longer applies: CPI uprating alone. + for year in range(2030, 2033): + assert amounts[year][t] == pytest.approx( + amounts[year - 1][t] * cpi[year] / cpi[year - 1], rel=1e-9 + ) + + +uplift_schedule = st.fixed_dictionaries( + {year: st.floats(0, 0.15) for year in range(2026, 2030)} +) + + +@PROPERTY_SETTINGS +@given(uplift_schedule) +def test_growth_is_cpi_times_the_change_in_uplift(schedule): + scenario = Scenario( + parameter_changes={UPLIFT: {str(y): u for y, u in schedule.items()}} + ) + _, amounts, cpi = _amounts(scenario) + _, baseline, _ = _amounts() + for t in CLAIMANT_TYPES: + # The legislated 2026-27 amounts carry the uplift in force. + assert amounts[2026][t] == pytest.approx( + baseline[2026][t] * (1 + schedule[2026]) / (1 + ACT_UPLIFT[2026]), + rel=1e-9, + ) + for year in range(2027, 2030): + assert amounts[year][t] == pytest.approx( + amounts[year - 1][t] + * max(1.0, cpi[year] / cpi[year - 1]) + * (1 + schedule[year]) + / (1 + schedule[year - 1]), + rel=1e-9, + ) + # Keyed to fiscal years, the reform ends with 2029-30, when the + # statutory 4.8% applies again. + assert amounts[2030][t] == pytest.approx( + amounts[2029][t] + * cpi[2030] + / cpi[2029] + * (1 + ACT_UPLIFT[2029]) + / (1 + schedule[2029]), + rel=1e-9, + ) + assert amounts[2030][t] == pytest.approx(baseline[2030][t], rel=1e-9) + + +@PROPERTY_SETTINGS +@given(st.integers(2026, 2030)) +def test_switching_rebalancing_off_removes_the_uplift_from_that_year(first_off): + scenario = Scenario(parameter_changes={ACTIVE: {str(first_off): False}}) + _, amounts, cpi = _amounts(scenario) + _, baseline, _ = _amounts() + for t in CLAIMANT_TYPES: + for year in YEARS: + in_force = 0.0 if year == first_off else _uplift(year) + assert amounts[year][t] == pytest.approx( + baseline[year][t] * (1 + in_force) / (1 + _uplift(year)), + rel=1e-9, + ) + + +def test_rebalancing_off_throughout_is_cpi_only_from_2025_26(): + scenario = Scenario(parameter_changes={ACTIVE: False}) + _, amounts, cpi = _amounts(scenario) + for t in CLAIMANT_TYPES: + # The legislated 2026-27 rates less their 2.3% uplift are the 2025-26 + # rates uprated by September 2025 CPI (3.8%) and rounded to the + # penny, as the rates were set at each step. The single under-25 rate + # was set above the Act's minimum, so is left out. + if t != "SINGLE_YOUNG": + assert amounts[2026][t] == pytest.approx( + round(amounts[2025][t] * 1.038, 2), abs=0.01 + ) + for year in range(2027, 2033): + assert amounts[year][t] == pytest.approx( + amounts[year - 1][t] * cpi[year] / cpi[year - 1], rel=1e-9 + ) + + +def test_amount_reforms_override_the_uplift(): + """A reform to the amount itself, as the fiscal-event replay's CPI-only + counterfactual sets, is used as given.""" + reformed = Simulation( + situation={ + "people": {"p": {"age": {2028: 30}}}, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + }, + reform={ + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": { + "2028": 430.0 + } + }, + ) + assert reformed.calculate("uc_standard_allowance", 2028)[0] == pytest.approx( + 430.0 * 12 + ) From 1ea25cb8ebc197bad4ab5fd9800b726e9a74119e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 07:56:12 -0400 Subject: [PATCH 2/7] Follow the benefit index, keep reform amounts, and allow scalar activation Review round 1 on #2242: - Treat the uplift as nil before its parameter starts, so switching rebalancing on from 2000 no longer crashes. - Rescale only amounts that still hold their amount.yaml value; an amount a reform sets is used as given. - Build the index from gov.benefit_uprating_cpi as uprating extends it, so a reform to that index is followed. Co-Authored-By: Claude Opus 5.5 --- .../create_standard_allowance_uprating.py | 104 +++++++++++++----- .../test_uc_standard_allowance_uplift.py | 42 +++++++ 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py index feb46f846e..b5212984fd 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -17,19 +17,21 @@ The 2026-27 amounts are written into ``standard_allowance/amount.yaml`` as legislated. That file is uprated by the index built here, ``gov.dwp.universal_credit.standard_allowance.uprating``: the benefit uprating -CPI index, with s. 1(3)'s 0% floor in 2026-27 to 2029-30, times (1 + that -year's uplift). Uprating the 2026-27 amounts by it gives each later year's -amount the CPI growth and the change in uplift. After 2029-30 the uplift stays -at 4.8%, so the allowance grows with CPI alone. The CPI part uses the same -index as ``gov.benefit_uprating_cpi``, so the difference from a CPI-only path -is exactly the uplift. - -A legislated amount written into ``amount.yaml`` for 2026-27 to 2029-30 -includes that year's uplift from the s. 1(4) table. If the uplift in force -differs (a reform, or ``rebalancing.active`` false, which means no uplift), -the amount is rescaled by (1 + uplift in force) / (1 + legislated uplift), so -every year's allowance carries the uplift in force and keeps the CPI growth -already in the legislated rate. +index ``gov.benefit_uprating_cpi``, with s. 1(3)'s 0% floor in 2026-27 to +2029-30, times (1 + that year's uplift). Uprating the 2026-27 amounts by it +gives each later year's amount the CPI growth and the change in uplift. After +2029-30 the uplift stays at 4.8%, so the allowance grows with CPI alone. The +benefit index is the model's measure of CPI for benefit uprating; s. 1(3) +names September CPI. Because it is the same index, the difference from a +CPI-only path is exactly the uplift. + +A legislated amount in ``amount.yaml`` for 2026-27 to 2029-30 includes that +year's uplift from the s. 1(4) table. If the uplift in force differs (a +reform, or ``rebalancing.active`` false, which means no uplift), the amount is +rescaled by (1 + uplift in force) / (1 + legislated uplift), so every year's +allowance carries the uplift in force and keeps the CPI growth already in the +legislated rate. Only entries that still hold the value in ``amount.yaml`` are +rescaled; an amount set by a reform is used as given. This runs in ``process_parameters`` before uprating, so a parameter change made through ``Scenario(parameter_changes=...)``, which re-runs that pipeline, @@ -37,15 +39,19 @@ re-run uprating; set ``standard_allowance.amount`` directly instead. """ +from functools import lru_cache +from pathlib import Path + +import yaml from policyengine_core.parameters import Parameter, ParameterNode, get_parameter INDEX_NAME = "gov.dwp.universal_credit.standard_allowance.uprating" +AMOUNT_FILE = Path(__file__).parents[1] / "standard_allowance" / "amount.yaml" # Universal Credit Act 2025 s. 1(1): tax years 2026-27 to 2029-30. FIRST_ACT_YEAR = 2026 LAST_ACT_YEAR = 2029 # s. 1(4): the uplift included in a legislated amount for each tax year. LEGISLATED_UPLIFT = {2026: 0.023, 2027: 0.031, 2028: 0.040, 2029: 0.048} -RESCALED = "_rescaled_to_uplift_in_force" def tax_year_instant(year: int) -> str: @@ -58,7 +64,9 @@ def uplift_in_force(rebalancing: ParameterNode, year: int) -> float: instant = tax_year_instant(year) if not rebalancing.active(instant): return 0.0 - return rebalancing.standard_allowance_uplift(instant) + uplift = rebalancing.standard_allowance_uplift(instant) + # Nil before the uplift parameter starts (2025-26). + return 0.0 if uplift is None else uplift def legislated_tax_year(instant_str: str) -> int: @@ -68,40 +76,78 @@ def legislated_tax_year(instant_str: str) -> int: return year if month >= 4 else year - 1 +@lru_cache(maxsize=1) +def legislated_amounts() -> dict: + """The amounts in amount.yaml by claimant type and date.""" + data = yaml.safe_load(AMOUNT_FILE.read_text()) + amounts = {} + for claimant_type, node in data.items(): + if not isinstance(node, dict) or "values" not in node: + continue + amounts[claimant_type] = { + str(instant): float(value["value"] if isinstance(value, dict) else value) + for instant, value in node["values"].items() + if value is not None + } + return amounts + + +def benefit_index_values(parameters: ParameterNode) -> dict: + """``gov.benefit_uprating_cpi`` on 1 January of each year of its uprating + index, as uprating will extend it: its own values up to its latest one, + then that value moved with its uprating index. A reform to the benefit + index itself is therefore followed.""" + benefit_index = parameters.gov.benefit_uprating_cpi + cpi_index = get_parameter(parameters, benefit_index.metadata["uprating"]) + last_instant = benefit_index.values_list[0].instant_str + value_at_last = benefit_index(last_instant) + cpi_at_last = cpi_index(last_instant) + values = {} + for entry in sorted(cpi_index.values_list, key=lambda e: e.instant_str): + if entry.instant_str <= last_instant or cpi_at_last is None: + values[entry.instant_str] = benefit_index(entry.instant_str) + else: + values[entry.instant_str] = value_at_last * entry.value / cpi_at_last + return values + + def add_uc_standard_allowance_uprating(parameters: ParameterNode) -> ParameterNode: universal_credit = parameters.gov.dwp.universal_credit rebalancing = universal_credit.rebalancing standard_allowance = universal_credit.standard_allowance + legislated = legislated_amounts() for amount in standard_allowance.amount.get_descendants(): - # Some scenarios re-run parameter processing without reloading, so - # rescale each loaded parameter once. - if not isinstance(amount, Parameter) or getattr(amount, RESCALED, False): + if not isinstance(amount, Parameter): continue - setattr(amount, RESCALED, True) + in_file = legislated.get(amount.name.split(".")[-1], {}) for value in amount.values_list: year = legislated_tax_year(value.instant_str) - if year in LEGISLATED_UPLIFT and value.value is not None: + file_value = in_file.get(value.instant_str) + if ( + year in LEGISLATED_UPLIFT + and value.value is not None + and file_value is not None + and abs(value.value - file_value) < 1e-9 + ): value.value *= (1 + uplift_in_force(rebalancing, year)) / ( 1 + LEGISLATED_UPLIFT[year] ) - cpi_index = get_parameter( - parameters, parameters.gov.benefit_uprating_cpi.metadata["uprating"] - ) - entries = sorted(cpi_index.values_list, key=lambda entry: entry.instant_str) values = {} cpi_path = 1.0 previous_cpi = None - for entry in entries: - year = int(entry.instant_str[:4]) + for instant, cpi in benefit_index_values(parameters).items(): + year = int(instant[:4]) + if cpi is None: + continue if previous_cpi is not None: - growth = entry.value / previous_cpi + growth = cpi / previous_cpi if FIRST_ACT_YEAR <= year <= LAST_ACT_YEAR: growth = max(1.0, growth) cpi_path *= growth - previous_cpi = entry.value - values[entry.instant_str] = cpi_path * (1 + uplift_in_force(rebalancing, year)) + previous_cpi = cpi + values[instant] = cpi_path * (1 + uplift_in_force(rebalancing, year)) standard_allowance.add_child( "uprating", diff --git a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py index 3f953609cd..263a9932bd 100644 --- a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -207,3 +207,45 @@ def test_amount_reforms_override_the_uplift(): assert reformed.calculate("uc_standard_allowance", 2028)[0] == pytest.approx( 430.0 * 12 ) + + +def test_scalar_activation_matches_baseline(): + """Switching rebalancing on for all years (a scalar change from 2000) + leaves the uplift nil before it starts and matches the baseline.""" + _, amounts, _ = _amounts(Scenario(parameter_changes={ACTIVE: True})) + _, baseline, _ = _amounts() + for year in YEARS: + for t in CLAIMANT_TYPES: + assert amounts[year][t] == pytest.approx(baseline[year][t], rel=1e-9) + + +@pytest.mark.parametrize( + "other_change", + [{ACTIVE: False}, {UPLIFT: {"2026": 0.10, "2027": 0.10}}], +) +def test_amount_set_by_a_reform_is_not_rescaled(other_change): + """An amount a reform sets is used as given, whatever the uplift.""" + scenario = Scenario( + parameter_changes={ + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": { + "2026": 500.0 + }, + **other_change, + } + ) + _, amounts, _ = _amounts(scenario) + assert amounts[2026]["SINGLE_OLD"] == pytest.approx(500.0, rel=1e-9) + + +def test_reform_to_the_benefit_index_is_followed(): + """With the benefit uprating index held flat, the allowance moves only + with the change in uplift.""" + scenario = Scenario(parameter_changes={"gov.benefit_uprating_cpi": 300.0}) + _, amounts, cpi = _amounts(scenario) + assert all(cpi[year] == pytest.approx(300.0) for year in YEARS) + for t in CLAIMANT_TYPES: + for year in range(2027, 2033): + assert amounts[year][t] == pytest.approx( + amounts[year - 1][t] * (1 + _uplift(year)) / (1 + _uplift(year - 1)), + rel=1e-9, + ) From 2248c19752d9a25e57e0dbab396f07d92e5c072c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:48:50 -0400 Subject: [PATCH 3/7] Read the benefit index on 30 April and rescale only file-loaded amounts Review round 2 on #2242: - Sample gov.benefit_uprating_cpi on 30 April of each year, so a fiscal-year reform to it reaches that year rather than the next. - Rescale only amount entries loaded from amount.yaml (they carry its file path), each at most once, so a reform that sets the legislated amount itself is used as given. Co-Authored-By: Claude Opus 5.5 --- .../create_standard_allowance_uprating.py | 55 +++++++------------ .../test_uc_standard_allowance_uplift.py | 31 +++++++++++ 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py index b5212984fd..aae334106a 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -30,8 +30,9 @@ reform, or ``rebalancing.active`` false, which means no uplift), the amount is rescaled by (1 + uplift in force) / (1 + legislated uplift), so every year's allowance carries the uplift in force and keeps the CPI growth already in the -legislated rate. Only entries that still hold the value in ``amount.yaml`` are -rescaled; an amount set by a reform is used as given. +legislated rate. Only entries loaded from ``amount.yaml`` are rescaled; an +amount set by a reform, even one equal to the legislated amount, is used as +given. This runs in ``process_parameters`` before uprating, so a parameter change made through ``Scenario(parameter_changes=...)``, which re-runs that pipeline, @@ -39,19 +40,17 @@ re-run uprating; set ``standard_allowance.amount`` directly instead. """ -from functools import lru_cache -from pathlib import Path - -import yaml from policyengine_core.parameters import Parameter, ParameterNode, get_parameter INDEX_NAME = "gov.dwp.universal_credit.standard_allowance.uprating" -AMOUNT_FILE = Path(__file__).parents[1] / "standard_allowance" / "amount.yaml" # Universal Credit Act 2025 s. 1(1): tax years 2026-27 to 2029-30. FIRST_ACT_YEAR = 2026 LAST_ACT_YEAR = 2029 # s. 1(4): the uplift included in a legislated amount for each tax year. LEGISLATED_UPLIFT = {2026: 0.023, 2027: 0.031, 2028: 0.040, 2029: 0.048} +# Marks an amount entry already rescaled, so processing twice cannot rescale +# it again. +RESCALED = "_rescaled_to_uplift_in_force" def tax_year_instant(year: int) -> str: @@ -76,27 +75,12 @@ def legislated_tax_year(instant_str: str) -> int: return year if month >= 4 else year - 1 -@lru_cache(maxsize=1) -def legislated_amounts() -> dict: - """The amounts in amount.yaml by claimant type and date.""" - data = yaml.safe_load(AMOUNT_FILE.read_text()) - amounts = {} - for claimant_type, node in data.items(): - if not isinstance(node, dict) or "values" not in node: - continue - amounts[claimant_type] = { - str(instant): float(value["value"] if isinstance(value, dict) else value) - for instant, value in node["values"].items() - if value is not None - } - return amounts - - def benefit_index_values(parameters: ParameterNode) -> dict: - """``gov.benefit_uprating_cpi`` on 1 January of each year of its uprating - index, as uprating will extend it: its own values up to its latest one, - then that value moved with its uprating index. A reform to the benefit - index itself is therefore followed.""" + """``gov.benefit_uprating_cpi`` for each year of its uprating index, as + uprating will extend it: its own values up to its latest one, then that + value moved with its uprating index. Each year is read on 30 April, the + date fiscal-year conversion samples, so a reform to the benefit index for + a fiscal year reaches that year. Keyed by 1 January, as the index is.""" benefit_index = parameters.gov.benefit_uprating_cpi cpi_index = get_parameter(parameters, benefit_index.metadata["uprating"]) last_instant = benefit_index.values_list[0].instant_str @@ -104,10 +88,11 @@ def benefit_index_values(parameters: ParameterNode) -> dict: cpi_at_last = cpi_index(last_instant) values = {} for entry in sorted(cpi_index.values_list, key=lambda e: e.instant_str): - if entry.instant_str <= last_instant or cpi_at_last is None: - values[entry.instant_str] = benefit_index(entry.instant_str) + instant = tax_year_instant(int(entry.instant_str[:4])) + if instant <= last_instant or cpi_at_last is None: + values[entry.instant_str] = benefit_index(instant) else: - values[entry.instant_str] = value_at_last * entry.value / cpi_at_last + values[entry.instant_str] = value_at_last * cpi_index(instant) / cpi_at_last return values @@ -116,23 +101,23 @@ def add_uc_standard_allowance_uprating(parameters: ParameterNode) -> ParameterNo rebalancing = universal_credit.rebalancing standard_allowance = universal_credit.standard_allowance - legislated = legislated_amounts() for amount in standard_allowance.amount.get_descendants(): if not isinstance(amount, Parameter): continue - in_file = legislated.get(amount.name.split(".")[-1], {}) for value in amount.values_list: year = legislated_tax_year(value.instant_str) - file_value = in_file.get(value.instant_str) + # Entries loaded from amount.yaml carry its path; entries a reform + # writes do not. if ( year in LEGISLATED_UPLIFT and value.value is not None - and file_value is not None - and abs(value.value - file_value) < 1e-9 + and value.file_path is not None + and not getattr(value, RESCALED, False) ): value.value *= (1 + uplift_in_force(rebalancing, year)) / ( 1 + LEGISLATED_UPLIFT[year] ) + setattr(value, RESCALED, True) values = {} cpi_path = 1.0 diff --git a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py index 263a9932bd..697fef69b5 100644 --- a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -249,3 +249,34 @@ def test_reform_to_the_benefit_index_is_followed(): amounts[year - 1][t] * (1 + _uplift(year)) / (1 + _uplift(year - 1)), rel=1e-9, ) + + +def test_amount_equal_to_the_legislated_one_set_by_a_reform_is_not_rescaled(): + """A reform that sets the legislated amount itself is still a reform: it + is used as given when rebalancing is off.""" + scenario = Scenario( + parameter_changes={ + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": { + "year:2026-04-01": 424.9 + }, + ACTIVE: False, + } + ) + _, amounts, _ = _amounts(scenario) + assert amounts[2026]["SINGLE_OLD"] == pytest.approx(424.9, rel=1e-9) + + +def test_fiscal_year_reform_to_the_benefit_index_reaches_that_year(): + """A benefit index set for one fiscal year moves that year's allowance.""" + scenario = Scenario(parameter_changes={"gov.benefit_uprating_cpi": {"2027": 300.0}}) + _, amounts, cpi = _amounts(scenario) + assert cpi[2027] == pytest.approx(300.0) + for t in CLAIMANT_TYPES: + assert amounts[2027][t] == pytest.approx( + amounts[2026][t] + * cpi[2027] + / cpi[2026] + * (1 + _uplift(2027)) + / (1 + _uplift(2026)), + rel=1e-9, + ) From 4796fd5b4dea3c12f2e1b4ff5b57ca06ba7904af Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 09:21:59 -0400 Subject: [PATCH 4/7] Record the periods a reform set, so restored amounts keep the uplift Review round 3 on #2242: after a bounded amount reform, Parameter.update writes an entry restoring the legislated amount, with no file path, and the builder took it for a reform amount, so it kept the statutory uplift with rebalancing off. apply_parameter_changes now records the periods each change set; the builder leaves entries in those periods as given and rescales every other entry by the uplift of the amount.yaml entry it carries. Co-Authored-By: Claude Opus 5.5 --- .../create_standard_allowance_uprating.py | 62 ++++++++++++++++--- policyengine_uk/simulation.py | 9 +-- .../test_uc_standard_allowance_uplift.py | 21 +++++++ policyengine_uk/utils/parameters.py | 26 ++++++++ 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py index aae334106a..53972e8484 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -30,9 +30,11 @@ reform, or ``rebalancing.active`` false, which means no uplift), the amount is rescaled by (1 + uplift in force) / (1 + legislated uplift), so every year's allowance carries the uplift in force and keeps the CPI growth already in the -legislated rate. Only entries loaded from ``amount.yaml`` are rescaled; an -amount set by a reform, even one equal to the legislated amount, is used as -given. +legislated rate. An amount a ``Scenario(parameter_changes=...)`` reform set, +even one equal to the legislated amount, is used as given. Every other entry +carries the legislated series, including the entry ``Parameter.update`` writes +after a bounded reform to restore it, and is rescaled by the uplift of the +legislated amount it carries. This runs in ``process_parameters`` before uprating, so a parameter change made through ``Scenario(parameter_changes=...)``, which re-runs that pipeline, @@ -40,9 +42,16 @@ re-run uprating; set ``standard_allowance.amount`` directly instead. """ +from functools import lru_cache +from pathlib import Path + +import yaml from policyengine_core.parameters import Parameter, ParameterNode, get_parameter +from policyengine_uk.utils.parameters import is_set_by_reform + INDEX_NAME = "gov.dwp.universal_credit.standard_allowance.uprating" +AMOUNT_FILE = Path(__file__).parents[1] / "standard_allowance" / "amount.yaml" # Universal Credit Act 2025 s. 1(1): tax years 2026-27 to 2029-30. FIRST_ACT_YEAR = 2026 LAST_ACT_YEAR = 2029 @@ -75,6 +84,33 @@ def legislated_tax_year(instant_str: str) -> int: return year if month >= 4 else year - 1 +@lru_cache(maxsize=1) +def legislated_amounts() -> dict: + """The amounts in amount.yaml by claimant type, as sorted (date, amount) + pairs.""" + data = yaml.safe_load(AMOUNT_FILE.read_text()) + amounts = {} + for claimant_type, node in data.items(): + if not isinstance(node, dict) or "values" not in node: + continue + amounts[claimant_type] = sorted( + (str(instant), float(value["value"] if isinstance(value, dict) else value)) + for instant, value in node["values"].items() + if value is not None + ) + return amounts + + +def legislated_amount_at(claimant_type: str, instant_str: str): + """The latest amount.yaml entry for a claimant type at or before an + instant, as (date, amount), or None.""" + source = None + for entry_instant, amount in legislated_amounts().get(claimant_type, []): + if entry_instant <= instant_str: + source = (entry_instant, amount) + return source + + def benefit_index_values(parameters: ParameterNode) -> dict: """``gov.benefit_uprating_cpi`` for each year of its uprating index, as uprating will extend it: its own values up to its latest one, then that @@ -104,16 +140,22 @@ def add_uc_standard_allowance_uprating(parameters: ParameterNode) -> ParameterNo for amount in standard_allowance.amount.get_descendants(): if not isinstance(amount, Parameter): continue + claimant_type = amount.name.split(".")[-1] for value in amount.values_list: - year = legislated_tax_year(value.instant_str) - # Entries loaded from amount.yaml carry its path; entries a reform - # writes do not. if ( - year in LEGISLATED_UPLIFT - and value.value is not None - and value.file_path is not None - and not getattr(value, RESCALED, False) + value.value is None + or getattr(value, RESCALED, False) + or is_set_by_reform(amount, value.instant_str) ): + continue + # The legislated amount this entry carries: itself for an entry + # loaded from amount.yaml, or the one an entry written after a + # bounded reform restores. + source = legislated_amount_at(claimant_type, value.instant_str) + if source is None or abs(value.value - source[1]) > 1e-9: + continue + year = legislated_tax_year(source[0]) + if year in LEGISLATED_UPLIFT: value.value *= (1 + uplift_in_force(rebalancing, year)) / ( 1 + LEGISLATED_UPLIFT[year] ) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 5feee2bc38..7b4dbc4296 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -18,6 +18,7 @@ from policyengine_uk.utils.parameters import ( check_parameter_not_removed, + record_reform_period, uk_fiscal_year_period, ) @@ -286,12 +287,12 @@ def apply_parameter_changes(self, changes: dict): # fiscal year, so the override covers 6 April to 5 April and # survives fiscal-year conversion at full strength. for time_period in changes[parameter]: - p.update( - period=uk_fiscal_year_period(time_period), - value=changes[parameter][time_period], - ) + period = uk_fiscal_year_period(time_period) + p.update(period=period, value=changes[parameter][time_period]) + record_reform_period(p, period) else: p.update(period="year:2000:100", value=changes[parameter]) + record_reform_period(p, "year:2000:100") self.tax_benefit_system.process_parameters() diff --git a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py index 697fef69b5..3fc2b7aa9f 100644 --- a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -280,3 +280,24 @@ def test_fiscal_year_reform_to_the_benefit_index_reaches_that_year(): / (1 + _uplift(2026)), rel=1e-9, ) + + +def test_amounts_restored_after_a_bounded_reform_carry_the_uplift_in_force(): + """A reform to one year's amount leaves later years on the legislated + series, which still takes the uplift in force (none, with rebalancing + off).""" + _, inactive, _ = _amounts(Scenario(parameter_changes={ACTIVE: False})) + scenario = Scenario( + parameter_changes={ + ACTIVE: False, + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": { + "2025": 500.0 + }, + } + ) + _, amounts, _ = _amounts(scenario) + assert amounts[2025]["SINGLE_OLD"] == pytest.approx(500.0, rel=1e-9) + for year in range(2026, 2030): + assert amounts[year]["SINGLE_OLD"] == pytest.approx( + inactive[year]["SINGLE_OLD"], rel=1e-9 + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index fdbe9b663a..003f2bf000 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -75,6 +75,32 @@ def uk_fiscal_year_period(time_period): return periods.Period(("day", start, days)) +REFORM_PERIODS = "_reform_periods" + + +def record_reform_period(parameter, period) -> None: + """Note a period a parameter change set on ``parameter``. + + ``Parameter.update`` writes the new value at the start of the period and, + after it, an entry restoring the value that applied before, with nothing to + tell the two apart. Processing steps that adjust legislated values use + these spans to leave the values a reform set as given while still treating + restored values as legislated. + """ + period = periods.period(period) + spans = getattr(parameter, REFORM_PERIODS, []) + spans.append((str(period.start), str(period.stop))) + setattr(parameter, REFORM_PERIODS, spans) + + +def is_set_by_reform(parameter, instant_str: str) -> bool: + """Whether a recorded parameter change set ``parameter`` at an instant.""" + return any( + start <= instant_str <= stop + for start, stop in getattr(parameter, REFORM_PERIODS, []) + ) + + def convert_to_fiscal_year_parameters(parameters): """ Convert parameters to use UK fiscal year values. From a6cee53b4879ebce437ec87bbae0e5cb782b13e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:08:20 -0400 Subject: [PATCH 5/7] Keep legislated standard allowance amounts fixed; put the uplift in the index Review round 4 on #2242 found another bounded-override case in the rescale of legislated amounts. Rather than track which entries a reform wrote, drop the rescale and the simulation.py hook it needed: the legislated 2026-27 amounts stay as legislated, and the index uses the uplift they include for legislated years (read from amount.yaml) and the uplift in force after. Switching rebalancing off or changing a later uplift moves every year after the legislated ones; changing a legislated year needs its amount set. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 7 +- .../create_standard_allowance_uprating.py | 123 ++++++------------ policyengine_uk/simulation.py | 9 +- .../test_uc_standard_allowance_uplift.py | 57 ++++---- policyengine_uk/utils/parameters.py | 26 ---- 5 files changed, 81 insertions(+), 141 deletions(-) diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 5966361acf..bff65d6ccc 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -42,7 +42,7 @@ Universal Credit Act 2025 s. 1 sets the minimum standard allowance for 2026-27 t Each uplift is measured against the CPI-only path from 2025-26, not compounded on the previous one, so from one year to the next the allowance grows by CPI times (1 + this year's uplift) / (1 + last year's uplift). After 2029-30 the uplift stays at 4.8% and the allowance grows with CPI alone. -The 2026-27 amounts are the legislated rates in `standard_allowance/amount.yaml`. Later years are uprated by `gov.dwp.universal_credit.standard_allowance.uprating`, the benefit uprating CPI index times (1 + the year's uplift), which `rebalancing/create_standard_allowance_uprating.py` builds when parameters are processed. A legislated rate for one of these years includes that year's statutory uplift; if the uplift in force differs, through a reform or with `rebalancing.active` false (no uplift), the rate is rescaled by (1 + uplift in force) / (1 + statutory uplift). +The 2026-27 amounts are the legislated rates in `standard_allowance/amount.yaml`, used as they are. Later years are uprated by `gov.dwp.universal_credit.standard_allowance.uprating`, which `rebalancing/create_standard_allowance_uprating.py` builds when parameters are processed: the benefit uprating CPI index times one plus the uplift. For a legislated year the uplift is the one its rates include (2.3% for 2026-27); for later years it is the uplift in force. So switching `rebalancing.active` off, or changing a later year's uplift, moves every year after 2026-27, which stays at its legislated rates. To change 2026-27 itself, set `gov.dwp.universal_credit.standard_allowance.amount`. ## Implementation @@ -73,17 +73,18 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +The 2026-27 standard allowance stays at its legislated rates, which include the 2.3% uplift; from 2027-28 it follows CPI alone from the 2026-27 rates less that uplift. + ### Changing the standard allowance uplift parameters ```python from policyengine_uk import Simulation, Scenario -# Set different uplift rates - e.g. 5% in 2026-27, 7% from 2027-28. +# Set different uplift rates from 2027-28 (2026-27 is legislated), e.g. 7%. # A bare year names the fiscal year from 6 April. scenario = Scenario( parameter_changes={ "gov.dwp.universal_credit.rebalancing.standard_allowance_uplift": { - "2026": 0.05, "2027": 0.07, "2028": 0.07, "2029": 0.07, diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py index 53972e8484..6d7142a518 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -14,27 +14,24 @@ its Step 3. From one year to the next the allowance therefore grows by CPI times (1 + this year's uplift) / (1 + last year's uplift). -The 2026-27 amounts are written into ``standard_allowance/amount.yaml`` as -legislated. That file is uprated by the index built here, -``gov.dwp.universal_credit.standard_allowance.uprating``: the benefit uprating -index ``gov.benefit_uprating_cpi``, with s. 1(3)'s 0% floor in 2026-27 to -2029-30, times (1 + that year's uplift). Uprating the 2026-27 amounts by it -gives each later year's amount the CPI growth and the change in uplift. After -2029-30 the uplift stays at 4.8%, so the allowance grows with CPI alone. The -benefit index is the model's measure of CPI for benefit uprating; s. 1(3) +The legislated amounts (2026-27 so far) are written into +``standard_allowance/amount.yaml`` and used as they are. Later years are +uprated by the index built here, ``gov.dwp.universal_credit.standard_allowance +.uprating``: the benefit uprating index ``gov.benefit_uprating_cpi``, with +s. 1(3)'s 0% floor in 2026-27 to 2029-30, times one plus the uplift. For a +year with legislated amounts the uplift is the one those amounts include, from +the s. 1(4) table; for later years it is the uplift in force (nil with +``rebalancing.active`` false). Uprating the legislated amounts by the index +gives each later year the CPI growth and the change in uplift, so switching +rebalancing off, or changing a later year's uplift, reaches every year after +the legislated ones; changing a legislated year needs its amount set. After +2029-30 the uplift stays at 4.8%, so the allowance grows with CPI alone. + +The benefit index is the model's measure of CPI for benefit uprating; s. 1(3) names September CPI. Because it is the same index, the difference from a -CPI-only path is exactly the uplift. - -A legislated amount in ``amount.yaml`` for 2026-27 to 2029-30 includes that -year's uplift from the s. 1(4) table. If the uplift in force differs (a -reform, or ``rebalancing.active`` false, which means no uplift), the amount is -rescaled by (1 + uplift in force) / (1 + legislated uplift), so every year's -allowance carries the uplift in force and keeps the CPI growth already in the -legislated rate. An amount a ``Scenario(parameter_changes=...)`` reform set, -even one equal to the legislated amount, is used as given. Every other entry -carries the legislated series, including the entry ``Parameter.update`` writes -after a bounded reform to restore it, and is rescaled by the uplift of the -legislated amount it carries. +CPI-only path is exactly the uplift. It is read on 30 April of each year, the +date fiscal-year conversion samples, as uprating will extend it, so a reform to +the benefit index reaches the allowance in the fiscal year it covers. This runs in ``process_parameters`` before uprating, so a parameter change made through ``Scenario(parameter_changes=...)``, which re-runs that pipeline, @@ -48,8 +45,6 @@ import yaml from policyengine_core.parameters import Parameter, ParameterNode, get_parameter -from policyengine_uk.utils.parameters import is_set_by_reform - INDEX_NAME = "gov.dwp.universal_credit.standard_allowance.uprating" AMOUNT_FILE = Path(__file__).parents[1] / "standard_allowance" / "amount.yaml" # Universal Credit Act 2025 s. 1(1): tax years 2026-27 to 2029-30. @@ -57,9 +52,6 @@ LAST_ACT_YEAR = 2029 # s. 1(4): the uplift included in a legislated amount for each tax year. LEGISLATED_UPLIFT = {2026: 0.023, 2027: 0.031, 2028: 0.040, 2029: 0.048} -# Marks an amount entry already rescaled, so processing twice cannot rescale -# it again. -RESCALED = "_rescaled_to_uplift_in_force" def tax_year_instant(year: int) -> str: @@ -68,6 +60,13 @@ def tax_year_instant(year: int) -> str: return f"{year}-04-30" +def tax_year(instant_str: str) -> int: + """Tax year of a dated amount. Rates are dated from the start of April, + before the 6 April start of the tax year they apply to.""" + year, month = int(instant_str[:4]), int(instant_str[5:7]) + return year if month >= 4 else year - 1 + + def uplift_in_force(rebalancing: ParameterNode, year: int) -> float: instant = tax_year_instant(year) if not rebalancing.active(instant): @@ -77,46 +76,28 @@ def uplift_in_force(rebalancing: ParameterNode, year: int) -> float: return 0.0 if uplift is None else uplift -def legislated_tax_year(instant_str: str) -> int: - """Tax year of a legislated amount. Rates are dated from the start of - April, before the 6 April start of the tax year they apply to.""" - year, month = int(instant_str[:4]), int(instant_str[5:7]) - return year if month >= 4 else year - 1 - - @lru_cache(maxsize=1) -def legislated_amounts() -> dict: - """The amounts in amount.yaml by claimant type, as sorted (date, amount) - pairs.""" +def last_legislated_year() -> int: + """The latest tax year with a legislated amount in amount.yaml for every + claimant type. Read from the file, so a reform that replaces an amount + does not change which years are legislated.""" data = yaml.safe_load(AMOUNT_FILE.read_text()) - amounts = {} - for claimant_type, node in data.items(): - if not isinstance(node, dict) or "values" not in node: - continue - amounts[claimant_type] = sorted( - (str(instant), float(value["value"] if isinstance(value, dict) else value)) + return min( + max( + tax_year(str(instant)) for instant, value in node["values"].items() if value is not None ) - return amounts - - -def legislated_amount_at(claimant_type: str, instant_str: str): - """The latest amount.yaml entry for a claimant type at or before an - instant, as (date, amount), or None.""" - source = None - for entry_instant, amount in legislated_amounts().get(claimant_type, []): - if entry_instant <= instant_str: - source = (entry_instant, amount) - return source + for node in data.values() + if isinstance(node, dict) and "values" in node + ) def benefit_index_values(parameters: ParameterNode) -> dict: """``gov.benefit_uprating_cpi`` for each year of its uprating index, as uprating will extend it: its own values up to its latest one, then that - value moved with its uprating index. Each year is read on 30 April, the - date fiscal-year conversion samples, so a reform to the benefit index for - a fiscal year reaches that year. Keyed by 1 January, as the index is.""" + value moved with its uprating index. Each year is read on 30 April and + keyed by 1 January, as the index is.""" benefit_index = parameters.gov.benefit_uprating_cpi cpi_index = get_parameter(parameters, benefit_index.metadata["uprating"]) last_instant = benefit_index.values_list[0].instant_str @@ -136,30 +117,7 @@ def add_uc_standard_allowance_uprating(parameters: ParameterNode) -> ParameterNo universal_credit = parameters.gov.dwp.universal_credit rebalancing = universal_credit.rebalancing standard_allowance = universal_credit.standard_allowance - - for amount in standard_allowance.amount.get_descendants(): - if not isinstance(amount, Parameter): - continue - claimant_type = amount.name.split(".")[-1] - for value in amount.values_list: - if ( - value.value is None - or getattr(value, RESCALED, False) - or is_set_by_reform(amount, value.instant_str) - ): - continue - # The legislated amount this entry carries: itself for an entry - # loaded from amount.yaml, or the one an entry written after a - # bounded reform restores. - source = legislated_amount_at(claimant_type, value.instant_str) - if source is None or abs(value.value - source[1]) > 1e-9: - continue - year = legislated_tax_year(source[0]) - if year in LEGISLATED_UPLIFT: - value.value *= (1 + uplift_in_force(rebalancing, year)) / ( - 1 + LEGISLATED_UPLIFT[year] - ) - setattr(value, RESCALED, True) + legislated_through = last_legislated_year() values = {} cpi_path = 1.0 @@ -174,7 +132,12 @@ def add_uc_standard_allowance_uprating(parameters: ParameterNode) -> ParameterNo growth = max(1.0, growth) cpi_path *= growth previous_cpi = cpi - values[instant] = cpi_path * (1 + uplift_in_force(rebalancing, year)) + if year <= legislated_through and year in LEGISLATED_UPLIFT: + # The uplift the legislated amounts for this year include. + uplift = LEGISLATED_UPLIFT[year] + else: + uplift = uplift_in_force(rebalancing, year) + values[instant] = cpi_path * (1 + uplift) standard_allowance.add_child( "uprating", diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index bfaa922385..2acef5aefe 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -18,7 +18,6 @@ from policyengine_uk.utils.parameters import ( check_parameter_not_removed, - record_reform_period, uk_fiscal_year_period, ) @@ -275,12 +274,12 @@ def apply_parameter_changes(self, changes: dict): # fiscal year, so the override covers 6 April to 5 April and # survives fiscal-year conversion at full strength. for time_period in changes[parameter]: - period = uk_fiscal_year_period(time_period) - p.update(period=period, value=changes[parameter][time_period]) - record_reform_period(p, period) + p.update( + period=uk_fiscal_year_period(time_period), + value=changes[parameter][time_period], + ) else: p.update(period="year:2000:100", value=changes[parameter]) - record_reform_period(p, "year:2000:100") self.tax_benefit_system.process_parameters() diff --git a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py index 3fc2b7aa9f..5039f5e99e 100644 --- a/policyengine_uk/tests/test_uc_standard_allowance_uplift.py +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -15,10 +15,12 @@ 2. Differential: the amounts equal an independent step-by-step implementation of s. 1(2), started from the 2026-27 Step 2 amounts (the legislated amounts less the 2.3% uplift). -3. The uplift in force: a reform to any year's uplift, or switching - rebalancing off (no uplift), scales that year's amount by - (1 + uplift) / (1 + statutory uplift), including the legislated 2026-27 - amounts, and leaves the CPI path alone. +3. The uplift in force: the legislated 2026-27 amounts stay as legislated; + a reform to a later year's uplift, or switching rebalancing off (no + uplift), scales each later year by (1 + uplift in force) / (1 + statutory + uplift) and leaves the CPI path alone. +4. Reforms: an amount a reform sets is used as given, and a reform to the + benefit index reaches the allowance in the fiscal year it covers. """ import pytest @@ -130,17 +132,16 @@ def test_growth_is_cpi_times_the_change_in_uplift(schedule): _, amounts, cpi = _amounts(scenario) _, baseline, _ = _amounts() for t in CLAIMANT_TYPES: - # The legislated 2026-27 amounts carry the uplift in force. - assert amounts[2026][t] == pytest.approx( - baseline[2026][t] * (1 + schedule[2026]) / (1 + ACT_UPLIFT[2026]), - rel=1e-9, - ) + # The legislated 2026-27 amounts stay as legislated, with the 2.3% + # uplift they include; later years follow the uplift in force. + assert amounts[2026][t] == pytest.approx(baseline[2026][t], rel=1e-9) for year in range(2027, 2030): + previous_uplift = ACT_UPLIFT[2026] if year == 2027 else schedule[year - 1] assert amounts[year][t] == pytest.approx( amounts[year - 1][t] * max(1.0, cpi[year] / cpi[year - 1]) * (1 + schedule[year]) - / (1 + schedule[year - 1]), + / (1 + previous_uplift), rel=1e-9, ) # Keyed to fiscal years, the reform ends with 2029-30, when the @@ -164,26 +165,28 @@ def test_switching_rebalancing_off_removes_the_uplift_from_that_year(first_off): _, baseline, _ = _amounts() for t in CLAIMANT_TYPES: for year in YEARS: - in_force = 0.0 if year == first_off else _uplift(year) + # 2026-27 is legislated, so only later years can lose the uplift. + in_force = 0.0 if year == first_off and year > 2026 else _uplift(year) assert amounts[year][t] == pytest.approx( baseline[year][t] * (1 + in_force) / (1 + _uplift(year)), rel=1e-9, ) -def test_rebalancing_off_throughout_is_cpi_only_from_2025_26(): +def test_rebalancing_off_throughout_follows_cpi_after_the_legislated_year(): scenario = Scenario(parameter_changes={ACTIVE: False}) _, amounts, cpi = _amounts(scenario) + _, baseline, _ = _amounts() for t in CLAIMANT_TYPES: - # The legislated 2026-27 rates less their 2.3% uplift are the 2025-26 - # rates uprated by September 2025 CPI (3.8%) and rounded to the - # penny, as the rates were set at each step. The single under-25 rate - # was set above the Act's minimum, so is left out. - if t != "SINGLE_YOUNG": - assert amounts[2026][t] == pytest.approx( - round(amounts[2025][t] * 1.038, 2), abs=0.01 - ) - for year in range(2027, 2033): + # 2026-27 is legislated. From 2027-28 the allowance is the 2026-27 + # Step 2 amount (the legislated amount less its 2.3% uplift) moved + # with CPI alone. + assert amounts[2026][t] == pytest.approx(baseline[2026][t], rel=1e-9) + assert amounts[2027][t] == pytest.approx( + amounts[2026][t] / (1 + ACT_UPLIFT[2026]) * cpi[2027] / cpi[2026], + rel=1e-9, + ) + for year in range(2028, 2033): assert amounts[year][t] == pytest.approx( amounts[year - 1][t] * cpi[year] / cpi[year - 1], rel=1e-9 ) @@ -223,7 +226,7 @@ def test_scalar_activation_matches_baseline(): "other_change", [{ACTIVE: False}, {UPLIFT: {"2026": 0.10, "2027": 0.10}}], ) -def test_amount_set_by_a_reform_is_not_rescaled(other_change): +def test_amount_set_by_a_reform_is_used_as_given(other_change): """An amount a reform sets is used as given, whatever the uplift.""" scenario = Scenario( parameter_changes={ @@ -251,7 +254,7 @@ def test_reform_to_the_benefit_index_is_followed(): ) -def test_amount_equal_to_the_legislated_one_set_by_a_reform_is_not_rescaled(): +def test_amount_equal_to_the_legislated_one_set_by_a_reform_is_used_as_given(): """A reform that sets the legislated amount itself is still a reform: it is used as given when rebalancing is off.""" scenario = Scenario( @@ -282,10 +285,10 @@ def test_fiscal_year_reform_to_the_benefit_index_reaches_that_year(): ) -def test_amounts_restored_after_a_bounded_reform_carry_the_uplift_in_force(): - """A reform to one year's amount leaves later years on the legislated - series, which still takes the uplift in force (none, with rebalancing - off).""" +def test_amounts_after_a_bounded_reform_follow_the_uplift_in_force(): + """A reform to one year's amount before the legislated year leaves the + legislated amounts and the later uprating as they would be without it + (with rebalancing off, the CPI-only path).""" _, inactive, _ = _amounts(Scenario(parameter_changes={ACTIVE: False})) scenario = Scenario( parameter_changes={ diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 003f2bf000..fdbe9b663a 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -75,32 +75,6 @@ def uk_fiscal_year_period(time_period): return periods.Period(("day", start, days)) -REFORM_PERIODS = "_reform_periods" - - -def record_reform_period(parameter, period) -> None: - """Note a period a parameter change set on ``parameter``. - - ``Parameter.update`` writes the new value at the start of the period and, - after it, an entry restoring the value that applied before, with nothing to - tell the two apart. Processing steps that adjust legislated values use - these spans to leave the values a reform set as given while still treating - restored values as legislated. - """ - period = periods.period(period) - spans = getattr(parameter, REFORM_PERIODS, []) - spans.append((str(period.start), str(period.stop))) - setattr(parameter, REFORM_PERIODS, spans) - - -def is_set_by_reform(parameter, instant_str: str) -> bool: - """Whether a recorded parameter change set ``parameter`` at an instant.""" - return any( - start <= instant_str <= stop - for start, stop in getattr(parameter, REFORM_PERIODS, []) - ) - - def convert_to_fiscal_year_parameters(parameters): """ Convert parameters to use UK fiscal year values. From 9b14b4c52a845aa72fc103aecc1dcb86643ec48a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:09:07 -0400 Subject: [PATCH 6/7] Describe the uplift index without the dropped rescale Co-Authored-By: Claude Opus 5.5 --- changelog.d/2239.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/2239.fixed.md b/changelog.d/2239.fixed.md index c3d8e47f48..e86a48e247 100644 --- a/changelog.d/2239.fixed.md +++ b/changelog.d/2239.fixed.md @@ -1 +1 @@ -Apply the Universal Credit Act 2025 s. 1 uplifts to the Universal Credit standard allowance for 2027-28 to 2029-30 (3.1%, 4.0% and 4.8% above the CPI-only path from 2025-26). The allowance is now uprated by a dedicated index, benefit uprating CPI times one plus the year's uplift, so it grows by CPI times the change in uplift each year and by CPI alone after 2029-30. A different uplift, or switching the rebalancing reforms off, also rescales the legislated 2026-27 amounts. `standard_allowance_uplift` was previously not read. +Apply the Universal Credit Act 2025 s. 1 uplifts to the Universal Credit standard allowance for 2027-28 to 2029-30 (3.1%, 4.0% and 4.8% above the CPI-only path from 2025-26). The allowance is now uprated by a dedicated index, benefit uprating CPI times one plus the uplift, so it grows by CPI times the change in uplift each year after the legislated 2026-27 rates, and by CPI alone after 2029-30. Switching the rebalancing reforms off, or changing a later year's uplift, now moves those years. `standard_allowance_uplift` was previously not read. From 2530e4ac4e08e1a90e92f719451d6c5492e36a9d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 18:47:55 -0400 Subject: [PATCH 7/7] Check the standard allowance projection against its uplift index The published-rates test skipped the four UC standard allowances because the model did not follow Universal Credit Act 2025 s. 1. It does now, through the allowance's own uprating index, so they are checked like any other series; only the LCWRA element (s. 3) stays an exception. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_dwp_published_rates.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/test_dwp_published_rates.py b/policyengine_uk/tests/test_dwp_published_rates.py index f4c60567ca..6d0f614497 100644 --- a/policyengine_uk/tests/test_dwp_published_rates.py +++ b/policyengine_uk/tests/test_dwp_published_rates.py @@ -17,10 +17,11 @@ 3. Projections continue from the last published rate: for a series published through 2026-27, an uprated parameter in 2027-28 equals the 2026-27 rate times the change in its uprating index, so there is no jump when - projections begin. The UC standard allowances and LCWRA element are left - out: the Universal Credit Act 2025 sets a minimum for the standard - allowance above CPI uprating (s.1) and freezes the LCWRA element (s.3) for - 2026-27 to 2029-30, which the model does not yet follow. + projections begin. The UC standard allowances are uprated by their own + index, CPI times the change in the Universal Credit Act 2025 s.1 uplift, + and are checked against it like any other series. The LCWRA element is + left out: s.3 freezes it for 2026-27 to 2029-30, which the model does not + yet follow. 4. Housing Benefit non-dependant deductions: each year's bands and deductions are the published ones, and the scale, read with lower edges inclusive as reg 74(2) words them ("not less than"), never falls as income rises. @@ -73,14 +74,10 @@ def test_no_start_instant_overrides_published_rates(name): assert not (isinstance(uprating, dict) and "start_instant" in uprating) -# Universal Credit Act 2025 (ukpga/2025/22) s.1 (standard allowance minimum: -# the previous Step 2 amount, increased by CPI and then by the uplift in its -# table) and s.3 (LCWRA element frozen for 2026-27 to 2029-30). +# Universal Credit Act 2025 (ukpga/2025/22) s.3: the LCWRA element is frozen +# for 2026-27 to 2029-30. The standard allowances follow s.1 through their own +# uprating index, so they are not exceptions. UC_ACT_2025_EXCEPTIONS = { - "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_YOUNG", - "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD", - "gov.dwp.universal_credit.standard_allowance.amount.COUPLE_YOUNG", - "gov.dwp.universal_credit.standard_allowance.amount.COUPLE_OLD", "gov.dwp.universal_credit.elements.disabled.amount", } @@ -96,7 +93,7 @@ def test_projection_continues_from_last_published_rate(name): if last < 2026: pytest.skip("series ends before 2026-27") if name in UC_ACT_2025_EXCEPTIONS: - pytest.skip("Universal Credit Act 2025 ss.1 and 3 depart from CPI uprating") + pytest.skip("Universal Credit Act 2025 s.3 departs from CPI uprating") index = node(uprating if isinstance(uprating, str) else uprating["parameter"]) ratio = index(f"{last + 1}-01-01") / index(f"{last}-01-01") expected = PARAMETERS[name]["values"][last] * ratio