Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/household-year-fallbacks.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The US household years in `household_options.time_period` no longer fall back to the US society-wide years: when the bundle has no certified SPM forecast, or it fails to load, building the US metadata raises an `SPM_CONFIGURATION_UNAVAILABLE` error and the Public API does not start. Household years are built only for the US and the UK. The served US and UK years are unchanged.
44 changes: 19 additions & 25 deletions policyengine_api/country.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,14 @@ def parameter_names(self) -> frozenset[str]:

def build_metadata(self) -> None:
"""Build the JSON-safe metadata document served by the metadata endpoints."""
economy_options = self.build_microsimulation_options()
self.metadata = self._json_safe(
{
"variables": self.build_variables(),
"parameters": self.build_parameters(),
"entities": self.build_entities(),
"variableModules": self.tax_benefit_system.variable_module_metadata,
"economy_options": economy_options,
"household_options": self.build_household_options(economy_options),
"economy_options": self.build_microsimulation_options(),
"household_options": self.build_household_options(),
"current_law_id": get_current_law_policy_id(self.country_id),
"basicInputs": self.tax_benefit_system.basic_inputs,
"modelled_policies": self.tax_benefit_system.modelled_policies,
Expand Down Expand Up @@ -319,35 +318,30 @@ def build_microsimulation_options(self) -> dict:
options["datasets"] = datasets
return options

def build_household_options(self, economy_options: dict) -> dict:
def build_household_options(self) -> dict:
"""Build the years offered for household calculations.

Each year has the shape of an ``economy_options`` year. The US years
are the certified SPM forecast's years up to the last CPI-U year
(``us_household_years``), or the society-wide years when the bundle
has no certified SPM forecast. The UK years follow the rule for the
UK society-wide years (``uk_economy_years``). Any other country
offers its society-wide years.
(``us_household_years``). Without a certified forecast that loads,
``certified_spm_forecast_years`` raises SPMValidationError
(``SPM_CONFIGURATION_UNAVAILABLE``), so the metadata is not built and
no other years are offered in their place. The UK years follow the
rule for the UK society-wide years (``uk_economy_years``). Any other
country raises ValueError: only the countries the Public API serves
(``SupportedCountryId``) have household years.
"""
parameters = self.tax_benefit_system.parameters
years = None
if self.country_id == "uk":
if self.country_id == "us":
years = us_household_years(parameters, certified_spm_forecast_years())
elif self.country_id == "uk":
years = uk_economy_years(parameters)
elif self.country_id == "us":
forecast_years = certified_spm_forecast_years()
if forecast_years is None:
logger.warning(
"The bundle has no certified SPM forecast; the US household "
"years are the US society-wide years"
)
else:
years = us_household_years(parameters, forecast_years)
if years is None:
return {
"time_period": [
dict(period) for period in economy_options.get("time_period", [])
]
}
else:
msg = (
f"No household years are defined for country {self.country_id!r}: "
"only the countries the Public API serves have them"
)
raise ValueError(msg)
return {"time_period": [{"name": year, "label": str(year)} for year in years]}

def build_variables(self) -> dict:
Expand Down
23 changes: 16 additions & 7 deletions policyengine_api/spm.py
Original file line number Diff line number Diff line change
Expand Up @@ -739,21 +739,30 @@ def spm_metadata(country_id: str) -> dict:
}


def certified_spm_forecast_years() -> tuple[int, ...] | None:
def certified_spm_forecast_years() -> tuple[int, ...]:
"""Return the years the bundle's certified US SPM forecast covers.

The US model refuses an SPM calculation in any other year
(``SPM_YEAR_UNAVAILABLE``). Return None in the cases in which
(``SPM_YEAR_UNAVAILABLE``). Raise SPMValidationError
(``SPM_CONFIGURATION_UNAVAILABLE``) in the cases in which
``spm_metadata`` reports SPM as unavailable: the bundle certifies no
forecast, or the certified one cannot be loaded. The forecast file is read
once per process and shared with ``spm_metadata``.
forecast, or the certified one cannot be loaded. That is the code the
readiness check and US requests already report when a US model that can
run canonical SPM has no usable certified forecast. The forecast file is
read once per process and shared with ``spm_metadata``.
"""
error_code = "SPM_CONFIGURATION_UNAVAILABLE"
try:
defaults = normalize_spm_selection("us", None)
except SPMValidationError:
return None
except SPMValidationError as error:
msg = f"The certified US SPM forecast is unavailable: {error.message}"
raise SPMValidationError(error_code, msg) from error
if defaults is None:
return None
msg = (
"The certified US SPM forecast is unavailable: the installed bundle "
"certifies no SPM measurement configuration"
)
raise SPMValidationError(error_code, msg)
forecast = _selected_forecast(defaults["forecast_content_sha256"])
return tuple(int(year) for year in forecast.years)

Expand Down
8 changes: 5 additions & 3 deletions policyengine_api/supported_countries.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
the revision is downgraded.

The v1 routes load the ``policyengine_<id>`` package of every listed country
(``policyengine_api.country``) and look up its current-law policy ID
(``policyengine_api.utils.get_current_law``), so a new country also needs its
package installed and that ID, or API v1 fails while it loads.
(``policyengine_api.country``), look up its current-law policy ID
(``policyengine_api.utils.get_current_law``) and build its household years
(``PolicyEngineCountry.build_household_options``), so a new country also
needs its package installed, that ID and a household year rule, or the v1
routes fail while they load.

A new country also needs the US-and-UK checks widened on the tables that
pair v1 records with their v2 copies (``legacy_policy_mappings``,
Expand Down
61 changes: 41 additions & 20 deletions tests/unit/test_household_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from policyengine_us.data.economic_assumptions import CPI_U_PARAM_PATH
from spm_calculator.rolling_forecast import load_forecast

from policyengine_api import country as country_module
from policyengine_api import spm
from policyengine_api.country import COUNTRIES, PolicyEngineCountry
from policyengine_api.economy_years import uk_economy_years
from policyengine_api.household_years import us_household_years
Expand All @@ -19,6 +19,11 @@
# request leaves them unchanged.
US_SOCIETY_WIDE_YEARS = list(range(2035, 2021, -1))

CERTIFIED_SPM_SETTINGS = {
"forecast_content_sha256": "a" * 64,
"scenario": "baseline",
}


def _metadata(country_id):
return MetadataService().get_metadata(country_id)
Expand Down Expand Up @@ -59,29 +64,45 @@ def test_us_household_years_stop_at_the_last_cpi_u_year_newest_first(tmp_path):
assert years == [2033, 2023, 2022]


def test_us_household_years_are_the_society_wide_years_without_a_forecast(
monkeypatch,
def _forecast_that_fails_to_load(_expected_sha256):
msg = "Forecast content SHA256 mismatch"
raise ValueError(msg)


@pytest.mark.parametrize(
("bundle", "model_supports_spm", "selected_forecast"),
[
pytest.param({}, False, None, id="no-certified-forecast-legacy-model"),
pytest.param({}, True, None, id="no-certified-forecast-canonical-model"),
pytest.param(
{"measurements": {"spm": dict(CERTIFIED_SPM_SETTINGS)}},
True,
_forecast_that_fails_to_load,
id="certified-forecast-fails-to-load",
),
],
)
def test_us_household_years_fail_without_a_certified_forecast(
monkeypatch, bundle, model_supports_spm, selected_forecast
):
monkeypatch.setattr(country_module, "certified_spm_forecast_years", lambda: None)
economy_time_period = [
{"name": year, "label": str(year)} for year in US_SOCIETY_WIDE_YEARS
]

options = _country_stand_in("us").build_household_options(
{"time_period": economy_time_period}
"""No other years stand in for a certified forecast that is missing."""
monkeypatch.setattr(spm, "_current_bundle", lambda: bundle)
monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: model_supports_spm)
if selected_forecast is not None:
monkeypatch.setattr(spm, "_selected_forecast", selected_forecast)

with pytest.raises(spm.SPMValidationError) as caught:
_country_stand_in("us").build_household_options()

assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE"
assert caught.value.message.startswith(
"The certified US SPM forecast is unavailable: "
)

assert options == {"time_period": economy_time_period}


def test_a_country_without_household_rules_offers_its_society_wide_years():
economy_time_period = [{"name": 2026, "label": "2026"}]

options = _country_stand_in("ca").build_household_options(
{"time_period": economy_time_period}
)

assert options == {"time_period": economy_time_period}
def test_a_country_the_public_api_does_not_serve_has_no_household_years():
with pytest.raises(ValueError, match="'ca'"):
_country_stand_in("ca").build_household_options()


def test_us_household_years_are_the_certified_forecast_years_to_cpi_u():
Expand Down
33 changes: 26 additions & 7 deletions tests/unit/test_spm_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,17 @@ def test_certified_forecast_years_are_the_artifacts_years():
assert spm.certified_spm_forecast_years() == (2022, 2035)


@pytest.mark.parametrize("model_supports_spm", [False, True])
def test_certified_forecast_years_are_none_without_a_certified_forecast(
monkeypatch, model_supports_spm
@pytest.mark.parametrize(
("model_supports_spm", "reason"),
[
(False, "the installed bundle certifies no SPM measurement configuration"),
(True, "The installed bundle has no certified SPM measurement configuration"),
],
)
def test_certified_forecast_years_raise_without_a_certified_forecast(
monkeypatch, model_supports_spm, reason
):
"""No certified forecast means no years, whether or not the model has SPM."""
"""No certified forecast is an error, whether or not the model has SPM."""
monkeypatch.setattr(spm, "_current_bundle", lambda: deepcopy(LEGACY_BUNDLE))
monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: model_supports_spm)

Expand All @@ -255,19 +261,32 @@ def must_not_load(_expected):
raise AssertionError(msg)

monkeypatch.setattr(spm, "_selected_forecast", must_not_load)
assert spm.certified_spm_forecast_years() is None
with pytest.raises(spm.SPMValidationError) as caught:
spm.certified_spm_forecast_years()
assert caught.value.to_dict() == {
"code": "SPM_CONFIGURATION_UNAVAILABLE",
"message": f"The certified US SPM forecast is unavailable: {reason}",
}


@pytest.mark.usefixtures("certified_bundle")
def test_certified_forecast_years_are_none_when_the_forecast_cannot_load(
def test_certified_forecast_years_raise_when_the_forecast_cannot_load(
monkeypatch,
):
def mismatched_artifact(_expected):
msg = "Forecast content SHA256 mismatch"
raise ValueError(msg)

monkeypatch.setattr(spm, "_selected_forecast", mismatched_artifact)
assert spm.certified_spm_forecast_years() is None
with pytest.raises(spm.SPMValidationError) as caught:
spm.certified_spm_forecast_years()
assert caught.value.to_dict() == {
"code": "SPM_CONFIGURATION_UNAVAILABLE",
"message": (
"The certified US SPM forecast is unavailable: "
"Forecast content SHA256 mismatch"
),
}


@pytest.mark.usefixtures("certified_bundle")
Expand Down
Loading