From 7217486087de8252a63133723f0d00daf57c61a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 12:38:03 -0700 Subject: [PATCH 1/3] Project the Carer's Allowance earnings limit from the national living wage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reg. 8(1) of the ICA Regulations 1976 states the limit as a fixed sum. From April 2025 the Government sets it at 16 times the hourly national living wage, rounded up to the nearest pound, "from April 2025 and in future years" (Explanatory Memorandum to SI 2025/352, para. 5.6; SI 2026/218 EM para. 5.3). The parameter had no projection, so every year after April 2026 held £204. Keep each legislated amount and derive later years with the repo's extend_after_last_stated projection step, from the national living wage in force on 30 April and a new hours parameter (16). A projected year never lowers the limit. Co-Authored-By: Claude Opus 5.5 --- .../ca-earnings-limit-nlw-projection.fixed.md | 1 + .../create_earnings_limit_projection.py | 61 +++++ .../dwp/carers_allowance/earnings_limit.yaml | 9 + ...ings_limit_national_living_wage_hours.yaml | 28 +++ policyengine_uk/tax_benefit_system.py | 8 + ...s_allowance_earnings_limit_projection.yaml | 71 ++++++ ...ers_allowance_earnings_limit_projection.py | 228 ++++++++++++++++++ 7 files changed, 406 insertions(+) create mode 100644 changelog.d/ca-earnings-limit-nlw-projection.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml create mode 100644 policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py diff --git a/changelog.d/ca-earnings-limit-nlw-projection.fixed.md b/changelog.d/ca-earnings-limit-nlw-projection.fixed.md new file mode 100644 index 0000000000..25cc514507 --- /dev/null +++ b/changelog.d/ca-earnings-limit-nlw-projection.fixed.md @@ -0,0 +1 @@ +Project the Carer's Allowance earnings limit after its last legislated amount as 16 hours at the national living wage, rounded up to the next pound, as the Government sets it from April 2025, instead of holding £204 in every later year. diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py new file mode 100644 index 0000000000..2617f8defc --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py @@ -0,0 +1,61 @@ +"""Project the Carer's Allowance earnings limit from the national living wage. + +Regulation 8(1) of the Social Security (Invalid Care Allowance) Regulations +1976 states the limit as a fixed sum, which each year's up-rating regulations +replace. From April 2025 the Government sets it at 16 times the hourly +national living wage, rounded up to the nearest pound, "from April 2025 and in +future years" (Explanatory Memorandum to SI 2025/352, paragraph 5.6): £196 +from 16 x £12.21 = £195.36, and £204 from 16 x £12.71 = £203.36 (SI 2026/218). +Keep the legislated amounts and derive only later years, from the national +living wage in force on 30 April, as the April up-rating sets the limit. + +The limit has never been cut: each amount since 2015 is at least the one +before it. A year in which the national living wage would give a lower limit +keeps the limit in force before it. + +Run after general uprating and before fiscal-year conversion, on both the +main tree and its baseline copy, as the Savings Credit threshold projection +is. Reprocessing a reform then reads its changed national living wage or +hours. +""" + +import math + +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import instant + +from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( + extend_after_last_stated, + last_projected_year, +) + + +def national_living_wage_limit(hours: float, national_living_wage: float) -> float: + """``hours`` at the national living wage, rounded up to the next pound. + + Rounding to six places first keeps an exact amount, such as 16 x £13.25 = + £212, from rounding up when a float holds the wage just above £13.25. + """ + return float(math.ceil(round(hours * national_living_wage, 6))) + + +def project_carers_allowance_earnings_limit( + parameters: ParameterNode, +) -> ParameterNode: + """Fill the years after the earnings limit's last stated value.""" + carers_allowance = parameters.gov.dwp.carers_allowance + wage = parameters.gov.hmrc.minimum_wage.non_apprentice + + def derive(sample, previous): + # The scale orders brackets by age, so its last amount is the adult + # rate: the national living wage, as in + # esa_exempt_work_higher_earnings_limit. + national_living_wage = wage.get_at_instant(instant(sample)).amounts[-1] + hours = carers_allowance.earnings_limit_national_living_wage_hours(sample) + return max(previous, national_living_wage_limit(hours, national_living_wage)) + + extend_after_last_stated( + carers_allowance.earnings_limit, last_projected_year(parameters), derive + ) + carers_allowance.clear_parent_cache() + return parameters diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml index 22c25451e7..77201e277e 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml @@ -1,4 +1,11 @@ description: Weekly earnings above which a person is treated as gainfully employed and so is not entitled to Carer's Allowance. The test uses net earnings in the preceding week. +# Each value is the amount the up-rating regulations set. From April 2025 the +# Government sets it at 16 times the hourly national living wage, rounded up +# to the nearest pound (Explanatory Memorandum to SI 2025/352, paragraph 5.6). +# Years after the last value here are projected that way, from the national +# living wage and gov.dwp.carers_allowance.earnings_limit_national_living_wage_hours, +# by create_earnings_limit_projection.py. A projected year never lowers the +# limit. metadata: economy: false period: week @@ -9,6 +16,8 @@ metadata: href: https://www.legislation.gov.uk/uksi/1976/409/regulation/8 - title: Social Security Contributions and Benefits Act 1992 s. 70(1)(b) href: https://www.legislation.gov.uk/ukpga/1992/4/section/70 + - title: Explanatory Memorandum to the Social Security Benefits Up-rating Regulations 2025 (SI 2025/352), paragraphs 5.3 and 5.6 + href: https://www.legislation.gov.uk/uksi/2025/352/pdfs/uksiem_20250352_en_001.pdf values: 2015-04-06: value: 110 diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml new file mode 100644 index 0000000000..2f0f9b03e5 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml @@ -0,0 +1,28 @@ +description: >- + Hours a week at the national living wage that set the Carer's Allowance + earnings limit in the years after its last legislated amount. Regulation 8(1) + of the Social Security (Invalid Care Allowance) Regulations 1976 states the + limit as a fixed sum, which the annual up-rating regulations replace. From + April 2025 the Government sets that sum at 16 times the hourly national living + wage, rounded up to the nearest pound, "from April 2025 and in future years" + (Explanatory Memorandum to SI 2025/352, paragraph 5.6). The model keeps each + legislated amount and projects later years as this many hours at the national + living wage in force on 30 April, rounded up to the next pound. A projected + year never lowers the limit. +values: + 2025-04-07: + value: 16 + reference: + - title: Explanatory Memorandum to the Social Security Benefits Up-rating Regulations 2025 (SI 2025/352), paragraphs 5.3 and 5.6 + href: https://www.legislation.gov.uk/uksi/2025/352/pdfs/uksiem_20250352_en_001.pdf +metadata: + unit: hour + period: week + label: Carer's Allowance earnings limit national living wage hours + reference: + - title: Explanatory Memorandum to the Social Security Benefits Up-rating Regulations 2025 (SI 2025/352), paragraphs 5.3 and 5.6 + href: https://www.legislation.gov.uk/uksi/2025/352/pdfs/uksiem_20250352_en_001.pdf + - title: Explanatory Memorandum to the Social Security Benefits Up-rating Regulations 2026 (SI 2026/218), paragraph 5.3 + href: https://www.legislation.gov.uk/uksi/2026/218/pdfs/uksiem_20260218_en_001.pdf + - title: Autumn Budget 2024 (HM Treasury, 30 October 2024) + href: https://www.gov.uk/government/publications/autumn-budget-2024/autumn-budget-2024-html diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index f1fd874a8f..7b70f2aaf8 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -30,6 +30,9 @@ from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import ( add_triple_lock, ) +from policyengine_uk.parameters.gov.dwp.carers_allowance.create_earnings_limit_projection import ( + project_carers_allowance_earnings_limit, +) from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_pension_age_allowances import ( project_pension_age_allowances, ) @@ -187,6 +190,9 @@ def process_parameter_tree( - Universal Credit standard allowance uprating (UC Act 2025 s. 1) - Universal Credit LCWRA element uprating (UC Act 2025 s. 3 freeze) - parameter uprating and backdating + - projections derived from uprated parameters: the Savings Credit + threshold, the pension-age Housing Benefit allowances and the Carer's + Allowance earnings limit - conversion to fiscal year parameters Returns the tree and each parameter's timeline after projection (for gov @@ -222,9 +228,11 @@ def process_parameter_tree( uprate_parameters(parameters) project_savings_credit_threshold(parameters) project_pension_age_allowances(parameters) + project_carers_allowance_earnings_limit(parameters) if annualise: project_savings_credit_threshold(parameters.baseline) project_pension_age_allowances(parameters.baseline) + project_carers_allowance_earnings_limit(parameters.baseline) parameters = backdate_parameters(parameters, BACKDATED_FROM) # Prior law's projected path for every parameter a reform can change: the # gov tree, before annualisation, and the trees outside it (household), diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml new file mode 100644 index 0000000000..9287fc4847 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml @@ -0,0 +1,71 @@ +# Carer's Allowance earnings limit after its last legislated amount (£204 a +# week from 6 April 2026, SI 2026/218). From April 2025 the Government sets +# the limit at 16 times the hourly national living wage, rounded up to the +# nearest pound (Explanatory Memorandum to SI 2025/352, paragraph 5.6), and +# the model projects later years that way. The model's national living wage +# holds at £12.71 after April 2026, so each later limit is 16 x £12.71 = +# £203.36, rounded up to £204. Earnings are entered net, as +# carers_allowance_earnings, so the cases do not depend on tax or NI. + +- name: 2027-28, net earnings of exactly £204 a week have not exceeded the limit. + period: 2027 + input: + age: 45 + carers_allowance_earnings: 204 * 52 + care_hours: 40 + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: false + +- name: 2027-28, net earnings of £205 a week are over the limit. + period: 2027 + input: + age: 45 + carers_allowance_earnings: 205 * 52 + care_hours: 40 + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: 2028-29, net earnings of exactly £204 a week have not exceeded the limit. + period: 2028 + input: + age: 45 + carers_allowance_earnings: 204 * 52 + care_hours: 40 + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: false + +- name: 2028-29, net earnings of £205 a week are over the limit. + period: 2028 + input: + age: 45 + carers_allowance_earnings: 205 * 52 + care_hours: 40 + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: 2030-31, net earnings of exactly £204 a week have not exceeded the limit. + period: 2030 + input: + age: 45 + carers_allowance_earnings: 204 * 52 + care_hours: 40 + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: false + +- name: 2030-31, net earnings of £205 a week are over the limit. + period: 2030 + input: + age: 45 + carers_allowance_earnings: 205 * 52 + care_hours: 40 + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py new file mode 100644 index 0000000000..576e9e15ab --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py @@ -0,0 +1,228 @@ +"""Carer's Allowance earnings limit after its last legislated amount. + +Reg. 8(1) of the Social Security (Invalid Care Allowance) Regulations 1976 +states the limit as a fixed sum, which the up-rating regulations replace each +year. From April 2025 the Government sets it at 16 times the hourly national +living wage, rounded up to the nearest pound, "from April 2025 and in future +years" (Explanatory Memorandum to SI 2025/352, paragraph 5.6): £196 from April +2025 and £204 from April 2026 (SI 2026/218, Explanatory Memorandum paragraph +5.3). The model projects the years after the last legislated amount the same +way, from its own national living wage. + +Invariants, for every national living wage path and hours value: + +1. Statutory precedence: each year up to the last legislated amount reads the + amount earnings_limit.yaml states for it, whatever the wage. +2. The rule reproduces the legislated amounts from the legislated wages: + ceil(16 x £12.21) = £196 and ceil(16 x £12.71) = £204. +3. Floor: no projected year is below the last legislated amount, nor below + the year before it. +4. Rule: a projected year is ceil(hours x the national living wage in force on + 30 April), unless that is below the year before's limit, which then holds. + Holding is intended: the limit has never been cut, and the rule is stated + for wages that rise. For a wage path that never falls below the last + legislated wage, every projected year equals the rule. +5. Exact pounds (differential against exact arithmetic): a wage at which the + hours come to a whole number of pounds, such as 16 x £13.25 = £212, gives + that number, not one more. +6. The entitlement test reads the projected limit. +""" + +import datetime +import math +from fractions import Fraction +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.parameters.gov.dwp.carers_allowance.create_earnings_limit_projection import ( + national_living_wage_limit, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +LIMIT_FILE = ( + Path(__file__).parents[1] + / "parameters/gov/dwp/carers_allowance/earnings_limit.yaml" +) +# (date, amount) for each amount the up-rating regulations set. +LIMIT_VALUES = sorted( + ( + date if isinstance(date, datetime.date) else datetime.date.fromisoformat(date), + entry["value"] if isinstance(entry, dict) else entry, + ) + for date, entry in yaml.safe_load(LIMIT_FILE.read_text())["values"].items() +) +LAST_DATE, LAST_LIMIT = LIMIT_VALUES[-1] +LAST_YEAR = LAST_DATE.year +FIRST_PROJECTED = LAST_YEAR + 1 +LAST_CHECKED = 2040 +# The adult brackets (21 to 22, 23 to 24, 25 and over): the national living +# wage since April 2024. +NLW_BRACKETS = (2, 3, 4) +WEEKS = 52 + +SITUATION = {"people": {"carer": {"age": {"2026": 45}}}} + + +def _wage_reform(wages): + """Set the national living wage for each {year: wage}.""" + return { + f"gov.hmrc.minimum_wage.non_apprentice[{bracket}].amount": { + str(year): wage for year, wage in wages.items() + } + for bracket in NLW_BRACKETS + } + + +def _paths(reform=None, years=range(2015, LAST_CHECKED + 1)): + """Each year's earnings limit and national living wage, as the model + reads them for that (fiscal) year.""" + parameters = Simulation( + situation=SITUATION, reform=reform + ).tax_benefit_system.parameters + limits, wages, hours = {}, {}, {} + for year in years: + p = parameters(f"{year}-01-01") + limits[year] = p.gov.dwp.carers_allowance.earnings_limit + wages[year] = p.gov.hmrc.minimum_wage.non_apprentice.amounts[-1] + if year >= 2025: + hours[year] = ( + p.gov.dwp.carers_allowance.earnings_limit_national_living_wage_hours + ) + return limits, wages, hours + + +def _stated_limit(year): + """The amount earnings_limit.yaml sets for the year: in force on 30 April.""" + return [v for d, v in LIMIT_VALUES if d <= datetime.date(year, 4, 30)][-1] + + +def _check_invariants(limits, wages, hours): + for year in range(2015, FIRST_PROJECTED): + assert limits[year] == _stated_limit(year), year + for year in range(FIRST_PROJECTED, LAST_CHECKED + 1): + rule = math.ceil(Fraction(str(hours[year])) * Fraction(str(wages[year]))) + assert limits[year] >= LAST_LIMIT, year + assert limits[year] >= limits[year - 1], year + assert limits[year] == max(limits[year - 1], rule), (year, wages[year]) + + +def test_the_rule_reproduces_the_legislated_amounts(): + """Invariant 2: the Explanatory Memoranda's rule gives the amounts the + regulations set, from the national living wage in force each April.""" + limits, wages, hours = _paths(years=range(2024, 2027)) + assert wages[2025] == 12.21 and wages[2026] == 12.71 + assert hours[2025] == hours[2026] == 16 + for year in (2025, 2026): + assert national_living_wage_limit(16, wages[year]) == limits[year] + assert limits[2025] == 196 and limits[2026] == 204 + + +def test_baseline_projection_follows_the_model_national_living_wage(): + """Invariants 1, 3 and 4 on the model's own projection, whatever its + national living wage path is.""" + _check_invariants(*_paths()) + + +pence = st.integers(min_value=1_000, max_value=2_000) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(wages=st.lists(pence, min_size=6, max_size=6)) +# A rising path that hits whole pounds: 16 x £13.25 = £212 and 16 x £13.50 = +# £216 exactly, which must not round up to £213 and £217. +@example(wages=[1_221, 1_271, 1_325, 1_350, 1_375, 1_400]) +# A wage that falls after 2027: the limit holds at £212 rather than falling. +@example(wages=[1_221, 1_271, 1_325, 1_200, 1_000, 1_400]) +# Wages below the legislated ones in the legislated years change nothing there. +@example(wages=[1_000, 1_000, 1_300, 1_300, 1_300, 1_300]) +def test_projection_follows_any_national_living_wage_path(wages): + """Invariants 1, 3 and 4 for a reformed wage in 2025 to 2030. Later years + take the model's own wage again.""" + reform = _wage_reform({year: p / 100 for year, p in zip(range(2025, 2031), wages)}) + limits, model_wages, hours = _paths(reform) + for year, p in zip(range(2025, 2031), wages): + assert model_wages[year] == pytest.approx(p / 100), year + _check_invariants(limits, model_wages, hours) + # A path that never falls below the last legislated wage follows the rule + # in every year it sets. + path = [12.71] + [p / 100 for p in wages[2:]] + if all(later >= earlier for earlier, later in zip(path, path[1:])): + for year, p in zip(range(FIRST_PROJECTED, 2031), wages[2:]): + assert limits[year] == math.ceil(Fraction(16 * p, 100)), year + + +def test_hours_change_only_projected_years(): + """Invariant 1 and 4 for the hours: 20 hours from 2027 at the model's wage + leaves 2025 and 2026 at their legislated amounts.""" + reform = { + "gov.dwp.carers_allowance.earnings_limit_national_living_wage_hours": { + "2025-01-01.2100-12-31": 20 + } + } + limits, wages, hours = _paths(reform) + assert limits[2025] == 196 and limits[2026] == 204 + assert hours[FIRST_PROJECTED] == 20 + _check_invariants(limits, wages, hours) + assert limits[FIRST_PROJECTED] == math.ceil( + Fraction(20) * Fraction(str(wages[FIRST_PROJECTED])) + ) + + +@pytest.mark.parametrize("representation", ["divided", "multiplied"]) +def test_whole_pounds_are_exact_for_every_wage_in_pence(representation): + """Invariant 5, exhaustively for wages from 1p to £50 and 1 to 40 hours, + against exact arithmetic. "multiplied" holds the wage as uprating's + rounding does (pence x 0.01), which can sit just above the exact amount: + 1,325 x 0.01 is 13.250000000000002.""" + for hours in range(1, 41): + for p in range(1, 5_001): + wage = p / 100 if representation == "divided" else p * 0.01 + expected = math.ceil(Fraction(hours * p, 100)) + assert national_living_wage_limit(hours, wage) == expected, (hours, p) + + +def test_entitlement_reads_the_projected_limit(): + """Invariant 6: a national living wage of £13.50 from April 2028 gives a + limit of £216 (16 x £13.50, a whole number of pounds). Earnings of £216 a + week are within it and £217 over it.""" + year = 2028 + reform = _wage_reform({year: 13.50}) + simulation = Simulation( + situation={ + "people": { + name: { + "age": {year: 45}, + "care_hours": {year: 40}, + "carers_allowance_earnings": {year: WEEKS * weekly}, + } + for name, weekly in (("within", 216), ("over", 217)) + }, + "benunits": { + "within": {"members": ["within"]}, + "over": {"members": ["over"]}, + }, + "households": { + "within": {"members": ["within"], "country": {year: "ENGLAND"}}, + "over": {"members": ["over"], "country": {year: "ENGLAND"}}, + }, + }, + reform=reform, + ) + parameters = simulation.tax_benefit_system.parameters(f"{year}-01-01") + assert parameters.gov.dwp.carers_allowance.earnings_limit == 216 + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + assert list(np.asarray(gainful, dtype=bool)) == [False, True] + allowance = simulation.calculate("carers_allowance", year) + assert allowance[0] > 0 and allowance[1] == 0 From 6a2f6848a5c70af5cf20075a711fe998c2bc1411 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 13:15:38 -0700 Subject: [PATCH 2/3] Drop the floor from the earnings limit projection Each projected year is now ceil(hours x NLW) alone. The floor had no source, never bound under current law, and under a reform made an hours cut inert and a one-year change permanent. Tests cover reforms that lower the limit, the band the rule reads and the baseline copy. The rounding example is corrected: 1,325 x 0.01 is exactly 13.25; 20 x (1,020 x 0.01) is 204.00000000000003. Co-Authored-By: Claude Opus 5.5 --- .../create_earnings_limit_projection.py | 18 ++- .../dwp/carers_allowance/earnings_limit.yaml | 3 +- ...ings_limit_national_living_wage_hours.yaml | 7 +- policyengine_uk/tax_benefit_system.py | 6 +- ...ers_allowance_earnings_limit_projection.py | 147 ++++++++++++------ 5 files changed, 122 insertions(+), 59 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py index 2617f8defc..b607dba62f 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py @@ -9,14 +9,17 @@ Keep the legislated amounts and derive only later years, from the national living wage in force on 30 April, as the April up-rating sets the limit. -The limit has never been cut: each amount since 2015 is at least the one -before it. A year in which the national living wage would give a lower limit -keeps the limit in force before it. +Each projected year is the rule alone. A reform that lowers the national +living wage or the hours lowers the limit, and one that changes a single year +changes that year only. While the national living wage never falls below its +April 2026 rate, no projected year is below £204. Run after general uprating and before fiscal-year conversion, on both the main tree and its baseline copy, as the Savings Credit threshold projection is. Reprocessing a reform then reads its changed national living wage or -hours. +hours. The exceptions edit the processed parameters, so they do not move the +limit: ``Scenario.apply`` on an already-built simulation, and a reform-dict +key naming a single day or month. """ import math @@ -33,8 +36,9 @@ def national_living_wage_limit(hours: float, national_living_wage: float) -> float: """``hours`` at the national living wage, rounded up to the next pound. - Rounding to six places first keeps an exact amount, such as 16 x £13.25 = - £212, from rounding up when a float holds the wage just above £13.25. + Rounding to six places first keeps an exact amount from rounding up when + the float product sits just above it: 20 x (1,020 x 0.01) is + 204.00000000000003, which must give £204, not £205. """ return float(math.ceil(round(hours * national_living_wage, 6))) @@ -52,7 +56,7 @@ def derive(sample, previous): # esa_exempt_work_higher_earnings_limit. national_living_wage = wage.get_at_instant(instant(sample)).amounts[-1] hours = carers_allowance.earnings_limit_national_living_wage_hours(sample) - return max(previous, national_living_wage_limit(hours, national_living_wage)) + return national_living_wage_limit(hours, national_living_wage) extend_after_last_stated( carers_allowance.earnings_limit, last_projected_year(parameters), derive diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml index 77201e277e..36eb4195b9 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit.yaml @@ -4,8 +4,7 @@ description: Weekly earnings above which a person is treated as gainfully employ # to the nearest pound (Explanatory Memorandum to SI 2025/352, paragraph 5.6). # Years after the last value here are projected that way, from the national # living wage and gov.dwp.carers_allowance.earnings_limit_national_living_wage_hours, -# by create_earnings_limit_projection.py. A projected year never lowers the -# limit. +# by create_earnings_limit_projection.py. metadata: economy: false period: week diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml index 2f0f9b03e5..a1fe4ae12c 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_limit_national_living_wage_hours.yaml @@ -7,8 +7,10 @@ description: >- wage, rounded up to the nearest pound, "from April 2025 and in future years" (Explanatory Memorandum to SI 2025/352, paragraph 5.6). The model keeps each legislated amount and projects later years as this many hours at the national - living wage in force on 30 April, rounded up to the next pound. A projected - year never lowers the limit. + living wage in force on 30 April, rounded up to the next pound. A change + made by Scenario.apply to an already-built simulation, or by a reform-dict + key naming a single day or month, edits the processed parameters and so does + not move the limit. values: 2025-04-07: value: 16 @@ -16,6 +18,7 @@ values: - title: Explanatory Memorandum to the Social Security Benefits Up-rating Regulations 2025 (SI 2025/352), paragraphs 5.3 and 5.6 href: https://www.legislation.gov.uk/uksi/2025/352/pdfs/uksiem_20250352_en_001.pdf metadata: + economy: false unit: hour period: week label: Carer's Allowance earnings limit national living wage hours diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 7b70f2aaf8..e5decfdb1a 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -190,9 +190,9 @@ def process_parameter_tree( - Universal Credit standard allowance uprating (UC Act 2025 s. 1) - Universal Credit LCWRA element uprating (UC Act 2025 s. 3 freeze) - parameter uprating and backdating - - projections derived from uprated parameters: the Savings Credit - threshold, the pension-age Housing Benefit allowances and the Carer's - Allowance earnings limit + - projections derived from other parameters after uprating: the + Savings Credit threshold, the pension-age Housing Benefit allowances + and the Carer's Allowance earnings limit - conversion to fiscal year parameters Returns the tree and each parameter's timeline after projection (for gov diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py index 576e9e15ab..9fa33b9413 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py @@ -15,17 +15,20 @@ amount earnings_limit.yaml states for it, whatever the wage. 2. The rule reproduces the legislated amounts from the legislated wages: ceil(16 x £12.21) = £196 and ceil(16 x £12.71) = £204. -3. Floor: no projected year is below the last legislated amount, nor below - the year before it. -4. Rule: a projected year is ceil(hours x the national living wage in force on - 30 April), unless that is below the year before's limit, which then holds. - Holding is intended: the limit has never been cut, and the rule is stated - for wages that rise. For a wage path that never falls below the last - legislated wage, every projected year equals the rule. +3. Rule: a projected year is ceil(hours x the national living wage in force on + 30 April), whatever the years before it were. A reform that lowers the + wage or the hours lowers the limit, and one that changes a single year + changes that year only. +4. Floor: while the national living wage is at least its April 2026 rate and + the hours at least 16, no projected year is below the last legislated + amount. The model's own projection never takes the wage below that rate. 5. Exact pounds (differential against exact arithmetic): a wage at which the hours come to a whole number of pounds, such as 16 x £13.25 = £212, gives - that number, not one more. + that number, not one more, for every wage in whole pence however the float + holds it. 6. The entitlement test reads the projected limit. +7. The baseline copy of the parameters keeps current law's limit under a + reform. """ import datetime @@ -65,27 +68,34 @@ # The adult brackets (21 to 22, 23 to 24, 25 and over): the national living # wage since April 2024. NLW_BRACKETS = (2, 3, 4) +# The national living wage in force when the last amount was legislated +# (£12.71 from April 2026), in pence. +LAST_WAGE_PENCE = 1_271 +HOURS = 16 WEEKS = 52 SITUATION = {"people": {"carer": {"age": {"2026": 45}}}} -def _wage_reform(wages): - """Set the national living wage for each {year: wage}.""" +def _wage_reform(wages, brackets=NLW_BRACKETS): + """Set the national living wage for each fiscal {year: wage}.""" return { f"gov.hmrc.minimum_wage.non_apprentice[{bracket}].amount": { str(year): wage for year, wage in wages.items() } - for bracket in NLW_BRACKETS + for bracket in brackets } -def _paths(reform=None, years=range(2015, LAST_CHECKED + 1)): +def _parameters(reform=None): + return Simulation(situation=SITUATION, reform=reform).tax_benefit_system.parameters + + +def _paths(reform=None, years=range(2015, LAST_CHECKED + 1), parameters=None): """Each year's earnings limit and national living wage, as the model reads them for that (fiscal) year.""" - parameters = Simulation( - situation=SITUATION, reform=reform - ).tax_benefit_system.parameters + if parameters is None: + parameters = _parameters(reform) limits, wages, hours = {}, {}, {} for year in years: p = parameters(f"{year}-01-01") @@ -103,14 +113,18 @@ def _stated_limit(year): return [v for d, v in LIMIT_VALUES if d <= datetime.date(year, 4, 30)][-1] +def _rule(hours, wage): + return math.ceil(Fraction(str(hours)) * Fraction(str(wage))) + + def _check_invariants(limits, wages, hours): + """Invariants 1, 3 and 4.""" for year in range(2015, FIRST_PROJECTED): assert limits[year] == _stated_limit(year), year for year in range(FIRST_PROJECTED, LAST_CHECKED + 1): - rule = math.ceil(Fraction(str(hours[year])) * Fraction(str(wages[year]))) - assert limits[year] >= LAST_LIMIT, year - assert limits[year] >= limits[year - 1], year - assert limits[year] == max(limits[year - 1], rule), (year, wages[year]) + assert limits[year] == _rule(hours[year], wages[year]), (year, wages[year]) + if wages[year] >= LAST_WAGE_PENCE / 100 and hours[year] >= HOURS: + assert limits[year] >= LAST_LIMIT, year def test_the_rule_reproduces_the_legislated_amounts(): @@ -126,11 +140,13 @@ def test_the_rule_reproduces_the_legislated_amounts(): def test_baseline_projection_follows_the_model_national_living_wage(): """Invariants 1, 3 and 4 on the model's own projection, whatever its - national living wage path is.""" - _check_invariants(*_paths()) - - -pence = st.integers(min_value=1_000, max_value=2_000) + national living wage path is. That path never falls below the April 2026 + rate, so no projected year is below £204.""" + limits, wages, hours = _paths() + _check_invariants(limits, wages, hours) + for year in range(FIRST_PROJECTED, LAST_CHECKED + 1): + assert wages[year] >= LAST_WAGE_PENCE / 100, year + assert limits[year] >= LAST_LIMIT, year @settings( @@ -139,33 +155,73 @@ def test_baseline_projection_follows_the_model_national_living_wage(): derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) -@given(wages=st.lists(pence, min_size=6, max_size=6)) +@given( + legislated=st.lists(st.integers(1_000, 2_000), min_size=2, max_size=2), + projected=st.lists(st.integers(LAST_WAGE_PENCE, 2_000), min_size=4, max_size=4), +) # A rising path that hits whole pounds: 16 x £13.25 = £212 and 16 x £13.50 = # £216 exactly, which must not round up to £213 and £217. -@example(wages=[1_221, 1_271, 1_325, 1_350, 1_375, 1_400]) -# A wage that falls after 2027: the limit holds at £212 rather than falling. -@example(wages=[1_221, 1_271, 1_325, 1_200, 1_000, 1_400]) +@example(legislated=[1_221, 1_271], projected=[1_325, 1_350, 1_375, 1_400]) +# A path that rises and falls back to the April 2026 rate: the limit follows +# it down to £204 and no further. +@example(legislated=[1_221, 1_271], projected=[1_400, 1_271, 1_300, 1_271]) # Wages below the legislated ones in the legislated years change nothing there. -@example(wages=[1_000, 1_000, 1_300, 1_300, 1_300, 1_300]) -def test_projection_follows_any_national_living_wage_path(wages): - """Invariants 1, 3 and 4 for a reformed wage in 2025 to 2030. Later years - take the model's own wage again.""" - reform = _wage_reform({year: p / 100 for year, p in zip(range(2025, 2031), wages)}) +@example(legislated=[1_000, 1_000], projected=[1_300, 1_300, 1_300, 1_300]) +def test_projection_follows_any_national_living_wage_path(legislated, projected): + """Invariants 1, 3 and 4 for any national living wage in 2025 and 2026 and + any at or above the April 2026 rate in 2027 to 2030: each projected year + is ceil(16 x the wage) and none is below £204. Later years take the + model's own wage again.""" + wages = [p / 100 for p in legislated + projected] + reform = _wage_reform(dict(zip(range(2025, 2031), wages))) limits, model_wages, hours = _paths(reform) - for year, p in zip(range(2025, 2031), wages): - assert model_wages[year] == pytest.approx(p / 100), year + for year, wage in zip(range(2025, 2031), wages): + assert model_wages[year] == pytest.approx(wage), year _check_invariants(limits, model_wages, hours) - # A path that never falls below the last legislated wage follows the rule - # in every year it sets. - path = [12.71] + [p / 100 for p in wages[2:]] - if all(later >= earlier for earlier, later in zip(path, path[1:])): - for year, p in zip(range(FIRST_PROJECTED, 2031), wages[2:]): - assert limits[year] == math.ceil(Fraction(16 * p, 100)), year + for year in range(FIRST_PROJECTED, LAST_CHECKED + 1): + assert limits[year] >= LAST_LIMIT, year + for year, p in zip(range(FIRST_PROJECTED, 2031), projected): + assert limits[year] == math.ceil(Fraction(HOURS * p, 100)), year + + +def test_reforms_lower_the_limit_and_change_only_their_own_years(): + """Invariants 3 and 7, and which wage the rule reads. + + - 12 hours in 2027-28 and 20 hours in 2028-29 only. + - A national living wage of £12.00 in 2029-30, below the April 2026 rate. + - £14.50 for the 25 and over band alone in 2030-31: the rule reads that + band, the last. + Each year takes the rule from that year's own hours and wage, so the limit + falls below £204 in 2027-28 and 2029-30 and returns to the model's path + after 2030-31. The baseline copy keeps current law's limit throughout.""" + reform = _wage_reform({2029: 12.00}) + reform["gov.hmrc.minimum_wage.non_apprentice[4].amount"]["2030"] = 14.50 + reform["gov.dwp.carers_allowance.earnings_limit_national_living_wage_hours"] = { + "2027": 12, + "2028": 20, + } + parameters = _parameters(reform) + limits, wages, hours = _paths(parameters=parameters) + current_law, current_wages, _ = _paths() + _check_invariants(limits, wages, hours) + assert hours[2027] == 12 and hours[2028] == 20 and hours[2029] == 16 + assert limits[2027] == _rule(12, current_wages[2027]) < LAST_LIMIT + assert limits[2028] == _rule(20, current_wages[2028]) + assert limits[2029] == 192 + assert wages[2030] == 14.50 + adult_bands = parameters("2030-01-01").gov.hmrc.minimum_wage.non_apprentice + assert adult_bands.amounts[2] == current_wages[2030] + assert limits[2030] == 232 + for year in range(2031, LAST_CHECKED + 1): + assert limits[year] == current_law[year], year + baseline = parameters.baseline.gov.dwp.carers_allowance.earnings_limit + for year in range(2015, LAST_CHECKED + 1): + assert baseline(f"{year}-04-30") == current_law[year], year def test_hours_change_only_projected_years(): - """Invariant 1 and 4 for the hours: 20 hours from 2027 at the model's wage - leaves 2025 and 2026 at their legislated amounts.""" + """Invariants 1 and 3 for the hours: 20 hours from 2025 on leaves 2025 + and 2026 at their legislated amounts and sets every later year.""" reform = { "gov.dwp.carers_allowance.earnings_limit_national_living_wage_hours": { "2025-01-01.2100-12-31": 20 @@ -184,8 +240,9 @@ def test_hours_change_only_projected_years(): def test_whole_pounds_are_exact_for_every_wage_in_pence(representation): """Invariant 5, exhaustively for wages from 1p to £50 and 1 to 40 hours, against exact arithmetic. "multiplied" holds the wage as uprating's - rounding does (pence x 0.01), which can sit just above the exact amount: - 1,325 x 0.01 is 13.250000000000002.""" + rounding does (pence x 0.01). The product can then sit just above the + exact amount: 20 x (1,020 x 0.01) is 204.00000000000003.""" + assert 20 * (1_020 * 0.01) > 204 for hours in range(1, 41): for p in range(1, 5_001): wage = p / 100 if representation == "divided" else p * 0.01 From 3c1962100ee9dd7521715af7da553f42695ea227 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 00:15:08 -0400 Subject: [PATCH 3/3] Test the rule on the wage in whole pence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uprating holds £14.45 as 14.450000000000001; exact arithmetic on that float makes 20 hours £290 rather than the model's (correct) £289. Also qualify the £204 floor on the hours, as the test's invariant 4 does. Co-Authored-By: Claude Opus 5.5 --- .../dwp/carers_allowance/create_earnings_limit_projection.py | 4 ++-- .../tests/test_carers_allowance_earnings_limit_projection.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py index b607dba62f..51b3d487b3 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py @@ -11,8 +11,8 @@ Each projected year is the rule alone. A reform that lowers the national living wage or the hours lowers the limit, and one that changes a single year -changes that year only. While the national living wage never falls below its -April 2026 rate, no projected year is below £204. +changes that year only. While the national living wage stays at or above its +April 2026 rate and the hours at or above 16, no projected year is below £204. Run after general uprating and before fiscal-year conversion, on both the main tree and its baseline copy, as the Savings Credit threshold projection diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py index 9fa33b9413..bc7ed2e461 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_projection.py @@ -114,7 +114,10 @@ def _stated_limit(year): def _rule(hours, wage): - return math.ceil(Fraction(str(hours)) * Fraction(str(wage))) + """ceil(hours x wage) in exact arithmetic, on the wage in whole pence: a + rate is set in pence, and uprating can hold £14.45 as 14.450000000000001, + which exactly x 20 would round up to £290 rather than £289.""" + return math.ceil(Fraction(str(hours)) * Fraction(str(round(wage, 2)))) def _check_invariants(limits, wages, hours):