Skip to content
1 change: 1 addition & 0 deletions changelog.d/2283.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. Anchored the inactive 2026-27 health-element counterfactual to September CPI with monthly-penny rounding, before projecting later years from that anchor.
4 changes: 3 additions & 1 deletion docs/book/policy/uc-rebalancing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it di

Two parameters hold this amount, and both are uprated by `gov.economic_assumptions.indices.uc_health_element`, so they follow the same path:

- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and from 2033-34, after the data's projection ends, when the rebalancing scenario no longer sets the element.
- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. For an inactive year processed by the rebalancing scenario, the scenario replaces that paid amount with the pre-Act counterfactual described below. From 2033-34, after the data's projection ends, the rebalancing scenario no longer sets the element and the formula reads this parameter.
- `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants in the years it sets the element, `uc_reform.REBALANCING_YEARS` (2026-27 to 2032-33).

A change to the freeze or to the benefit uprating index moves both, and so does a reform that sets the index, `gov.economic_assumptions.indices.uc_health_element`, directly (it replaces the freeze and the no-fall floor for the years it names). A reform to either amount itself does not change the other.
Expand Down Expand Up @@ -92,6 +92,8 @@ scenario = Scenario(
sim = Simulation(scenario=scenario)
```

For the health component, the inactive 2026-27 amount is a declared CPI-only counterfactual: the 2025-26 monthly LCWRA amount (£423.27) increased by September 2025 CPI (3.8%) and rounded to monthly pennies (£439.35, or £5,272.20 a year). SSAA 1992 section 150 allows discretion over Universal Credit uprating and rounding; this convention is not an enacted LCWRA rate or the only legally possible amount. From 2027-28, the model's ordinary benefit uprating index projects growth from that 2026-27 anchor. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that fixed 2026-27 anchor, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. Applying an amount change through `Scenario.apply` also refreshes an already-calculated health element and final Universal Credit award. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. The scenario covers tax years through 2032-33; from 2033-34 the inactive setting falls back to the underlying amount path, so it does not provide a continuous long-run “Act off” projection.

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
Expand Down
105 changes: 102 additions & 3 deletions policyengine_uk/scenarios/uc_reform.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from policyengine_uk.model_api import Scenario
from policyengine_uk.model_api import Scenario, Variable
from policyengine_uk import Microsimulation
from policyengine_uk.utils.data_source import built_from_data
from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR
from policyengine_uk.utils.supplied_inputs import set_model_value
from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input
import numpy as np
import warnings
from policyengine_core.periods import instant
from weakref import WeakKeyDictionary
from policyengine_core.periods import instant, period as period_


