From c7c67de47d0d69399cb65090de889c59815ca433 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:28:54 +0400 Subject: [PATCH 1/4] Add failing tests for dataset supported years (#729) Tests for a public supported_years(country_id, dataset_path) that builds a single-year dataset's projection with the country model's extend_single_year_dataset, and for create_datasets and ensure_datasets refusing any other year with UnsupportedDatasetYearError before creating a year file, unless allow_unsupported_years=True. Existing tests that mock create_datasets now expect the new keyword. Co-Authored-By: Claude Opus 5.5 --- tests/test_dataset_years.py | 673 +++++++++++++++++++++++++++++++ tests/test_release_manifests.py | 1 + tests/test_us_year_file_stamp.py | 8 +- 3 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 tests/test_dataset_years.py diff --git a/tests/test_dataset_years.py b/tests/test_dataset_years.py new file mode 100644 index 00000000..3c200334 --- /dev/null +++ b/tests/test_dataset_years.py @@ -0,0 +1,673 @@ +"""The years a country model supports a single-year dataset for (#729). + +A supported year is one the installed country model projects its single-year +dataset to. ``supported_years`` builds the multi-year dataset with the +model's ``extend_single_year_dataset``, as the model's own ``Simulation`` +does for a single-year file, and keeps the years from the data year onwards. +policyengine-core fills any other year by carrying the latest projected +inputs forward or uprating them, so ``create_datasets`` and +``ensure_datasets`` refuse such a year before creating any year file, unless +``allow_unsupported_years=True``. + +Most tests stub the country model and the dataset, so they run without +either. One test per country runs the rule on the installed model with a tiny +dataset, and one per country runs it on the bundle's default dataset when +``POLICYENGINE_UK_DEFAULT_DATASET`` or ``POLICYENGINE_US_DEFAULT_DATASET`` +names that file. +""" + +from __future__ import annotations + +import importlib +import inspect +import os +import pickle +import sys +import types +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pandas as pd +import pytest + +from policyengine.tax_benefit_models.uk import datasets as uk_datasets +from policyengine.tax_benefit_models.us import datasets as us_datasets + +COUNTRY_DATASET_MODULES = {"uk": uk_datasets, "us": us_datasets} +COUNTRY_PACKAGES = {"uk": "policyengine_uk", "us": "policyengine_us"} +UK_PARAMETERS = object() + + +@pytest.fixture +def dataset_years(): + """The module under test, imported per test so each test fails alone.""" + + return importlib.import_module("policyengine.provenance.dataset_years") + + +def _install_modules(monkeypatch, modules: dict[str, dict[str, object]]) -> None: + """Serve stand-in modules under the given names for one test.""" + + for module_name, attributes in modules.items(): + module = types.ModuleType(module_name) + for attribute_name, value in attributes.items(): + setattr(module, attribute_name, value) + monkeypatch.setitem(sys.modules, module_name, module) + + +def _stub_uk_model(monkeypatch, *, data_year, projected_years, multi_year=None): + """Stand in for policyengine-uk; returns the extension's recorded calls.""" + + extension_calls = [] + + def load_single_year_dataset(file_path): + return SimpleNamespace(time_period=str(data_year), file_path=file_path) + + def extend_single_year_dataset(dataset, parameters): + extension_calls.append((dataset, parameters)) + if multi_year is not None: + return multi_year + return SimpleNamespace(years=list(projected_years)) + + _install_modules( + monkeypatch, + { + "policyengine_uk.data.dataset_schema": { + "UKSingleYearDataset": load_single_year_dataset + }, + "policyengine_uk.data.economic_assumptions": { + "extend_single_year_dataset": extend_single_year_dataset + }, + "policyengine_uk.system": { + "system": SimpleNamespace(parameters=UK_PARAMETERS) + }, + }, + ) + return extension_calls + + +def _stub_us_model(monkeypatch, *, data_year, projected_years, forecast_years): + """Stand in for policyengine-us and spm-calculator; returns the + extension's recorded calls.""" + + extension_calls = [] + + def load_single_year_dataset(file_path): + return SimpleNamespace(time_period=str(data_year), file_path=file_path) + + def extend_single_year_dataset(*arguments, **keyword_arguments): + extension_calls.append((arguments, keyword_arguments)) + return SimpleNamespace(years=list(projected_years)) + + _install_modules( + monkeypatch, + { + "policyengine_us.data.dataset_schema": { + "USSingleYearDataset": load_single_year_dataset + }, + "policyengine_us.data.economic_assumptions": { + "extend_single_year_dataset": extend_single_year_dataset + }, + "spm_calculator.rolling_forecast": { + "load_forecast": lambda: SimpleNamespace(years=tuple(forecast_years)) + }, + }, + ) + return extension_calls + + +# --- The UK rule -------------------------------------------------------------- + + +def test_uk_years_run_from_the_data_year_to_the_projection_end( + monkeypatch, dataset_years +): + _stub_uk_model(monkeypatch, data_year=2024, projected_years=range(2024, 2033)) + + assert dataset_years.supported_years("uk", "data.h5") == list(range(2024, 2033)) + + +def test_uk_years_the_projection_holds_before_the_data_year_are_not_supported( + monkeypatch, dataset_years +): + _stub_uk_model( + monkeypatch, data_year=2025, projected_years=[2023, 2024, 2025, 2026] + ) + + assert dataset_years.supported_years("uk", "data.h5") == [2025, 2026] + + +def test_uk_projection_is_built_as_policyengine_uk_simulation_builds_it( + monkeypatch, dataset_years +): + """The single-year file is extended with the system's parameters and the + model's default projection end, as ``policyengine_uk.Simulation`` does.""" + + extension_calls = _stub_uk_model( + monkeypatch, data_year=2024, projected_years=[2024, 2025] + ) + + dataset_years.supported_years("uk", Path("source.h5")) + + ((extended_dataset, parameters),) = extension_calls + assert extended_dataset.file_path == "source.h5" + assert parameters is UK_PARAMETERS + + +def test_uk_projection_without_a_years_attribute_reads_its_datasets( + monkeypatch, dataset_years +): + multi_year = SimpleNamespace(datasets={2026: None, 2024: None, 2025: None}) + _stub_uk_model( + monkeypatch, data_year=2024, projected_years=[], multi_year=multi_year + ) + + assert dataset_years.supported_years("uk", "data.h5") == [2024, 2025, 2026] + + +def _write_tiny_uk_dataset(path: Path, fiscal_year: int = 2024) -> Path: + from policyengine_uk.data import UKSingleYearDataset + + UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + {"household_id": [1], "household_weight": [1.0], "region": ["LONDON"]} + ), + fiscal_year=fiscal_year, + ).save(str(path)) + return path + + +def test_uk_rule_on_the_installed_model_ends_at_its_default_projection_end( + tmp_path, dataset_years +): + pytest.importorskip("policyengine_uk") + from policyengine_uk.data.capital_gains_projection import CGT_INDEX_PATH + from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR + from policyengine_uk.system import system + + capital_gains_projection = system.parameters.get_child(CGT_INDEX_PATH).metadata[ + "projection" + ] + projection_end = min( + PROJECTION_END_YEAR, + capital_gains_projection.get("end_year", PROJECTION_END_YEAR), + ) + path = _write_tiny_uk_dataset(tmp_path / "tiny_uk_2024.h5") + + assert dataset_years.supported_years("uk", path) == list( + range(2024, projection_end + 1) + ) + + +# --- The US rule -------------------------------------------------------------- + + +def test_us_years_end_at_the_policyengine_us_projection_end(monkeypatch, dataset_years): + _stub_us_model( + monkeypatch, + data_year=2024, + projected_years=range(2024, 2036), + forecast_years=range(2022, 2041), + ) + + assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2036)) + + +def test_us_years_after_the_last_spm_forecast_year_are_not_supported( + monkeypatch, dataset_years +): + """With policyengine-us#10227 the projection reaches 2036, but the SPM + forecast of spm-calculator 1.0.0 ends in 2035.""" + + _stub_us_model( + monkeypatch, + data_year=2024, + projected_years=range(2024, 2037), + forecast_years=range(2022, 2036), + ) + + assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2036)) + + +def test_us_allowance_adds_2022_and_2023_before_the_data_year( + monkeypatch, dataset_years +): + _stub_us_model( + monkeypatch, + data_year=2024, + projected_years=range(2024, 2031), + forecast_years=range(2022, 2036), + ) + + assert dataset_years.US_EARLY_YEAR_ALLOWANCE == frozenset({2022, 2023}) + assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2031)) + + +def test_us_years_before_the_data_year_outside_the_allowance_are_not_supported( + monkeypatch, dataset_years +): + _stub_us_model( + monkeypatch, + data_year=2024, + projected_years=range(2019, 2031), + forecast_years=range(2018, 2036), + ) + + assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2031)) + + +def test_removing_the_us_allowance_is_a_change_to_one_constant( + monkeypatch, dataset_years +): + _stub_us_model( + monkeypatch, + data_year=2024, + projected_years=range(2024, 2036), + forecast_years=range(2022, 2036), + ) + monkeypatch.setattr(dataset_years, "US_EARLY_YEAR_ALLOWANCE", frozenset()) + + assert dataset_years.supported_years("us", "data.h5") == list(range(2024, 2036)) + + +def test_us_projection_uses_the_models_default_end_year(monkeypatch, dataset_years): + """``policyengine_us.Microsimulation`` passes the single-year dataset alone, + so ``extend_single_year_dataset`` projects to its default end year.""" + + extension_calls = _stub_us_model( + monkeypatch, + data_year=2024, + projected_years=range(2024, 2036), + forecast_years=range(2022, 2036), + ) + + dataset_years.supported_years("us", Path("source.h5")) + + ((arguments, keyword_arguments),) = extension_calls + assert [dataset.file_path for dataset in arguments] == ["source.h5"] + assert keyword_arguments == {} + + +def _write_tiny_us_dataset(path: Path, year: int = 2024) -> Path: + person = pd.DataFrame( + { + "person_id": [1], + "person_household_id": [1], + "person_tax_unit_id": [1], + "person_spm_unit_id": [1], + "person_family_id": [1], + "person_marital_unit_id": [1], + "person_weight": [1.0], + "age": [40], + "employment_income": [30_000.0], + } + ) + frames = {"person": person} + for group in ("household", "tax_unit", "spm_unit", "family", "marital_unit"): + frames[group] = pd.DataFrame({f"{group}_id": [1], f"{group}_weight": [1.0]}) + frames["household"]["state_code"] = ["CA"] + with pd.HDFStore(path, mode="w") as store: + for entity, frame in frames.items(): + store.put(entity, frame, format="table", data_columns=True) + store.put("_time_period", pd.Series([year]), format="table") + return path + + +def test_us_rule_on_the_installed_model(tmp_path, dataset_years): + pytest.importorskip("policyengine_us") + from policyengine_us.data.economic_assumptions import _get_default_end_year + from policyengine_us.system import system + from spm_calculator.rolling_forecast import load_forecast + + last_year = min(_get_default_end_year(system), max(load_forecast().years)) + path = _write_tiny_us_dataset(tmp_path / "tiny_us_2024.h5") + + assert dataset_years.supported_years("us", path) == sorted( + set(range(2024, last_year + 1)) | {2022, 2023} + ) + + +def test_an_unknown_country_is_refused(dataset_years): + with pytest.raises(ValueError, match="Unknown country 'ca'"): + dataset_years.supported_years("ca", "data.h5") + + +# --- The error ---------------------------------------------------------------- + + +def test_the_error_names_the_year_the_country_and_the_supported_years( + dataset_years, +): + error = dataset_years.UnsupportedDatasetYearError( + "us", "populace_us_2024", [2036], range(2022, 2036) + ) + + assert isinstance(error, ValueError) + assert error.country_id == "us" + assert error.dataset == "populace_us_2024" + assert error.years == [2036] + assert error.supported_years == list(range(2022, 2036)) + message = str(error) + assert message.startswith( + "US dataset 'populace_us_2024' is not supported for year 2036. " + "Supported years: 2022–2035." + ) + assert "allow_unsupported_years=True" in message + + +def test_the_error_lists_each_unsupported_year_and_each_run_of_supported_years( + dataset_years, +): + error = dataset_years.UnsupportedDatasetYearError( + "uk", "data.h5", [2040, 2021, 2040], [2025, 2022, 2023] + ) + + assert error.years == [2021, 2040] + assert error.supported_years == [2022, 2023, 2025] + assert str(error).startswith( + "UK dataset 'data.h5' is not supported for years 2021, 2040. " + "Supported years: 2022–2023, 2025." + ) + + +def test_the_error_says_when_no_year_is_supported(dataset_years): + error = dataset_years.UnsupportedDatasetYearError("uk", "data.h5", [2030], []) + + assert "Supported years: none." in str(error) + + +def test_the_error_keeps_its_contents_through_pickling(dataset_years): + error = dataset_years.UnsupportedDatasetYearError( + "us", "populace_us_2024", [2036], range(2022, 2036) + ) + + restored = pickle.loads(pickle.dumps(error)) + + assert type(restored) is dataset_years.UnsupportedDatasetYearError + assert str(restored) == str(error) + assert ( + restored.country_id, + restored.dataset, + restored.years, + restored.supported_years, + ) == ("us", "populace_us_2024", [2036], list(range(2022, 2036))) + + +def test_the_function_and_error_are_exported_from_provenance(dataset_years): + from policyengine import provenance + + assert provenance.supported_years is dataset_years.supported_years + assert ( + provenance.UnsupportedDatasetYearError + is dataset_years.UnsupportedDatasetYearError + ) + + +# --- The refusal -------------------------------------------------------------- + + +def test_the_refusal_names_only_the_unsupported_years(monkeypatch, dataset_years): + monkeypatch.setattr( + dataset_years, "supported_years", lambda country_id, dataset_path: [2024, 2025] + ) + + with pytest.raises(dataset_years.UnsupportedDatasetYearError) as refusal: + dataset_years.refuse_unsupported_years( + "uk", "data", "data.h5", [2025, 2026, 2023] + ) + + assert refusal.value.years == [2023, 2026] + assert refusal.value.supported_years == [2024, 2025] + dataset_years.refuse_unsupported_years("uk", "data", "data.h5", [2024, 2025]) + + +def test_the_refusal_reads_no_dataset_when_no_year_is_requested( + monkeypatch, dataset_years +): + def refuse_to_compute(country_id, dataset_path): + raise AssertionError("supported years were computed for no year") + + monkeypatch.setattr(dataset_years, "supported_years", refuse_to_compute) + + dataset_years.refuse_unsupported_years("us", "data", "missing.h5", []) + + +# --- create_datasets and ensure_datasets ---------------------------------------- + + +class ReachedCreation(Exception): + """Raised by the stand-in Microsimulation: creation began.""" + + +@pytest.fixture +def country_microsimulation(monkeypatch, request): + """A stand-in country package whose Microsimulation records its calls.""" + + country_id = request.param + microsimulation = Mock(side_effect=ReachedCreation) + monkeypatch.setitem( + sys.modules, + COUNTRY_PACKAGES[country_id], + SimpleNamespace(Microsimulation=microsimulation), + ) + return country_id, microsimulation + + +def _source_file(path: Path) -> str: + """A local source; its contents are never read because the rule is stubbed.""" + + path.write_bytes(b"source dataset") + return str(path) + + +def _stub_supported_years(monkeypatch, dataset_years, supported_by_path): + computed_for = [] + + def supported_years(country_id, dataset_path): + computed_for.append((country_id, str(dataset_path))) + return supported_by_path[str(dataset_path)] + + monkeypatch.setattr(dataset_years, "supported_years", supported_years) + return computed_for + + +@pytest.mark.parametrize("country_microsimulation", ["uk", "us"], indirect=True) +@pytest.mark.parametrize("helper_name", ["create_datasets", "ensure_datasets"]) +def test_an_unsupported_year_is_refused_before_any_year_file_is_created( + tmp_path, monkeypatch, dataset_years, country_microsimulation, helper_name +): + country_id, microsimulation = country_microsimulation + source = _source_file(tmp_path / "tiny.h5") + computed_for = _stub_supported_years( + monkeypatch, dataset_years, {source: [2024, 2025]} + ) + data_folder = tmp_path / "data" + helper = getattr(COUNTRY_DATASET_MODULES[country_id], helper_name) + + with pytest.raises(dataset_years.UnsupportedDatasetYearError) as refusal: + helper( + datasets=[source], + years=[2025, 2026], + data_folder=str(data_folder), + allow_unmanaged=True, + ) + + assert refusal.value.country_id == country_id + assert refusal.value.dataset == source + assert refusal.value.years == [2026] + assert refusal.value.supported_years == [2024, 2025] + assert computed_for == [(country_id, source)] + microsimulation.assert_not_called() + assert not list(data_folder.glob("*_year_*.h5")) + + +@pytest.mark.parametrize("country_microsimulation", ["uk", "us"], indirect=True) +def test_every_dataset_is_checked_before_any_year_file_is_created( + tmp_path, monkeypatch, dataset_years, country_microsimulation +): + country_id, microsimulation = country_microsimulation + supported_source = _source_file(tmp_path / "supported.h5") + unsupported_source = _source_file(tmp_path / "unsupported.h5") + _stub_supported_years( + monkeypatch, + dataset_years, + {supported_source: [2024, 2025, 2026], unsupported_source: [2024, 2025]}, + ) + + with pytest.raises(dataset_years.UnsupportedDatasetYearError) as refusal: + COUNTRY_DATASET_MODULES[country_id].create_datasets( + datasets=[supported_source, unsupported_source], + years=[2026], + data_folder=str(tmp_path / "data"), + allow_unmanaged=True, + ) + + assert refusal.value.dataset == unsupported_source + microsimulation.assert_not_called() + + +@pytest.mark.parametrize("country_microsimulation", ["uk", "us"], indirect=True) +@pytest.mark.parametrize("helper_name", ["create_datasets", "ensure_datasets"]) +def test_a_supported_year_goes_on_to_creation( + tmp_path, monkeypatch, dataset_years, country_microsimulation, helper_name +): + country_id, microsimulation = country_microsimulation + source = _source_file(tmp_path / "tiny.h5") + _stub_supported_years(monkeypatch, dataset_years, {source: [2024, 2025, 2026]}) + helper = getattr(COUNTRY_DATASET_MODULES[country_id], helper_name) + + with pytest.raises(ReachedCreation): + helper( + datasets=[source], + years=[2025, 2026], + data_folder=str(tmp_path / "data"), + allow_unmanaged=True, + ) + + microsimulation.assert_called_once() + + +@pytest.mark.parametrize("country_microsimulation", ["uk", "us"], indirect=True) +@pytest.mark.parametrize("helper_name", ["create_datasets", "ensure_datasets"]) +def test_the_opt_out_creates_an_unsupported_year_without_the_check( + tmp_path, monkeypatch, dataset_years, country_microsimulation, helper_name +): + country_id, microsimulation = country_microsimulation + source = _source_file(tmp_path / "tiny.h5") + + def refuse_to_compute(country_id, dataset_path): + raise AssertionError("the opt-out still computed supported years") + + monkeypatch.setattr(dataset_years, "supported_years", refuse_to_compute) + helper = getattr(COUNTRY_DATASET_MODULES[country_id], helper_name) + + with pytest.raises(ReachedCreation): + helper( + datasets=[source], + years=[2040], + data_folder=str(tmp_path / "data"), + allow_unmanaged=True, + allow_unsupported_years=True, + ) + + microsimulation.assert_called_once() + + +@pytest.mark.parametrize("country_id", ["uk", "us"]) +@pytest.mark.parametrize("allow_unsupported_years", [False, True]) +def test_ensure_datasets_passes_the_opt_out_to_create_datasets( + tmp_path, monkeypatch, country_id, allow_unsupported_years +): + module = COUNTRY_DATASET_MODULES[country_id] + create_datasets = Mock(return_value={"created": None}) + monkeypatch.setattr(module, "create_datasets", create_datasets) + source = _source_file(tmp_path / "tiny.h5") + + module.ensure_datasets( + datasets=[source], + years=[2026], + data_folder=str(tmp_path / "data"), + allow_unmanaged=True, + allow_unsupported_years=allow_unsupported_years, + ) + + create_datasets.assert_called_once_with( + datasets=[source], + years=[2026], + data_folder=str(tmp_path / "data"), + allow_unmanaged=True, + allow_unsupported_years=allow_unsupported_years, + ) + + +# --- The UK provenance code uses the same rule ---------------------------------- + + +def test_the_uk_qualification_check_projects_with_the_public_rule( + monkeypatch, dataset_years +): + from policyengine.provenance import uk_candidate + + computed_for = _stub_supported_years( + monkeypatch, dataset_years, {"candidate.h5": [2024, 2025]} + ) + default_projection = ( + inspect.signature(uk_candidate.supported_years) + .parameters["projected_years"] + .default + ) + + assert default_projection(Path("candidate.h5")) == [2024, 2025] + assert computed_for == [("uk", "candidate.h5")] + + +def test_execution_receipts_project_with_the_public_rule(monkeypatch, dataset_years): + from policyengine.provenance import receipt + + computed_for = _stub_supported_years( + monkeypatch, dataset_years, {"run.h5": [2024, 2025, 2026]} + ) + uncached_projection = receipt._projected_years.__wrapped__ + + assert uncached_projection("run.h5", "0" * 64, "2.151.0") == (2024, 2025, 2026) + assert computed_for == [("uk", "run.h5")] + + +# --- The bundle's default datasets on the pinned models ------------------------- + +#: What ``supported_years`` returns for each bundle default dataset under the +#: pinned policyengine-uk 2.151.0, policyengine-us 2.2.1 and spm-calculator +#: 1.0.0 (measured 30 Oct 2026). +PINNED_DEFAULT_DATASET_YEARS = { + "uk": list(range(2024, 2033)), + "us": list(range(2022, 2036)), +} + + +@pytest.mark.parametrize("country_id", ["uk", "us"]) +def test_the_default_dataset_is_supported_for_the_pinned_years( + country_id, dataset_years +): + """Runs only with the bundle's default dataset file, named by + POLICYENGINE_UK_DEFAULT_DATASET or POLICYENGINE_US_DEFAULT_DATASET.""" + + variable = f"POLICYENGINE_{country_id.upper()}_DEFAULT_DATASET" + path = os.environ.get(variable) + if not path: + pytest.skip(f"set {variable} to the bundle's default {country_id} dataset") + pytest.importorskip(COUNTRY_PACKAGES[country_id]) + + assert ( + dataset_years.supported_years(country_id, path) + == PINNED_DEFAULT_DATASET_YEARS[country_id] + ) diff --git a/tests/test_release_manifests.py b/tests/test_release_manifests.py index abc8b1a2..d158ea03 100644 --- a/tests/test_release_manifests.py +++ b/tests/test_release_manifests.py @@ -478,6 +478,7 @@ def test__given_us_ensure_datasets_without_dataset__then_uses_certified_default( years=[2026], data_folder="./data", allow_unmanaged=False, + allow_unsupported_years=False, ) def test__given_explicit_uri__then_managed_resolution_requires_opt_in(self): diff --git a/tests/test_us_year_file_stamp.py b/tests/test_us_year_file_stamp.py index 38d4acba..1fc4b838 100644 --- a/tests/test_us_year_file_stamp.py +++ b/tests/test_us_year_file_stamp.py @@ -875,7 +875,13 @@ def _make_state(path: Path, state: str) -> None: _write_year_file(path, **written) -def _create_current_files(datasets, years, data_folder, allow_unmanaged=False): +def _create_current_files( + datasets, + years, + data_folder, + allow_unmanaged=False, + allow_unsupported_years=False, +): """Stand in for ``create_datasets``: write current year files.""" for year in years: _write_year_file( From 16567c627c4bd5f3fcb33d0c135aa7567f1e86e5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:37:02 +0400 Subject: [PATCH 2/4] Refuse dataset years the country model does not project to (#729) Add policyengine.provenance.supported_years(country_id, dataset_path). It builds a single-year dataset's multi-year projection with the country model's extend_single_year_dataset, as the model's own Simulation does and without running any formula, and returns the years from the data year onwards. The US years also stop at the last year of spm-calculator's SPM forecast, and US_EARLY_YEAR_ALLOWANCE keeps 2022 and 2023 supported. The UK qualification check and execution receipts use it instead of their own copies of the rule. create_datasets and ensure_datasets (UK and US) make every source available, then raise UnsupportedDatasetYearError for a requested year the source is not supported for, before creating any year file. allow_unsupported_years=True turns the check off. Existing year files are still loaded without the check. Co-Authored-By: Claude Opus 5.5 --- changelog.d/729.added.md | 1 + changelog.d/729.changed.md | 1 + docs/microsim.md | 37 ++++ src/policyengine/provenance/__init__.py | 7 + src/policyengine/provenance/dataset_years.py | 199 ++++++++++++++++++ src/policyengine/provenance/receipt.py | 22 +- src/policyengine/provenance/uk_candidate.py | 22 +- .../tax_benefit_models/uk/datasets.py | 53 ++++- .../tax_benefit_models/us/datasets.py | 44 +++- tests/test_dataset_years.py | 11 +- 10 files changed, 363 insertions(+), 34 deletions(-) create mode 100644 changelog.d/729.added.md create mode 100644 changelog.d/729.changed.md create mode 100644 src/policyengine/provenance/dataset_years.py diff --git a/changelog.d/729.added.md b/changelog.d/729.added.md new file mode 100644 index 00000000..dcdbfa8d --- /dev/null +++ b/changelog.d/729.added.md @@ -0,0 +1 @@ +Added `policyengine.provenance.supported_years(country_id, dataset_path)`, which returns the years the installed country model supports a single-year dataset for: the years its `extend_single_year_dataset` projects the file to, built as the model's own `Simulation` builds them and without running any formula, from the data year onwards. The UK runs to policyengine-uk's default projection end (2024–2032 for a 2024–25 dataset on policyengine-uk 2.151.0). The US runs to the earlier of policyengine-us's default projection end and the last year of spm-calculator's SPM forecast, and `US_EARLY_YEAR_ALLOWANCE` keeps 2022 and 2023 supported for now (2022–2035 for `populace_us_2024` on policyengine-us 2.2.1 and spm-calculator 1.0.0). The UK qualification check and execution receipts now use this function rather than their own copies of the rule. Added `UnsupportedDatasetYearError`, a `ValueError` whose message and `country_id`, `dataset`, `years` and `supported_years` attributes name the refused years, the country and the supported years. diff --git a/changelog.d/729.changed.md b/changelog.d/729.changed.md new file mode 100644 index 00000000..7dd82545 --- /dev/null +++ b/changelog.d/729.changed.md @@ -0,0 +1 @@ +`create_datasets` and `ensure_datasets` (UK and US) now raise `UnsupportedDatasetYearError` for a requested year the source dataset is not supported for, after making the source available and before creating any year file; `allow_unsupported_years=True` turns the check off. The US used to create year files for any year, from inputs policyengine-core carried forward or uprated, and the UK failed with `No dataset found for year ...`. Year files that already exist are loaded without the check, so a US year file that an earlier release created for an unsupported year is still reused until it is deleted. diff --git a/docs/microsim.md b/docs/microsim.md index b0aaf898..8faef238 100644 --- a/docs/microsim.md +++ b/docs/microsim.md @@ -152,6 +152,43 @@ List datasets already known to the country: pe.us.load_datasets() # or pe.uk.load_datasets() ``` +### Supported years + +A dataset is supported only for the years the installed country model +projects it to. `supported_years` builds that projection from a single-year +dataset file as the model's own `Simulation` does, with the model's +`extend_single_year_dataset` and without running any formula, and returns +the years from the dataset's data year onwards: + +```python +from policyengine.provenance import supported_years + +supported_years("us", "./data/populace_us_2024.h5") # 2022 to 2035 +supported_years("uk", "./data/microcosm_uk_2024_25.h5") # 2024 to 2032 +``` + +- **UK:** from the data year to policyengine-uk's default projection end, + `PROJECTION_END_YEAR` (2032 in policyengine-uk 2.151.0), or the end of a + capital gains projection that ends earlier. +- **US:** from the data year to the earlier of policyengine-us's default + projection end (the last year in its `gov.bls.cpi.cpi_u` parameter file, + 2035 in policyengine-us 2.2.1) and the last year of spm-calculator's SPM + forecast (2035 in spm-calculator 1.0.0), after which the poverty figures + have no thresholds. The constant `US_EARLY_YEAR_ALLOWANCE` in + `policyengine.provenance.dataset_years` also keeps 2022 and 2023 + supported, although the certified Populace US 2024 holds almost no data + for them. + +A calculation for another year still returns results, because +policyengine-core carries the latest projected inputs forward or uprates +them, so a successful run is not evidence of support. `create_datasets` and +`ensure_datasets` therefore raise `UnsupportedDatasetYearError` (from +`policyengine.provenance`) for any other year before they create a year +file. The error's `country_id`, `dataset`, `years` and `supported_years` +attributes name the request and the years that are supported. Pass +`allow_unsupported_years=True` to create the year files anyway. Year files +that already exist in the data folder are loaded without the check. + ### US local-area dataset Alongside the certified national default, the bundle registers a US dataset for diff --git a/src/policyengine/provenance/__init__.py b/src/policyengine/provenance/__init__.py index b9eea8d3..791fa580 100644 --- a/src/policyengine/provenance/__init__.py +++ b/src/policyengine/provenance/__init__.py @@ -7,6 +7,7 @@ .. code-block:: python from policyengine.provenance import ( + supported_years, get_release_manifest, get_data_release_manifest, build_trace_tro_from_release_bundle, @@ -42,6 +43,12 @@ from .dataset_materialization import ( materialize_dataset as materialize_dataset, ) +from .dataset_years import ( + UnsupportedDatasetYearError as UnsupportedDatasetYearError, +) +from .dataset_years import ( + supported_years as supported_years, +) from .manifest import ( CertifiedDataArtifact as CertifiedDataArtifact, ) diff --git a/src/policyengine/provenance/dataset_years.py b/src/policyengine/provenance/dataset_years.py new file mode 100644 index 00000000..d3926850 --- /dev/null +++ b/src/policyengine/provenance/dataset_years.py @@ -0,0 +1,199 @@ +"""The years a country model supports a single-year dataset for. + +A supported year is one the installed country model projects its single-year +dataset to. The model's own ``Simulation`` builds that projection from a +single-year file with ``extend_single_year_dataset``; :func:`supported_years` +builds it the same way, without running any formula, and keeps the years from +the dataset's data year onwards. + +A calculation for another year still succeeds: policyengine-core fills a year +outside the projection by carrying the latest projected inputs forward or +uprating them. A result there is therefore not evidence that its year is +supported, and ``create_datasets`` and ``ensure_datasets`` refuse such a year +with :class:`UnsupportedDatasetYearError` unless asked not to. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from typing import Any, Union + +COUNTRY_IDS = ("uk", "us") + +#: Years the US rule supports although they come before the data year of the +#: certified ``populace_us_2024``. The certified dataset holds almost no data +#: for them: its household weights sum to 57,240 in each of 2022 and 2023, +#: against 122,537,088 in 2024, and employment income is zero. They stay +#: supported (not provisional) by a decision of 2026-10-28 and 2026-10-30 +#: (PolicyEngine/policyengine.py#729). Removing them is a change to this +#: constant alone. +US_EARLY_YEAR_ALLOWANCE: frozenset[int] = frozenset({2022, 2023}) + + +class UnsupportedDatasetYearError(ValueError): + """Requested years are outside the years a dataset is supported for. + + Attributes: + country_id: The country, ``"uk"`` or ``"us"``. + dataset: The dataset the years were requested for, as requested. + years: The requested years that are not supported, sorted. + supported_years: Every year the dataset is supported for, sorted. + """ + + def __init__( + self, + country_id: str, + dataset: str, + years: Iterable[int], + supported_years: Iterable[int], + ): + self.country_id = country_id + self.dataset = dataset + self.years = sorted({int(year) for year in years}) + self.supported_years = sorted({int(year) for year in supported_years}) + super().__init__(self._message()) + + def __reduce__(self) -> tuple[Any, ...]: + # The default reduction passes only the message back to __init__. + return ( + type(self), + (self.country_id, self.dataset, self.years, self.supported_years), + ) + + def _message(self) -> str: + noun = "year" if len(self.years) == 1 else "years" + requested = ", ".join(str(year) for year in self.years) + return ( + f"{self.country_id.upper()} dataset {self.dataset!r} is not supported " + f"for {noun} {requested}. Supported years: " + f"{_describe_years(self.supported_years)}. The installed model " + "projects the dataset only to its supported years; policyengine-core " + "would fill any other year by carrying the latest projected inputs " + "forward or uprating them. Pass allow_unsupported_years=True to " + "create year files for it anyway." + ) + + +def supported_years(country_id: str, dataset_path: Union[str, Path]) -> list[int]: + """Return the years the installed country model supports a dataset for. + + ``dataset_path`` is a local single-year dataset file, such as the + certified source that ``create_datasets`` creates year files from. The + multi-year dataset is built as the country model's own ``Simulation`` + builds it for a single-year file, with the model's + ``extend_single_year_dataset`` and its default projection end, and + without running any formula. The years it holds from the dataset's data + year onwards are supported, with these country rules: + + - **UK:** policyengine-uk projects to ``PROJECTION_END_YEAR``, or to the + last year of a capital gains projection that ends earlier. + - **US:** policyengine-us projects to the last year in its + ``gov.bls.cpi.cpi_u`` parameter file. Years after the last year of + spm-calculator's SPM forecast are not supported, since the poverty + figures have no thresholds there. :data:`US_EARLY_YEAR_ALLOWANCE` adds + 2022 and 2023. + + Args: + country_id: ``"uk"`` or ``"us"``. + dataset_path: Path of the single-year dataset file. + + Returns: + The supported years, sorted ascending without duplicates. + """ + + if country_id == "uk": + return _uk_supported_years(Path(dataset_path)) + if country_id == "us": + return _us_supported_years(Path(dataset_path)) + raise ValueError( + f"Unknown country {country_id!r}; expected one of {list(COUNTRY_IDS)}." + ) + + +def refuse_unsupported_years( + country_id: str, + dataset: str, + dataset_path: Union[str, Path], + years: Iterable[int], +) -> None: + """Raise :class:`UnsupportedDatasetYearError` if a year is not supported. + + The supported years are computed from ``dataset_path`` only when ``years`` + holds at least one year. + + Args: + country_id: ``"uk"`` or ``"us"``. + dataset: The dataset as requested, named in the error. + dataset_path: Path of the single-year dataset file. + years: The requested years. + """ + + requested = sorted({int(year) for year in years}) + if not requested: + return + supported = supported_years(country_id, dataset_path) + unsupported = [year for year in requested if year not in supported] + if unsupported: + raise UnsupportedDatasetYearError(country_id, dataset, unsupported, supported) + + +def _uk_supported_years(dataset_path: Path) -> list[int]: + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + from policyengine_uk.system import system + + dataset = UKSingleYearDataset(str(dataset_path)) + data_year = _data_year(dataset) + multi_year_dataset = extend_single_year_dataset(dataset, system.parameters) + return sorted( + year for year in _projected_years(multi_year_dataset) if year >= data_year + ) + + +def _us_supported_years(dataset_path: Path) -> list[int]: + from policyengine_us.data.dataset_schema import USSingleYearDataset + from policyengine_us.data.economic_assumptions import extend_single_year_dataset + from spm_calculator.rolling_forecast import load_forecast + + dataset = USSingleYearDataset(file_path=str(dataset_path)) + data_year = _data_year(dataset) + multi_year_dataset = extend_single_year_dataset(dataset) + last_spm_forecast_year = max(int(year) for year in load_forecast().years) + projected = { + year + for year in _projected_years(multi_year_dataset) + if data_year <= year <= last_spm_forecast_year + } + return sorted(projected | US_EARLY_YEAR_ALLOWANCE) + + +def _data_year(dataset: Any) -> int: + """The year of a single-year dataset's observed data.""" + + return int(str(dataset.time_period)[:4]) + + +def _projected_years(multi_year_dataset: Any) -> set[int]: + """The years a country model's multi-year dataset holds.""" + + years = getattr(multi_year_dataset, "years", None) + if years is None: + years = list(multi_year_dataset.datasets) + return {int(year) for year in years} + + +def _describe_years(years: list[int]) -> str: + """Sorted years as runs of consecutive years, such as ``2022–2035``.""" + + if not years: + return "none" + runs: list[list[int]] = [[years[0], years[0]]] + for year in years[1:]: + if year == runs[-1][1] + 1: + runs[-1][1] = year + else: + runs.append([year, year]) + return ", ".join( + str(first) if first == last else f"{first}–{last}" for first, last in runs + ) diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py index 1da4a1ee..4e5cc0f1 100644 --- a/src/policyengine/provenance/receipt.py +++ b/src/policyengine/provenance/receipt.py @@ -127,6 +127,7 @@ model_validator, ) +from policyengine.provenance import dataset_years from policyengine.provenance.bytecode import code_shape as _code_shape from policyengine.provenance.bytecode import compile_source from policyengine.provenance.canonical import ( @@ -3818,25 +3819,14 @@ def _receipt_years(years: Any) -> list[int]: def _projected_years( dataset_path: str, dataset_sha256: str, model_version: str ) -> tuple[int, ...]: - """Years the installed policyengine-uk projects a single-year dataset to. + """Years the installed policyengine-uk supports a single-year dataset for. - The same rule as the qualification ``supported_years`` gate: build the - multi-year dataset as ``policyengine_uk.Simulation`` does for a - single-year file, without running a formula, and keep the years from the - dataset's own year onwards. Cached by file bytes and model version. + :func:`policyengine.provenance.dataset_years.supported_years` for the UK, + the rule the qualification ``supported_years`` check also uses, cached by + file bytes and model version. """ - from policyengine_uk.data.dataset_schema import UKSingleYearDataset - from policyengine_uk.data.economic_assumptions import extend_single_year_dataset - from policyengine_uk.system import system - - dataset = UKSingleYearDataset(dataset_path) - first = int(str(dataset.time_period)[:4]) - multi_year = extend_single_year_dataset(dataset, system.parameters) - years = getattr(multi_year, "years", None) - if years is None: - years = list(multi_year.datasets) - return tuple(sorted(int(year) for year in years if int(year) >= first)) + return tuple(dataset_years.supported_years("uk", dataset_path)) def _certificate_fields( diff --git a/src/policyengine/provenance/uk_candidate.py b/src/policyengine/provenance/uk_candidate.py index 4599babf..fec2c903 100644 --- a/src/policyengine/provenance/uk_candidate.py +++ b/src/policyengine/provenance/uk_candidate.py @@ -33,6 +33,7 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version +from policyengine.provenance import dataset_years from policyengine.provenance.manifest import DataReleaseManifest, _specifier_matches from policyengine.provenance.qualification import ( CandidatePair, @@ -422,24 +423,15 @@ def data_year(dataset_path: Path) -> int: def projected_years_from_model(dataset_path: Path) -> list[int]: - """The years policyengine-uk projects a single-year dataset to. + """The years the installed policyengine-uk supports a dataset for. - Builds the multi-year dataset exactly as ``policyengine_uk.Simulation`` - does for a single-year file (``extend_single_year_dataset`` with the - system's parameters), without running any formula. + :func:`policyengine.provenance.dataset_years.supported_years` for the UK: + the years ``extend_single_year_dataset`` projects the single-year file + to, built as ``policyengine_uk.Simulation`` builds them, from the data + year onwards. """ - from policyengine_uk.data.dataset_schema import UKSingleYearDataset - from policyengine_uk.data.economic_assumptions import extend_single_year_dataset - from policyengine_uk.system import system - - multi_year = extend_single_year_dataset( - UKSingleYearDataset(str(dataset_path)), system.parameters - ) - years = getattr(multi_year, "years", None) - if years is None: - years = list(multi_year.datasets) - return sorted(int(year) for year in years) + return dataset_years.supported_years("uk", dataset_path) def supported_years( diff --git a/src/policyengine/tax_benefit_models/uk/datasets.py b/src/policyengine/tax_benefit_models/uk/datasets.py index 04937d85..4fa04ae7 100644 --- a/src/policyengine/tax_benefit_models/uk/datasets.py +++ b/src/policyengine/tax_benefit_models/uk/datasets.py @@ -6,6 +6,7 @@ from pydantic import ConfigDict, Field from policyengine.core import Dataset, YearData +from policyengine.provenance import dataset_years from policyengine.provenance.dataset_materialization import ( materialize_dataset, ) @@ -288,9 +289,35 @@ def create_datasets( years: list[int] = [2026, 2027, 2028, 2029, 2030], data_folder: str = "./data", allow_unmanaged: bool = False, + allow_unsupported_years: bool = False, ) -> dict[str, PolicyEngineUKDataset]: + """Create UK year files from logical dataset names, URIs or local paths. + + Each requested year must be one the installed policyengine-uk supports + the source dataset for (see + :func:`policyengine.provenance.dataset_years.supported_years`). Every + source is made available as a local file (downloaded unless it is a + local path or a verified copy is already in ``data_folder``) and checked + before any year file is created. + + Args: + datasets: Logical dataset names, Hugging Face URIs or, with + ``allow_unmanaged``, local paths. ``None`` uses the certified + default. + years: Years to create year files for. + data_folder: Directory the sources are cached in and the year files + are written to. + allow_unmanaged: Accept datasets outside the release bundle. + allow_unsupported_years: Skip the supported-years check. + policyengine-uk still cannot create a year outside its + projection. + + Raises: + UnsupportedDatasetYearError: A requested year is not supported for a + source, unless ``allow_unsupported_years``. + """ dataset_requests: list[Optional[str]] = [None] if datasets is None else datasets - result = {} + sources = [] for dataset in dataset_requests: source = materialize_dataset( "uk", @@ -298,6 +325,13 @@ def create_datasets( allow_unmanaged=allow_unmanaged, data_dir=Path(data_folder), ) + if not allow_unsupported_years: + dataset_years.refuse_unsupported_years( + "uk", dataset or source.name, source.path, years + ) + sources.append(source) + result = {} + for source in sources: dataset_stem = source.name from policyengine_uk import Microsimulation @@ -380,6 +414,7 @@ def ensure_datasets( years: list[int] = [2026, 2027, 2028, 2029, 2030], data_folder: str = "./data", allow_unmanaged: bool = False, + allow_unsupported_years: bool = False, ) -> dict[str, PolicyEngineUKDataset]: """Ensure datasets exist, loading if available or creating if not. @@ -387,13 +422,28 @@ def ensure_datasets( policyengine.py kept the observed year their tables were projected from (see ``load_datasets``), so they are created again rather than loaded. + A year file that has to be created is created only for a year the + installed policyengine-uk supports its source dataset for: otherwise + ``create_datasets`` raises ``UnsupportedDatasetYearError`` before + creating any year file. Year files that already exist are loaded + without that check: policyengine-uk creates a year file only for a year + it projects the source to. + Args: datasets: List of HuggingFace dataset paths years: List of years to load/create data for data_folder: Directory containing or to save the dataset files + allow_unmanaged: Accept datasets outside the release bundle. + allow_unsupported_years: Skip the supported-years check (see + ``create_datasets``). Returns: Dictionary mapping dataset keys to PolicyEngineUKDataset objects + + Raises: + UnsupportedDatasetYearError: A requested year is not supported for a + dataset whose year files have to be created, unless + ``allow_unsupported_years``. """ if datasets is None: datasets = [get_release_manifest("uk").default_dataset] @@ -421,4 +471,5 @@ def ensure_datasets( years=years, data_folder=data_folder, allow_unmanaged=allow_unmanaged, + allow_unsupported_years=allow_unsupported_years, ) diff --git a/src/policyengine/tax_benefit_models/us/datasets.py b/src/policyengine/tax_benefit_models/us/datasets.py index 320a538a..850022b3 100644 --- a/src/policyengine/tax_benefit_models/us/datasets.py +++ b/src/policyengine/tax_benefit_models/us/datasets.py @@ -13,6 +13,7 @@ from pydantic import ConfigDict, Field from policyengine.core import Dataset, YearData +from policyengine.provenance import dataset_years from policyengine.provenance.dataset_materialization import ( MaterializedDataset, materialize_dataset, @@ -407,6 +408,7 @@ def create_datasets( years: list[int] = [2024, 2025, 2026, 2027, 2028], data_folder: str = "./data", allow_unmanaged: bool = False, + allow_unsupported_years: bool = False, ) -> dict[str, PolicyEngineUSDataset]: """Create PolicyEngineUSDataset instances from logical dataset names or URLs. @@ -416,20 +418,36 @@ def create_datasets( ``legacy_inputs.YEAR_FILE_STAMP_H5_DATASET``), which ``ensure_datasets`` and ``load_datasets`` check. + Each requested year must be one the installed policyengine-us supports + the source dataset for (see + :func:`policyengine.provenance.dataset_years.supported_years`). + policyengine-core would fill any other year by carrying the latest + projected inputs forward or uprating them. Every source is made available + as a local file (downloaded unless it is a local path or a verified copy + is already in ``data_folder``) and checked before any year file is + created. + Args: datasets: List of logical dataset names or HuggingFace dataset URLs years: List of years to extract data for data_folder: Directory to save the dataset files + allow_unmanaged: Accept datasets outside the release bundle. + allow_unsupported_years: Skip the supported-years check and create + year files for any requested year. Returns: Dictionary mapping dataset keys (e.g., "populace_us_2024") to PolicyEngineUSDataset objects + + Raises: + UnsupportedDatasetYearError: A requested year is not supported for a + source, unless ``allow_unsupported_years``. """ from policyengine_us import Microsimulation from policyengine.tax_benefit_models.us.spm import resolve_spm_selection dataset_requests: list[Optional[str]] = datasets or [None] - result = {} + sources = [] for dataset in dataset_requests: source = materialize_dataset( "us", @@ -437,6 +455,13 @@ def create_datasets( allow_unmanaged=allow_unmanaged, data_dir=Path(data_folder), ) + if not allow_unsupported_years: + dataset_years.refuse_unsupported_years( + "us", dataset or source.name, source.path, years + ) + sources.append(source) + result = {} + for source in sources: dataset_stem = source.name sim = Microsimulation(dataset=source.path, spm=resolve_spm_selection()) # The register the mapping below uses, stamped into each year file. @@ -1369,6 +1394,7 @@ def ensure_datasets( years: list[int] = [2024, 2025, 2026, 2027, 2028], data_folder: str = "./data", allow_unmanaged: bool = False, + allow_unsupported_years: bool = False, ) -> dict[str, PolicyEngineUSDataset]: """Ensure datasets exist, loading if available or creating if not. @@ -1380,13 +1406,28 @@ def ensure_datasets( year file is created again from the source dataset, and each out-of-date file is logged with the reason. + Year files are created only for years the installed policyengine-us + supports the source dataset for: otherwise ``create_datasets`` raises + ``UnsupportedDatasetYearError`` before creating any year file. Year + files that already exist and are current are loaded without that check, + so one created for an unsupported year, by ``allow_unsupported_years=True`` + or by a policyengine.py release before the check, is still reused. + Args: datasets: List of HuggingFace dataset paths years: List of years to load/create data for data_folder: Directory containing or to save the dataset files + allow_unmanaged: Accept datasets outside the release bundle. + allow_unsupported_years: Skip the supported-years check (see + ``create_datasets``). Returns: Dictionary mapping dataset keys to PolicyEngineUSDataset objects + + Raises: + UnsupportedDatasetYearError: A requested year is not supported for a + dataset whose year files have to be created, unless + ``allow_unsupported_years``. """ datasets = datasets or [get_release_manifest("us").default_dataset] @@ -1433,4 +1474,5 @@ def ensure_datasets( years=years, data_folder=data_folder, allow_unmanaged=allow_unmanaged, + allow_unsupported_years=allow_unsupported_years, ) diff --git a/tests/test_dataset_years.py b/tests/test_dataset_years.py index 3c200334..92e4c091 100644 --- a/tests/test_dataset_years.py +++ b/tests/test_dataset_years.py @@ -179,8 +179,17 @@ def _write_tiny_uk_dataset(path: Path, fiscal_year: int = 2024) -> Path: } ), benunit=pd.DataFrame({"benunit_id": [1]}), + # policyengine-uk's projection uprates council tax and rent, so a + # dataset it can project stores both. household=pd.DataFrame( - {"household_id": [1], "household_weight": [1.0], "region": ["LONDON"]} + { + "household_id": [1], + "household_weight": [1.0], + "region": ["LONDON"], + "tenure_type": ["RENT_PRIVATELY"], + "council_tax": [1_500.0], + "rent": [9_000.0], + } ), fiscal_year=fiscal_year, ).save(str(path)) From 3a22de232e99b4f8729a71682e503177f220d30e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:32:27 +0400 Subject: [PATCH 3/4] Expect US supported years to start at the data year (#729) Society-wide runs are to refuse US years before the dataset's data year, so the US rule tests now expect 2024 onwards for a 2024 dataset, with no 2022 or 2023. The tests of US_EARLY_YEAR_ALLOWANCE are replaced by a test that the rule adds no year the projection does not hold, and the pinned-version expectation for populace_us_2024 becomes 2024-2035. Co-Authored-By: Claude Opus 5.5 --- tests/test_dataset_years.py | 51 +++++++++++++------------------------ 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/tests/test_dataset_years.py b/tests/test_dataset_years.py index 92e4c091..7a57081d 100644 --- a/tests/test_dataset_years.py +++ b/tests/test_dataset_years.py @@ -229,7 +229,7 @@ def test_us_years_end_at_the_policyengine_us_projection_end(monkeypatch, dataset forecast_years=range(2022, 2041), ) - assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2036)) + assert dataset_years.supported_years("us", "data.h5") == list(range(2024, 2036)) def test_us_years_after_the_last_spm_forecast_year_are_not_supported( @@ -245,26 +245,14 @@ def test_us_years_after_the_last_spm_forecast_year_are_not_supported( forecast_years=range(2022, 2036), ) - assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2036)) - - -def test_us_allowance_adds_2022_and_2023_before_the_data_year( - monkeypatch, dataset_years -): - _stub_us_model( - monkeypatch, - data_year=2024, - projected_years=range(2024, 2031), - forecast_years=range(2022, 2036), - ) + assert dataset_years.supported_years("us", "data.h5") == list(range(2024, 2036)) - assert dataset_years.US_EARLY_YEAR_ALLOWANCE == frozenset({2022, 2023}) - assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2031)) +def test_us_years_before_the_data_year_are_not_supported(monkeypatch, dataset_years): + """Years the projection and the SPM forecast hold before the data year are + not supported: the certified 2024 dataset holds almost no data for 2022 + and 2023.""" -def test_us_years_before_the_data_year_outside_the_allowance_are_not_supported( - monkeypatch, dataset_years -): _stub_us_model( monkeypatch, data_year=2024, @@ -272,21 +260,18 @@ def test_us_years_before_the_data_year_outside_the_allowance_are_not_supported( forecast_years=range(2018, 2036), ) - assert dataset_years.supported_years("us", "data.h5") == list(range(2022, 2031)) + assert dataset_years.supported_years("us", "data.h5") == list(range(2024, 2031)) -def test_removing_the_us_allowance_is_a_change_to_one_constant( - monkeypatch, dataset_years -): +def test_us_rule_adds_no_year_the_projection_does_not_hold(monkeypatch, dataset_years): _stub_us_model( monkeypatch, data_year=2024, - projected_years=range(2024, 2036), + projected_years=range(2024, 2031), forecast_years=range(2022, 2036), ) - monkeypatch.setattr(dataset_years, "US_EARLY_YEAR_ALLOWANCE", frozenset()) - assert dataset_years.supported_years("us", "data.h5") == list(range(2024, 2036)) + assert dataset_years.supported_years("us", "data.h5") == list(range(2024, 2031)) def test_us_projection_uses_the_models_default_end_year(monkeypatch, dataset_years): @@ -341,9 +326,7 @@ def test_us_rule_on_the_installed_model(tmp_path, dataset_years): last_year = min(_get_default_end_year(system), max(load_forecast().years)) path = _write_tiny_us_dataset(tmp_path / "tiny_us_2024.h5") - assert dataset_years.supported_years("us", path) == sorted( - set(range(2024, last_year + 1)) | {2022, 2023} - ) + assert dataset_years.supported_years("us", path) == list(range(2024, last_year + 1)) def test_an_unknown_country_is_refused(dataset_years): @@ -358,18 +341,18 @@ def test_the_error_names_the_year_the_country_and_the_supported_years( dataset_years, ): error = dataset_years.UnsupportedDatasetYearError( - "us", "populace_us_2024", [2036], range(2022, 2036) + "us", "populace_us_2024", [2036], range(2024, 2036) ) assert isinstance(error, ValueError) assert error.country_id == "us" assert error.dataset == "populace_us_2024" assert error.years == [2036] - assert error.supported_years == list(range(2022, 2036)) + assert error.supported_years == list(range(2024, 2036)) message = str(error) assert message.startswith( "US dataset 'populace_us_2024' is not supported for year 2036. " - "Supported years: 2022–2035." + "Supported years: 2024–2035." ) assert "allow_unsupported_years=True" in message @@ -397,7 +380,7 @@ def test_the_error_says_when_no_year_is_supported(dataset_years): def test_the_error_keeps_its_contents_through_pickling(dataset_years): error = dataset_years.UnsupportedDatasetYearError( - "us", "populace_us_2024", [2036], range(2022, 2036) + "us", "populace_us_2024", [2036], range(2024, 2036) ) restored = pickle.loads(pickle.dumps(error)) @@ -409,7 +392,7 @@ def test_the_error_keeps_its_contents_through_pickling(dataset_years): restored.dataset, restored.years, restored.supported_years, - ) == ("us", "populace_us_2024", [2036], list(range(2022, 2036))) + ) == ("us", "populace_us_2024", [2036], list(range(2024, 2036))) def test_the_function_and_error_are_exported_from_provenance(dataset_years): @@ -659,7 +642,7 @@ def test_execution_receipts_project_with_the_public_rule(monkeypatch, dataset_ye #: 1.0.0 (measured 30 Oct 2026). PINNED_DEFAULT_DATASET_YEARS = { "uk": list(range(2024, 2033)), - "us": list(range(2022, 2036)), + "us": list(range(2024, 2036)), } From dc1a82557a716634f530c020f70178237ceccf99 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:34:29 +0400 Subject: [PATCH 4/4] Refuse US dataset years before the data year (#729) Remove US_EARLY_YEAR_ALLOWANCE, so the US rule now runs from the dataset's data year to policyengine-us's default projection end, capped at the last year of spm-calculator's SPM forecast. Society-wide runs no longer accept 2022 or 2023 for populace_us_2024, which holds almost no data for them. On the certified populace_us_2024 (sha256 6496cc43...) under policyengine-us 2.2.1 and spm-calculator 1.0.0, supported_years("us", ...) returns 2024-2035. The docstring, docs/microsim.md and the changelog fragment describe the new rule. Co-Authored-By: Claude Opus 5.5 --- changelog.d/729.added.md | 2 +- docs/microsim.md | 9 ++++---- src/policyengine/provenance/dataset_years.py | 23 +++++++------------- tests/test_dataset_years.py | 2 +- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/changelog.d/729.added.md b/changelog.d/729.added.md index dcdbfa8d..61eb201c 100644 --- a/changelog.d/729.added.md +++ b/changelog.d/729.added.md @@ -1 +1 @@ -Added `policyengine.provenance.supported_years(country_id, dataset_path)`, which returns the years the installed country model supports a single-year dataset for: the years its `extend_single_year_dataset` projects the file to, built as the model's own `Simulation` builds them and without running any formula, from the data year onwards. The UK runs to policyengine-uk's default projection end (2024–2032 for a 2024–25 dataset on policyengine-uk 2.151.0). The US runs to the earlier of policyengine-us's default projection end and the last year of spm-calculator's SPM forecast, and `US_EARLY_YEAR_ALLOWANCE` keeps 2022 and 2023 supported for now (2022–2035 for `populace_us_2024` on policyengine-us 2.2.1 and spm-calculator 1.0.0). The UK qualification check and execution receipts now use this function rather than their own copies of the rule. Added `UnsupportedDatasetYearError`, a `ValueError` whose message and `country_id`, `dataset`, `years` and `supported_years` attributes name the refused years, the country and the supported years. +Added `policyengine.provenance.supported_years(country_id, dataset_path)`, which returns the years the installed country model supports a single-year dataset for: the years its `extend_single_year_dataset` projects the file to, built as the model's own `Simulation` builds them and without running any formula, from the data year onwards. The UK runs to policyengine-uk's default projection end (2024–2032 for a 2024–25 dataset on policyengine-uk 2.151.0). The US runs from the data year to the earlier of policyengine-us's default projection end and the last year of spm-calculator's SPM forecast (2024–2035 for `populace_us_2024` on policyengine-us 2.2.1 and spm-calculator 1.0.0), so society-wide runs no longer accept US years before the data year, such as 2022 and 2023. The UK qualification check and execution receipts now use this function rather than their own copies of the rule. Added `UnsupportedDatasetYearError`, a `ValueError` whose message and `country_id`, `dataset`, `years` and `supported_years` attributes name the refused years, the country and the supported years. diff --git a/docs/microsim.md b/docs/microsim.md index 8faef238..8bd2c425 100644 --- a/docs/microsim.md +++ b/docs/microsim.md @@ -163,7 +163,7 @@ the years from the dataset's data year onwards: ```python from policyengine.provenance import supported_years -supported_years("us", "./data/populace_us_2024.h5") # 2022 to 2035 +supported_years("us", "./data/populace_us_2024.h5") # 2024 to 2035 supported_years("uk", "./data/microcosm_uk_2024_25.h5") # 2024 to 2032 ``` @@ -174,10 +174,9 @@ supported_years("uk", "./data/microcosm_uk_2024_25.h5") # 2024 to 2032 projection end (the last year in its `gov.bls.cpi.cpi_u` parameter file, 2035 in policyengine-us 2.2.1) and the last year of spm-calculator's SPM forecast (2035 in spm-calculator 1.0.0), after which the poverty figures - have no thresholds. The constant `US_EARLY_YEAR_ALLOWANCE` in - `policyengine.provenance.dataset_years` also keeps 2022 and 2023 - supported, although the certified Populace US 2024 holds almost no data - for them. + have no thresholds. Society-wide runs are not supported for years before + the data year, such as 2022 and 2023 for Populace US 2024, because the + certified dataset holds almost no data for them. A calculation for another year still returns results, because policyengine-core carries the latest projected inputs forward or uprates diff --git a/src/policyengine/provenance/dataset_years.py b/src/policyengine/provenance/dataset_years.py index d3926850..2401dcb9 100644 --- a/src/policyengine/provenance/dataset_years.py +++ b/src/policyengine/provenance/dataset_years.py @@ -21,15 +21,6 @@ COUNTRY_IDS = ("uk", "us") -#: Years the US rule supports although they come before the data year of the -#: certified ``populace_us_2024``. The certified dataset holds almost no data -#: for them: its household weights sum to 57,240 in each of 2022 and 2023, -#: against 122,537,088 in 2024, and employment income is zero. They stay -#: supported (not provisional) by a decision of 2026-10-28 and 2026-10-30 -#: (PolicyEngine/policyengine.py#729). Removing them is a change to this -#: constant alone. -US_EARLY_YEAR_ALLOWANCE: frozenset[int] = frozenset({2022, 2023}) - class UnsupportedDatasetYearError(ValueError): """Requested years are outside the years a dataset is supported for. @@ -91,8 +82,11 @@ def supported_years(country_id: str, dataset_path: Union[str, Path]) -> list[int - **US:** policyengine-us projects to the last year in its ``gov.bls.cpi.cpi_u`` parameter file. Years after the last year of spm-calculator's SPM forecast are not supported, since the poverty - figures have no thresholds there. :data:`US_EARLY_YEAR_ALLOWANCE` adds - 2022 and 2023. + figures have no thresholds there. Years before the data year are not + supported either, although the SPM forecast covers 2022 and 2023: the + certified ``populace_us_2024`` holds almost no data for them (its + household weights sum to 57,240 in each of 2022 and 2023, against + 122,537,088 in 2024, and employment income is zero). Args: country_id: ``"uk"`` or ``"us"``. @@ -160,12 +154,11 @@ def _us_supported_years(dataset_path: Path) -> list[int]: data_year = _data_year(dataset) multi_year_dataset = extend_single_year_dataset(dataset) last_spm_forecast_year = max(int(year) for year in load_forecast().years) - projected = { + return sorted( year for year in _projected_years(multi_year_dataset) if data_year <= year <= last_spm_forecast_year - } - return sorted(projected | US_EARLY_YEAR_ALLOWANCE) + ) def _data_year(dataset: Any) -> int: @@ -184,7 +177,7 @@ def _projected_years(multi_year_dataset: Any) -> set[int]: def _describe_years(years: list[int]) -> str: - """Sorted years as runs of consecutive years, such as ``2022–2035``.""" + """Sorted years as runs of consecutive years, such as ``2024–2035``.""" if not years: return "none" diff --git a/tests/test_dataset_years.py b/tests/test_dataset_years.py index 7a57081d..7a1345b5 100644 --- a/tests/test_dataset_years.py +++ b/tests/test_dataset_years.py @@ -639,7 +639,7 @@ def test_execution_receipts_project_with_the_public_rule(monkeypatch, dataset_ye #: What ``supported_years`` returns for each bundle default dataset under the #: pinned policyengine-uk 2.151.0, policyengine-us 2.2.1 and spm-calculator -#: 1.0.0 (measured 30 Oct 2026). +#: 1.0.0 (UK measured 30 Oct 2026, US 3 Nov 2026). PINNED_DEFAULT_DATASET_YEARS = { "uk": list(range(2024, 2033)), "us": list(range(2024, 2036)),