diff --git a/changelog.d/2239.fixed.md b/changelog.d/2239.fixed.md new file mode 100644 index 0000000000..e86a48e247 --- /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 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. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index b58e3170e0..bff65d6ccc 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -33,14 +33,16 @@ Whether a benefit unit gets the new-claimant rate is the benefit unit's `uc_rece ## 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`, 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 @@ -48,7 +50,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, 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, 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. @@ -71,19 +73,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, } } ) @@ -91,6 +95,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 @@ -110,8 +116,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..6d7142a518 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_standard_allowance_uprating.py @@ -0,0 +1,166 @@ +"""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.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. 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, +reaches the allowance. A ``reform=`` dict applied after processing does not +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} + + +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 + standard_allowance = universal_credit.standard_allowance + 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) + + 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 acefffbd4a..5ae5d66362 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.dwp.universal_credit.standard_allowance.uprating 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.dwp.universal_credit.standard_allowance.uprating 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.dwp.universal_credit.standard_allowance.uprating 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.dwp.universal_credit.standard_allowance.uprating 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 de15332e5a..9657248647 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -158,8 +158,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 06acdeb86c..ad9f14f9e4 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_september_cpi_uprating import ( add_september_cpi_uprating, ) @@ -100,6 +103,7 @@ def process_parameters(self) -> None: - 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) - Parameter uprating and backdating - Conversion to fiscal year parameters """ @@ -113,6 +117,7 @@ def process_parameters(self) -> None: self.parameters = add_september_cpi_uprating(self.parameters) self.parameters = create_economic_assumption_indices(self.parameters) self.parameters = add_threshold_indexation(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_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 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..5039f5e99e --- /dev/null +++ b/policyengine_uk/tests/test_uc_standard_allowance_uplift.py @@ -0,0 +1,306 @@ +"""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: 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 +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 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 + )