# Universal Credit Act 2025: the first tax year of the rebalancing (2026-27,
Expand Down Expand Up @@ -214,7 +215,97 @@ def _new_claimant_health_element_status(
return status


def _lcwra_amount_is_overridden(sim, year: int) -> bool:
"""An explicit LCWRA amount keeps precedence over the inactive path."""
from policyengine_uk.utils.dated_reforms import legacy_override_bounds
from policyengine_uk.tax_benefit_system import (
reform_parameter_defines,
parameter_reads_calendar_years,
)

system = sim.tax_benefit_system
overrides = list(getattr(system, "legacy_overrides", ()))
# Single-day constructor overrides run after this modifier. Respect
# their requested window before caching an inactive award.
for dated in getattr(sim, "_building_dated_reforms", None) or ():
_, pending = dated.read(
reform_parameter_defines(),
calendar_years=parameter_reads_calendar_years(),
)
overrides.extend(pending)
statutory_sample = instant(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}")
annual_sample = instant(f"{year}-01-01")
return any(
change.path == LCWRA_AMOUNT and change.start <= statutory_sample <= change.stop
for change in getattr(system, "dated_assignments", ())
) or any(
change.path == LCWRA_AMOUNT and start <= annual_sample <= stop
for change in overrides
for start, stop in [legacy_override_bounds(change, system.parameters)]
)


def _inactive_health_element_monthly(sim, year: int) -> float:
"""The CPI-only counterfactual, independent of previous paid awards.

Anchor 2026-27 to the pre-Act amount increased by September 2025 CPI,
rounded to monthly pennies. This is a scenario convention, not an
enacted LCWRA rate or a uniquely required SSAA 1992 s.150 rounding rule.
Later years follow the ordinary benefit index from that 2026 anchor.
"""
parameters = sim.tax_benefit_system.parameters
before = parameters(str(FIRST_REBALANCING_YEAR - 1)).gov
anchor = round(
float(before.dwp.universal_credit.elements.disabled.amount)
* _relevant_cpi_factor(sim, FIRST_REBALANCING_YEAR),
2,
)
anchor_index = parameters(str(FIRST_REBALANCING_YEAR)).gov.benefit_uprating_cpi
return anchor * parameters(str(year)).gov.benefit_uprating_cpi / anchor_index


# Only functions registered here are this internal amount wrapper. External
# wrappers, even ones copying function attributes, keep their own provenance.
_INACTIVE_HEALTH_ELEMENT_ORIGINS = WeakKeyDictionary()


def lcwra_formula_origin(formula, period):
"""Resolve the formula delegated by our internal LCWRA amount wrapper."""
if formula is None:
return None
original = _INACTIVE_HEALTH_ELEMENT_ORIGINS.get(formula)
return original.get_formula(period) if original is not None else formula


def _add_inactive_health_element_formula(sim):
"""Keep the counterfactual as calculated output, invalidated by reforms."""
system = sim.tax_benefit_system
original = system.get_variable("uc_LCWRA_element")
if getattr(original, "_inactive_health_element_formula", False):
return

class uc_LCWRA_element(Variable):
def formula(benunit, period, parameters):
current = original.get_formula(period)(benunit, period, parameters)
year = period.start.year
if year not in REBALANCING_YEARS:
return current
simulation = benunit.simulation
p = parameters(period).gov.dwp.universal_credit
if p.rebalancing.active or _lcwra_amount_is_overridden(simulation, year):
return current
amount = p.elements.disabled.amount
if amount == 0:
return current
return current * _inactive_health_element_monthly(simulation, year) / amount

_INACTIVE_HEALTH_ELEMENT_ORIGINS[uc_LCWRA_element.formula] = original
variable = system.update_variable(uc_LCWRA_element)
variable._inactive_health_element_formula = True


def add_universal_credit_reform(sim: Microsimulation):
_add_inactive_health_element_formula(sim)
rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing

generator = np.random.default_rng(43)
Expand All @@ -227,6 +318,14 @@ def add_universal_credit_reform(sim: Microsimulation):
is_data = built_from_data(sim)
for year in REBALANCING_YEARS:
if not rebalancing.active(year):
population = sim.get_variable_population("uc_LCWRA_element")
if (
supplied_input(population, "uc_LCWRA_element", period_(year))
is not None
):
continue
# Recalculate through the formula, preserving user-supplied awards.
sim.delete_arrays("uc_LCWRA_element", year)
continue
is_post_2025_claimant = _new_claimant_health_element_status(
sim, year, uc_seed < new_claimant_share(year), is_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -940,3 +940,94 @@ def test_an_element_is_entered_exactly_when_its_last_store_was_supplied(
assert is_entered(simulation, year) == entered, operations
for clone_source, was_entered in earlier:
assert is_entered(clone_source, year) == was_entered, operations


@pytest.mark.parametrize("year", [2025, 2026])
@pytest.mark.parametrize("active", [False, True])
@pytest.mark.parametrize("holder_role", ["claimant", "other"])
def test_internal_lcwra_wrapper_keeps_the_model_claimant_filter(
year, active, holder_role
):
"""An internal amount wrapper does not turn nonclaimant circumstances into an award."""
from policyengine_uk.model_api import Scenario
from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform

claimant, other = blank("claimant", 40), blank("other", 30)
(claimant if holder_role == "claimant" else other)[
"uc_limited_capability_for_WRA"
] = True
sim = Simulation(
situation=situation([([claimant, other], {"working_tax_credit": 0})], year),
scenario=Scenario(
parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active},
applied_before_data_load=True,
),
)
assert sim.calculate("uc_LCWRA_element", year)[0] > 0
assert not is_entered(sim, year)
assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant")
add_universal_credit_reform(sim)
sim.delete_arrays(UC_SPECIFIED, year)
assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant")


@pytest.mark.parametrize("active", [False, True])
@pytest.mark.parametrize("amount", [0, 5_000])
def test_internal_lcwra_wrapper_keeps_explicit_positive_and_nil_entries(active, amount):
from policyengine_uk.model_api import Scenario

sim = Simulation(
situation=element_situation(2026, amount),
scenario=Scenario(
parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active},
applied_before_data_load=True,
),
)
assert is_entered(sim, 2026)
assert bool(sim.calculate(UC_SPECIFIED, 2026)[0]) == (amount > 0)


@pytest.mark.parametrize("year", [2025, 2026])
@pytest.mark.parametrize("amount", [0, 6_000])
@pytest.mark.parametrize("when", ["constructor", "after_internal_wrapper"])
def test_internal_lcwra_wrapper_preserves_real_external_formula_origin(
year, amount, when
):
from policyengine_uk.model_api import Scenario
from policyengine_uk.variables.gov.dwp.universal_credit.benefit_cap.is_uc_benefit_cap_exempt_specified_benefit import (
calculated_by,
uc_LCWRA_element as model_lcwra_element,
)
from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform

reform = element_reform("uc_LCWRA_element", amount)
sim = Simulation(
situation=element_situation(year),
reform=reform if when == "constructor" else None,
)
if when == "after_internal_wrapper":
Scenario.from_reform(reform).apply(sim)
# Origin/formula unit precondition: discard the previously model-owned
# award. This does not certify structural-reform public cache invalidation.
sim.delete_arrays("uc_LCWRA_element", year)
assert sim.calculate("uc_LCWRA_element", year)[0] == amount
assert not calculated_by(
sim.populations["benunit"],
"uc_LCWRA_element",
period(year),
model_lcwra_element,
)
assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0)
add_universal_credit_reform(sim)
sim.delete_arrays(UC_SPECIFIED, year)
# Origin/formula unit precondition: discard the previously model-owned
# award. This does not certify structural-reform public cache invalidation.
sim.delete_arrays("uc_LCWRA_element", year)
assert sim.calculate("uc_LCWRA_element", year)[0] == amount
assert not calculated_by(
sim.populations["benunit"],
"uc_LCWRA_element",
period(year),
model_lcwra_element,
)
assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0)
Loading
Loading