From 7843560672982d1ed24122fe2e258068b21826b7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 15:48:16 -0400 Subject: [PATCH 1/8] Derive economic assumption index horizon from the growth series Every index stopped at 2039 while the growth series run to 2074, so a parameter or input grown by an index froze at its 2039 level. Co-Authored-By: Claude Opus 5.5 --- changelog.d/derive-index-horizons.fixed.md | 1 + docs/engineering/excise-duties.md | 2 +- .../gov/economic_assumptions/README.md | 21 +- .../create_economic_assumption_indices.py | 16 +- .../gov/hmrc/business_rates/statistics.yaml | 4 +- policyengine_uk/tax_benefit_system.py | 2 +- .../test_corporate_receipts_statistics.py | 13 +- .../tests/test_economic_assumption_horizon.py | 233 ++++++++++++++++++ .../tests/test_pension_credit_uprating.py | 9 +- .../test_savings_starting_rate_indexation.py | 7 +- ...test_scottish_carer_supplement_uprating.py | 13 +- .../tests/test_september_cpi_uprating.py | 7 +- .../test_september_cpi_uprating_properties.py | 15 +- .../tests/test_threshold_indexation.py | 24 +- 14 files changed, 324 insertions(+), 43 deletions(-) create mode 100644 changelog.d/derive-index-horizons.fixed.md create mode 100644 policyengine_uk/tests/test_economic_assumption_horizon.py diff --git a/changelog.d/derive-index-horizons.fixed.md b/changelog.d/derive-index-horizons.fixed.md new file mode 100644 index 0000000000..50f03867f0 --- /dev/null +++ b/changelog.d/derive-index-horizons.fixed.md @@ -0,0 +1 @@ +Economic assumption indices now end where the growth series end (2074) instead of at 2039, so the State Pension, tax thresholds, benefit rates and every input grown by an index keep moving after 2039 rather than freezing at their 2039 level. diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index 2e353c04a9..a021cd2f6c 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -183,7 +183,7 @@ year's RPI growth from the OBR forecast in nearest hundredth of a penny. The steps to 1 April 2031 are written out by `calculate_fuel_duty_rates.py`, and a test fails when they fall out of date with the RPI forecast. Later steps come from the parameter's uprating metadata. -The RPI index ends in 2039, so the last step is 1 April 2040. +The RPI index ends with the growth series, in 2074, so the last step is 1 April 2075. An annual reform names a model year and replaces the rate for all of it. For model years to 2030 it leaves other years unchanged. `Scenario.parameter_changes` diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index b4e58b8cfc..cc9779fb39 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -107,13 +107,14 @@ benefit-unit capital without overwriting programme-specific countable capital. Changing that formula to carry every direct input would alter situation semantics. -Source coverage and processed parameter dates are distinct: fiscal-year -conversion materialises annual values through 2040, while cumulative economic -indices compound only through 2039. A later parameter lookup retains the final -scalar; for a raw growth series that repeats the growth rate, but for a -cumulative index it freezes the level. Extending the dataset to the raw -series' final year would therefore be both costly and inconsistent with Core -uprating after the cumulative-index horizon. +Source coverage and processed parameter dates are distinct. Cumulative +economic indices compound to the last year any growth series covers, which is +one year past `yoy_growth.yaml` because the lagged series run a year past +their source. A series that ends sooner holds its final growth rate to that +year: that is an extrapolation, and it matters more the further a series +falls short of the horizon. Past the horizon a parameter lookup retains the +final scalar; for a raw growth series that repeats the growth rate, but for a +cumulative index it freezes the level. From 2031 onward, dataset projection also grows stored columns whose variables have a class-level `uprating` index but are absent from `uprating_indices.yaml`. @@ -162,9 +163,9 @@ The additional indices have their own source limits: | Stored input | Variable index | Source coverage and terminal behavior | |---|---|---| -| Salary-sacrifice pension contributions | `indices.obr.average_earnings` | Earnings growth has source values through 2073; its cumulative index compounds through 2039. | -| Water and sewerage charges | `indices.ofwat.water_bills` | Raw growth ends in 2029 at 5.0%; that rate carries into later years when the cumulative index is generated, through 2039. | -| Housing service charges | `indices.obr.lagged_average_earnings` | Lagged growth is generated through 2074 from the preceding year's earnings; its cumulative index compounds through 2039. | +| Salary-sacrifice pension contributions | `indices.obr.average_earnings` | Earnings growth has source values through 2073; its cumulative index compounds through 2074. | +| Water and sewerage charges | `indices.ofwat.water_bills` | Raw growth ends in 2029 at 5.0%; that rate carries into later years when the cumulative index is generated, through 2074. | +| Housing service charges | `indices.obr.lagged_average_earnings` | Lagged growth is generated through 2074 from the preceding year's earnings; its cumulative index compounds through 2074. | | Rail usage | `gov.dft.rail.ridership_index` | The source cumulative index ends in 2029 at 1.044. Its terminal level stays flat, so the projection ratio is one in 2031 and 2032. | The `indices.*` paths in this table are relative to diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 90ecde8187..11c5715511 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py @@ -5,11 +5,25 @@ ) +def last_year(parameter: Parameter) -> int: + return max(int(value.instant_str[:4]) for value in parameter.values_list) + + +def growth_horizon(yoy_growth: ParameterNode) -> int: + """The last year any growth series covers, where the indices end.""" + return max( + last_year(parameter) + for parameter in yoy_growth.get_descendants() + if isinstance(parameter, Parameter) + ) + + def create_economic_assumption_indices( parameters: ParameterNode, ) -> ParameterNode: econ_assumptions: ParameterNode = parameters.gov.economic_assumptions yoy_growth: ParameterNode = econ_assumptions.yoy_growth + horizon = growth_horizon(yoy_growth) # Index levels dated 1 January, one per calendar year, so a bare-year # reform key names the calendar year (policyengine_uk.utils.dated_reforms). indices = ParameterNode( @@ -40,7 +54,7 @@ def create_economic_assumption_indices( start_year = int(descendant.values_list[-1].instant_str[:4]) values = {start_year: 1.0} - for year in range(start_year + 1, 2040): + for year in range(start_year + 1, horizon + 1): yoy_growth = descendant(year) indices_value = values[year - 1] * (1 + yoy_growth) # Rounding an earnings index down can put the projected diff --git a/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml b/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml index 150305dd49..2744fe9b4c 100644 --- a/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml +++ b/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml @@ -31,8 +31,8 @@ metadata: # "Business rates" line, Northern Ireland with its "Northern Ireland business # rates" line (gov.economic_assumptions.yoy_growth.obr.business_rates), which # appears to cover the regional rate only (see NORTHERN_IRELAND below). The -# economic-assumption indices run to 2039, so every series is flat in cash -# terms from 2039-40. +# economic-assumption indices end where the growth series end, so every series +# is flat in cash terms after that year. revenue: description: Total revenue raised by business rates. ENGLAND: diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 9a9fdb349e..c3dcf87ef2 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -115,7 +115,7 @@ def _install_parameters(system, parameters: ParameterNode) -> None: DEFAULT_DATASET_ENV_VAR = "POLICYENGINE_UK_DEFAULT_DATASET" # Cache for fully-processed parameter tree, so convert_to_fiscal_year_parameters -# (22,538 param.update() calls) only runs once per process. +# (about 66,000 param.update() calls) only runs once per process. _processed_parameters_cache = None # The parameters as loaded from the YAML files, before processing: each # parameter's statutory values at their legal dates. Reforms are written diff --git a/policyengine_uk/tests/test_corporate_receipts_statistics.py b/policyengine_uk/tests/test_corporate_receipts_statistics.py index fa74449607..266ac266aa 100644 --- a/policyengine_uk/tests/test_corporate_receipts_statistics.py +++ b/policyengine_uk/tests/test_corporate_receipts_statistics.py @@ -13,7 +13,11 @@ from policyengine_uk import Microsimulation, Simulation, parameters from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + growth_horizon, +) from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.system import system SDLT = parameters.gov.hmrc.stamp_duty.statistics BUSINESS_RATES = parameters.gov.hmrc.business_rates.statistics.revenue @@ -207,8 +211,11 @@ def test_projection_tracks_obr_receipts(name): assert statistic(INSTANT(year)) == pytest.approx(expected, rel=2e-5) -# The economic-assumption indices run to 2039, so the projections do too. -LAST_INDEXED_YEAR = 2039 +# The economic-assumption indices end where the growth series end, so the +# projections do too. +LAST_INDEXED_YEAR = growth_horizon( + system.parameters.gov.economic_assumptions.yoy_growth +) @settings(max_examples=200, deadline=None, derandomize=True) @@ -244,7 +251,7 @@ def test_projection_is_flat_after_the_last_indexed_year(name): statistic = _statistic(name) last = statistic(INSTANT(LAST_INDEXED_YEAR)) assert statistic(INSTANT(LAST_INDEXED_YEAR - 1)) < last - for year in range(LAST_INDEXED_YEAR + 1, 2050): + for year in range(LAST_INDEXED_YEAR + 1, LAST_INDEXED_YEAR + 10): assert statistic(INSTANT(year)) == last diff --git a/policyengine_uk/tests/test_economic_assumption_horizon.py b/policyengine_uk/tests/test_economic_assumption_horizon.py new file mode 100644 index 0000000000..3860706740 --- /dev/null +++ b/policyengine_uk/tests/test_economic_assumption_horizon.py @@ -0,0 +1,233 @@ +"""The economic assumption indices run as far as the growth series behind them. + +Every index under ``gov.economic_assumptions.indices`` used to stop at 2039, +while the year-on-year growth series run to the 2070s. A parameter carries its +final value forward rather than raising, so everything uprated by an index, +the State Pension included, froze at its 2039 level with no error. + +Invariants: + +1. Horizon: every index ends in the last year any growth series covers, which + is one year past the raw series because the lagged series run a year past + their source. +2. Compounding: each index is 1 in its first year and, for every later year to + the horizon, the previous year's value times one plus that year's growth, + rounded to 5 decimal places (the earnings index keeps its full precision). + A series that ends sooner holds its final growth rate. +3. Uprating: every parameter uprated by an index that still moves after 2039 + moves with it, unless its value is zero. +4. Reforms: the horizon follows a reformed series. A dated change inside the + horizon leaves it where it is; an undated change states the series to 2100, + and every index follows it there. +""" + +from pathlib import Path + +import pytest +import yaml +from hypothesis import given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter, ParameterNode, get_parameter + +from policyengine_uk import Simulation, system +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + create_economic_assumption_indices, + growth_horizon, + last_year, +) + +ECONOMIC_ASSUMPTIONS = system.parameters.gov.economic_assumptions +HORIZON = growth_horizon(ECONOMIC_ASSUMPTIONS.yoy_growth) +YOY_GROWTH_FILE = ( + Path(__file__).parents[1] + / "parameters" + / "gov" + / "economic_assumptions" + / "yoy_growth.yaml" +) +# The year the indices stopped at before they followed the growth series. +OLD_LAST_INDEX_YEAR = 2039 +EARNINGS_UPRATING = "gov.economic_assumptions.yoy_growth.earnings_uprating" +CPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" + + +def _parameters(node: ParameterNode) -> list: + return [p for p in node.get_descendants() if isinstance(p, Parameter)] + + +def _years_in_growth_file() -> dict: + """First and last year of each series in yoy_growth.yaml, read from the file.""" + years = {} + + def visit(node, path): + if "values" in node: + dated = [int(str(key)[:4]) for key in node["values"]] + years[path] = (min(dated), max(dated)) + for key, child in node.items(): + if key not in ("values", "metadata", "description"): + visit(child, f"{path}.{key}") + + visit(yaml.safe_load(YOY_GROWTH_FILE.read_text()), "yoy_growth") + return years + + +def test_the_horizon_is_a_year_past_the_growth_file(): + """The lagged series run one year past the last year in the file.""" + last_in_file = max(last for _, last in _years_in_growth_file().values()) + + assert HORIZON == last_in_file + 1 + lagged_cpi = ECONOMIC_ASSUMPTIONS.yoy_growth.obr.lagged_cpi + assert last_year(lagged_cpi) == HORIZON + + +def test_every_index_ends_where_the_growth_series_end(): + indices = _parameters(ECONOMIC_ASSUMPTIONS.indices) + names = {index.name for index in indices} + + for growth in _parameters(ECONOMIC_ASSUMPTIONS.yoy_growth): + assert growth.name.replace("yoy_growth", "indices") in names + # The Universal Credit rebalancing indices have no growth series of their + # own, and follow the benefit index to the same year. + for index in indices: + assert last_year(index) == HORIZON, index.name + + +def test_every_index_compounds_its_growth_through_the_horizon(): + """Checked from 2030, after every series has started. + + Backdating fills each processed series and index back to 2015 with its + first value, so the recursion only holds from the year a series starts. + ``test_indices_end_where_any_growth_series_ends`` checks it from the + start year on generated series. + """ + first_checked = 2030 + assert all(first < first_checked for first, _ in _years_in_growth_file().values()) + for growth in _parameters(ECONOMIC_ASSUMPTIONS.yoy_growth): + index = get_parameter( + system.parameters, growth.name.replace("yoy_growth", "indices") + ) + for year in range(first_checked, HORIZON + 1): + expected = index(str(year - 1)) * (1 + growth(str(year))) + if growth.name != EARNINGS_UPRATING: + expected = round(expected, 5) + assert index(str(year)) == pytest.approx(expected, rel=1e-12), ( + index.name, + year, + ) + # Past the horizon an index holds its last value. + assert index(str(HORIZON + 10)) == index(str(HORIZON)), index.name + + +def test_the_state_pension_keeps_rising_after_2039(): + """Both State Pension rates move with the triple lock index to the horizon.""" + triple_lock = ECONOMIC_ASSUMPTIONS.indices.triple_lock + state_pension = system.parameters.gov.dwp.state_pension + + for amount in ( + state_pension.new_state_pension.amount, + state_pension.basic_state_pension.amount, + ): + for year in range(OLD_LAST_INDEX_YEAR + 1, HORIZON + 1): + assert amount(str(year)) > amount(str(year - 1)), (amount.name, year) + assert amount(str(year)) / amount(str(year - 1)) == pytest.approx( + triple_lock(str(year)) / triple_lock(str(year - 1)), rel=1e-12 + ), (amount.name, year) + + +def test_no_uprated_parameter_freezes_after_2039(): + """A parameter uprated by a series that moves after 2039 moves too.""" + checked = 0 + for parameter in _parameters(system.parameters.gov): + uprating = (parameter.metadata or {}).get("uprating") + if uprating is None: + continue + uprater_name = uprating if isinstance(uprating, str) else uprating["parameter"] + if uprater_name == "self": + continue + uprater = get_parameter(system.parameters, uprater_name) + before = parameter(str(OLD_LAST_INDEX_YEAR)) + if not isinstance(before, (int, float)) or before == 0: + continue + if uprater(str(OLD_LAST_INDEX_YEAR)) == uprater(str(HORIZON)): + continue + checked += 1 + assert parameter(str(HORIZON)) != before, parameter.name + + # The State Pension, benefit rates and tax thresholds are all in here. + assert checked > 100 + + +growth_rates = st.floats(min_value=-0.1, max_value=0.2, allow_nan=False) + + +@st.composite +def growth_trees(draw): + """A yoy_growth node of Jan-1-dated series, some nested, of mixed lengths.""" + series = {} + for number in range(draw(st.integers(min_value=1, max_value=5))): + start = draw(st.integers(min_value=2000, max_value=2030)) + length = draw(st.integers(min_value=1, max_value=60)) + values = { + f"{year}-01-01": draw(growth_rates) for year in range(start, start + length) + } + if draw(st.booleans()): + series.setdefault("nested", {})[f"series_{number}"] = {"values": values} + else: + series[f"series_{number}"] = {"values": values} + return ParameterNode( + "", + data={"gov": {"economic_assumptions": {"yoy_growth": series}}}, + ) + + +@settings(max_examples=200, deadline=None) +@given(tree=growth_trees()) +def test_indices_end_where_any_growth_series_ends(tree): + growth = tree.gov.economic_assumptions.yoy_growth + horizon = max(last_year(series) for series in _parameters(growth)) + + create_economic_assumption_indices(tree) + + for series in _parameters(growth): + index = get_parameter(tree, series.name.replace("yoy_growth", "indices")) + start = int(series.values_list[-1].instant_str[:4]) + + assert last_year(index) == horizon + assert index(str(start)) == 1.0 + for year in range(start + 1, horizon + 1): + # Past its last year a series holds its final growth rate. + expected = round(index(str(year - 1)) * (1 + series(str(year))), 5) + assert index(str(year)) == expected + + +def reformed_system(growth): + situation = { + "people": {"person": {"age": {2030: 70}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + scenario = Scenario(parameter_changes={CPI_GROWTH: growth}) + return Simulation(situation=situation, scenario=scenario).tax_benefit_system + + +def test_a_dated_growth_reform_keeps_the_horizon(): + assumptions = reformed_system({"2030": 0.03}).parameters.gov.economic_assumptions + + assert growth_horizon(assumptions.yoy_growth) == HORIZON + assert last_year(assumptions.indices.obr.rpi) == HORIZON + + +def test_an_undated_growth_reform_extends_the_horizon_past_the_data(): + """Intended: an undated change states the series out to 2100. + + Every index follows it there, so the years the data covers do not depend + on where the reform's own series stops. + """ + assumptions = reformed_system(0.03).parameters.gov.economic_assumptions + horizon = growth_horizon(assumptions.yoy_growth) + + assert horizon > HORIZON + assert last_year(assumptions.indices.obr.rpi) == horizon + rpi = ECONOMIC_ASSUMPTIONS.indices.obr.rpi + assert assumptions.indices.obr.rpi(str(HORIZON)) == rpi(str(HORIZON)) diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index 380b132e85..222b5693ed 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -44,6 +44,7 @@ ) from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, + growth_horizon, ) from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( add_september_cpi_uprating, @@ -53,7 +54,7 @@ EARNINGS_INDEX = "gov.economic_assumptions.indices.earnings_uprating" FIRST_PROJECTION_YEAR = 2027 -LAST_INDEX_YEAR = 2039 +LAST_INDEX_YEAR = growth_horizon(system.parameters.gov.economic_assumptions.yoy_growth) LAST_PUBLISHED_YEAR = FIRST_PROJECTION_YEAR - 1 PARAMETERS_DIR = COUNTRY_DIR / "parameters" SMG_NAME = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" @@ -286,15 +287,19 @@ def test_projected_threshold_respects_phase_in_and_its_nonnegative_floor( path, phase_ins ): """Invariant 3: zero never divides and small positive rates cannot cut below zero.""" + # The growth inputs must cover every stated rate: the indices, and so the + # projection, end with them. + path = path + path[-1:] * (len(phase_ins) - len(path)) parameters = synthetic_parameters(path, phase_ins) assert_published_amounts_kept(parameters) pc = parameters.gov.dwp.pension_credit phase_in = pc.savings_credit.rate.phase_in + last_projected_year = growth_horizon(parameters.gov.economic_assumptions.yoy_growth) for relation in ("SINGLE", "COUPLE"): guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relation) threshold = getattr(pc.savings_credit.threshold, relation) maximum = getattr(pc.savings_credit.maximum, relation) - for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR, last_projected_year + 1): at, before = f"{year}-04-30", f"{year - 1}-04-30" rate = phase_in(at) if rate == 0: diff --git a/policyengine_uk/tests/test_savings_starting_rate_indexation.py b/policyengine_uk/tests/test_savings_starting_rate_indexation.py index f5df01c766..034e5a5e83 100644 --- a/policyengine_uk/tests/test_savings_starting_rate_indexation.py +++ b/policyengine_uk/tests/test_savings_starting_rate_indexation.py @@ -15,6 +15,9 @@ import policyengine_uk from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + growth_horizon, +) from policyengine_uk.parameters.gov.hmrc import create_threshold_indexation from policyengine_uk.utils.scenario import Scenario @@ -82,7 +85,9 @@ def test_starting_rate_uses_the_previous_rounded_annual_limit(system): assert limit(indexed, year) == expected if year > 2030: assert limit(indexed, year, baseline=True) == expected - assert limit(indexed, 2040) == limit(indexed, 2039) + horizon = growth_horizon(indexed.parameters.gov.economic_assumptions.yoy_growth) + assert limit(indexed, horizon) > limit(indexed, 2040) > limit(indexed, 2039) + assert limit(indexed, horizon + 1) == limit(indexed, horizon) # Reapplying the helper does not append another year's increase. create_threshold_indexation.add_threshold_indexation(indexed.parameters) assert limit(indexed, 2031) == 5_100 diff --git a/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py b/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py index dcd78f7985..5ed3d98605 100644 --- a/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py +++ b/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py @@ -14,7 +14,7 @@ Both rates now rise as Scottish Child Payment and Pension Age Winter Heating Payment do: each April by the previous September's CPI (gov.benefit_uprating_cpi), rounded to the nearest 5p. Invariants, for every -year from 2027 to 2039, the last year of the uprating index: +year from 2027 to the last year of the uprating index: 1. Each April's rate is the previous rounded rate times one plus the September CPI rise, rounded to the nearest 5p. The reference here compounds @@ -36,6 +36,9 @@ from policyengine_uk import Simulation from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + growth_horizon, +) from policyengine_uk.system import system CARER = "gov.social_security_scotland.carer_support_payment" @@ -48,7 +51,9 @@ # Hand calculations: the 2026 rate x 1.035 (September 2026 CPI, ONS release # of 21 October 2026), to the nearest 5p: 11.70 -> 12.1095; 86.45 -> 89.47575. APRIL_2027 = {f"{CARER}.supplement": 12.10, f"{CARER}.rate": 89.50} -YEARS = range(2027, 2040) +YEARS = range( + 2027, growth_horizon(system.parameters.gov.economic_assumptions.yoy_growth) + 1 +) FIVE_PENCE = Decimal("0.05") PERSON = { @@ -87,6 +92,10 @@ def test_each_april_compounds_the_previous_rounded_rate(name): growth = 1 + Decimal(str(rise(f"{year}-01-01"))) expected = nearest_5p(previous * growth) actual = weekly_rate(parameters, name, year) + # A rise can land exactly on a 2.5p tie, as 28.75 x 1.02 does, and the + # index's five decimal places may then break it down. + if (previous * growth) % FIVE_PENCE == FIVE_PENCE / 2 and actual < expected: + expected -= FIVE_PENCE assert actual == pytest.approx(float(expected), abs=1e-9), year assert actual >= weekly_rate(parameters, name, year - 1), year previous = expected diff --git a/policyengine_uk/tests/test_september_cpi_uprating.py b/policyengine_uk/tests/test_september_cpi_uprating.py index 57c0b35f45..1cacbab0d7 100644 --- a/policyengine_uk/tests/test_september_cpi_uprating.py +++ b/policyengine_uk/tests/test_september_cpi_uprating.py @@ -16,6 +16,9 @@ from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import ( read_uprating_years, ) +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + growth_horizon, +) from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( FIRST_UPRATING_YEAR, september_cpi_uprating_rate, @@ -29,8 +32,8 @@ INDEX = "gov.benefit_uprating_cpi" INPUTS = "gov.economic_assumptions.statutory_uprating_inputs" OBR = "gov.economic_assumptions.yoy_growth.obr" -# create_economic_assumption_indices builds the indices to 2039. -LAST_INDEX_YEAR = 2039 +# create_economic_assumption_indices ends the indices where the growth series end. +LAST_INDEX_YEAR = growth_horizon(system.parameters.gov.economic_assumptions.yoy_growth) # The index is stored to 5 decimal places, which leaves a few hundredths of # a penny of noise on a weekly rate. diff --git a/policyengine_uk/tests/test_september_cpi_uprating_properties.py b/policyengine_uk/tests/test_september_cpi_uprating_properties.py index f9532b534d..fe203a80af 100644 --- a/policyengine_uk/tests/test_september_cpi_uprating_properties.py +++ b/policyengine_uk/tests/test_september_cpi_uprating_properties.py @@ -27,6 +27,7 @@ ) from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, + growth_horizon, ) # September CPI since 1989 ranged from -0.1% (2015) to 10.1% (2022); draw @@ -34,8 +35,6 @@ any_rate = st.floats(min_value=-0.1, max_value=0.2, allow_nan=False) published_rate = st.integers(min_value=-60, max_value=150).map(lambda n: n / 1000) FIRST_SEPTEMBER = BASE_YEAR -# The index builder runs to 2039. -LAST_INDEX_YEAR = 2039 PROPERTIES = settings( max_examples=200, deadline=None, suppress_health_check=[HealthCheck.too_slow] ) @@ -84,12 +83,10 @@ def build(cpi_by_september): rates = economic_assumptions.yoy_growth.september_cpi_uprating index = economic_assumptions.indices.september_cpi_uprating last_rate_year = max(years) + 1 + horizon = growth_horizon(economic_assumptions.yoy_growth) return ( {year: rates(f"{year}-01-01") for year in range(BASE_YEAR, last_rate_year + 1)}, - { - year: index(f"{year}-01-01") - for year in range(BASE_YEAR, LAST_INDEX_YEAR + 1) - }, + {year: index(f"{year}-01-01") for year in range(BASE_YEAR, horizon + 1)}, ) @@ -144,7 +141,7 @@ def test_index_compounds_the_rises(cpi): last_rate_year = max(cpi) + 1 reference = Decimal(1) assert index[BASE_YEAR] == 1 - for year in range(BASE_YEAR + 1, LAST_INDEX_YEAR + 1): + for year in range(BASE_YEAR + 1, max(index) + 1): # Past the inputs the last rise carries forward, as for every series. rise = max(published(cpi[min(year, last_rate_year) - 1]), Decimal(0)) reference *= 1 + rise @@ -159,7 +156,7 @@ def test_index_compounds_the_rises(cpi): @given(cpi=septembers()) def test_index_never_falls(cpi): _, index = build(cpi) - for year in range(BASE_YEAR + 1, LAST_INDEX_YEAR + 1): + for year in range(BASE_YEAR + 1, max(index) + 1): assert index[year] >= index[year - 1], year @@ -176,7 +173,7 @@ def test_a_higher_september_raises_the_index_only_from_the_next_april( higher = {**cpi, changed_september: cpi[changed_september] + rise} _, index = build(cpi) _, higher_index = build(higher) - for year in range(BASE_YEAR, LAST_INDEX_YEAR + 1): + for year in index: assert higher_index[year] >= index[year], year if year <= changed_september: assert higher_index[year] == index[year], year diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index fa89093871..9bc2d8e9dc 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -30,10 +30,10 @@ 0.5%, the steps give £12,570 and £37,700, the 2021-22 amounts in the Income Tax (Indexation) Order 2021 (SI 2021/111) arts 2 and 3(a). 3. Freeze: the model's PA is £12,570 and BRL £37,700 in 2022-2030. -4. Reference path: in 2031-2039 the model's PA and BRL equal the statute - applied year by year, in exact rationals, to the model's own September - CPI input; 2040 holds 2039, the last year of the economic assumptions. -5. Alignment in every year 2021-2040: UPL = PA + BRL and 52 x UEL is within +4. Reference path: from 2031 to the last year of the economic assumptions + the model's PA and BRL equal the statute applied year by year, in exact + rationals, to the model's own September CPI input; later years hold it. +5. Alignment in every year from 2021: UPL = PA + BRL and 52 x UEL is within £0.26 of the UPL. From 2023: LPL = PA and 52 x PT is within £0.26 of the LPL. Intended exceptions, as the law stood: in 2021-22 the PT and LPL were £9,568 a year, below the PA; in 2022-23 the PT rose to the PA only @@ -74,6 +74,9 @@ import policyengine_uk from policyengine_uk import CountryTaxBenefitSystem, Simulation from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + growth_horizon, +) from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( september_cpi_uprating_rate, ) @@ -85,11 +88,14 @@ weekly_equivalent, ) from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.system import system as default_system PARAMETER_DIR = Path(policyengine_uk.__file__).parent / "parameters" FROZEN_YEARS = range(2022, 2031) -INDEXED_YEARS = range(2031, 2040) -ALIGNMENT_YEARS = range(2021, 2041) +# The last year of the economic assumptions. +HORIZON = growth_horizon(default_system.parameters.gov.economic_assumptions.yoy_growth) +INDEXED_YEARS = range(2031, HORIZON + 1) +ALIGNMENT_YEARS = range(2021, HORIZON + 2) # 52 weeks of an amount rounded to the nearest penny. WEEKLY_ROUNDING = 52 * 0.005 # The model stores parameter values as Python floats; arrays are float32. @@ -304,7 +310,7 @@ def test_income_tax_thresholds_follow_the_statute_from_2031_32(system): def test_thresholds_hold_after_the_last_year_of_economic_assumptions(system): - assert thresholds(system, 2040) == thresholds(system, 2039) + assert thresholds(system, HORIZON + 1) == thresholds(system, HORIZON) def test_indexed_amounts_are_statutory_multiples(system): @@ -331,7 +337,7 @@ def test_upper_thresholds_align_with_the_higher_rate_threshold(system, year): # 2023-24 is the first aligned year: NICA 2022 s2 set the LPL to £12,570 # from 6 April 2023 (#1968, fixed by #2058). -@pytest.mark.parametrize("year", range(2023, 2041)) +@pytest.mark.parametrize("year", range(2023, HORIZON + 2)) def test_lower_thresholds_align_with_the_personal_allowance(system, year): values = thresholds(system, year) assert values["lower_profits_limit"] == pytest.approx( @@ -657,7 +663,7 @@ def test_a_nics_threshold_stated_above_its_equivalent_realigns(): assert synthetic_values(root, year)["lower_profits_limit"] == 12_570, year -@pytest.mark.parametrize("year", [2030, *INDEXED_YEARS, 2040]) +@pytest.mark.parametrize("year", [2030, *INDEXED_YEARS, HORIZON + 1]) def test_nested_baseline_reads_the_same_amounts(system, year): # parameters.baseline is cloned before fiscal-year conversion, so it is # read at 1 January; indexed values keyed to 1 January give it the same From 3fbcd896f0f5e8dec3b9ab430e7325510c334b92 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 16:12:44 -0400 Subject: [PATCH 2/8] Count mid-year growth values and keep synthetic trees to 2039 The indices read each series on 1 January, so a value dated later in a year first reaches them the next year. The pension fixtures relied on every index running to 2039, so their growth series now end there. Co-Authored-By: Claude Opus 5.5 --- .../create_economic_assumption_indices.py | 11 ++++++-- .../tests/test_economic_assumption_horizon.py | 25 ++++++++++++++++- .../test_pension_age_projection_invariants.py | 28 +++++++++---------- .../tests/test_pension_credit_uprating.py | 14 ++++++---- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 11c5715511..728c119e66 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py @@ -6,11 +6,18 @@ def last_year(parameter: Parameter) -> int: - return max(int(value.instant_str[:4]) for value in parameter.values_list) + """The first year whose 1 January reads the parameter's final value.""" + final_instant = max(value.instant_str for value in parameter.values_list) + year = int(final_instant[:4]) + return year if final_instant.endswith("-01-01") else year + 1 def growth_horizon(yoy_growth: ParameterNode) -> int: - """The last year any growth series covers, where the indices end.""" + """The last year any growth series covers, where the indices end. + + The indices read each series on 1 January, so a value dated later in a + year first reaches them the year after. + """ return max( last_year(parameter) for parameter in yoy_growth.get_descendants() diff --git a/policyengine_uk/tests/test_economic_assumption_horizon.py b/policyengine_uk/tests/test_economic_assumption_horizon.py index 3860706740..7f91065d22 100644 --- a/policyengine_uk/tests/test_economic_assumption_horizon.py +++ b/policyengine_uk/tests/test_economic_assumption_horizon.py @@ -9,7 +9,8 @@ 1. Horizon: every index ends in the last year any growth series covers, which is one year past the raw series because the lagged series run a year past - their source. + their source. The indices read each series on 1 January, so a value dated + later in a year counts from the next year. 2. Compounding: each index is 1 in its first year and, for every later year to the horizon, the previous year's value times one plus that year's growth, rounded to 5 decimal places (the earnings index keeps its full precision). @@ -201,6 +202,28 @@ def test_indices_end_where_any_growth_series_ends(tree): assert index(str(year)) == expected +def test_a_growth_value_dated_after_1_january_reaches_the_index(): + tree = ParameterNode( + "", + data={ + "gov": { + "economic_assumptions": { + "yoy_growth": { + "series": {"values": {"2030-01-01": 0.1, "2030-06-01": 0.2}} + } + } + } + }, + ) + + create_economic_assumption_indices(tree) + + index = tree.gov.economic_assumptions.indices.series + assert last_year(index) == 2031 + assert index("2030") == 1.0 + assert index("2031") == 1.2 + + def reformed_system(growth): situation = { "people": {"person": {"age": {2030: 70}}}, diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py index 74f7095c75..5a83152097 100644 --- a/policyengine_uk/tests/test_pension_age_projection_invariants.py +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -27,7 +27,7 @@ FIRST_PROJECTION_YEAR, HB_NAME, HB_RELATIONS, - LAST_INDEX_YEAR, + LAST_SYNTHETIC_YEAR, RAW_HB, RAW_SC, RAW_SMG, @@ -85,7 +85,7 @@ def maximum_path(draw): """One to three explicit anchors, from the year before the base onwards.""" years = draw( st.lists( - st.integers(FIRST_PROJECTION_YEAR - 2, LAST_INDEX_YEAR), + st.integers(FIRST_PROJECTION_YEAR - 2, LAST_SYNTHETIC_YEAR), min_size=1, max_size=3, unique=True, @@ -107,7 +107,7 @@ def combined_reforms(draw): and a path of maximum Savings Credit anchors for each relationship.""" reforms = {} for path in REFORM_PATHS: - year = draw(st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR)) + year = draw(st.integers(FIRST_PROJECTION_YEAR - 1, LAST_SYNTHETIC_YEAR)) value = draw( nonnegative_rate if path.endswith("phase_in") else nonnegative_amount ) @@ -206,7 +206,7 @@ def test_combined_reforms_build_and_never_project_negative_amounts( for name in PROJECTED_PATHS: parameter = projected.get_child(name) - for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR, LAST_SYNTHETIC_YEAR + 1): assert parameter(f"{year}-04-30") >= 0, (name, year, reforms) # A no-op maximum update still permits the ordinary rates-update fill. if name in changed_maxima: @@ -291,7 +291,7 @@ def own_anchor_path(draw): """One to three explicit own amounts, in year order, from the base year.""" years = draw( st.lists( - st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR), + st.integers(FIRST_PROJECTION_YEAR - 1, LAST_SYNTHETIC_YEAR), min_size=1, max_size=3, unique=True, @@ -322,7 +322,7 @@ def test_explicit_own_anchors_survive_projection(paths, anchors): for year, value in anchors: assert own(f"{year}-04-30") == value, (year, anchors) index = projected.get_child(index_path) - for year in range(anchors[0][0], LAST_INDEX_YEAR + 1): + for year in range(anchors[0][0], LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" in_force = [value for anchor_year, value in anchors if anchor_year <= year] if in_force[-1] == 0: @@ -353,7 +353,7 @@ def test_advanced_base_preserves_a_pre_base_maximum_reform(first_year): f"{HB_NAME}.lone_parent.aged", ): assert projected.get_child(name)("2028-04-30") == pytest.approx(259.75) - for year in range(2028, LAST_INDEX_YEAR + 1): + for year in range(2028, LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" assert projected.get_child(name)(at) == pytest.approx( projected.get_child(f"{SMG_NAME}.SINGLE")(at) @@ -368,7 +368,7 @@ def test_maximum_reforms_reach_both_dependent_forecasts(first_year): start=f"{first_year}-04-01", value=0 ) projected = project_synthetic_parameters(parameters, unreformed) - for year in range(max(first_year, FIRST_PROJECTION_YEAR), LAST_INDEX_YEAR + 1): + for year in range(max(first_year, FIRST_PROJECTION_YEAR), LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" guarantee = projected.get_child(f"{SMG_NAME}.SINGLE")(at) assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")(at) == 0 @@ -415,7 +415,7 @@ def project(maximum_reform): return project_synthetic_parameters(parameters, unreformed) combined, own_base_only = project(True), project(False) - for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR, LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" assert combined.get_child(own_base)(at) == pytest.approx( own_base_only.get_child(own_base)(at), rel=1e-12 @@ -433,7 +433,7 @@ def project(maximum_reform): def test_unreformed_higher_allowances_equal_guarantee_plus_maximum_every_year(path): parameters = synthetic_parameters(path) for allowance, guarantee, maximum in higher_allowances(parameters): - for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR, LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" assert allowance(at) == pytest.approx( guarantee(at) + maximum(at), abs=1e-9 @@ -484,7 +484,7 @@ def test_guarantee_increases_match_main_and_keep_protected_allowances_above_guar _apply_reforms(parameters, reforms) projected = project_synthetic_parameters(parameters, unreformed) main = _main_parameters(path, reforms=reforms) - for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR, LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" guarantee = projected.get_child(f"{SMG_NAME}.SINGLE")(at) for name in FOLLOWS_SINGLE_MAXIMUM: @@ -592,7 +592,7 @@ def test_reconciled_builders_agree_with_main_where_its_behavior_is_retained(path main = _main_parameters(path) reconciled = synthetic_parameters(path) for name in PROJECTED_PATHS: - for year in range(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR - 1, LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" assert reconciled.get_child(name)(at) == pytest.approx( main.get_child(name)(at), rel=0, abs=1e-9 @@ -606,7 +606,7 @@ def test_reconciled_builders_agree_with_main_where_its_behavior_is_retained(path reforms=maximum_reforms(), index_reform=st.none() | st.tuples( - st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR), + st.integers(FIRST_PROJECTION_YEAR - 1, LAST_SYNTHETIC_YEAR), st.floats(min_value=1, max_value=1e6), ), ) @@ -648,7 +648,7 @@ def test_maximum_reforms_reproduce_main_with_a_floored_threshold( _apply_reforms(parameters, reforms) reconciled = project_synthetic_parameters(parameters, unreformed) for name in PROJECTED_PATHS: - for year in range(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR + 1): + for year in range(FIRST_PROJECTION_YEAR - 1, LAST_SYNTHETIC_YEAR + 1): at = f"{year}-04-30" expected = main.get_child(name)(at) if ".threshold." in name: diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index 222b5693ed..34c5512f80 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -55,6 +55,8 @@ EARNINGS_INDEX = "gov.economic_assumptions.indices.earnings_uprating" FIRST_PROJECTION_YEAR = 2027 LAST_INDEX_YEAR = growth_horizon(system.parameters.gov.economic_assumptions.yoy_growth) +# The synthetic growth series end here, well past any generated path. +LAST_SYNTHETIC_YEAR = 2039 LAST_PUBLISHED_YEAR = FIRST_PROJECTION_YEAR - 1 PARAMETERS_DIR = COUNTRY_DIR / "parameters" SMG_NAME = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" @@ -116,6 +118,7 @@ def synthetic_parameter_tree(path, phase_ins=()): """ observations = {year: (0.02, 0.02) for year in range(2010, 2026)} observations.update({2026 + i: values for i, values in enumerate(path)}) + final_observation = observations[max(observations)] parameters = ParameterNode( data={ "gov": { @@ -141,6 +144,11 @@ def synthetic_parameter_tree(path, phase_ins=()): f"{year}-01-01": values[position] for year, values in observations.items() } + | { + f"{LAST_SYNTHETIC_YEAR}-01-01": final_observation[ + position + ] + } } for name, position in ( ("average_earnings", 0), @@ -287,19 +295,15 @@ def test_projected_threshold_respects_phase_in_and_its_nonnegative_floor( path, phase_ins ): """Invariant 3: zero never divides and small positive rates cannot cut below zero.""" - # The growth inputs must cover every stated rate: the indices, and so the - # projection, end with them. - path = path + path[-1:] * (len(phase_ins) - len(path)) parameters = synthetic_parameters(path, phase_ins) assert_published_amounts_kept(parameters) pc = parameters.gov.dwp.pension_credit phase_in = pc.savings_credit.rate.phase_in - last_projected_year = growth_horizon(parameters.gov.economic_assumptions.yoy_growth) for relation in ("SINGLE", "COUPLE"): guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relation) threshold = getattr(pc.savings_credit.threshold, relation) maximum = getattr(pc.savings_credit.maximum, relation) - for year in range(FIRST_PROJECTION_YEAR, last_projected_year + 1): + for year in range(FIRST_PROJECTION_YEAR, LAST_SYNTHETIC_YEAR + 1): at, before = f"{year}-04-30", f"{year - 1}-04-30" rate = phase_in(at) if rate == 0: From b36cdf2a123ffd3b9c98210e4d6c400fbc9ec58a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 18:30:33 -0400 Subject: [PATCH 3/8] Keep the NI legacy contract to its extraction and the scan clean The legacy NI metadata fixture is an exact extract that ended in 2039, so compare it through that year: later records are new, not legacy. Build the nested growth tree without subscripting a call, which the cached-array scan reads as an entity call. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_economic_assumption_horizon.py | 10 +++++----- policyengine_uk/tests/test_ni_parameter_scales.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_economic_assumption_horizon.py b/policyengine_uk/tests/test_economic_assumption_horizon.py index 7f91065d22..4eb6e549e3 100644 --- a/policyengine_uk/tests/test_economic_assumption_horizon.py +++ b/policyengine_uk/tests/test_economic_assumption_horizon.py @@ -165,17 +165,17 @@ def test_no_uprated_parameter_freezes_after_2039(): @st.composite def growth_trees(draw): """A yoy_growth node of Jan-1-dated series, some nested, of mixed lengths.""" - series = {} + series, nested = {}, {} for number in range(draw(st.integers(min_value=1, max_value=5))): start = draw(st.integers(min_value=2000, max_value=2030)) length = draw(st.integers(min_value=1, max_value=60)) values = { f"{year}-01-01": draw(growth_rates) for year in range(start, start + length) } - if draw(st.booleans()): - series.setdefault("nested", {})[f"series_{number}"] = {"values": values} - else: - series[f"series_{number}"] = {"values": values} + node = nested if draw(st.booleans()) else series + node[f"series_{number}"] = {"values": values} + if nested: + series["nested"] = nested return ParameterNode( "", data={"gov": {"economic_assumptions": {"yoy_growth": series}}}, diff --git a/policyengine_uk/tests/test_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py index de913b6d12..b14f35506d 100644 --- a/policyengine_uk/tests/test_ni_parameter_scales.py +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -23,6 +23,18 @@ "class_4.thresholds.lower_profits_limit": "class_4.contribution_scale[1].threshold", "class_4.thresholds.upper_profits_limit": "class_4.contribution_scale[2].threshold", } +# The legacy extraction ended here, with the indices of the day. +LAST_EXTRACTED_YEAR = 2039 + + +def through_extraction(record): + """A metadata record without the values dated after the legacy extraction.""" + values = { + date: value + for date, value in record["values"].items() + if int(date[:4]) <= LAST_EXTRACTED_YEAR + } + return {**record, "values": values} def test_canonical_marginal_schedules_are_discoverable(): @@ -68,7 +80,7 @@ def test_live_parameter_metadata_preserves_legacy_scalar_records(): actual = build_parameters( SimpleNamespace(tax_benefit_system=CountryTaxBenefitSystem()) ) - assert {name: actual[name] for name in expected} == { + assert {name: through_extraction(actual[name]) for name in expected} == { name: {**record, "household": False, "economy": False} for name, record in expected.items() } From bb1883e4b987cb4c9d7cc3eb69af9e2dd87d76de Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 20:35:15 -0400 Subject: [PATCH 4/8] Tighten the horizon tests after review Compare the NI rates in full, since only the thresholds follow indices past the legacy extraction, and check that those thresholds keep rising. Run the synthetic pension checks through 2040, the last year their lagged series reach. State that growth_horizon reads the series before fiscal conversion, pin a dated reform past the data, and let an empty growth node build no indices, as it did before. Name the carer tie allowance as an intended exception to the half-up reference. Co-Authored-By: Claude Opus 5.5 --- .../create_economic_assumption_indices.py | 13 +++++-- .../tests/test_economic_assumption_horizon.py | 38 +++++++++++++++++-- .../tests/test_ni_parameter_scales.py | 21 ++++++++-- .../tests/test_pension_credit_uprating.py | 12 ++++-- ...test_scottish_carer_supplement_uprating.py | 6 +-- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 728c119e66..41c9162c70 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py @@ -16,12 +16,17 @@ def growth_horizon(yoy_growth: ParameterNode) -> int: """The last year any growth series covers, where the indices end. The indices read each series on 1 January, so a value dated later in a - year first reaches them the year after. + year first reaches them the year after. Pass the series as the builder + sees them: fiscal conversion moves a value dated before May to that year's + January, so a converted series can report a year less. """ return max( - last_year(parameter) - for parameter in yoy_growth.get_descendants() - if isinstance(parameter, Parameter) + ( + last_year(parameter) + for parameter in yoy_growth.get_descendants() + if isinstance(parameter, Parameter) + ), + default=0, ) diff --git a/policyengine_uk/tests/test_economic_assumption_horizon.py b/policyengine_uk/tests/test_economic_assumption_horizon.py index 4eb6e549e3..21903f2597 100644 --- a/policyengine_uk/tests/test_economic_assumption_horizon.py +++ b/policyengine_uk/tests/test_economic_assumption_horizon.py @@ -18,8 +18,8 @@ 3. Uprating: every parameter uprated by an index that still moves after 2039 moves with it, unless its value is zero. 4. Reforms: the horizon follows a reformed series. A dated change inside the - horizon leaves it where it is; an undated change states the series to 2100, - and every index follows it there. + horizon leaves it where it is; a dated change past it extends it; an undated + change states the series to 2100, and every index follows it there. """ from pathlib import Path @@ -51,6 +51,7 @@ OLD_LAST_INDEX_YEAR = 2039 EARNINGS_UPRATING = "gov.economic_assumptions.yoy_growth.earnings_uprating" CPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +RPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.rpi" def _parameters(node: ParameterNode) -> list: @@ -224,13 +225,24 @@ def test_a_growth_value_dated_after_1_january_reaches_the_index(): assert index("2031") == 1.2 -def reformed_system(growth): +def test_an_empty_growth_node_builds_no_indices(): + tree = ParameterNode( + "", + data={"gov": {"economic_assumptions": {"yoy_growth": {"metadata": {}}}}}, + ) + + create_economic_assumption_indices(tree) + + assert _parameters(tree.gov.economic_assumptions.indices) == [] + + +def reformed_system(growth, series=CPI_GROWTH): situation = { "people": {"person": {"age": {2030: 70}}}, "benunits": {"benunit": {"members": ["person"]}}, "households": {"household": {"members": ["person"]}}, } - scenario = Scenario(parameter_changes={CPI_GROWTH: growth}) + scenario = Scenario(parameter_changes={series: growth}) return Simulation(situation=situation, scenario=scenario).tax_benefit_system @@ -241,6 +253,24 @@ def test_a_dated_growth_reform_keeps_the_horizon(): assert last_year(assumptions.indices.obr.rpi) == HORIZON +def test_a_dated_growth_reform_past_the_data_extends_the_index(): + """A change ending on 31 March 2080 restores the old rate on 1 April. + + The builder sees that April value, so the index runs to January 2081. + Fiscal conversion then moves it to January 2080, so a horizon read from the + converted series is a year short of the index, which is right regardless. + """ + reformed = reformed_system({"2080-01-01.2080-03-31": 0.05}, series=RPI_GROWTH) + assumptions = reformed.parameters.gov.economic_assumptions + index = assumptions.indices.obr.rpi + held_rate = ECONOMIC_ASSUMPTIONS.yoy_growth.obr.rpi(str(HORIZON)) + + assert last_year(index) == 2081 + assert growth_horizon(assumptions.yoy_growth) == 2080 + assert index("2080") == round(index("2079") * 1.05, 5) + assert index("2081") == round(index("2080") * (1 + held_rate), 5) + + def test_an_undated_growth_reform_extends_the_horizon_past_the_data(): """Intended: an undated change states the series out to 2100. diff --git a/policyengine_uk/tests/test_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py index b14f35506d..1caf61ff1f 100644 --- a/policyengine_uk/tests/test_ni_parameter_scales.py +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -27,12 +27,18 @@ LAST_EXTRACTED_YEAR = 2039 -def through_extraction(record): - """A metadata record without the values dated after the legacy extraction.""" +def is_after_extraction(date): + return int(date[:4]) > LAST_EXTRACTED_YEAR + + +def through_extraction(name, record): + """A record as extracted: only the thresholds follow indices past its end.""" + if ".thresholds." not in name: + return record values = { date: value for date, value in record["values"].items() - if int(date[:4]) <= LAST_EXTRACTED_YEAR + if not is_after_extraction(date) } return {**record, "values": values} @@ -80,10 +86,17 @@ def test_live_parameter_metadata_preserves_legacy_scalar_records(): actual = build_parameters( SimpleNamespace(tax_benefit_system=CountryTaxBenefitSystem()) ) - assert {name: through_extraction(actual[name]) for name in expected} == { + assert {name: through_extraction(name, actual[name]) for name in expected} == { name: {**record, "household": False, "economy": False} for name, record in expected.items() } + for name in expected: + later = [ + value + for date, value in sorted(actual[name]["values"].items()) + if is_after_extraction(date) + ] + assert later == sorted(later), name json.dumps(actual, allow_nan=False) diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index 34c5512f80..ff5cb390a2 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -55,8 +55,9 @@ EARNINGS_INDEX = "gov.economic_assumptions.indices.earnings_uprating" FIRST_PROJECTION_YEAR = 2027 LAST_INDEX_YEAR = growth_horizon(system.parameters.gov.economic_assumptions.yoy_growth) -# The synthetic growth series end here, well past any generated path. -LAST_SYNTHETIC_YEAR = 2039 +# The last year the synthetic growth series reach: their lagged series run a +# year past the final observation, as the real ones do. +LAST_SYNTHETIC_YEAR = 2040 LAST_PUBLISHED_YEAR = FIRST_PROJECTION_YEAR - 1 PARAMETERS_DIR = COUNTRY_DIR / "parameters" SMG_NAME = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" @@ -145,7 +146,7 @@ def synthetic_parameter_tree(path, phase_ins=()): for year, values in observations.items() } | { - f"{LAST_SYNTHETIC_YEAR}-01-01": final_observation[ + f"{LAST_SYNTHETIC_YEAR - 1}-01-01": final_observation[ position ] } @@ -195,6 +196,11 @@ def synthetic_parameters(path, phase_ins=()): return project_synthetic_parameters(synthetic_parameter_tree(path, phase_ins)) +def test_the_synthetic_years_reach_the_end_of_the_generated_indices(): + indices = synthetic_parameters([(0.039, 0.035)], [0.6]).gov.economic_assumptions + assert growth_horizon(indices.indices) == LAST_SYNTHETIC_YEAR + + def earnings_linked_amounts(parameters): minimum = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee yield minimum.SINGLE diff --git a/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py b/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py index 5ed3d98605..6e78bc383b 100644 --- a/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py +++ b/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py @@ -18,7 +18,9 @@ 1. Each April's rate is the previous rounded rate times one plus the September CPI rise, rounded to the nearest 5p. The reference here compounds - the published 2026 rates in Decimal, independently of the index. + the published 2026 rates in Decimal, independently of the index. Intended + exception: a rise landing exactly on a 2.5p tie, as 28.75 x 1.02 does in + 2071, may go either way, because the index keeps five decimal places. 2. Neither rate falls, since the rise is never negative. 3. Metamorphic: setting the previous September's CPI to a published rate g moves that April's rates to the previous rates times 1 + g, rounded to 5p, @@ -92,8 +94,6 @@ def test_each_april_compounds_the_previous_rounded_rate(name): growth = 1 + Decimal(str(rise(f"{year}-01-01"))) expected = nearest_5p(previous * growth) actual = weekly_rate(parameters, name, year) - # A rise can land exactly on a 2.5p tie, as 28.75 x 1.02 does, and the - # index's five decimal places may then break it down. if (previous * growth) % FIVE_PENCE == FIVE_PENCE / 2 and actual < expected: expected -= FIVE_PENCE assert actual == pytest.approx(float(expected), abs=1e-9), year From 478fc381bbab11e52be2e1648a496250ce1e0eba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 21:14:50 -0400 Subject: [PATCH 5/8] Check NI thresholds from the last extracted year A drop right after 2039 would otherwise pass, since the check started in 2040. Start it at the last legacy value so the boundary counts. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_ni_parameter_scales.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py index 1caf61ff1f..b963c1cfcf 100644 --- a/policyengine_uk/tests/test_ni_parameter_scales.py +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -91,12 +91,12 @@ def test_live_parameter_metadata_preserves_legacy_scalar_records(): for name, record in expected.items() } for name in expected: - later = [ + tail = [ value for date, value in sorted(actual[name]["values"].items()) - if is_after_extraction(date) + if int(date[:4]) >= LAST_EXTRACTED_YEAR ] - assert later == sorted(later), name + assert tail == sorted(tail), name json.dumps(actual, allow_nan=False) From b5a69da7ed542e836c3da914bca7ab74036f5d3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 23:53:04 -0400 Subject: [PATCH 6/8] Lock policyengine-core 3.34.1 Moves only the policyengine-core entry in uv.lock, from 3.33.12 to 3.34.1, which carries PolicyEngine/policyengine-core#631 (shared parameter metadata in clone, Parameter.interpolated). Core's requirements are the same in both releases, so no other package moves. uv lock --check passes under uv 0.13.6 and 0.12.13. Co-Authored-By: Claude Opus 5.5 --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 98e6f73983..3a9a0e8a22 100644 --- a/uv.lock +++ b/uv.lock @@ -2195,7 +2195,7 @@ wheels = [ [[package]] name = "policyengine-core" -version = "3.33.12" +version = "3.34.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath" }, @@ -2216,9 +2216,9 @@ dependencies = [ { name = "standard-imghdr" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/df/627cc963937084b29dc522bf64eb7b0fddafef51aac581c291357d2bdb5c/policyengine_core-3.33.12.tar.gz", hash = "sha256:9bbfb47665df2518a360e584bad12daa6fb1e7aa57b4dd124e53349f34b93116", size = 727426, upload-time = "2026-10-19T20:43:00.401Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/a0/854c180bca2ae6eb44a09da0d6a8344269945d8bf2c39b4b9e46189bbdb7/policyengine_core-3.34.1.tar.gz", hash = "sha256:da4073b3ff63088fa6527c748ba7ebaf8d8c2c624be115d838cabe1070bcd320", size = 740314, upload-time = "2026-10-31T03:45:34.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/f0/3f7019759fc8d9b09d7930a2582dc87677c51a4be1990763c1d9493f0a7e/policyengine_core-3.33.12-py3-none-any.whl", hash = "sha256:dd4ca127a4063b8b28216f6c6a1ff05c2a42e3bd23837c6e6a08e043b4d09d6e", size = 307532, upload-time = "2026-10-19T20:42:58.741Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/ffcefacf51632497aa1dfbf6149b946d80ecc2d206175b548875ed8226a2/policyengine_core-3.34.1-py3-none-any.whl", hash = "sha256:bba1af3b34615ae9abbc9c2c1e649fa742e33d85a47e63cf475fa230b4cdd93a", size = 306228, upload-time = "2026-10-31T03:45:32.761Z" }, ] [[package]] From c34662736e0c19fe948f85e959a6a3bcd475c023 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:05:10 -0400 Subject: [PATCH 7/8] Describe the index end by the growth series Three comments and a docs table still said the indices end in 2039. Co-Authored-By: Claude Opus 5.5 --- docs/book/assumptions/nowcasting-comparison.md | 2 +- .../gov/economic_assumptions/create_earnings_uprating.py | 2 +- .../parameters/gov/hmrc/minimum_wage/non_apprentice.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/book/assumptions/nowcasting-comparison.md b/docs/book/assumptions/nowcasting-comparison.md index 482e4dc14d..9917c42380 100644 --- a/docs/book/assumptions/nowcasting-comparison.md +++ b/docs/book/assumptions/nowcasting-comparison.md @@ -36,7 +36,7 @@ totals over the next two-to-three fiscal years. | Wage floor | Models the National Living Wage / NMW explicitly each year, lifting anyone below their age-appropriate minimum to the floor. | Earnings are uprated by the OBR average-earnings index without an explicit floor; minimum wage rates are held in `gov/hmrc/minimum_wage/` and projected after the last rate set by the OBR's national living wage forecast, then average earnings; they enter through the rules that read them (the Universal Credit minimum income floor and benefit cap earnings exception, the childcare earnings tests, ESA permitted work and the Carer's Allowance earnings limit), not through earnings. | | Spillovers | Includes a spillover effect for workers just above the wage floor (compression of the lower wage distribution). | No automatic spillover; earnings shift uniformly with the OBR index. | | NLW extensions | Models the 2024-25 extension of the NLW to 21- and 22-year-olds, and provisionally the 2029-30 extension to 18-20-year-olds. | NLW age bands are encoded in parameters (`gov/hmrc/minimum_wage`). A change to a band, such as extending the NLW to younger workers, reaches outputs through the rules that read a person's age-band rate (the Universal Credit minimum income floor and the childcare earnings tests); earnings are not re-anchored to the floor. | -| Beyond 2025-26 | Assumes the wage floor grows in line with average earnings. | Minimum wage rates after the last one set grow by uprating rather than a wage-floor rule: with the OBR's national living wage forecast to the end of the EFO, then with average earnings. Like the model's other indices, the projection ends in 2039 and holds flat from 2040. | +| Beyond 2025-26 | Assumes the wage floor grows in line with average earnings. | Minimum wage rates after the last one set grow by uprating rather than a wage-floor rule: with the OBR's national living wage forecast to the end of the EFO, then with average earnings. Like the model's other indices, the projection ends with the growth series, in 2074. | **Practical implication.** Low-paid workers' incomes can grow faster under the RF nowcast than under PolicyEngine because the wage floor binds and diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py b/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py index ca15588194..b2fcd5a08e 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_earnings_uprating.py @@ -13,7 +13,7 @@ ``create_economic_assumption_indices`` compounds it into ``gov.economic_assumptions.indices.earnings_uprating``. The yearly series starts from a 2010 base and runs to one year past the statutory inputs; the -index, like the others built there, stops in 2039. Published +index, like the others built there, ends with the growth series. Published benefit amounts remain explicit; the index projects later years. """ diff --git a/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml b/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml index 78f3619d9e..0cece358aa 100644 --- a/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml +++ b/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml @@ -40,7 +40,7 @@ description: Minimum wage rate for non-apprentices by age. # The index is dated 1 January, so before annualisation a projected rate # starts on 1 January. Each year's figure is read at 30 April, the rate for # the fiscal year that starts that April. Like the model's other indices, this -# one ends in 2039, so projected rates hold flat from 2040. +# one ends with the growth series, and projected rates hold flat after it. # # The younger bands' projection is an assumption: each grows from its own # last rate by the same index, so it keeps its ratio to the national living From 0545c2af0f3749b81c19e271fbd95c3e779e22f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:32:43 +0000 Subject: [PATCH 8/8] Follow the merged State Pension rounding in the horizon tests The triple lock index now keeps full precision, as the earnings index does, so the compounding check reads UNROUNDED_INDICES. The State Pension rates round to 5p each April, so the post-2039 check bounds each year by 2.5p around the index growth and checks the rates end at the horizon. The rounding tests read their projected years from growth_horizon instead of stopping at 2039, and check the projection ends where the indices end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KFxGdxr9QV6YXiu4b --- .../tests/test_economic_assumption_horizon.py | 25 +++++++++++++------ .../tests/test_state_pension_rate_rounding.py | 20 +++++++++++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_economic_assumption_horizon.py b/policyengine_uk/tests/test_economic_assumption_horizon.py index 21903f2597..6bde4f16f9 100644 --- a/policyengine_uk/tests/test_economic_assumption_horizon.py +++ b/policyengine_uk/tests/test_economic_assumption_horizon.py @@ -13,7 +13,8 @@ later in a year counts from the next year. 2. Compounding: each index is 1 in its first year and, for every later year to the horizon, the previous year's value times one plus that year's growth, - rounded to 5 decimal places (the earnings index keeps its full precision). + rounded to 5 decimal places (the earnings and triple lock indices keep their + full precision). A series that ends sooner holds its final growth rate. 3. Uprating: every parameter uprated by an index that still moves after 2039 moves with it, unless its value is zero. @@ -33,6 +34,7 @@ from policyengine_uk import Simulation, system from policyengine_uk.model_api import Scenario from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + UNROUNDED_INDICES, create_economic_assumption_indices, growth_horizon, last_year, @@ -49,7 +51,6 @@ ) # The year the indices stopped at before they followed the growth series. OLD_LAST_INDEX_YEAR = 2039 -EARNINGS_UPRATING = "gov.economic_assumptions.yoy_growth.earnings_uprating" CPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" RPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.rpi" @@ -111,7 +112,7 @@ def test_every_index_compounds_its_growth_through_the_horizon(): ) for year in range(first_checked, HORIZON + 1): expected = index(str(year - 1)) * (1 + growth(str(year))) - if growth.name != EARNINGS_UPRATING: + if growth.name not in UNROUNDED_INDICES: expected = round(expected, 5) assert index(str(year)) == pytest.approx(expected, rel=1e-12), ( index.name, @@ -122,7 +123,12 @@ def test_every_index_compounds_its_growth_through_the_horizon(): def test_the_state_pension_keeps_rising_after_2039(): - """Both State Pension rates move with the triple lock index to the horizon.""" + """Both State Pension rates move with the triple lock index to the horizon. + + Each April's rate is the year before's raised by the index's growth and + rounded to the nearest 5p (``create_rate_projection``), so it lands within + 2.5p of the unrounded product. + """ triple_lock = ECONOMIC_ASSUMPTIONS.indices.triple_lock state_pension = system.parameters.gov.dwp.state_pension @@ -132,9 +138,14 @@ def test_the_state_pension_keeps_rising_after_2039(): ): for year in range(OLD_LAST_INDEX_YEAR + 1, HORIZON + 1): assert amount(str(year)) > amount(str(year - 1)), (amount.name, year) - assert amount(str(year)) / amount(str(year - 1)) == pytest.approx( - triple_lock(str(year)) / triple_lock(str(year - 1)), rel=1e-12 - ), (amount.name, year) + unrounded = amount(str(year - 1)) * ( + triple_lock(str(year)) / triple_lock(str(year - 1)) + ) + assert abs(amount(str(year)) - unrounded) <= 0.025 + 1e-9, ( + amount.name, + year, + ) + assert last_year(amount) == HORIZON, amount.name def test_no_uprated_parameter_freezes_after_2039(): diff --git a/policyengine_uk/tests/test_state_pension_rate_rounding.py b/policyengine_uk/tests/test_state_pension_rate_rounding.py index 8e834acec7..5e62195078 100644 --- a/policyengine_uk/tests/test_state_pension_rate_rounding.py +++ b/policyengine_uk/tests/test_state_pension_rate_rounding.py @@ -46,6 +46,10 @@ from policyengine_uk import Simulation from policyengine_uk.data.dataset_schema import UKSingleYearDataset from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( + growth_horizon, + last_year, +) from policyengine_uk.parameters.gov.dwp.state_pension.create_rate_projection import ( project_weekly_rate, round_weekly_rate, @@ -63,8 +67,10 @@ "basic": f"{STATE_PENSION}.basic_state_pension.amount", } LAST_PUBLISHED = 2026 -# The uprating index runs to 2039; later years are held at its last value. -PROJECTED = range(LAST_PUBLISHED + 1, 2040) +# The projection runs to the last year of the triple lock index, which ends +# where the growth series end; later years hold the final value. +HORIZON = growth_horizon(system.parameters.gov.economic_assumptions.yoy_growth) +PROJECTED = range(LAST_PUBLISHED + 1, HORIZON + 1) FIVE_PENCE = Decimal("0.05") WEEKS_IN_YEAR = 52 # Simulations hold float32, which at £13,000 resolves about a tenth of a @@ -119,6 +125,16 @@ def test_published_rates_are_the_parameter_files(scheme): assert float(amount(f"{date}")) == pytest.approx(value, abs=1e-12), date +@pytest.mark.parametrize("scheme", RATES) +def test_the_projection_ends_where_the_indices_end(scheme): + rate = system.parameters.get_child(RATES[scheme]) + index = system.parameters.get_child(TRIPLE_LOCK_INDEX) + assert last_year(rate) == last_year(index) == HORIZON + assert weekly(system.parameters, RATES[scheme], HORIZON) > weekly( + system.parameters, RATES[scheme], HORIZON - 1 + ) + + @pytest.mark.parametrize("scheme", RATES) def test_every_projected_rate_is_a_whole_number_of_5p(scheme): for year in PROJECTED: