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/changelog.d/uc-lcwra-s3-freeze.fixed.md b/changelog.d/uc-lcwra-s3-freeze.fixed.md new file mode 100644 index 0000000000..dd1dff7569 --- /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. 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 2c29ca7079..23054209fa 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -20,6 +20,17 @@ 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 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. + +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`. For a tax year without a legislated amount, the model uses the lowest amount the Universal Credit Act 2025 s. 4 duty allows. For every standard allowance amount, the protected amount plus that allowance must be at least the previous year's sum, increased by the relevant CPI percentage: the CPI 12-month rate in the September before the tax year, never below 0% (s. 4(4)(a)). The model reads that rate from `gov.economic_assumptions.statutory_uprating_inputs.cpi_september`. Section 3 switches off the element's ordinary uprating, so the amount otherwise stays where it was. Run from the 2025-26 amounts at the 3.8% September 2025 CPI rate, this rule gives the legislated £429.80. @@ -48,7 +59,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, 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. @@ -110,10 +121,34 @@ 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) +``` + +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. + +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 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 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..138d04750a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -0,0 +1,212 @@ +"""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. + +``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 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 + +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. +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) + # Core's order: the index ratio first, then the amount. + return parameter(last) * (uprater(max(projected)) / uprater(last)) + + +def index_years(parameters: ParameterNode) -> range: + """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] + 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: + """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. 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": { + "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..2a998c8b1b --- /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, unless an amount is stated for the year (as Universal Credit Act 2025 s. 2 states £217.26 for 2026-27). 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/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/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 9e4175a690..17a92c13f5 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -25,6 +25,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, ) @@ -92,6 +95,7 @@ def _add_generated_parameters(parameters: ParameterNode) -> ParameterNode: parameters = add_september_cpi_uprating(parameters) parameters = create_economic_assumption_indices(parameters) parameters = add_threshold_indexation(parameters) + parameters = add_uc_health_element_uprating(parameters) parameters = add_lsr_deprecation_aliases(parameters) return parameters @@ -111,6 +115,7 @@ def process_parameter_tree( - 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 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_dwp_published_rates.py b/policyengine_uk/tests/test_dwp_published_rates.py index f4c60567ca..de6e2c8eb4 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 left out: the + Universal Credit Act 2025 sets a minimum for the standard allowance above + CPI uprating (s.1), which the model does not yet follow. The LCWRA element + is checked against its own uprating index, which holds it for 2026-27 to + 2029-30 as s.3 freezes it. 4. Housing Benefit non-dependant deductions: each year's bands and deductions are the published ones, and the scale, read with lower edges inclusive as reg 74(2) words them ("not less than"), never falls as income rises. @@ -75,13 +76,12 @@ def test_no_start_instant_overrides_published_rates(name): # 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). +# table). UC_ACT_2025_EXCEPTIONS = { "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_YOUNG", "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD", "gov.dwp.universal_credit.standard_allowance.amount.COUPLE_YOUNG", "gov.dwp.universal_credit.standard_allowance.amount.COUPLE_OLD", - "gov.dwp.universal_credit.elements.disabled.amount", } @@ -96,7 +96,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.1 departs from CPI uprating") index = node(uprating if isinstance(uprating, str) else uprating["parameter"]) ratio = index(f"{last + 1}-01-01") / index(f"{last}-01-01") expected = PARAMETERS[name]["values"][last] * ratio diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py new file mode 100644 index 0000000000..788c4e3759 --- /dev/null +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -0,0 +1,528 @@ +"""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, unless an amount is + stated for that year (as s. 2 states £217.26 for 2026-27). +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, 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 + 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 +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, +) + +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 ( + 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" +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 +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)) + + +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": { + "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 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, 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 + # 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_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.""" + simulation = Simulation(situation=SITUATION) + monthly = system.parameters.get_child(AMOUNT)(at(2030)) + # The formula returns float32. + assert float(simulation.calculate("uc_LCWRA_element", 2030)[0]) == pytest.approx( + 12 * monthly, rel=1e-6 + ) + + +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). + + +@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) + assert_one_path(parameters, range(2026, 2032)) + + +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) + 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 + ) + 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) + ), + "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(): + """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] + assert_one_path(parameters, range(2026, 2034)) + + +# 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, + 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; ``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): + 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, + **(benefit_values or {}), + }, + "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, + } + }, + "new_claimant_health_element": { + "values": {"2026-01-01": ACT_AMOUNT}, + "metadata": { + "uprating": "gov.dwp.universal_credit.elements.disabled.uprating" + }, + }, + }, + "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, 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, (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) + 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) + assert_one_path(root, range(2026, INDEX_LAST_YEAR + 1)) + + +@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, + ) + assert_one_path(root, range(2026, INDEX_LAST_YEAR + 1)) + + +@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) + # 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))