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/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/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index ada614d612..6c44e0db57 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -193,7 +193,7 @@ year's RPI growth from the OBR forecast in hundredth of a penny. The script writes the steps to 1 April 2031, and a test fails when they fall out of date with the RPI forecast. After a new EFO, set the script's July rate and RPI growth to its forecast and rerun it. 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 88543fbde4..55500e49da 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -120,13 +120,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`. @@ -179,9 +180,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_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/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 90ecde8187..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 @@ -5,11 +5,37 @@ ) +def last_year(parameter: Parameter) -> int: + """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 indices read each series on 1 January, so a value dated later in a + 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) + ), + default=0, + ) + + 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 +66,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/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 diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 6db84d01f4..726b7c2af8 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -118,7 +118,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..21903f2597 --- /dev/null +++ b/policyengine_uk/tests/test_economic_assumption_horizon.py @@ -0,0 +1,286 @@ +"""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. 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). + 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; 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 + +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" +RPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.rpi" + + +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, 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) + } + 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}}}, + ) + + +@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 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 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={series: 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_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. + + 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_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py index de913b6d12..b963c1cfcf 100644 --- a/policyengine_uk/tests/test_ni_parameter_scales.py +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -23,6 +23,24 @@ "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 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 not is_after_extraction(date) + } + return {**record, "values": values} def test_canonical_marginal_schedules_are_discoverable(): @@ -68,10 +86,17 @@ 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(name, actual[name]) for name in expected} == { name: {**record, "household": False, "economy": False} for name, record in expected.items() } + for name in expected: + tail = [ + value + for date, value in sorted(actual[name]["values"].items()) + if int(date[:4]) >= LAST_EXTRACTED_YEAR + ] + assert tail == sorted(tail), name json.dumps(actual, allow_nan=False) 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 380b132e85..ff5cb390a2 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,10 @@ 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) +# 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" @@ -115,6 +119,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": { @@ -140,6 +145,11 @@ def synthetic_parameter_tree(path, phase_ins=()): f"{year}-01-01": values[position] for year, values in observations.items() } + | { + f"{LAST_SYNTHETIC_YEAR - 1}-01-01": final_observation[ + position + ] + } } for name, position in ( ("average_earnings", 0), @@ -186,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 @@ -294,7 +309,7 @@ def test_projected_threshold_respects_phase_in_and_its_nonnegative_floor( 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_SYNTHETIC_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 ce6eb5eca9..6dbff40430 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..6e78bc383b 100644 --- a/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py +++ b/policyengine_uk/tests/test_scottish_carer_supplement_uprating.py @@ -14,11 +14,13 @@ 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 - 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, @@ -36,6 +38,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 +53,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 +94,8 @@ 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) + 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 diff --git a/uv.lock b/uv.lock index d3b7398694..08bfa4a4a9 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]]