From dc14ed64b83e65adf36c8bc91d459976e1c2a178 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:45:32 -0400 Subject: [PATCH 1/6] Freeze the UC LCWRA element for 2026-27 to 2029-30 (UC Act 2025 s. 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universal Credit Act 2025 s. 3(1) disapplies SSAA 1992 s. 150(1) and (2) for the LCWRA and LCW elements in the tax years ending 5 April 2026 to 5 April 2029. An up-rating order takes effect from the tax year after it is made (s. 150(10)), so the rates for 2026-27 to 2029-30 are not uprated. The element was uprated by gov.benefit_uprating_cpi, giving £222.26 for 2027-28. It is now uprated by gov.dwp.universal_credit.elements.disabled. uprating, which a process_parameters builder makes from the benefit index (as uprating extends it), held flat while rebalancing.health_element_freeze and rebalancing.active are true, and never falling. Refs #2280. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-lcwra-s3-freeze.fixed.md | 1 + .../elements/disabled/amount.yaml | 10 +- .../create_health_element_uprating.py | 189 ++++++++++++++++++ .../rebalancing/health_element_freeze.yaml | 24 +++ policyengine_uk/tax_benefit_system.py | 5 + 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 changelog.d/uc-lcwra-s3-freeze.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/health_element_freeze.yaml diff --git a/changelog.d/uc-lcwra-s3-freeze.fixed.md b/changelog.d/uc-lcwra-s3-freeze.fixed.md new file mode 100644 index 0000000000..b663b4419b --- /dev/null +++ b/changelog.d/uc-lcwra-s3-freeze.fixed.md @@ -0,0 +1 @@ +- Hold the Universal Credit LCWRA element at £217.26 a month for 2026-27 to 2029-30, as Universal Credit Act 2025 s. 3 freezes it, and uprate it from that amount by the benefit uprating index from 2030-31. It was CPI-uprated from 2027-28. The freeze is `gov.dwp.universal_credit.rebalancing.health_element_freeze` and applies while the rebalancing reforms are active. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/disabled/amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/disabled/amount.yaml index 3319c90ac8..651d02248e 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/disabled/amount.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/disabled/amount.yaml @@ -1,4 +1,4 @@ -description: Limited capability for work-related activity element amount under the Universal Credit. +description: Limited capability for work and work-related activity (LCWRA) element amount under Universal Credit. From April 2026 this is the amount for claimants other than pre-2026, severe conditions criteria or terminally ill claimants, which Universal Credit Act 2025 s. 3 freezes for 2026-27 to 2029-30. values: 2016-04-10: 315.6 2017-04-10: 318.76 @@ -14,10 +14,16 @@ values: metadata: period: month label: Universal Credit disability element amount - uprating: gov.benefit_uprating_cpi + # Benefit uprating CPI, held for 2026-27 to 2029-30 (Universal Credit Act + # 2025 s. 3); built by rebalancing/create_health_element_uprating.py. + uprating: gov.dwp.universal_credit.elements.disabled.uprating unit: currency-GBP reference: - title: The Universal Credit Regulations 2013 reg. 27 (LCWRA element), amounts in reg. 36 href: https://www.legislation.gov.uk/uksi/2013/376/regulation/36 + - title: Universal Credit Act 2025 s. 2 (2026-27 amount) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/2 + - title: Universal Credit Act 2025 s. 3 (freeze for 2026-27 to 2029-30) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/3 - title: Benefit and pension rates 2026 to 2027 href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py new file mode 100644 index 0000000000..0727aecc63 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -0,0 +1,189 @@ +"""Build the uprating index for the Universal Credit LCWRA element. + +Universal Credit Act 2025 s. 3(1): + + Subsections (1) and (2) of section 150 of the Social Security + Administration Act 1992 (annual up-rating of benefits) do not apply, in + the tax years ending with 5 April 2026, 5 April 2027, 5 April 2028 and + 5 April 2029, to the LCWRA element or the LCW element. + +SSAA 1992 s. 150(1) has the Secretary of State review the Universal Credit +amounts (s. 150(1)(n)) in each tax year, and s. 150(2) provides for an +up-rating order where prices have risen; for these amounts the increase is +at the Secretary of State's discretion (s. 150(2)(b)), as s. 150(3) does not +list them. An order brings its changes into force in the week beginning with +the first Monday of the tax year after the one in which it is made +(s. 150(10)(a)(i)), for Universal Credit from the first assessment period +beginning on or after that day (s. 150(10ZA) and (10ZB)). The four +disapplied reviews, in 2025-26 to 2028-29, are therefore those that would +have uprated the rates for 2026-27 to 2029-30, the years named in the +section's heading. The review in 2029-30 is not disapplied, so the element +can rise again from April 2030. + +``elements/disabled/amount.yaml`` holds the 2026-27 amount set by s. 2 +(£217.26 a month) and is uprated by the index built here, +``gov.dwp.universal_credit.elements.disabled.uprating``. The index moves +with ``gov.benefit_uprating_cpi``, the model's benefit uprating index, as +uprating will extend it (including any reform to it), except in a tax year +in which ``rebalancing.health_element_freeze`` and ``rebalancing.active`` are +both true, when it stays at the previous year's value. It never falls, as +s. 150(2) provides only for increases. Uprating an amount by it therefore +holds the amount through the frozen years and then raises it by each later +year's increase in the benefit index, from the frozen amount rather than +with the frozen years' increases added back. + +The pre-2026 (protected) amount of the element is also frozen by s. 3 and +raised under s. 4; ``scenarios/uc_reform.py`` sets it for 2026-27 to +2029-30. The LCW element is not modelled. + +This runs in ``process_parameters`` before uprating, so a change made through +``Scenario(parameter_changes=...)``, which re-runs that pipeline, reaches the +element: to the freeze, to ``rebalancing.active``, to the benefit index or +the forecasts behind it, or to the amount itself, which is then held or +uprated from the changed value. A ``reform=`` dict is applied after +processing and does not re-run uprating; it changes the years it names. +""" + +from typing import Dict + +from policyengine_core.parameters import Parameter, ParameterNode, get_parameter + +INDEX_NAME = "gov.dwp.universal_credit.elements.disabled.uprating" +BENEFIT_INDEX_NAME = "gov.benefit_uprating_cpi" +# Fiscal-year conversion reads each tax year's value at 30 April. +FISCAL_YEAR_SAMPLE_MONTH_DAY = "04-30" +# Index values are keyed to 1 January, as core uprating keys its values. +INDEX_VALUE_MONTH_DAY = "01-01" + + +def tax_year_instant(year: int) -> str: + """Date at which parameters are read for the tax year from April + ``year``.""" + return f"{year}-{FISCAL_YEAR_SAMPLE_MONTH_DAY}" + + +def latest_instant(parameter: Parameter) -> str: + return max(value.instant_str for value in parameter.values_list) + + +def uprating_parameter_name(parameter: Parameter): + """Name of the parameter that uprates ``parameter``, or None.""" + meta = parameter.metadata.get("uprating") + if meta is None: + return None + if isinstance(meta, str): + return meta + if set(meta) != {"parameter"}: + raise NotImplementedError( + f"{parameter.name} is uprated with options {sorted(meta)}; the " + "Universal Credit health element uprating reads it only as a " + "plain uprating index." + ) + return meta["parameter"] + + +def projected_value(parameter: Parameter, parameters: ParameterNode, at: str): + """``parameter`` at ``at`` as core uprating will extend it. + + Core uprating starts from the latest stated value and adds, for each + value of the uprating index dated after it, that value times the index's + change since the latest stated date. Read before uprating runs, the + parameter itself holds only its stated values. + """ + last = latest_instant(parameter) + uprater_name = uprating_parameter_name(parameter) + if at <= last or uprater_name is None: + return parameter(at) + uprater = get_parameter(parameters, uprater_name) + if uprating_parameter_name(uprater) is not None: + raise NotImplementedError( + f"{uprater.name}, which uprates {parameter.name}, is itself " + "uprated; the Universal Credit health element uprating reads " + "only a fully stated index." + ) + projected = [ + value.instant_str + for value in uprater.values_list + if last < value.instant_str <= at + ] + if not projected: + return parameter(at) + return parameter(last) * uprater(max(projected)) / uprater(last) + + +def index_years(parameters: ParameterNode) -> range: + """Years the benefit index covers once uprated: its own stated years and + those of the index that uprates it.""" + benefit_index = get_parameter(parameters, BENEFIT_INDEX_NAME) + instants = [value.instant_str for value in benefit_index.values_list] + uprater_name = uprating_parameter_name(benefit_index) + if uprater_name is not None: + uprater = get_parameter(parameters, uprater_name) + instants += [value.instant_str for value in uprater.values_list] + years = [int(instant[:4]) for instant in instants] + return range(min(years), max(years) + 1) + + +def is_frozen(rebalancing: ParameterNode, year: int) -> bool: + """Whether the element keeps the previous year's amount in the tax year + from April ``year``. A year before either parameter starts is not.""" + instant = tax_year_instant(year) + return bool(rebalancing.health_element_freeze(instant)) and bool( + rebalancing.active(instant) + ) + + +def health_element_index_values(parameters: ParameterNode) -> Dict[str, float]: + """The uprating index, keyed to 1 January of each year.""" + benefit_index = get_parameter(parameters, BENEFIT_INDEX_NAME) + rebalancing = parameters.gov.dwp.universal_credit.rebalancing + values = {} + index = 1.0 + previous = None + for year in index_years(parameters): + current = projected_value(benefit_index, parameters, tax_year_instant(year)) + if current is None: + continue + if previous is not None and not is_frozen(rebalancing, year): + index *= max(1.0, current / previous) + previous = current + values[f"{year}-{INDEX_VALUE_MONTH_DAY}"] = index + return values + + +def add_uc_health_element_uprating(parameters: ParameterNode) -> ParameterNode: + """Add ``gov.dwp.universal_credit.elements.disabled.uprating``. + + Runs after ``create_economic_assumption_indices``, which builds the index + that extends ``gov.benefit_uprating_cpi``. + """ + parameters.gov.dwp.universal_credit.elements.disabled.add_child( + "uprating", + Parameter( + INDEX_NAME, + data={ + "description": ( + "Uprating index for the Universal Credit LCWRA element: " + "the benefit uprating index, held flat in the tax years " + "for which the Universal Credit Act 2025 freezes the " + "element and never falling." + ), + "values": health_element_index_values(parameters), + "metadata": { + "unit": "/1", + "label": "Universal Credit health element uprating index", + "reference": [ + { + "title": "Universal Credit Act 2025 s. 3", + "href": "https://www.legislation.gov.uk/ukpga/2025/22/section/3", + }, + { + "title": "Social Security Administration Act 1992 s. 150", + "href": "https://www.legislation.gov.uk/ukpga/1992/5/section/150", + }, + ], + }, + }, + ), + ) + return parameters diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/health_element_freeze.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/health_element_freeze.yaml new file mode 100644 index 0000000000..40d1510e03 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/health_element_freeze.yaml @@ -0,0 +1,24 @@ +description: >- + While this parameter is true, the Universal Credit limited capability for work and work-related activity (LCWRA) element keeps its amount from the previous tax year instead of being uprated. Universal Credit Act 2025 s. 3(1) disapplies the annual up-rating review and order in Social Security Administration Act 1992 s. 150(1) and (2) for the element in the tax years ending 5 April 2026 to 5 April 2029. An up-rating order changes rates from the tax year after the one in which it is made (s. 150(10)), so the frozen rates are those for 2026-27 to 2029-30, the years named in the section's heading. A year here is the tax year whose rates it governs, from 6 April. The freeze applies only while the rebalancing reforms are active. +values: + 2015-01-01: false + 2026-04-06: + value: true + metadata: + reference: + - title: Universal Credit Act 2025 s. 3(1) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/3 + 2030-04-06: + value: false + metadata: + reference: + - title: Universal Credit Act 2025 s. 3(1) (disapplication ends with the tax year ending 5 April 2029) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/3 +metadata: + unit: bool + label: Universal Credit health element uprating freeze + reference: + - title: Universal Credit Act 2025 s. 3 + href: https://www.legislation.gov.uk/ukpga/2025/22/section/3 + - title: Social Security Administration Act 1992 s. 150(1), (2) and (10) + href: https://www.legislation.gov.uk/ukpga/1992/5/section/150 diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 06acdeb86c..d31494d97e 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_health_element_uprating import ( + add_uc_health_element_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 LCWRA element uprating (UC Act 2025 s. 3 freeze) - 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_health_element_uprating(self.parameters) self.parameters = add_lsr_deprecation_aliases(self.parameters) # Create baseline parameters for reform comparisons From cccbaf0f7af157a74f53ca1dc55d4523ee54f4c8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 19:12:22 -0400 Subject: [PATCH 2/6] Test the UC LCWRA element freeze and its uprating afterwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Examples for 2026-27 to 2030-31; a differential against the processed benefit index; reforms (freeze or rebalancing off, one year unfrozen, a changed amount, a reform dict); and Hypothesis properties: frozen years hold for any CPI path, later years grow with the benefit index and never fall, any freeze/active schedule holds exactly its years, and a reformed amount is held or uprated from its value. YAML cases pin 2027-28 to 2029-30 at £2,607.12 a year. Mutation check: main's uprating fails 8 pytest and 3 YAML cases; dropping the no-fall floor fails 3 properties; ignoring rebalancing.active fails 2. Refs #2280. Co-Authored-By: Claude Opus 5.5 --- .../uc_LCWRA_element.yaml | 41 ++ .../tests/test_uc_health_element_freeze.py | 363 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 policyengine_uk/tests/test_uc_health_element_freeze.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/limited_work_ability/uc_LCWRA_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/limited_work_ability/uc_LCWRA_element.yaml index d06e4aac78..4c3e4ab923 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/limited_work_ability/uc_LCWRA_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/limited_work_ability/uc_LCWRA_element.yaml @@ -23,3 +23,44 @@ members: person output: uc_LCWRA_element: 2_607.12 + +# Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: +# £217.26 a month (s. 2) in each year. +- name: 2027 LCWRA amount is frozen at the 2026-27 rate. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person: + uc_limited_capability_for_WRA: true + benunits: + benunit: + members: person + output: + uc_LCWRA_element: 2_607.12 + +- name: 2028 LCWRA amount is frozen at the 2026-27 rate. + period: 2028 + absolute_error_margin: 0.01 + input: + people: + person: + uc_limited_capability_for_WRA: true + benunits: + benunit: + members: person + output: + uc_LCWRA_element: 2_607.12 + +- name: 2029 LCWRA amount is frozen at the 2026-27 rate. + period: 2029 + absolute_error_margin: 0.01 + input: + people: + person: + uc_limited_capability_for_WRA: true + benunits: + benunit: + members: person + output: + uc_LCWRA_element: 2_607.12 diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py new file mode 100644 index 0000000000..aea8116143 --- /dev/null +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -0,0 +1,363 @@ +"""Universal Credit LCWRA element freeze, Universal Credit Act 2025 s. 3 +(issue #2280). + +s. 3(1) disapplies the annual up-rating in Social Security Administration Act +1992 s. 150(1) and (2) for the LCWRA element in the tax years ending 5 April +2026 to 5 April 2029. An up-rating order takes effect from the tax year after +the one in which it is made (s. 150(10)), so the rates for 2026-27 to 2029-30 +stay at the £217.26 a month that s. 2 sets for 2026-27. From 2030-31 the +element is uprated again from that amount. + +Invariants: + +1. Freeze: in each tax year in which ``rebalancing.health_element_freeze`` + and ``rebalancing.active`` are true (2026-27 to 2029-30 in law), the + amount equals the previous year's, for any CPI path. +2. Uprating otherwise: in any other year the amount is the previous year's + times the growth in the benefit uprating index, floored at no change + (s. 150(2) provides only for increases). So it never falls, and with + non-negative CPI it grows exactly with the benefit index. +3. Differential: on the full parameter pipeline, the amount equals an + independent recursion over the processed benefit index. +4. Reforms take effect: a change to the amount is held or uprated from the + changed value; switching the freeze or the rebalancing reforms off + restores CPI uprating; a different CPI forecast leaves the frozen years + alone and moves the later ones. +""" + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters.operations.uprate_parameters import ( + uprate_parameters, +) + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.dwp.universal_credit.rebalancing.create_health_element_uprating import ( + add_uc_health_element_uprating, +) +from policyengine_uk.system import system + +AMOUNT = "gov.dwp.universal_credit.elements.disabled.amount" +FREEZE = "gov.dwp.universal_credit.rebalancing.health_element_freeze" +ACTIVE = "gov.dwp.universal_credit.rebalancing.active" +OBR_CPI = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +# Universal Credit Act 2025 s. 2: the 2026-27 amount, a month. +ACT_AMOUNT = 217.26 +FROZEN_YEARS = range(2026, 2030) +FIRST_UPRATED_YEAR = 2030 +HORIZON = 2040 + + +def at(year: int) -> str: + """Fiscal-year conversion reads each tax year at 30 April.""" + return f"{year}-04-30" + + +def amount_path(parameters, years) -> dict: + node = parameters.get_child(AMOUNT) + return {year: node(at(year)) for year in years} + + +def benefit_index(parameters, year: int) -> float: + return parameters.gov.benefit_uprating_cpi(at(year)) + + +# A single claimant aged 30 with LCWRA in 2030-31. +SITUATION = { + "people": { + "person": { + "age": {2030: 30}, + "uc_limited_capability_for_WRA": {2030: True}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} + + +def scenario_parameters(parameter_changes: dict): + simulation = Simulation( + situation=SITUATION, + scenario=Scenario( + parameter_changes=parameter_changes, applied_before_data_load=True + ), + ) + return simulation.tax_benefit_system.parameters + + +# Examples on the full pipeline. + + +@pytest.mark.parametrize("year", FROZEN_YEARS) +def test_frozen_at_the_act_amount_in_2026_27_to_2029_30(year): + assert system.parameters.get_child(AMOUNT)(at(year)) == pytest.approx( + ACT_AMOUNT, abs=1e-9 + ) + + +def test_2030_31_is_uprated_by_one_year_of_the_benefit_index(): + """The first uprating after the freeze starts from £217.26 and adds one + year's growth, not the frozen years' growth as well.""" + parameters = system.parameters + growth = benefit_index(parameters, 2030) / benefit_index(parameters, 2029) + assert parameters.get_child(AMOUNT)(at(2030)) == pytest.approx( + ACT_AMOUNT * growth, rel=1e-12 + ) + # Main had £217.26 uprated by 2026-27 to 2030-31 CPI. + four_years = benefit_index(parameters, 2030) / benefit_index(parameters, 2026) + assert parameters.get_child(AMOUNT)(at(2030)) < ACT_AMOUNT * four_years + + +def test_differential_against_the_processed_benefit_index(): + """Invariant 3: an independent recursion over the benefit index as the + full pipeline uprated it, for the baseline and the unconverted copy.""" + parameters = system.parameters + expected = {2026: ACT_AMOUNT} + for year in range(2027, HORIZON + 1): + growth = benefit_index(parameters, year) / benefit_index(parameters, year - 1) + frozen = year in FROZEN_YEARS + expected[year] = expected[year - 1] * (1 if frozen else max(1.0, growth)) + actual = amount_path(parameters, range(2026, HORIZON + 1)) + assert actual == pytest.approx(expected, rel=1e-12) + # parameters.baseline is not fiscal-year converted and keys uprated values + # to 1 January, so it reads each tax year's amount from 1 January. + baseline = parameters.baseline.get_child(AMOUNT) + for year in range(2027, HORIZON + 1): + assert baseline(f"{year}-01-01") == pytest.approx(expected[year], rel=1e-12) + + +def test_household_element_after_the_freeze(): + """From 2030-31 no scenario modifier sets the element, so a household + with LCWRA gets twelve times the uprated monthly amount.""" + simulation = Simulation(situation=SITUATION) + monthly = system.parameters.get_child(AMOUNT)(at(2030)) + assert simulation.calculate("uc_LCWRA_element", 2030)[0] == pytest.approx( + 12 * monthly, rel=1e-9 + ) + + +# Reforms on the full pipeline (invariant 4). + + +@pytest.mark.parametrize("parameter", [FREEZE, ACTIVE]) +def test_switching_the_freeze_off_restores_cpi_uprating(parameter): + parameters = scenario_parameters({parameter: False}) + path = amount_path(parameters, range(2026, 2032)) + assert path[2026] == pytest.approx(ACT_AMOUNT) + for year in range(2027, 2032): + growth = benefit_index(parameters, year) / benefit_index(parameters, 2026) + assert path[year] == pytest.approx(ACT_AMOUNT * growth, rel=1e-12) + + +def test_unfreezing_one_year_uprates_that_year_and_holds_the_rest(): + parameters = scenario_parameters({FREEZE: {"2028": False}}) + path = amount_path(parameters, range(2026, 2031)) + growth = benefit_index(parameters, 2028) / benefit_index(parameters, 2027) + assert path[2027] == pytest.approx(ACT_AMOUNT) + assert path[2028] == pytest.approx(ACT_AMOUNT * growth, rel=1e-12) + assert path[2029] == pytest.approx(path[2028], rel=1e-12) + + +def test_a_changed_amount_is_held_through_the_freeze(): + """A one-year change applies to its tax year; the stated amount resumes + after it and is held, then uprated.""" + parameters = scenario_parameters({AMOUNT: {"2027": 250}}) + path = amount_path(parameters, range(2026, 2031)) + assert path[2026] == pytest.approx(ACT_AMOUNT) + assert path[2027] == pytest.approx(250) + assert path[2028] == pytest.approx(ACT_AMOUNT) + assert path[2029] == pytest.approx(ACT_AMOUNT) + growth = benefit_index(parameters, 2030) / benefit_index(parameters, 2029) + assert path[2030] == pytest.approx(ACT_AMOUNT * growth, rel=1e-12) + + +def test_a_reform_dict_changes_the_year_it_names(): + simulation = Simulation(situation=SITUATION, reform={AMOUNT: {"2030": 300}}) + assert simulation.calculate("uc_LCWRA_element", 2030)[0] == pytest.approx(3_600) + + +CPI_GROWTH = st.floats(min_value=-0.05, max_value=0.15, allow_nan=False) + + +@settings( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.lists(CPI_GROWTH, min_size=8, max_size=8)) +def test_any_cpi_forecast_leaves_the_frozen_years_alone(growth): + """Invariants 1 and 2 on the full pipeline, for an OBR CPI path for + 2025 to 2032 (the benefit index lags it a year).""" + parameters = scenario_parameters( + { + OBR_CPI: { + f"year:{year}": rate for year, rate in zip(range(2025, 2033), growth) + } + } + ) + path = amount_path(parameters, range(2026, 2034)) + assert [path[year] for year in FROZEN_YEARS] == pytest.approx([ACT_AMOUNT] * 4) + for year in range(FIRST_UPRATED_YEAR, 2034): + ratio = benefit_index(parameters, year) / benefit_index(parameters, year - 1) + assert path[year] == pytest.approx(path[year - 1] * max(1.0, ratio), rel=1e-12) + assert path[year] >= path[year - 1] + + +# Properties on a small parameter tree run through the same builder and core +# uprating, which makes many examples cheap. + +INDEX_FIRST_YEAR = 2010 +INDEX_LAST_YEAR = 2041 +YEARS = range(2025, INDEX_LAST_YEAR + 1) + + +def uprated_tree( + cpi_growth, + freeze_values=None, + active_values=None, + amount_values=None, +): + """A tree with the parameters the builder reads, after the builder and + core uprating have run. ``cpi_growth`` gives the lagged CPI index's + growth into each year from 2011.""" + lagged_cpi = {f"{INDEX_FIRST_YEAR}-01-01": 1.0} + level = 1.0 + for year, rate in zip(range(INDEX_FIRST_YEAR + 1, INDEX_LAST_YEAR + 1), cpi_growth): + level *= 1 + rate + lagged_cpi[f"{year}-01-01"] = level + data = { + "gov": { + "benefit_uprating_cpi": { + "values": {"2005-01-01": 100, "2010-01-01": 114.48}, + "metadata": { + "uprating": "gov.economic_assumptions.indices.obr.lagged_cpi" + }, + }, + "economic_assumptions": { + "indices": {"obr": {"lagged_cpi": {"values": lagged_cpi}}} + }, + "dwp": { + "universal_credit": { + "rebalancing": { + "active": {"values": active_values or {"2025-01-01": True}}, + "health_element_freeze": { + "values": freeze_values + or { + "2015-01-01": False, + "2026-04-06": True, + "2030-04-06": False, + } + }, + }, + "elements": { + "disabled": { + "amount": { + "values": amount_values + or {"2025-04-01": 423.27, "2026-04-01": ACT_AMOUNT}, + "metadata": { + "uprating": "gov.dwp.universal_credit.elements.disabled.uprating" + }, + } + } + }, + } + }, + } + } + root = ParameterNode("", data=data) + add_uc_health_element_uprating(root) + uprate_parameters(root) + return root + + +def tree_rule_holds(root, frozen, start_year, start_amount): + """Each year after ``start_year`` is the previous year's amount, held when + frozen and otherwise uprated by the benefit index, never down.""" + amount = root.get_child(AMOUNT) + assert amount(at(start_year)) == pytest.approx(start_amount, rel=1e-12) + for year in range(start_year + 1, INDEX_LAST_YEAR + 1): + previous = amount(at(year - 1)) + ratio = benefit_index(root, year) / benefit_index(root, year - 1) + expected = previous if frozen(year) else previous * max(1.0, ratio) + assert amount(at(year)) == pytest.approx(expected, rel=1e-12) + assert amount(at(year)) >= previous * (1 - 1e-12) + + +TREE_SETTINGS = settings(max_examples=200, deadline=None, derandomize=True) +GROWTH_PATHS = st.lists( + CPI_GROWTH, + min_size=INDEX_LAST_YEAR - INDEX_FIRST_YEAR, + max_size=INDEX_LAST_YEAR - INDEX_FIRST_YEAR, +) + + +@TREE_SETTINGS +@given(GROWTH_PATHS) +def test_frozen_years_hold_for_any_cpi_path(growth): + """Invariant 1 with the statutory schedule, including falling prices.""" + root = uprated_tree(growth) + amount = root.get_child(AMOUNT) + for year in FROZEN_YEARS: + assert amount(at(year)) == pytest.approx(ACT_AMOUNT, abs=1e-9) + tree_rule_holds(root, lambda year: year in FROZEN_YEARS, 2026, ACT_AMOUNT) + + +@TREE_SETTINGS +@given(st.lists(st.floats(0, 0.15), min_size=31, max_size=31)) +def test_after_the_freeze_the_amount_grows_with_non_negative_cpi(growth): + """Invariant 2: with non-negative CPI, every year from 2030-31 grows by + exactly the benefit index's growth from the frozen amount.""" + root = uprated_tree(growth) + amount = root.get_child(AMOUNT) + for year in range(FIRST_UPRATED_YEAR, INDEX_LAST_YEAR + 1): + ratio = benefit_index(root, year) / benefit_index(root, 2029) + assert amount(at(year)) == pytest.approx(ACT_AMOUNT * ratio, rel=1e-12) + assert amount(at(year)) >= amount(at(year - 1)) + + +@TREE_SETTINGS +@given( + GROWTH_PATHS, + st.sets(st.integers(2026, INDEX_LAST_YEAR)), + st.sets(st.integers(2026, INDEX_LAST_YEAR)), +) +def test_any_freeze_schedule_holds_exactly_its_years(growth, frozen, inactive): + """Invariants 1 and 2 for any schedule of frozen and inactive years: a + year is held only when it is frozen and the reforms are active.""" + freeze_values = {"2015-01-01": False} + active_values = {"2015-01-01": True} + for year in YEARS: + freeze_values[f"{year}-04-06"] = year in frozen + active_values[f"{year}-04-06"] = year not in inactive + root = uprated_tree(growth, freeze_values, active_values) + tree_rule_holds( + root, + lambda year: year in frozen and year not in inactive, + 2026, + ACT_AMOUNT, + ) + + +@TREE_SETTINGS +@given( + GROWTH_PATHS, + st.integers(2026, 2035), + st.floats(0, 1_000, allow_nan=False), +) +def test_a_reformed_amount_is_held_or_uprated_from_its_value(growth, year, value): + """Invariant 4: an amount stated from any year is used in that year and + projected from with the same rule.""" + amount_values = { + "2025-04-01": 423.27, + "2026-04-01": ACT_AMOUNT, + f"{year}-04-06": value, + } + root = uprated_tree(growth, amount_values=amount_values) + tree_rule_holds(root, lambda y: y in FROZEN_YEARS, year, value) + # Years before the change keep the statutory path. + for earlier in range(2026, year): + assert root.get_child(AMOUNT)(at(earlier)) == pytest.approx(ACT_AMOUNT) From 5efd4897c45a5878f97d5393b64f26240da10af6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 19:13:16 -0400 Subject: [PATCH 3/6] Document the UC LCWRA element freeze (UC Act 2025 s. 3) Refs #2280. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 5082996119..b1bac0be78 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -20,6 +20,8 @@ The reforms combine a higher standard allowance, protected awards for existing h From April 2026, new Universal Credit claimants who qualify for the Limited Capability for Work-Related Activity (LCWRA) element receive a fixed monthly amount of £217.26. +Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it disapplies the annual up-rating in Social Security Administration Act 1992 s. 150(1) and (2) in the tax years ending 5 April 2026 to 5 April 2029, and an up-rating order takes effect from the tax year after the one in which it is made. The new-claimant amount (`elements.disabled.amount`) therefore stays at £217.26 a month to 2029-30. From 2030-31 it is uprated from £217.26 by the benefit uprating index, one year's growth at a time, and never falls. The amount is uprated by `gov.dwp.universal_credit.elements.disabled.uprating`, which `rebalancing/create_health_element_uprating.py` builds when parameters are processed: the benefit uprating index, held flat in each year in which `rebalancing.health_element_freeze` and `rebalancing.active` are both true. + Existing recipients are treated differently. Their LCWRA amount is uprated so that the combined value of: - their standard allowance, and @@ -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**: YAML files define the reform's activation status, the health element amount for new claimants, the health element freeze, and the standard allowance uplift rates. - **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. @@ -111,10 +113,32 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +### Lifting the health element freeze + +```python +from policyengine_uk import Simulation, Scenario + +# Uprate the LCWRA element by CPI from 2028-29 instead of freezing it. +# A bare year names the fiscal year from 6 April. +scenario = Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.health_element_freeze": { + "2028": False, + "2029": False, + } + } +) + +sim = Simulation(scenario=scenario) +``` + +The freeze reaches the element through parameter processing, which `Scenario(parameter_changes=...)` re-runs; a `reform=` dictionary does not re-run it. + ## Legislative reference The reforms are based on the Universal Credit Bill and its impact assessment: +- 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 - https://assets.publishing.service.gov.uk/media/689ca49e1c63de6de5bb1298/withdrawn-universal-credit-bill-uc-rebalancing-impact-assessment.pdf From f68400f597aa480334d8e280ef0061c7f5b075c5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 20:18:00 -0400 Subject: [PATCH 4/6] Uprate the scenario's new-claimant health element by the same index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: for 2026-27 to 2029-30 the rebalancing scenario pays new claimants rebalancing.new_claimant_health_element, a flat £217.26, so lifting the freeze changed elements.disabled.amount but no simulated award in those years. new_claimant_health_element is now uprated by gov.dwp.universal_credit.elements.disabled.uprating as well. The two amounts follow one path under any freeze schedule or CPI forecast, and a lifted freeze reaches a simulated new claimant (£221.60 in 2028-29 with the freeze lifted from that year). Baseline amounts do not change. Tests: the one-path invariant on the baseline, under freeze and CPI reforms, and in the tree properties; a simulation test with a forced new claimant. Without the uprating line 5 tests fail. Docs: say which parameter a simulation reads in which years, apply the example before the data load, and note the no-fall floor is this element's only. The index's description says how to change it. Refs #2280. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-lcwra-s3-freeze.fixed.md | 2 +- docs/book/policy/uc-rebalancing.md | 16 +++- .../create_health_element_uprating.py | 14 ++- .../new_claimant_health_element.yaml | 11 ++- .../tests/test_uc_health_element_freeze.py | 91 +++++++++++++++++++ 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/changelog.d/uc-lcwra-s3-freeze.fixed.md b/changelog.d/uc-lcwra-s3-freeze.fixed.md index b663b4419b..dd1dff7569 100644 --- a/changelog.d/uc-lcwra-s3-freeze.fixed.md +++ b/changelog.d/uc-lcwra-s3-freeze.fixed.md @@ -1 +1 @@ -- Hold the Universal Credit LCWRA element at £217.26 a month for 2026-27 to 2029-30, as Universal Credit Act 2025 s. 3 freezes it, and uprate it from that amount by the benefit uprating index from 2030-31. It was CPI-uprated from 2027-28. The freeze is `gov.dwp.universal_credit.rebalancing.health_element_freeze` and applies while the rebalancing reforms are active. +- Hold the Universal Credit LCWRA element at £217.26 a month for 2026-27 to 2029-30, as Universal Credit Act 2025 s. 3 freezes it, and uprate it from that amount by the benefit uprating index from 2030-31. It was CPI-uprated from 2027-28. The freeze is `gov.dwp.universal_credit.rebalancing.health_element_freeze` and applies while the rebalancing reforms are active. The amount the rebalancing scenario pays new claimants (`rebalancing.new_claimant_health_element`) follows the same path, so lifting the freeze reaches them. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index d4838a6d57..d70c048fb3 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -20,7 +20,14 @@ The reforms combine a higher standard allowance, protected awards for existing h From April 2026, new Universal Credit claimants who qualify for the Limited Capability for Work-Related Activity (LCWRA) element receive a fixed monthly amount of £217.26. -Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it disapplies the annual up-rating in Social Security Administration Act 1992 s. 150(1) and (2) in the tax years ending 5 April 2026 to 5 April 2029, and an up-rating order takes effect from the tax year after the one in which it is made. The new-claimant amount (`elements.disabled.amount`) therefore stays at £217.26 a month to 2029-30. From 2030-31 it is uprated from £217.26 by the benefit uprating index, one year's growth at a time, and never falls. The amount is uprated by `gov.dwp.universal_credit.elements.disabled.uprating`, which `rebalancing/create_health_element_uprating.py` builds when parameters are processed: the benefit uprating index, held flat in each year in which `rebalancing.health_element_freeze` and `rebalancing.active` are both true. +Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it disapplies the annual up-rating in Social Security Administration Act 1992 s. 150(1) and (2) in the tax years ending 5 April 2026 to 5 April 2029, and an up-rating order takes effect from the tax year after the one in which it is made. The new-claimant amount therefore stays at £217.26 a month to 2029-30. From 2030-31 it is uprated from £217.26 by the benefit uprating index, one year's growth at a time. It never falls, because s. 150(2) provides only for increases; other Universal Credit amounts follow the benefit uprating index directly, which gives the same result whenever the index rises. + +Two parameters hold this amount, and both are uprated by `gov.dwp.universal_credit.elements.disabled.uprating`, 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 from 2030-31, and whenever the rebalancing reforms are inactive. +- `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants for 2026-27 to 2029-30. + +`rebalancing/create_health_element_uprating.py` builds the index when parameters are processed: the benefit uprating index, held flat in each year in which `rebalancing.health_element_freeze` and `rebalancing.active` are both true. Existing recipients are treated differently. Universal Credit Regulations 2013 reg. 36 gives one amount, the protected LCWRA amount, to every pre-2026 claimant, severe conditions criteria claimant or terminally ill claimant, whatever their age or couple status. For 2026-27 it is £429.80 a month ([SI 2026/113](https://www.legislation.gov.uk/uksi/2026/113/made) reg. 3(3)(b)), stored in `gov.dwp.universal_credit.rebalancing.protected_health_element`. @@ -123,13 +130,16 @@ scenario = Scenario( "2028": False, "2029": False, } - } + }, + applied_before_data_load=True, ) sim = Simulation(scenario=scenario) ``` -The freeze reaches the element through parameter processing, which `Scenario(parameter_changes=...)` re-runs; a `reform=` dictionary does not re-run it. +New claimants then get the uprated amount in 2028-29 and 2029-30, and every later year starts from that higher amount. The protected amount for existing claimants does not change; it follows the s. 4 floor. + +`applied_before_data_load=True` is needed because the rebalancing scenario sets the 2026-27 to 2029-30 health elements when the simulation is built. The freeze reaches the element through parameter processing, which `Scenario(parameter_changes=...)` re-runs; a `reform=` dictionary does not re-run it. ## Legislative reference diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py index 0727aecc63..fbd50672de 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -32,10 +32,19 @@ year's increase in the benefit index, from the frozen amount rather than with the frozen years' increases added back. +``rebalancing/new_claimant_health_element.yaml``, the copy of the s. 2 amount +that ``scenarios/uc_reform.py`` pays new claimants for 2026-27 to 2029-30, is +uprated by the same index, so a simulation sees the same path as the element +amount when the freeze or the CPI forecast changes. A reform to one of the two +amounts does not move the other. + The pre-2026 (protected) amount of the element is also frozen by s. 3 and raised under s. 4; ``scenarios/uc_reform.py`` sets it for 2026-27 to 2029-30. The LCW element is not modelled. +Other Universal Credit amounts are uprated by ``gov.benefit_uprating_cpi`` +directly and would fall with it; the two agree whenever the index rises. + This runs in ``process_parameters`` before uprating, so a change made through ``Scenario(parameter_changes=...)``, which re-runs that pipeline, reaches the element: to the freeze, to ``rebalancing.active``, to the benefit index or @@ -166,7 +175,10 @@ def add_uc_health_element_uprating(parameters: ParameterNode) -> ParameterNode: "Uprating index for the Universal Credit LCWRA element: " "the benefit uprating index, held flat in the tax years " "for which the Universal Credit Act 2025 freezes the " - "element and never falling." + "element and never falling. It is built when parameters " + "are processed; to change it, change " + "gov.dwp.universal_credit.rebalancing." + "health_element_freeze or the benefit uprating index." ), "values": health_element_index_values(parameters), "metadata": { diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml index cf59221afb..9a988ae508 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml @@ -1,9 +1,16 @@ -description: Monthly value of the Universal Credit health element for new claimants from April 2026. +description: Monthly value of the Universal Credit health element (the LCWRA element) for new claimants from April 2026. The rebalancing scenario pays it to new claimants for 2026-27 to 2029-30. It follows the same path as the LCWRA element amount, frozen for 2026-27 to 2029-30 by Universal Credit Act 2025 s. 3 and uprated afterwards. values: 2026-01-01: 217.26 metadata: unit: currency-GBP label: Universal Credit new claimant health element amount + # The same index as elements/disabled/amount.yaml, so the two amounts move + # together when the freeze or the CPI forecast changes. + uprating: gov.dwp.universal_credit.elements.disabled.uprating reference: + - title: Universal Credit Act 2025 s. 2 (amount from 6 April 2026) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/2 + - title: Universal Credit Act 2025 s. 3 (freeze for 2026-27 to 2029-30) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/3 - title: Universal Credit Bill - href: https://bills.parliament.uk/publications/62123/documents/6889#page=16 \ No newline at end of file + href: https://bills.parliament.uk/publications/62123/documents/6889#page=16 diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py index aea8116143..c9a63135f0 100644 --- a/policyengine_uk/tests/test_uc_health_element_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -23,6 +23,11 @@ changed value; switching the freeze or the rebalancing reforms off restores CPI uprating; a different CPI forecast leaves the frozen years alone and moves the later ones. +5. One path: ``rebalancing.new_claimant_health_element``, the copy of the + amount the rebalancing scenario pays new claimants for 2026-27 to + 2029-30, equals the element amount in every year from 2026-27, under any + freeze schedule or CPI path. So a lifted freeze reaches a simulated new + claimant. """ import pytest @@ -33,6 +38,7 @@ uprate_parameters, ) +import policyengine_uk.scenarios.uc_reform as uc_reform from policyengine_uk import Simulation from policyengine_uk.model_api import Scenario from policyengine_uk.parameters.gov.dwp.universal_credit.rebalancing.create_health_element_uprating import ( @@ -43,6 +49,7 @@ AMOUNT = "gov.dwp.universal_credit.elements.disabled.amount" FREEZE = "gov.dwp.universal_credit.rebalancing.health_element_freeze" ACTIVE = "gov.dwp.universal_credit.rebalancing.active" +NEW_CLAIMANT_AMOUNT = "gov.dwp.universal_credit.rebalancing.new_claimant_health_element" OBR_CPI = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" # Universal Credit Act 2025 s. 2: the 2026-27 amount, a month. ACT_AMOUNT = 217.26 @@ -65,6 +72,14 @@ def benefit_index(parameters, year: int) -> float: return parameters.gov.benefit_uprating_cpi(at(year)) +def assert_one_path(parameters, years) -> None: + """Invariant 5: the scenario's new-claimant amount equals the element + amount.""" + new_claimant = parameters.get_child(NEW_CLAIMANT_AMOUNT) + for year, amount in amount_path(parameters, years).items(): + assert new_claimant(at(year)) == pytest.approx(amount, rel=1e-12) + + # A single claimant aged 30 with LCWRA in 2030-31. SITUATION = { "people": { @@ -139,6 +154,11 @@ def test_household_element_after_the_freeze(): ) +def test_the_scenario_pays_new_claimants_the_element_amount(): + """Invariant 5 on the baseline.""" + assert_one_path(system.parameters, range(2026, HORIZON + 1)) + + # Reforms on the full pipeline (invariant 4). @@ -150,6 +170,7 @@ def test_switching_the_freeze_off_restores_cpi_uprating(parameter): for year in range(2027, 2032): growth = benefit_index(parameters, year) / benefit_index(parameters, 2026) assert path[year] == pytest.approx(ACT_AMOUNT * growth, rel=1e-12) + assert_one_path(parameters, range(2026, 2032)) def test_unfreezing_one_year_uprates_that_year_and_holds_the_rest(): @@ -159,6 +180,67 @@ def test_unfreezing_one_year_uprates_that_year_and_holds_the_rest(): assert path[2027] == pytest.approx(ACT_AMOUNT) assert path[2028] == pytest.approx(ACT_AMOUNT * growth, rel=1e-12) assert path[2029] == pytest.approx(path[2028], rel=1e-12) + assert_one_path(parameters, range(2026, 2031)) + + +class _NewClaimantDraw: + """Stands in for the scenario's random draw: every benefit unit draws 0, + below each year's new-claimant share.""" + + def random(self, size): + return uc_reform.np.zeros(size) + + +def new_claimant_situation(years) -> dict: + """A single claimant aged 30 with LCWRA who is a new claimant.""" + benunit = {"members": ["person"]} + # Where the claim status is an input, state it; the draw then has no say. + if "uc_receives_new_claimant_health_element" in system.variables: + benunit["uc_receives_new_claimant_health_element"] = { + year: True for year in years + } + return { + "people": { + "person": { + "age": {year: 30 for year in years}, + "employment_income": {year: 0 for year in years}, + "uc_limited_capability_for_WRA": {year: True for year in years}, + } + }, + "benunits": {"benunit": benunit}, + "households": {"household": {"members": ["person"]}}, + } + + +def test_lifting_the_freeze_reaches_a_simulated_new_claimant(monkeypatch): + """Invariant 5 in a simulation: for 2026-27 to 2029-30 the rebalancing + scenario sets the element, and a new claimant's follows the freeze.""" + monkeypatch.setattr( + uc_reform.np.random, "default_rng", lambda seed: _NewClaimantDraw() + ) + years = range(2026, 2030) + frozen = Simulation(situation=new_claimant_situation(years)) + for year in years: + assert frozen.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + 12 * ACT_AMOUNT, rel=1e-6 + ) + lifted = Simulation( + situation=new_claimant_situation(years), + scenario=Scenario( + parameter_changes={FREEZE: {"2028": False, "2029": False}}, + applied_before_data_load=True, + ), + ) + parameters = lifted.tax_benefit_system.parameters + assert lifted.calculate("uc_LCWRA_element", 2027)[0] == pytest.approx( + 12 * ACT_AMOUNT, rel=1e-6 + ) + for year in (2028, 2029): + growth = benefit_index(parameters, year) / benefit_index(parameters, 2027) + assert growth > 1 + assert lifted.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + 12 * ACT_AMOUNT * growth, rel=1e-6 + ) def test_a_changed_amount_is_held_through_the_freeze(): @@ -205,6 +287,7 @@ def test_any_cpi_forecast_leaves_the_frozen_years_alone(growth): ratio = benefit_index(parameters, year) / benefit_index(parameters, year - 1) assert path[year] == pytest.approx(path[year - 1] * max(1.0, ratio), rel=1e-12) assert path[year] >= path[year - 1] + assert_one_path(parameters, range(2026, 2034)) # Properties on a small parameter tree run through the same builder and core @@ -252,6 +335,12 @@ def uprated_tree( "2030-04-06": False, } }, + "new_claimant_health_element": { + "values": {"2026-01-01": ACT_AMOUNT}, + "metadata": { + "uprating": "gov.dwp.universal_credit.elements.disabled.uprating" + }, + }, }, "elements": { "disabled": { @@ -304,6 +393,7 @@ def test_frozen_years_hold_for_any_cpi_path(growth): for year in FROZEN_YEARS: assert amount(at(year)) == pytest.approx(ACT_AMOUNT, abs=1e-9) tree_rule_holds(root, lambda year: year in FROZEN_YEARS, 2026, ACT_AMOUNT) + assert_one_path(root, range(2026, INDEX_LAST_YEAR + 1)) @TREE_SETTINGS @@ -340,6 +430,7 @@ def test_any_freeze_schedule_holds_exactly_its_years(growth, frozen, inactive): 2026, ACT_AMOUNT, ) + assert_one_path(root, range(2026, INDEX_LAST_YEAR + 1)) @TREE_SETTINGS From 262a2c7d9d44de9714123e01bfa27bba371aa9fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:54:05 -0400 Subject: [PATCH 5/6] Follow a benefit-index change dated after 30 April into the next tax year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2: the uprating index stopped at the calendar year of the last benefit-index (or lagged CPI) entry. A value dated after 30 April first reaches the next tax year's 30 April sample, so that year's change was lost. Reproduced on the full pipeline: a one-year change to the benefit index from 1 June 2038 restores the stated value on 1 June 2039; the processed index doubles from 2039-40 to 2040-41, but the element stayed at £217.26 instead of £434.52. index_years now runs to the first tax year whose 30 April sample sees the last entry, using create_threshold_indexation.first_tax_year. Baseline amounts do not change. Tests: the reviewer's case on the full pipeline (both amounts, against the independent recursion), and a property with up to three benefit-index values at any date, including after 30 April and past the last uprating entry. Reverting the fix fails both. Also from the review: the docs say a reform to either amount does not move the other, and a test comment describes core's latest-stated-value rule rather than calling it the statutory path. Refs #2280. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 2 + .../create_health_element_uprating.py | 15 +++- .../tests/test_uc_health_element_freeze.py | 88 ++++++++++++++++--- 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index d70c048fb3..73fe1536c8 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -27,6 +27,8 @@ Two parameters hold this amount, and both are uprated by `gov.dwp.universal_cred - `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it from 2030-31, and whenever the rebalancing reforms are inactive. - `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants for 2026-27 to 2029-30. +A change to the freeze or to the benefit uprating index moves both. A reform to either amount itself does not change the other. + `rebalancing/create_health_element_uprating.py` builds the index when parameters are processed: the benefit uprating index, held flat in each year in which `rebalancing.health_element_freeze` and `rebalancing.active` are both true. Existing recipients are treated differently. Universal Credit Regulations 2013 reg. 36 gives one amount, the protected LCWRA amount, to every pre-2026 claimant, severe conditions criteria claimant or terminally ill claimant, whatever their age or couple status. For 2026-27 it is £429.80 a month ([SI 2026/113](https://www.legislation.gov.uk/uksi/2026/113/made) reg. 3(3)(b)), stored in `gov.dwp.universal_credit.rebalancing.protected_health_element`. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py index fbd50672de..4efaf51326 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -57,6 +57,10 @@ from policyengine_core.parameters import Parameter, ParameterNode, get_parameter +from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( + first_tax_year, +) + INDEX_NAME = "gov.dwp.universal_credit.elements.disabled.uprating" BENEFIT_INDEX_NAME = "gov.benefit_uprating_cpi" # Fiscal-year conversion reads each tax year's value at 30 April. @@ -121,16 +125,19 @@ def projected_value(parameter: Parameter, parameters: ParameterNode, at: str): def index_years(parameters: ParameterNode) -> range: - """Years the benefit index covers once uprated: its own stated years and - those of the index that uprates it.""" + """Tax years in which the benefit index, once uprated, can change: from the + first year of its own values or its uprater's to the first tax year whose + 30 April sample sees the last of them. A value dated after 30 April, such + as the one a one-year reform restores, first reaches the next tax year.""" benefit_index = get_parameter(parameters, BENEFIT_INDEX_NAME) instants = [value.instant_str for value in benefit_index.values_list] uprater_name = uprating_parameter_name(benefit_index) if uprater_name is not None: uprater = get_parameter(parameters, uprater_name) instants += [value.instant_str for value in uprater.values_list] - years = [int(instant[:4]) for instant in instants] - return range(min(years), max(years) + 1) + first = min(int(instant[:4]) for instant in instants) + last = max(first_tax_year(instant) for instant in instants) + return range(first, last + 1) def is_frozen(rebalancing: ParameterNode, year: int) -> bool: diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py index c9a63135f0..2f8fbed865 100644 --- a/policyengine_uk/tests/test_uc_health_element_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -18,7 +18,9 @@ (s. 150(2) provides only for increases). So it never falls, and with non-negative CPI it grows exactly with the benefit index. 3. Differential: on the full parameter pipeline, the amount equals an - independent recursion over the processed benefit index. + independent recursion over the processed benefit index, read at the + 30 April sampling date, including a benefit index value dated after + 30 April. 4. Reforms take effect: a change to the amount is held or uprated from the changed value; switching the freeze or the rebalancing reforms off restores CPI uprating; a different CPI forecast leaves the frozen years @@ -126,15 +128,23 @@ def test_2030_31_is_uprated_by_one_year_of_the_benefit_index(): assert parameters.get_child(AMOUNT)(at(2030)) < ACT_AMOUNT * four_years -def test_differential_against_the_processed_benefit_index(): - """Invariant 3: an independent recursion over the benefit index as the - full pipeline uprated it, for the baseline and the unconverted copy.""" - parameters = system.parameters +def statutory_path(parameters, last_year: int) -> dict: + """Invariant 3's independent recursion: £217.26 for 2026-27, then each + year the previous year's amount, held in the frozen years and otherwise + raised by the processed benefit index's growth, never down.""" expected = {2026: ACT_AMOUNT} - for year in range(2027, HORIZON + 1): + for year in range(2027, last_year + 1): growth = benefit_index(parameters, year) / benefit_index(parameters, year - 1) frozen = year in FROZEN_YEARS expected[year] = expected[year - 1] * (1 if frozen else max(1.0, growth)) + return expected + + +def test_differential_against_the_processed_benefit_index(): + """Invariant 3: an independent recursion over the benefit index as the + full pipeline uprated it, for the baseline and the unconverted copy.""" + parameters = system.parameters + expected = statutory_path(parameters, HORIZON) actual = amount_path(parameters, range(2026, HORIZON + 1)) assert actual == pytest.approx(expected, rel=1e-12) # parameters.baseline is not fiscal-year converted and keys uprated values @@ -144,6 +154,23 @@ def test_differential_against_the_processed_benefit_index(): assert baseline(f"{year}-01-01") == pytest.approx(expected[year], rel=1e-12) +def test_a_benefit_index_change_after_30_april_reaches_the_next_tax_year(): + """Review round 2: a one-year change to the benefit index from 1 June + 2038 ends on 1 June 2039, when the stated value comes back. That first + shows in the 30 April 2040 sample, a year after the last uprating entry, + and the element must follow it there.""" + parameters = scenario_parameters( + {"gov.benefit_uprating_cpi": {"year:2038-06-01": 57.24}} + ) + assert benefit_index(parameters, 2039) == pytest.approx(57.24) + assert benefit_index(parameters, 2040) > benefit_index(parameters, 2039) + expected = statutory_path(parameters, HORIZON) + actual = amount_path(parameters, range(2026, HORIZON + 1)) + assert actual == pytest.approx(expected, rel=1e-12) + assert actual[2040] > actual[2039] + assert_one_path(parameters, range(2026, HORIZON + 1)) + + def test_household_element_after_the_freeze(): """From 2030-31 no scenario modifier sets the element, so a household with LCWRA gets twelve times the uprated monthly amount.""" @@ -303,10 +330,12 @@ def uprated_tree( freeze_values=None, active_values=None, amount_values=None, + benefit_values=None, ): """A tree with the parameters the builder reads, after the builder and core uprating have run. ``cpi_growth`` gives the lagged CPI index's - growth into each year from 2011.""" + growth into each year from 2011; ``benefit_values`` adds stated values + of the benefit index at any dates.""" lagged_cpi = {f"{INDEX_FIRST_YEAR}-01-01": 1.0} level = 1.0 for year, rate in zip(range(INDEX_FIRST_YEAR + 1, INDEX_LAST_YEAR + 1), cpi_growth): @@ -315,7 +344,11 @@ def uprated_tree( data = { "gov": { "benefit_uprating_cpi": { - "values": {"2005-01-01": 100, "2010-01-01": 114.48}, + "values": { + "2005-01-01": 100, + "2010-01-01": 114.48, + **(benefit_values or {}), + }, "metadata": { "uprating": "gov.economic_assumptions.indices.obr.lagged_cpi" }, @@ -363,12 +396,12 @@ def uprated_tree( return root -def tree_rule_holds(root, frozen, start_year, start_amount): +def tree_rule_holds(root, frozen, start_year, start_amount, last_year=None): """Each year after ``start_year`` is the previous year's amount, held when frozen and otherwise uprated by the benefit index, never down.""" amount = root.get_child(AMOUNT) assert amount(at(start_year)) == pytest.approx(start_amount, rel=1e-12) - for year in range(start_year + 1, INDEX_LAST_YEAR + 1): + for year in range(start_year + 1, (last_year or INDEX_LAST_YEAR) + 1): previous = amount(at(year - 1)) ratio = benefit_index(root, year) / benefit_index(root, year - 1) expected = previous if frozen(year) else previous * max(1.0, ratio) @@ -449,6 +482,39 @@ def test_a_reformed_amount_is_held_or_uprated_from_its_value(growth, year, value } root = uprated_tree(growth, amount_values=amount_values) tree_rule_holds(root, lambda y: y in FROZEN_YEARS, year, value) - # Years before the change keep the statutory path. + # Core uprating projects only from a parameter's latest stated value, so a + # value stated from a later year leaves the earlier years at the last + # stated amount (£217.26) rather than uprating them. This is core's rule + # for every uprated parameter, not the statutory path for 2030-31 on. for earlier in range(2026, year): assert root.get_child(AMOUNT)(at(earlier)) == pytest.approx(ACT_AMOUNT) + + +BENEFIT_INDEX_ENTRIES = st.dictionaries( + st.builds( + lambda year, month, day: f"{year}-{month:02d}-{day:02d}", + st.integers(2026, INDEX_LAST_YEAR + 1), + st.integers(1, 12), + st.integers(1, 28), + ), + st.floats(50, 300, allow_nan=False), + max_size=3, +) + + +@TREE_SETTINGS +@given(GROWTH_PATHS, BENEFIT_INDEX_ENTRIES) +def test_any_dated_benefit_index_values_are_followed(growth, benefit_values): + """Invariants 1 to 3 when the benefit index has stated values on any + date, including after 30 April and after the last uprating entry: the + amount follows the processed index, read at 30 April, to the first tax + year that sees the last value.""" + root = uprated_tree(growth, benefit_values=benefit_values) + tree_rule_holds( + root, + lambda year: year in FROZEN_YEARS, + 2026, + ACT_AMOUNT, + last_year=INDEX_LAST_YEAR + 3, + ) + assert_one_path(root, range(2026, INDEX_LAST_YEAR + 4)) From e94f46c7c7ce3f9e14dbb245c82f41336cda59ae Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 11:07:54 -0400 Subject: [PATCH 6/6] Allowlist the new skip reason; describe main's reform routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4 (d96de849): - The changed skip reason in test_dwp_published_rates.py ("s.1 departs") was not in .github/test_outcome_allowlist.toml, so Test / Result would report four unallowlisted skips. Verified with check_test_shards.py: 4 unallowlisted on the old allowlist, none on the new one; 64 skips in all. - With main's dated reforms, a scenario applied after the data load and a reform dict keyed by year both re-run parameter processing before the rebalancing scenario sets the 2026-29 elements. The builder docstring and uc-rebalancing.md said otherwise; they now describe the routes, and the example drops applied_before_data_load. The simulation test checks all three routes reach a new claimant's award (£221.60, £226.04 a month). Refs #2280. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 4 +- docs/book/policy/uc-rebalancing.md | 5 +-- .../create_health_element_uprating.py | 15 +++++--- .../tests/test_uc_health_element_freeze.py | 38 +++++++++++-------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 1b50e36c70..0db28c8e39 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -61,5 +61,5 @@ why = "Microsimulation tests need the private dataset. Runs without its credenti [[allow]] nodeid = "policyengine_uk/tests/test_dwp_published_rates.py::test_projection_continues_from_last_published_rate[[]*" outcome = "skipped" -reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 ss\.1 and 3 depart from CPI uprating)$' -why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the five the Universal Credit Act 2025 ss.1 and 3 set apart from CPI uprating (the four UC standard allowances and the LCWRA element, #1925). 66 cases on 12 October 2026." +reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 s\.1 departs from CPI uprating)$' +why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the four UC standard allowances, whose minimum the Universal Credit Act 2025 s.1 sets above CPI uprating (#1925). The LCWRA element, frozen by s.3, is checked against its own uprating index (#2284). 64 cases on 12 October 2026." diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 0c3133a5f5..23054209fa 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -134,8 +134,7 @@ scenario = Scenario( "2028": False, "2029": False, } - }, - applied_before_data_load=True, + } ) sim = Simulation(scenario=scenario) @@ -143,7 +142,7 @@ sim = Simulation(scenario=scenario) New claimants then get the uprated amount in 2028-29 and 2029-30, and every later year starts from that higher amount. The protected amount for existing claimants does not change; it follows the s. 4 floor. -`applied_before_data_load=True` is needed because the rebalancing scenario sets the 2026-27 to 2029-30 health elements when the simulation is built. The freeze reaches the element through parameter processing, which `Scenario(parameter_changes=...)` re-runs; a `reform=` dictionary does not re-run it. +The freeze reaches the element through parameter processing. A `Scenario(parameter_changes=...)` change and a `reform=` dictionary keyed by year (`reform={"gov.dwp.universal_credit.rebalancing.health_element_freeze": {"2028": False, "2029": False}}`) both re-run it, before the rebalancing scenario sets the 2026-27 to 2029-30 health elements. A `reform=` key naming a single day edits the annualised parameter on that day only and does not. ## Legislative reference diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py index a5069c0f9f..138d04750a 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -45,12 +45,15 @@ Other Universal Credit amounts are uprated by ``gov.benefit_uprating_cpi`` directly and would fall with it; the two agree whenever the index rises. -This runs in ``process_parameters`` before uprating, so a change made through -``Scenario(parameter_changes=...)``, which re-runs that pipeline, reaches the -element: to the freeze, to ``rebalancing.active``, to the benefit index or -the forecasts behind it, or to the amount itself, which is then held or -uprated from the changed value. A ``reform=`` dict is applied after -processing and does not re-run uprating; it changes the years it names. +This runs with the other generated series before uprating (see +``process_parameter_tree``). A reform written onto the statutory timeline +re-runs that processing (``policyengine_uk.utils.dated_reforms``): a +``Scenario(parameter_changes=...)`` change, or a ``reform=`` dict entry keyed +by a year or a ``start.stop`` range. So a change to the freeze, to +``rebalancing.active``, to the benefit index or the forecasts behind it, or to +an amount itself reaches the element, which is then held or uprated from the +changed value. A ``reform=`` dict key naming a single day still edits the +annualised parameter on that day only and does not re-run uprating. """ from typing import Dict diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py index df5000c296..788c4e3759 100644 --- a/policyengine_uk/tests/test_uc_health_element_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -253,23 +253,29 @@ def test_lifting_the_freeze_reaches_a_simulated_new_claimant(monkeypatch): assert frozen.calculate("uc_LCWRA_element", year)[0] == pytest.approx( 12 * ACT_AMOUNT, rel=1e-6 ) - lifted = Simulation( - situation=new_claimant_situation(years), - scenario=Scenario( - parameter_changes={FREEZE: {"2028": False, "2029": False}}, - applied_before_data_load=True, + lift = {FREEZE: {"2028": False, "2029": False}} + # Every route that writes the change onto the statutory timeline reaches + # the award: a scenario applied before or after the data load, and a + # reform dict keyed by year. + routes = { + "scenario before data load": dict( + scenario=Scenario(parameter_changes=lift, applied_before_data_load=True) ), - ) - parameters = lifted.tax_benefit_system.parameters - assert lifted.calculate("uc_LCWRA_element", 2027)[0] == pytest.approx( - 12 * ACT_AMOUNT, rel=1e-6 - ) - for year in (2028, 2029): - growth = benefit_index(parameters, year) / benefit_index(parameters, 2027) - assert growth > 1 - assert lifted.calculate("uc_LCWRA_element", year)[0] == pytest.approx( - 12 * ACT_AMOUNT * growth, rel=1e-6 - ) + "scenario after data load": dict(scenario=Scenario(parameter_changes=lift)), + "reform dict": dict(reform=lift), + } + for route, kwargs in routes.items(): + lifted = Simulation(situation=new_claimant_situation(years), **kwargs) + parameters = lifted.tax_benefit_system.parameters + assert float(lifted.calculate("uc_LCWRA_element", 2027)[0]) == pytest.approx( + 12 * ACT_AMOUNT, rel=1e-6 + ), route + for year in (2028, 2029): + growth = benefit_index(parameters, year) / benefit_index(parameters, 2027) + assert growth > 1, route + assert float( + lifted.calculate("uc_LCWRA_element", year)[0] + ) == pytest.approx(12 * ACT_AMOUNT * growth, rel=1e-6), route def test_a_changed_amount_is_held_through_the_freeze():