diff --git a/changelog.d/2239.fixed.md b/changelog.d/2239.fixed.md new file mode 100644 index 0000000000..cc24e9dbc9 --- /dev/null +++ b/changelog.d/2239.fixed.md @@ -0,0 +1 @@ +- Applied the Universal Credit Act 2025 standard allowance uplifts for 2027-28 to 2029-30 through a dedicated uprating index under `gov.economic_assumptions.indices`, including when dated reforms rebuild parameters, while preserving the legislated 2026-27 rates. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 80f9162c97..89ac3d4db3 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -48,14 +48,20 @@ Later years hold 22%. That is an assumption, not a source: the share keeps risin ## 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. +The model projects future inflation using `gov.benefit_uprating_cpi`, its benefit uprating index, rather than a separate September CPI series for the standard allowance. + +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`, used as they are. Later years are uprated by `gov.economic_assumptions.indices.uc_standard_allowance`, 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`. + +For a single claimant under 25, the legislated 2026-27 amount is £338.58, above the Act's £336.60 minimum (£316.98 increased by 3.8% CPI, then 2.3%, with rounding at each step). The projection carries that margin forward; dividing the legislated rate by 1.023 gives the projection's CPI-only base, rather than the Act's Step 2 minimum for that claimant type. ## Implementation @@ -63,7 +69,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**: YAML files define the reform's activation status, the health element amounts for new claimants and for protected (pre-2026) claimants, the health element freeze, and the standard allowance uplift rates. +- **Parameters**: YAML files define the reform's activation status, the health element amounts for new claimants and for protected (pre-2026) claimants, the health element freeze, and the 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. @@ -86,19 +92,21 @@ 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, 7% in 2027 +# 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-01-01": 0.05, - "2027-01-01": 0.07, - "2028-01-01": 0.07, - "2029-01-01": 0.07, + "2027": 0.07, + "2028": 0.07, + "2029": 0.07, } } ) @@ -106,6 +114,8 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +The uplift reaches the allowance through parameter processing. Both `Scenario(parameter_changes=...)` and a `reform=` dictionary keyed by fiscal year re-run that pipeline, including after parameters are reset and reprocessed. A bounded override of `standard_allowance.amount` uses the override as given during its period and restores the projected allowance afterwards. + ### Changing the health element amount for new claimants ```python @@ -150,8 +160,9 @@ The freeze reaches the element through parameter processing. A `Scenario(paramet ## 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://www.legislation.gov.uk/ukpga/2025/22/section/3 - https://bills.parliament.uk/publications/62123/documents/6889 - https://bills.parliament.uk/publications/62124/documents/6892 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..cff177db5b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -0,0 +1,170 @@ +"""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 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.economic_assumptions.indices.uc_standard_allowance``: 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. 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. + +The projection carries forward any margin in a legislated rate above the +Act's minimum. In 2026-27 the single under-25 rate is GBP 338.58, above the +Act's GBP 336.60 minimum; its CPI-only base here is 338.58 / 1.023. + +This runs once per tree in the shared parameter generation pipeline before +uprating. Dated changes through ``Scenario(parameter_changes=...)`` or a +``reform=`` dict rebuild that pipeline, as does resetting and reprocessing +parameters, so they reach the allowance. +""" + +from functools import lru_cache +from pathlib import Path + +import yaml +from policyengine_core.parameters import Parameter, ParameterNode, get_parameter + +INDEX_NAME = "gov.economic_assumptions.indices.uc_standard_allowance" +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} + + +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 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): + return 0.0 + uplift = rebalancing.standard_allowance_uplift(instant) + # Nil before the uplift parameter starts (2025-26). + return 0.0 if uplift is None else uplift + + +@lru_cache(maxsize=1) +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()) + return min( + max( + tax_year(str(instant)) + for instant, value in node["values"].items() + if value is not None + ) + 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 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 + 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): + 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 * cpi_index(instant) / 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 + legislated_through = last_legislated_year() + + values = {} + cpi_path = 1.0 + previous_cpi = None + 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 = cpi / previous_cpi + if FIRST_ACT_YEAR <= year <= LAST_ACT_YEAR: + growth = max(1.0, growth) + cpi_path *= growth + previous_cpi = cpi + 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) + + parameters.gov.economic_assumptions.indices.add_child( + "uc_standard_allowance", + 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 acefffbd4a..ecfee8e092 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 @@ -19,7 +19,7 @@ SINGLE_YOUNG: 2026-04-01: 338.58 metadata: label: Universal Credit single amount (under 25) - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.uc_standard_allowance unit: currency-GBP period: month SINGLE_OLD: @@ -42,7 +42,7 @@ SINGLE_OLD: 2026-04-01: 424.9 metadata: label: Universal Credit single amount (over 25) - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.uc_standard_allowance unit: currency-GBP period: month COUPLE_YOUNG: @@ -65,7 +65,7 @@ COUPLE_YOUNG: 2026-04-01: 528.34 metadata: label: Universal Credit couple amount (both under 25) - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.uc_standard_allowance unit: currency-GBP period: month COUPLE_OLD: @@ -93,7 +93,7 @@ COUPLE_OLD: period: month metadata: unit: currency-GBP - uprating: gov.benefit_uprating_cpi + uprating: gov.economic_assumptions.indices.uc_standard_allowance propagate_metadata_to_children: true reference: - title: Benefit and pension rates 2020 to 2021 (DWP, gov.uk) @@ -111,3 +111,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 0260469ef3..c534044877 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -188,8 +188,9 @@ def add_universal_credit_reform(sim: Microsimulation): # exception reads entries differently). set_model_value(sim, "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 6b50f8da39..8a67912988 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -33,6 +33,9 @@ from policyengine_uk.parameters.gov.dwp.universal_credit.rebalancing.create_health_element_uprating import ( add_uc_health_element_uprating, ) +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_september_cpi_uprating import ( add_september_cpi_uprating, ) @@ -105,6 +108,7 @@ add_september_cpi_uprating, create_economic_assumption_indices, add_threshold_indexation, + add_uc_standard_allowance_uprating, add_uc_health_element_uprating, add_lsr_deprecation_aliases, ) @@ -152,6 +156,7 @@ def process_parameter_tree( - September CPI uprating - economic assumption indices - income tax and NICs threshold indexation after the freeze + - Universal Credit standard allowance uprating (UC Act 2025 s. 1) - Universal Credit LCWRA element uprating (UC Act 2025 s. 3 freeze) - parameter uprating and backdating - conversion to fiscal year parameters diff --git a/policyengine_uk/tests/test_dwp_published_rates.py b/policyengine_uk/tests/test_dwp_published_rates.py index de6e2c8eb4..5323fd9de6 100644 --- a/policyengine_uk/tests/test_dwp_published_rates.py +++ b/policyengine_uk/tests/test_dwp_published_rates.py @@ -17,10 +17,10 @@ 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 are left out: the - Universal Credit Act 2025 sets a minimum for the standard allowance above - CPI uprating (s.1), which the model does not yet follow. The LCWRA element - is checked against its own uprating index, which holds it for 2026-27 to + 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 + checked against its own uprating index, which holds it for 2026-27 to 2029-30 as s.3 freezes it. 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 @@ -74,17 +74,6 @@ 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). -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", -} - - @pytest.mark.parametrize("name", sorted(PARAMETERS)) def test_projection_continues_from_last_published_rate(name): """Invariant 3.""" @@ -95,8 +84,6 @@ def test_projection_continues_from_last_published_rate(name): last = max(int(y) for y in PARAMETERS[name]["values"]) if last < 2026: pytest.skip("series ends before 2026-27") - if name in UC_ACT_2025_EXCEPTIONS: - pytest.skip("Universal Credit Act 2025 s.1 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 diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index 21ae54cf3e..732e58f2ab 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -396,6 +396,7 @@ def test_generated_parameters_are_the_series_processing_builds(): built = path.startswith((f"{INDICES}.", f"{GROWTH}.")) and not statutory(path) assert generates(path) is built, path assert generates(TRIPLE_LOCK) + assert generates(f"{INDICES}.uc_standard_allowance") assert not generates(MINIMUM_RATE) # The CGT projection index is added after processing, outside it. assert not generates(f"{INDICES}.capital_gains") 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..6244f130f6 --- /dev/null +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -0,0 +1,351 @@ +"""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 model's 2026-27 amounts in +amount.yaml stay fixed; later years are uprated by CPI times the change in +uplift. SINGLE_YOUNG starts at £338.58, above the Act's £336.60 minimum; +projecting that model anchor carries its margin above the minimum forward. + +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 application + of s. 1(2)'s growth to the model's 2026-27 anchors, less their 2.3% + uplift. This does not assert equality to the Act's minimum amounts. +3. The uplift in force: the model's 2026-27 amounts stay fixed; + 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 +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, reform=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, + reform=reform, + ) + 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_follows_the_act_uplift_schedule(): + """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_projection_follows_act_growth_from_modelled_2026_anchors(): + """Apply the Act's growth to the existing anchors, including the margin + above the minimum for single claimants under 25.""" + _, amounts, cpi = _amounts() + for t in CLAIMANT_TYPES: + # Modelled Step 2 anchor: the 2026-27 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 + ) + + +@pytest.mark.parametrize( + "reformed_uplift,expected_2029", + [(None, 435.2836754643207), (0.10, 456.8817204301076)], +) +def test_uplift_survives_dated_reform_rebuild_and_reprocessing( + reformed_uplift, expected_2029 +): + """Dated reform rebuilding and reset/reprocess retain the allowance + generator and an uplift reform applied through the reform= route.""" + changes = { + "gov.benefit_uprating_cpi": {"2026-04-06.2100-12-31": 300.0}, + } + if reformed_uplift is not None: + changes[UPLIFT] = {"2029-04-06.2030-04-05": reformed_uplift} + sim, amounts, _ = _amounts(reform=changes) + # With CPI held flat, £424.90 / 1.023 is the Step 2 anchor. Its 2029 + # amount is that anchor times 1.048 (or the reformed 1.10), not £424.90. + assert amounts[2029]["SINGLE_OLD"] == pytest.approx(expected_2029, rel=1e-9) + + # An unrelated dated reform forces a further rebuild of the same tree. + sim.apply_parameter_changes( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06.2100-12-31": 0.21}} + ) + system = sim.tax_benefit_system + + def assert_uplift(): + allowance = system.parameters( + "2029-06-01" + ).gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD + assert allowance == pytest.approx(expected_2029, rel=1e-9) + + assert_uplift() + # Reprocessing more than once also guards against applying the uplift + # twice while replaying the dated assignments. + for _ in range(2): + system.reset_parameters() + system.process_parameters() + assert_uplift() + + +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 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 + previous_uplift), + 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: + # 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_follows_cpi_after_the_legislated_year(): + scenario = Scenario(parameter_changes={ACTIVE: False}) + _, amounts, cpi = _amounts(scenario) + _, baseline, _ = _amounts() + for t in CLAIMANT_TYPES: + # 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 + ) + + +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 + ) + + +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_used_as_given(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, + ) + + +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( + 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, + ) + + +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={ + 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 + )