From 0efa34f7214e2570f5dd4117b6f9d8c151e92c83 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:59:33 +0400 Subject: [PATCH 1/9] Define compact SPM provenance contracts --- policyengine_api/spm.py | 233 ++++++++++++++++++++++++++---- tests/unit/test_spm_provenance.py | 194 +++++++++++++++++++++++++ tests/unit/test_spm_settings.py | 32 ++-- 3 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 tests/unit/test_spm_provenance.py diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index c3c627ea9..f4970ad55 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -8,9 +8,10 @@ import importlib import inspect +from collections.abc import Mapping, Sequence from functools import lru_cache from datetime import date -from typing import Any, Literal +from typing import Literal from pydantic import ( BaseModel, @@ -97,18 +98,197 @@ def validate_location(self): class SPMProvenance(BaseModel): - """JSON receipt matching the country's public provenance contract.""" + """Compact public receipt for one canonical SPM execution.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + schema_version: Literal["canonical-spm-provenance-v2"] + forecast_id: str = Field(min_length=1, pattern=r"^\S+$") + forecast_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + scenario: str = Field(min_length=1, pattern=r"^\S+$") + geography_kind: Literal["county", "national", "metro"] + geography_id: str | None = Field(min_length=1) + county_vintage: str = Field(pattern=r"^[0-9]{4}$") + as_of: date | None + years: tuple[str, ...] + runtime_versions: "SPMRuntimeVersions" + + @field_validator("years") + @classmethod + def validate_years(cls, years: tuple[str, ...]) -> tuple[str, ...]: + if any( + len(year) != 4 or not year.isascii() or not year.isdigit() for year in years + ): + raise ValueError("years must contain four-digit calendar years") + if tuple(sorted(set(years))) != years: + raise ValueError("years must be sorted and unique") + return years + + @model_validator(mode="after") + def validate_location(self) -> "SPMProvenance": + if self.geography_kind == "metro": + if self.geography_id is None or not self.geography_id.strip(): + raise ValueError("A metro SPM receipt requires geography_id") + elif self.geography_id is not None: + raise ValueError("Only a metro SPM receipt accepts geography_id") + return self + + +class SPMRuntimeVersions(BaseModel): + """Package versions required to reproduce one certified SPM execution.""" + + model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True) + policyengine: str | None + policyengine_core: str | None = Field(alias="policyengine-core") + policyengine_us: str | None = Field(alias="policyengine-us") + spm_calculator: str | None = Field(alias="spm-calculator") + + +SPMProvenance.model_rebuild() + + +class SPMCalculationProvenance(BaseModel): + """A resolved SPM configuration and its matching compact receipt.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + spm_config: SPMSelection + spm_provenance: SPMProvenance + + +class SPMExecutionProvenance(BaseModel): + """One shared compact receipt and the executions that produced it.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + receipt: SPMProvenance + execution_count: int = Field(ge=1) + + +class SPMComparisonProvenance(BaseModel): + """Compact baseline and reform receipts for a society-wide result.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + schema_version: Literal["canonical-spm-comparison-v2"] + baseline: SPMExecutionProvenance + reform: SPMExecutionProvenance + + @model_validator(mode="after") + def validate_compatible_receipts(self) -> "SPMComparisonProvenance": + if self.baseline.receipt != self.reform.receipt: + raise ValueError("baseline and reform SPM receipts differ") + return self - model_config = ConfigDict(extra="forbid") - forecast_id: str - forecast_sha256: str - scenario: str - geography_kind: str - runtime_versions: dict[str, str | None] - years: dict[str, dict[str, Any]] - geographies: list[dict[str, Any]] - composition_method: str - storage_method: str + +def build_spm_provenance( + *, + forecast_id: str, + forecast_sha256: str, + selection: SPMSelection, + years: Sequence[str], + runtime_versions: SPMRuntimeVersions, +) -> SPMProvenance: + """Build one compact receipt from typed calculation inputs.""" + + if selection.forecast_content_sha256 != forecast_sha256: + raise ValueError("SPM receipt differs from the SPM configuration") + if selection.scenario is None: + raise ValueError("Resolved SPM configuration must include scenario") + return SPMProvenance( + schema_version="canonical-spm-provenance-v2", + forecast_id=forecast_id, + forecast_sha256=forecast_sha256, + scenario=selection.scenario, + geography_kind=selection.geography_kind, + geography_id=selection.geography_id, + county_vintage=selection.county_vintage, + as_of=selection.as_of, + years=tuple(years), + runtime_versions=runtime_versions, + ) + + +def _required_string(source: Mapping[str, object], field: str) -> str: + value = source.get(field) + if not isinstance(value, str) or not value: + raise ValueError(f"Country SPM receipt requires {field}") + return value + + +def build_spm_calculation_provenance( + config: SPMSelection, + receipt: Mapping[str, object], +) -> SPMCalculationProvenance: + """Reduce the country-owned rich receipt to the public compact contract.""" + + forecast_sha256 = _required_string(receipt, "forecast_sha256") + scenario = _required_string(receipt, "scenario") + geography_kind = _required_string(receipt, "geography_kind") + if ( + forecast_sha256 != config.forecast_content_sha256 + or scenario != config.scenario + or geography_kind != config.geography_kind + ): + raise ValueError("Country SPM receipt differs from the SPM configuration") + + source_years = receipt.get("years") + if not isinstance(source_years, Mapping): + raise ValueError("Country SPM receipt requires a years mapping") + years: list[str] = [] + for year in source_years: + if not isinstance(year, str): + raise ValueError("Country SPM receipt years must be strings") + years.append(year) + + source_versions = receipt.get("runtime_versions") + if not isinstance(source_versions, Mapping): + raise ValueError("Country SPM receipt requires runtime_versions") + runtime_versions = SPMRuntimeVersions.model_validate(source_versions) + if any( + version is None + for version in ( + runtime_versions.policyengine, + runtime_versions.policyengine_core, + runtime_versions.policyengine_us, + runtime_versions.spm_calculator, + ) + ): + raise ValueError("Certified SPM receipts require every runtime version") + + compact = build_spm_provenance( + forecast_id=_required_string(receipt, "forecast_id"), + forecast_sha256=forecast_sha256, + selection=config, + years=tuple(sorted(years)), + runtime_versions=runtime_versions, + ) + return SPMCalculationProvenance( + spm_config=config, + spm_provenance=compact, + ) + + +def _collapse_execution_receipts( + side: Literal["baseline", "reform"], + receipts: Sequence[SPMProvenance], +) -> SPMExecutionProvenance: + if not receipts: + raise ValueError(f"{side} SPM receipts are empty") + first = receipts[0] + if any(receipt != first for receipt in receipts[1:]): + raise ValueError(f"{side} SPM receipts differ") + return SPMExecutionProvenance(receipt=first, execution_count=len(receipts)) + + +def build_spm_comparison_provenance( + *, + baseline_receipts: Sequence[SPMProvenance], + reform_receipts: Sequence[SPMProvenance], +) -> SPMComparisonProvenance: + """Collapse matching child receipts into one comparison receipt.""" + + return SPMComparisonProvenance( + schema_version="canonical-spm-comparison-v2", + baseline=_collapse_execution_receipts("baseline", baseline_receipts), + reform=_collapse_execution_receipts("reform", reform_receipts), + ) class SPMValidationError(ValueError): @@ -413,25 +593,24 @@ def spm_metadata(country_id: str) -> dict: def calculation_spm_receipt(simulation) -> dict: - """Read existing calculation receipts; never request SPM calculations here.""" + """Reduce a country's existing receipt; never request calculations here.""" if not hasattr(simulation, "spm_config"): return {} - # A receipt this API cannot read in full is an uncertified country contract, - # not a caller error and not an internal failure. Report it as a typed - # configuration failure rather than letting pydantic surface a 500, and - # never publish a receipt with unrecognized fields dropped. try: - return { - "spm_config": SPMSelection.model_validate(simulation.spm_config).model_dump( - mode="json" - ), - "spm_provenance": SPMProvenance.model_validate( - simulation.spm_provenance() - ).model_dump(mode="json"), - } - except ValidationError as error: + resolved = resolved_spm_settings(simulation.spm_config) + if resolved is None: + raise ValueError("Country SPM configuration is incomplete") + source = simulation.spm_provenance() + if not isinstance(source, Mapping): + raise ValueError("Country SPM receipt must be an object") + calculation = build_spm_calculation_provenance( + SPMSelection.model_validate(resolved), + source, + ) + return calculation.model_dump(mode="json", by_alias=True) + except (ValidationError, ValueError) as error: raise SPMValidationError( "SPM_CONFIGURATION_UNAVAILABLE", "The installed country model reported an SPM receipt this API cannot " - f"certify: {readable_validation_error(error)}", + f"certify: {error_message(error)}", ) from error diff --git a/tests/unit/test_spm_provenance.py b/tests/unit/test_spm_provenance.py new file mode 100644 index 000000000..2a7147df2 --- /dev/null +++ b/tests/unit/test_spm_provenance.py @@ -0,0 +1,194 @@ +"""Compact canonical SPM provenance contracts.""" + +import json + +import pytest +from pydantic import ValidationError + +from policyengine_api.spm import ( + SPMComparisonProvenance, + SPMProvenance, + SPMRuntimeVersions, + SPMSelection, + build_spm_calculation_provenance, + build_spm_comparison_provenance, + build_spm_provenance, +) + + +SHA256 = "a" * 64 +SELECTION = SPMSelection.model_validate( + { + "forecast_content_sha256": SHA256, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } +) +VERSIONS = SPMRuntimeVersions.model_validate( + { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + } +) + + +def _receipt(**changes: object) -> SPMProvenance: + values: dict[str, object] = { + "forecast_id": "spm-rolling-2026-09-09", + "forecast_sha256": SHA256, + "selection": SELECTION, + "years": ("2026",), + "runtime_versions": VERSIONS, + } + values.update(changes) + return build_spm_provenance(**values) + + +def test_compact_receipt_has_only_the_public_v2_fields() -> None: + receipt = _receipt() + + assert receipt.model_dump(mode="json", by_alias=True) == { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-rolling-2026-09-09", + "forecast_sha256": SHA256, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2026"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + }, + } + assert len(receipt.model_dump_json(by_alias=True).encode()) < 1_024 + + +@pytest.mark.parametrize( + "changes", + [ + {"forecast_sha256": "A" * 64}, + {"years": ("26",)}, + {"years": ("2027", "2026")}, + {"years": ("2026", "2026")}, + ], +) +def test_compact_receipt_rejects_noncanonical_values( + changes: dict[str, object], +) -> None: + with pytest.raises((ValidationError, ValueError)): + _receipt(**changes) + + +def test_compact_receipt_rejects_the_previous_diagnostic_shape() -> None: + previous = { + "forecast_id": "spm-rolling-2026-09-09", + "forecast_sha256": SHA256, + "scenario": "ce_trend", + "geography_kind": "national", + "runtime_versions": {}, + "years": {"2026": {}}, + "geographies": [], + "composition_method": "classified inputs", + "storage_method": "formula", + } + + with pytest.raises(ValidationError): + SPMProvenance.model_validate(previous) + + +def test_country_receipt_adapter_extracts_only_compact_scalars() -> None: + country_receipt: dict[str, object] = { + "forecast_id": "spm-rolling-2026-09-09", + "forecast_sha256": SHA256, + "scenario": "ce_trend", + "geography_kind": "national", + "runtime_versions": VERSIONS.model_dump(mode="json", by_alias=True), + "years": {"2027": {"large": "diagnostic"}, "2026": {}}, + "geographies": [{"large": "mapping"}], + "composition_method": "classified inputs", + "storage_method": "formula", + } + + calculation = build_spm_calculation_provenance(SELECTION, country_receipt) + + assert calculation.spm_config == SELECTION + assert calculation.spm_provenance.years == ("2026", "2027") + encoded = calculation.model_dump_json(by_alias=True) + assert "diagnostic" not in encoded + assert "mapping" not in encoded + assert "composition_method" not in encoded + + +def test_country_receipt_must_agree_with_adjacent_configuration() -> None: + country_receipt: dict[str, object] = { + "forecast_id": "spm-rolling-2026-09-09", + "forecast_sha256": SHA256, + "scenario": "different", + "geography_kind": "national", + "runtime_versions": VERSIONS.model_dump(mode="json", by_alias=True), + "years": {}, + } + + with pytest.raises(ValueError, match="differs from the SPM configuration"): + build_spm_calculation_provenance(SELECTION, country_receipt) + + +def test_comparison_collapses_identical_child_receipts() -> None: + receipt = _receipt() + + comparison = build_spm_comparison_provenance( + baseline_receipts=(receipt,) * 20, + reform_receipts=(receipt,) * 20, + ) + + assert comparison.baseline.receipt == receipt + assert comparison.baseline.execution_count == 20 + assert comparison.reform.receipt == receipt + assert comparison.reform.execution_count == 20 + assert len(comparison.model_dump_json(by_alias=True).encode()) < 2_048 + + +@pytest.mark.parametrize("side", ["baseline", "reform"]) +def test_comparison_rejects_inconsistent_child_receipts(side: str) -> None: + receipt = _receipt() + different = _receipt(years=("2027",)) + inputs = { + "baseline_receipts": (receipt,), + "reform_receipts": (receipt,), + } + inputs[f"{side}_receipts"] = (receipt, different) + + with pytest.raises(ValueError, match=f"{side} SPM receipts differ"): + build_spm_comparison_provenance(**inputs) + + +def test_comparison_rejects_previous_list_shape() -> None: + receipt = _receipt().model_dump(mode="json", by_alias=True) + + with pytest.raises(ValidationError): + SPMComparisonProvenance.model_validate( + {"baseline": [receipt], "reform": [receipt]} + ) + + +def test_compact_models_survive_json_round_trip() -> None: + comparison = build_spm_comparison_provenance( + baseline_receipts=(_receipt(),), + reform_receipts=(_receipt(),), + ) + + assert ( + SPMComparisonProvenance.model_validate_json( + json.dumps(comparison.model_dump(mode="json", by_alias=True)) + ) + == comparison + ) diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index 615fba9d2..f22b3b75e 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -432,8 +432,8 @@ def test_resolved_settings_reject_unreadable_receipt_settings(settings): assert spm.resolved_spm_settings(settings) is None -def test_receipt_keeps_the_countrys_own_omissions(certified_bundle): - """A published receipt repeats the country's wire shape, nulls and all.""" +def test_receipt_expands_config_and_reduces_country_diagnostics(certified_bundle): + """The public receipt contains one resolved config and compact provenance.""" omitted = { key: value for key, value in RESOLVED_SELECTION.items() if value is not None } @@ -441,8 +441,19 @@ def test_receipt_keeps_the_countrys_own_omissions(certified_bundle): spm_config=omitted, spm_provenance=lambda: deepcopy(COUNTRY_RECEIPT) ) receipt = spm.calculation_spm_receipt(simulation) - assert receipt["spm_config"] == omitted - assert receipt["spm_provenance"] == COUNTRY_RECEIPT + assert receipt["spm_config"] == RESOLVED_SELECTION + assert receipt["spm_provenance"] == { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "test-artifact", + "forecast_sha256": ARTIFACT_HASH, + "scenario": "baseline", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": [], + "runtime_versions": COUNTRY_RECEIPT["runtime_versions"], + } COUNTRY_RECEIPT = { @@ -450,7 +461,12 @@ def test_receipt_keeps_the_countrys_own_omissions(certified_bundle): "forecast_sha256": ARTIFACT_HASH, "scenario": "baseline", "geography_kind": "county", - "runtime_versions": {"policyengine-us": "test"}, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, "years": {}, "geographies": [], "composition_method": "test composition", @@ -465,13 +481,9 @@ def test_receipt_keeps_the_countrys_own_omissions(certified_bundle): spm_config={**RESOLVED_SELECTION, "threshold_method": "canonical"}, spm_provenance=lambda: COUNTRY_RECEIPT, ), - SimpleNamespace( - spm_config=RESOLVED_SELECTION, - spm_provenance=lambda: {**COUNTRY_RECEIPT, "unreviewed_field": True}, - ), SimpleNamespace(spm_config=RESOLVED_SELECTION, spm_provenance=lambda: {}), ], - ids=["extra-setting", "extra-receipt-field", "incomplete-receipt"], + ids=["extra-setting", "incomplete-receipt"], ) def test_uncertifiable_country_receipt_is_typed_not_an_internal_failure(simulation): """An unreadable receipt is an uncertified country contract, never a 500.""" From 9b2dc4713efbb505bde14162d8d1e7e0701c2720 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:10:41 +0400 Subject: [PATCH 2/9] Persist only compact household SPM receipts --- policyengine_api/routes/household_routes.py | 2 +- .../runtime_cache/household_calculations.py | 45 ++++++---- .../services/household_calculation_service.py | 46 +++++++++- .../services/simulation_service.py | 33 ++++++++ policyengine_api/spm.py | 31 +++++++ tests/integration/test_runtime_cache_redis.py | 21 +++-- tests/unit/routes/test_canonical_spm.py | 28 ++++--- .../test_household_calculations.py | 76 +++++++++++++---- .../unit/services/test_simulation_service.py | 84 +++++++++++++++++++ tests/unit/test_country_spm.py | 37 ++++++-- 10 files changed, 347 insertions(+), 56 deletions(-) diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index 5f2c65dd9..071102b4c 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -231,7 +231,7 @@ def _calculation_cache_key(*args, **kwargs): country_id = request.view_args["country_id"] return hash_object( { - "schema": 2, + "schema": 3, "path": request.full_path, "payload": request.get_json(), "spm": g.spm, diff --git a/policyengine_api/runtime_cache/household_calculations.py b/policyengine_api/runtime_cache/household_calculations.py index 363f9d49e..966afa329 100644 --- a/policyengine_api/runtime_cache/household_calculations.py +++ b/policyengine_api/runtime_cache/household_calculations.py @@ -10,10 +10,10 @@ CacheNamespace, RecoverableJSONCache, ) -from policyengine_api.spm import SPMProvenance, resolved_spm_settings +from policyengine_api.spm import validate_spm_calculation_provenance -HOUSEHOLD_CALCULATION_SCHEMA_VERSION = 2 +HOUSEHOLD_CALCULATION_SCHEMA_VERSION = 3 HOUSEHOLD_CALCULATION_TTL_SECONDS = 86_400 @@ -72,21 +72,18 @@ def get( for value in (spm_config, spm_provenance) ): return None - if identity.spm is not None: - # A stored receipt may omit its null values, so compare resolved - # settings rather than raw JSON. Requiring exact equality would miss - # the cache on every replay of a canonical calculation. - if resolved_spm_settings(spm_config) != identity.spm: + if identity.spm is None: + if spm_config is not None or spm_provenance is not None: return None + else: try: - receipt = SPMProvenance.model_validate(spm_provenance) - except ValidationError: + calculation = validate_spm_calculation_provenance( + spm_config, + spm_provenance, + ) + except (ValidationError, ValueError): return None - if ( - receipt.forecast_sha256 != identity.spm.get("forecast_content_sha256") - or receipt.scenario != identity.spm.get("scenario") - or receipt.geography_kind != identity.spm.get("geography_kind") - ): + if calculation.spm_config.model_dump(mode="json") != identity.spm: return None return CachedHouseholdCalculation( household=household, @@ -100,4 +97,22 @@ def set( identity: HouseholdCalculationIdentity, value: CachedHouseholdCalculation, ) -> bool: - return self._cache.set(asdict(identity), asdict(value)) + if identity.spm is None: + if value.spm_config is not None or value.spm_provenance is not None: + return False + return self._cache.set(asdict(identity), asdict(value)) + try: + calculation = validate_spm_calculation_provenance( + value.spm_config, + value.spm_provenance, + ) + except (ValidationError, ValueError): + return False + if calculation.spm_config.model_dump(mode="json") != identity.spm: + return False + payload = asdict(value) + payload["spm_config"] = calculation.spm_config.model_dump(mode="json") + payload["spm_provenance"] = calculation.spm_provenance.model_dump( + mode="json", by_alias=True + ) + return self._cache.set(asdict(identity), payload) diff --git a/policyengine_api/services/household_calculation_service.py b/policyengine_api/services/household_calculation_service.py index 3d6284567..ff8f90845 100644 --- a/policyengine_api/services/household_calculation_service.py +++ b/policyengine_api/services/household_calculation_service.py @@ -26,7 +26,10 @@ ) from policyengine_api.utils.deprecated_inputs import drop_deprecated_inputs from policyengine_api.utils.input_validation import find_unrecognized_inputs -from policyengine_api.spm import normalize_spm_selection +from policyengine_api.spm import ( + normalize_spm_selection, + validate_spm_calculation_provenance, +) @dataclass(frozen=True) @@ -73,6 +76,28 @@ def __init__(self, invalid_inputs: list[Any]) -> None: super().__init__("Household or policy contains unrecognized inputs") +def _validated_spm_fields( + spm_config: object, + spm_provenance: object, + expected_selection: dict | None, +) -> tuple[dict | None, dict | None]: + if expected_selection is None: + if spm_config is None and spm_provenance is None: + return None, None + raise ValueError("Unexpected SPM provenance for a calculation without SPM") + calculation = validate_spm_calculation_provenance( + spm_config, + spm_provenance, + ) + canonical_config = calculation.spm_config.model_dump(mode="json") + if canonical_config != expected_selection: + raise ValueError("SPM calculation configuration differs from the request") + return ( + canonical_config, + calculation.spm_provenance.model_dump(mode="json", by_alias=True), + ) + + def get_household_year(household: dict) -> int | str: household_year: int | str = date.today().year household_age_list = list( @@ -305,6 +330,16 @@ def calculate_stored_household( calculation = CalculationResult( household=raw_calculation, ) + spm_config, spm_provenance = _validated_spm_fields( + calculation.spm_config, + calculation.spm_provenance, + spm, + ) + calculation = replace( + calculation, + spm_config=spm_config, + spm_provenance=spm_provenance, + ) record_cache_event( family="household-calculation", event="recompute", @@ -398,11 +433,16 @@ def calculate_prepared_household( else: household = raw_calculation.household calculation_warnings = tuple(getattr(raw_calculation, "warnings", ())) + spm_config, spm_provenance = _validated_spm_fields( + getattr(raw_calculation, "spm_config", None), + getattr(raw_calculation, "spm_provenance", None), + prepared.spm, + ) return HouseholdCalculationResult( household=household, warnings=(prepared.warnings + calculation_warnings), - spm_config=getattr(raw_calculation, "spm_config", None), - spm_provenance=getattr(raw_calculation, "spm_provenance", None), + spm_config=spm_config, + spm_provenance=spm_provenance, ) @observability_runtime.span( diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index dfbe828ce..64ac114eb 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -8,6 +8,7 @@ from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import Simulation, SimulationRun +from policyengine_api.spm import validate_spm_calculation_provenance from policyengine_api.utils.population_identity import ( canonical_numeric_household_id, population_id_matches, @@ -52,6 +53,21 @@ def _select_simulation( def _latest_successful_run_id(runs: list[SimulationRun]) -> str | None: return next((run.id for run in runs if run.status == "complete"), None) + @staticmethod + def _has_stale_spm_output(output: object) -> bool: + if not isinstance(output, dict) or not ( + "spm_config" in output or "spm_provenance" in output + ): + return False + try: + validate_spm_calculation_provenance( + output.get("spm_config"), + output.get("spm_provenance"), + ) + except ValueError: + return True + return False + def _ensure_simulation_dual_write_state( self, session: Session, @@ -67,6 +83,11 @@ def _ensure_simulation_dual_write_state( if simulation is None: raise ValueError(f"Simulation #{simulation_id} not found") + if self._has_stale_spm_output(simulation.output): + simulation.status = "pending" + simulation.output = None + simulation.error_message = None + spec = { "country_id": simulation.country_id, "population_id": simulation.population_id, @@ -252,6 +273,18 @@ def update_simulation( return None if isinstance(values.get("output"), str): values["output"] = json.loads(values["output"]) + output_value = values.get("output") + if isinstance(output_value, dict) and ( + "spm_config" in output_value or "spm_provenance" in output_value + ): + calculation = validate_spm_calculation_provenance( + output_value.get("spm_config"), + output_value.get("spm_provenance"), + ) + values["output"] = { + **output_value, + **calculation.model_dump(mode="json", by_alias=True), + } with self._sessions.begin() as session: simulation = self._select_simulation( session, diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index f4970ad55..419a565c7 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -153,6 +153,22 @@ class SPMCalculationProvenance(BaseModel): spm_config: SPMSelection spm_provenance: SPMProvenance + @model_validator(mode="after") + def validate_matching_receipt(self) -> "SPMCalculationProvenance": + receipt = self.spm_provenance + config = self.spm_config + receipt_as_of = receipt.as_of.isoformat() if receipt.as_of is not None else None + if ( + receipt.forecast_sha256 != config.forecast_content_sha256 + or receipt.scenario != config.scenario + or receipt.geography_kind != config.geography_kind + or receipt.geography_id != config.geography_id + or receipt.county_vintage != config.county_vintage + or receipt_as_of != config.as_of + ): + raise ValueError("SPM receipt differs from the SPM configuration") + return self + class SPMExecutionProvenance(BaseModel): """One shared compact receipt and the executions that produced it.""" @@ -394,6 +410,21 @@ def resolved_spm_settings(settings: object) -> dict | None: return {name: getattr(config, name) for name in SPMSelection.model_fields} +def validate_spm_calculation_provenance( + config: object, + provenance: object, +) -> SPMCalculationProvenance: + """Validate a transported compact receipt against its resolved selection.""" + + resolved = resolved_spm_settings(config) + if resolved is None: + raise ValueError("SPM configuration is incomplete") + return SPMCalculationProvenance( + spm_config=SPMSelection.model_validate(resolved), + spm_provenance=SPMProvenance.model_validate(provenance), + ) + + def _current_bundle() -> dict: from policyengine_api.constants import _policyengine_bundle diff --git a/tests/integration/test_runtime_cache_redis.py b/tests/integration/test_runtime_cache_redis.py index 913e9193d..9c107da22 100644 --- a/tests/integration/test_runtime_cache_redis.py +++ b/tests/integration/test_runtime_cache_redis.py @@ -125,20 +125,29 @@ def test_cached_calculation_receipts_survive_a_real_round_trip(redis_pair) -> No name: setting for name, setting in selection.items() if setting is not None }, spm_provenance={ + "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", "forecast_sha256": selection["forecast_content_sha256"], "scenario": "baseline", "geography_kind": "national", - "runtime_versions": {"policyengine-us": "test-only"}, - "years": {"2026": {"status": "forecast"}}, - "geographies": [], - "composition_method": "classified-inputs", - "storage_method": "formula", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "runtime_versions": { + "policyengine": "test-only", + "policyengine-core": "test-only", + "policyengine-us": "test-only", + "spm-calculator": "test-only", + }, + "years": ["2026"], }, ) assert HouseholdCalculationCache(first, namespace).set(identity, value) - assert HouseholdCalculationCache(second, namespace).get(identity) == value + cached = HouseholdCalculationCache(second, namespace).get(identity) + assert cached is not None + assert cached.spm_config == selection + assert cached.spm_provenance == value.spm_provenance other = HouseholdCalculationIdentity( **{**vars(identity), "spm": {**selection, "geography_kind": "county"}} ) diff --git a/tests/unit/routes/test_canonical_spm.py b/tests/unit/routes/test_canonical_spm.py index 2dd51f9d3..c90e744d6 100644 --- a/tests/unit/routes/test_canonical_spm.py +++ b/tests/unit/routes/test_canonical_spm.py @@ -69,15 +69,21 @@ def calculate(self, household, policy, **kwargs): None if config is None else { + "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", "forecast_sha256": config["forecast_content_sha256"], "scenario": config["scenario"], "geography_kind": config["geography_kind"], - "runtime_versions": {"policyengine-us": "test-only"}, - "years": {"2026": {"status": "forecast"}}, - "geographies": [], - "composition_method": "classified-inputs", - "storage_method": "formula", + "geography_id": config["geography_id"], + "county_vintage": config["county_vintage"], + "as_of": config["as_of"], + "runtime_versions": { + "policyengine": "test-only", + "policyengine-core": "test-only", + "policyengine-us": "test-only", + "spm-calculator": "test-only", + }, + "years": ["2026"], } ) return CalculationResult(household, (), config, receipt) @@ -570,11 +576,13 @@ def omitting_null_settings(household, policy, **kwargs): url = f"/us/household/{created.json['result']['household_id']}/policy/2" first = client.get(url) assert first.status_code == 200, first.json - assert set(first.json["spm_config"]) == { - "forecast_content_sha256", - "scenario", - "geography_kind", - "county_vintage", + assert first.json["spm_config"] == { + "forecast_content_sha256": FORECAST_HASH, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, } cached = client.get(url) assert cached.status_code == 200 diff --git a/tests/unit/runtime_cache/test_household_calculations.py b/tests/unit/runtime_cache/test_household_calculations.py index 27b23a044..d14f645c2 100644 --- a/tests/unit/runtime_cache/test_household_calculations.py +++ b/tests/unit/runtime_cache/test_household_calculations.py @@ -10,6 +10,7 @@ HouseholdCalculationCache, HouseholdCalculationIdentity, ) +from policyengine_api.spm import SPMSelection, build_spm_calculation_provenance def _namespace() -> CacheNamespace: @@ -93,15 +94,21 @@ def test_receipt_bearing_payload_supersedes_the_receiptless_schema() -> None: "as_of": None, } SPM_RECEIPT = { + "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", "forecast_sha256": "a" * 64, "scenario": "baseline", "geography_kind": "national", - "runtime_versions": {"policyengine-us": "test"}, - "years": {}, - "geographies": [], - "composition_method": "test composition", - "storage_method": "test storage", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, + "years": [], } @@ -112,7 +119,7 @@ def test_receipt_bearing_payload_supersedes_the_receiptless_schema() -> None: {**SPM_RECEIPT, "forecast_sha256": "b" * 64}, {**SPM_RECEIPT, "scenario": "other"}, {**SPM_RECEIPT, "geography_kind": "county"}, - {**SPM_RECEIPT, "years": []}, + {**SPM_RECEIPT, "years": ["2027", "2026"]}, {**SPM_RECEIPT, "unexpected": True}, ], ids=["empty", "hash", "scenario", "geography", "years-shape", "unknown-field"], @@ -125,11 +132,41 @@ def test_household_cache_rejects_invalid_or_mismatched_spm_receipts(receipt): spm_config=SPM_CONFIG, spm_provenance=receipt, ) - assert cache.set(identity, value) is True + assert cache.set(identity, value) is False assert cache.get(identity) is None -@pytest.mark.parametrize("years", [{}, {"2024": {"source": "test"}}]) +def test_household_cache_rejects_previous_diagnostic_receipt_before_write(): + backend = InMemoryCacheBackend() + cache = HouseholdCalculationCache(backend, _namespace()) + identity = _identity(spm=SPM_CONFIG) + previous_receipt = { + "forecast_id": "test-artifact", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "runtime_versions": {}, + "years": {}, + "geographies": [], + "composition_method": "test", + "storage_method": "test", + } + + assert ( + cache.set( + identity, + CachedHouseholdCalculation( + household={"people": {}}, + spm_config=SPM_CONFIG, + spm_provenance=previous_receipt, + ), + ) + is False + ) + assert backend._values == {} + + +@pytest.mark.parametrize("years", [[], ["2024"]]) def test_household_cache_keeps_valid_receipts_including_lazy_tax_only(years): cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) identity = _identity(spm=SPM_CONFIG) @@ -139,7 +176,10 @@ def test_household_cache_keeps_valid_receipts_including_lazy_tax_only(years): spm_provenance={**SPM_RECEIPT, "years": years}, ) assert cache.set(identity, value) is True - assert cache.get(identity) == value + cached = cache.get(identity) + assert cached is not None + assert cached.spm_config == SPM_CONFIG + assert cached.spm_provenance == {**SPM_RECEIPT, "years": years} @pytest.mark.parametrize("calculate_spm", [False, True], ids=["tax-only", "measured"]) @@ -154,7 +194,9 @@ def test_real_country_provider_receipt_round_trips_household_cache(calculate_spm tenure="renter", ) config = country_spm.spm_config(provider) - receipt = provider.provenance() + receipt = build_spm_calculation_provenance( + SPMSelection.model_validate(config), provider.provenance() + ).spm_provenance.model_dump(mode="json", by_alias=True) assert bool(receipt["years"]) is calculate_spm cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) @@ -165,7 +207,10 @@ def test_real_country_provider_receipt_round_trips_household_cache(calculate_spm spm_provenance=receipt, ) assert cache.set(identity, value) is True - assert cache.get(identity) == value + cached = cache.get(identity) + assert cached is not None + assert cached.spm_config == config + assert cached.spm_provenance == receipt OMITTED_NULL_CONFIG = { @@ -183,7 +228,10 @@ def test_household_cache_hits_when_receipt_omits_null_settings(): spm_provenance=SPM_RECEIPT, ) assert cache.set(identity, value) is True - assert cache.get(identity) == value + cached = cache.get(identity) + assert cached is not None + assert cached.spm_config == SPM_CONFIG + assert cached.spm_provenance == SPM_RECEIPT @pytest.mark.parametrize("omitted", sorted(OMITTED_NULL_CONFIG)) @@ -198,7 +246,7 @@ def test_household_cache_rejects_receipt_omitting_a_nonnull_setting(omitted): }, spm_provenance=SPM_RECEIPT, ) - assert cache.set(identity, value) is True + assert cache.set(identity, value) is False assert cache.get(identity) is None @@ -221,5 +269,5 @@ def test_household_cache_rejects_receipt_with_different_settings(changed): spm_config={**OMITTED_NULL_CONFIG, **changed}, spm_provenance=SPM_RECEIPT, ) - assert cache.set(identity, value) is True + assert cache.set(identity, value) is False assert cache.get(identity) is None diff --git a/tests/unit/services/test_simulation_service.py b/tests/unit/services/test_simulation_service.py index bc5d4ef2e..5e7b224bb 100644 --- a/tests/unit/services/test_simulation_service.py +++ b/tests/unit/services/test_simulation_service.py @@ -162,6 +162,90 @@ def test_update_simulation_accepts_legacy_json_text_at_wire_boundary(service): assert updated.output == {"result": 42} +def test_update_simulation_rejects_incomplete_spm_provenance(service): + simulation = service.get_or_create_simulation( + "us", "household-1", "household", 1 + ).simulation + + with pytest.raises(ValueError, match="SPM"): + service.update_simulation( + "us", + simulation.id, + status="complete", + output={"spm_config": {"geography_kind": "national"}}, + ) + + +def test_update_simulation_persists_only_compact_spm_provenance(service): + simulation = service.get_or_create_simulation( + "us", "household-1", "household", 1 + ).simulation + config = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + provenance = { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-rolling-2026-09-09", + "forecast_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2026"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + }, + } + + updated = service.update_simulation( + "us", + simulation.id, + status="complete", + output={"result": 42, "spm_config": config, "spm_provenance": provenance}, + ) + + assert updated.output == { + "result": 42, + "spm_config": config, + "spm_provenance": provenance, + } + + +def test_get_or_create_marks_previous_spm_output_for_recalculation( + service, + orm_session_factory, +): + simulation = service.get_or_create_simulation( + "us", "household-1", "household", 1 + ).simulation + with orm_session_factory.begin() as session: + stored = session.get(Simulation, simulation.id) + stored.status = "complete" + stored.output = { + "result": 42, + "spm_config": {"geography_kind": "national"}, + "spm_provenance": { + "years": {"2026": {"large": "diagnostics"}}, + }, + } + + refreshed = service.get_or_create_simulation( + "us", "household-1", "household", 1 + ).simulation + + assert refreshed.status == "pending" + assert refreshed.output is None + + def test_update_simulation_without_values_is_a_noop(service): simulation = service.get_or_create_simulation( "us", "household-1", "household", 1 diff --git a/tests/unit/test_country_spm.py b/tests/unit/test_country_spm.py index a36d9f8a0..f392df43e 100644 --- a/tests/unit/test_country_spm.py +++ b/tests/unit/test_country_spm.py @@ -191,13 +191,25 @@ def __init__(self, *, tax_benefit_system, situation, spm): def test_tax_only_result_reads_provenance_without_calculating_spm(monkeypatch): calculations = [] - selection = spm.SPMSelection(geography_kind="national").model_dump() + selection = spm.SPMSelection( + forecast_content_sha256="a" * 64, + scenario="baseline", + geography_kind="national", + geography_id=None, + county_vintage="2020", + as_of=None, + ).model_dump() receipt = { "forecast_id": "test-artifact", "forecast_sha256": "a" * 64, "scenario": "baseline", "geography_kind": "national", - "runtime_versions": {"policyengine-us": "test"}, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, "years": {}, "geographies": [], "composition_method": "test adult classification", @@ -224,12 +236,23 @@ def calculate(variable, period): ) result = country.calculate(requested_household("spm_unit_federal_tax"), None) assert calculations == ["spm_unit_federal_tax"] - assert result.spm_provenance["years"] == {} + assert result.spm_provenance["years"] == [] assert ( result.household["spm_units"]["spm_unit"]["spm_unit_federal_tax"]["2024"] == 123 ) assert json.loads(json.dumps(result.spm_config)) == selection - assert json.loads(json.dumps(result.spm_provenance)) == receipt + assert result.spm_provenance == { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "test-artifact", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": [], + "runtime_versions": receipt["runtime_versions"], + } @pytest.fixture @@ -263,7 +286,7 @@ def test_real_country_state_only_tax_succeeds_and_receipt_stays_empty( result.household["spm_units"]["spm_unit"]["spm_unit_federal_tax"]["2024"] is not None ) - assert result.spm_provenance["years"] == {} + assert result.spm_provenance["years"] == [] json.dumps(result.spm_provenance) @@ -422,7 +445,7 @@ def test_real_http_tax_only_succeeds_without_geography(real_http_client): json={"household": requested_household("spm_unit_federal_tax")}, ) assert response.status_code == 200, response.json - assert response.json["spm_provenance"]["years"] == {} + assert response.json["spm_provenance"]["years"] == [] @pytest.mark.parametrize( @@ -592,7 +615,7 @@ def test_real_http_tax_only_outside_artifact_years_is_lazy( ] is not None ) - assert response.json["spm_provenance"]["years"] == {} + assert response.json["spm_provenance"]["years"] == [] @pytest.mark.parametrize("geography", ["national", "metro"]) From 347751cf8fc82e8a568d0015e45149b46b2f9e27 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:14:52 +0400 Subject: [PATCH 3/9] Require compact society-wide SPM receipts --- .../runtime_cache/reform_impacts.py | 3 +- .../services/budget_window_cache.py | 3 +- policyengine_api/services/economy_service.py | 2 + policyengine_api/worker_spm.py | 43 +++--- .../routes/test_spm_year_worker_polling.py | 26 ++-- .../unit/runtime_cache/test_reform_impacts.py | 28 +++- .../unit/services/test_budget_window_cache.py | 32 ++++- tests/unit/services/test_worker_spm.py | 133 ++++++++++-------- 8 files changed, 176 insertions(+), 94 deletions(-) diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index 571e5b24c..09c96c0f0 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -19,8 +19,7 @@ record_cache_event, ) - -REFORM_IMPACT_SCHEMA_VERSION = 1 +REFORM_IMPACT_SCHEMA_VERSION = 2 REFORM_IMPACT_TTL_SECONDS = 2_592_000 REFORM_IMPACT_INDEX_LIMIT = 1_000 REFORM_IMPACT_START_CLAIM_TTL_SECONDS = 300 diff --git a/policyengine_api/services/budget_window_cache.py b/policyengine_api/services/budget_window_cache.py index 9402bc952..9da6475ac 100644 --- a/policyengine_api/services/budget_window_cache.py +++ b/policyengine_api/services/budget_window_cache.py @@ -19,9 +19,8 @@ ) from policyengine_api.runtime_cache.dependencies import get_runtime_cache_context - BUDGET_WINDOW_CACHE_FAMILY = "budget-window" -BUDGET_WINDOW_CACHE_SCHEMA_VERSION = 2 +BUDGET_WINDOW_CACHE_SCHEMA_VERSION = 3 BUDGET_WINDOW_STARTING_TTL_SECONDS = 300 BUDGET_WINDOW_BATCH_TTL_SECONDS = 86_400 BUDGET_WINDOW_RESULT_TTL_SECONDS = 2_592_000 diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 9cec6e90c..ce7112851 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -1530,6 +1530,8 @@ def _build_options_hash( bundle_parts.append(f"data_version={data_version}") if policyengine_version: bundle_parts.append(f"policyengine_version={policyengine_version}") + if options.get("spm") is not None: + bundle_parts.append("spm_provenance_schema=canonical-spm-comparison-v2") if runtime_app_name: bundle_parts.append(f"runtime_app_name={runtime_app_name}") return "[" + "&".join([option_pairs, *bundle_parts]).strip("&") + "]" diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index 28d8566c8..bcf059c01 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -1,12 +1,13 @@ """Canonical economy execution requires the selected worker's certified capability.""" from policyengine_api.spm import ( + SPMComparisonProvenance, SPMSelection, - SPMProvenance, SPMValidationError, error_message, normalize_spm_selection, resolved_spm_settings, + validate_spm_calculation_provenance, ) @@ -100,27 +101,27 @@ def validate_worker_result( raise ValueError("Worker result has incomplete resolved SPM settings") if resolved != selection: raise ValueError("Worker result SPM settings differ from the request") - receipts = result.get("spm_provenance") - if not isinstance(receipts, dict) or set(receipts) != {"baseline", "reform"}: - raise ValueError("Worker result has no baseline/reform SPM receipts") + comparison = SPMComparisonProvenance.model_validate( + result.get("spm_provenance") + ) for side in ("baseline", "reform"): - if not isinstance(receipts[side], list) or not receipts[side]: - raise ValueError("Worker result has an empty SPM receipt") - for item in receipts[side]: - receipt = SPMProvenance.model_validate(item) - if ( - expected_year is not None - and str(expected_year) not in receipt.years - ): - raise ValueError("Worker SPM receipt does not cover requested year") - if ( - receipt.forecast_sha256 != selection["forecast_content_sha256"] - or receipt.scenario != selection["scenario"] - or receipt.geography_kind != selection["geography_kind"] - ): - raise ValueError( - "Worker result SPM provenance differs from the request" - ) + execution = getattr(comparison, side) + receipt = validate_spm_calculation_provenance( + resolved, + execution.receipt, + ).spm_provenance + if expected_year is not None and str(expected_year) not in receipt.years: + raise ValueError("Worker SPM receipt does not cover requested year") + if any( + version is None + for version in ( + receipt.runtime_versions.policyengine, + receipt.runtime_versions.policyengine_core, + receipt.runtime_versions.policyengine_us, + receipt.runtime_versions.spm_calculator, + ) + ): + raise ValueError("Worker SPM receipt has incomplete runtime versions") except (ValueError, TypeError, KeyError) as exc: raise SPMValidationError( "SPM_CONFIGURATION_UNAVAILABLE", error_message(exc) diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index c79f023f4..d68b32d38 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -15,7 +15,6 @@ from policyengine_api.services.economy_service import EconomyService from policyengine_api.services.reform_impacts_service import ReformImpactsService - SELECTION = { "forecast_content_sha256": "a" * 64, "scenario": "baseline", @@ -32,26 +31,33 @@ def segmented_result(year, invalid_side=None): receipt = { + "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-only", "forecast_sha256": SELECTION["forecast_content_sha256"], "scenario": SELECTION["scenario"], "geography_kind": SELECTION["geography_kind"], - "runtime_versions": {}, - "years": {year: {}}, - "geographies": [], - "composition_method": "classified", - "storage_method": "formula", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, + "years": [year], } result = { "year": year, "spm_config": SELECTION, "spm_provenance": { - "baseline": [dict(receipt), dict(receipt)], - "reform": [dict(receipt), dict(receipt)], + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": dict(receipt), "execution_count": 2}, + "reform": {"receipt": dict(receipt), "execution_count": 2}, }, } if invalid_side: - result["spm_provenance"][invalid_side][1]["years"] = {"2000": {}} + result["spm_provenance"][invalid_side]["receipt"]["years"] = ["2000"] return result @@ -75,7 +81,7 @@ def test_typed_worker_error_is_terminal_and_replays_after_service_recreation( if isinstance(failure, str): typed_error = { "code": "SPM_CONFIGURATION_UNAVAILABLE", - "message": "Worker SPM receipt does not cover requested year", + "message": "baseline and reform SPM receipts differ", } side = failure.removeprefix("segmented-") worker_result = ( diff --git a/tests/unit/runtime_cache/test_reform_impacts.py b/tests/unit/runtime_cache/test_reform_impacts.py index 43445b86a..c7ef0877d 100644 --- a/tests/unit/runtime_cache/test_reform_impacts.py +++ b/tests/unit/runtime_cache/test_reform_impacts.py @@ -4,7 +4,11 @@ import pytest -from policyengine_api.runtime_cache.core import CacheCoordinationError, CacheNamespace +from policyengine_api.runtime_cache.core import ( + CacheCoordinationError, + CacheNamespace, + encode_envelope, +) from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.reform_impacts import ( REFORM_IMPACT_START_CLAIM_TTL_SECONDS, @@ -189,6 +193,28 @@ def test_reform_impact_record_and_indexes_share_one_jittered_ttl( assert set(backend._expires.values()) == {123} +def test_previous_schema_reform_impact_is_not_read() -> None: + backend = InMemoryCacheBackend() + namespace = _namespace() + cache = ReformImpactCache(backend, namespace) + legacy = _impact("legacy", "[spm=canonical]", 1) + legacy_key = namespace.key("reform-impact", 1, {"execution_id": "legacy"}) + backend.set( + legacy_key, + encode_envelope( + "reform-impact", + 1, + { + **legacy.__dict__, + "start_time": legacy.start_time.isoformat(), + "end_time": None, + }, + ), + ) + + assert cache.get_by_execution_id("legacy") is None + + def test_reform_impact_updates_and_deletes_only_matching_computing_values() -> None: backend = InMemoryCacheBackend() cache = ReformImpactCache(backend, _namespace()) diff --git a/tests/unit/services/test_budget_window_cache.py b/tests/unit/services/test_budget_window_cache.py index 1c9009c5e..24e3896c8 100644 --- a/tests/unit/services/test_budget_window_cache.py +++ b/tests/unit/services/test_budget_window_cache.py @@ -3,6 +3,7 @@ import pytest from policyengine_api.runtime_cache.core import CacheCoordinationError +from policyengine_api.runtime_cache.core import CacheNamespace, encode_envelope from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.services.budget_window_cache import ( BUDGET_WINDOW_BATCH_TTL_SECONDS, @@ -80,7 +81,36 @@ def test_build_key_is_stable_for_request_identity(): ) assert first == second - assert first.startswith("policyengine:test:api:budget-window:v2:") + assert first.startswith("policyengine:test:api:budget-window:v3:") + + +def test_previous_schema_cache_identity_is_not_read(): + backend = FakeRedis() + namespace = CacheNamespace("test", "api") + cache = BudgetWindowCache(client=backend, namespace=namespace) + identity = { + "api_version": "v1", + "baseline_policy_id": 1, + "country_id": "us", + "dataset": "default", + "options_hash": "[spm=canonical]", + "reform_policy_id": 2, + "region": "us", + "time_period": "budget_window:2026:2", + } + old_key = namespace.key("budget-window", 2, identity) + backend.set( + f"{old_key}:state", + encode_envelope( + "budget-window", + 2, + {"status": "completed", "result": {"legacy": True}}, + ), + ) + current_key = cache.build_key(**identity) + + assert current_key != old_key + assert cache.get_state(current_key) is None def test_claim_batch_start_allows_one_starter_and_preserves_identity(): diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py index 1bbcc17c7..9c406cb62 100644 --- a/tests/unit/services/test_worker_spm.py +++ b/tests/unit/services/test_worker_spm.py @@ -9,7 +9,7 @@ from policyengine_api.routes.economy_routes import economy_bp from policyengine_api.services.economy_service import EconomyService from policyengine_api.spm import SPMValidationError -from policyengine_api.worker_spm import validate_worker_spm +from policyengine_api.worker_spm import validate_worker_result, validate_worker_spm @pytest.mark.parametrize("selection", [None, {"geography_kind": "national"}]) @@ -174,6 +174,37 @@ def test_canonical_economy_errors_use_existing_validation_envelope(path): "as_of": None, } CAPABILITY = {"contract_version": "canonical-spm-v1", "defaults": SELECTION} +RUNTIME_VERSIONS = { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", +} + + +def worker_receipt(*, years=("2026",)): + return { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "test-only", + "forecast_sha256": SELECTION["forecast_content_sha256"], + "scenario": SELECTION["scenario"], + "geography_kind": SELECTION["geography_kind"], + "geography_id": SELECTION["geography_id"], + "county_vintage": SELECTION["county_vintage"], + "as_of": SELECTION["as_of"], + "years": list(years), + "runtime_versions": RUNTIME_VERSIONS, + } + + +def worker_comparison(*, baseline=None, reform=None, execution_count=1): + baseline = baseline or worker_receipt() + reform = reform or worker_receipt() + return { + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": baseline, "execution_count": execution_count}, + "reform": {"receipt": reform, "execution_count": execution_count}, + } def test_certified_worker_selection_enters_identity_and_submission(): @@ -200,6 +231,15 @@ def test_certified_worker_selection_enters_identity_and_submission(): ) != service._build_options_hash( options={"spm": changed}, model_version="test", dataset="default" ) + assert ( + "spm_provenance_schema=canonical-spm-comparison-v2" + in service._build_options_hash( + options={"spm": SELECTION}, model_version="test", dataset="default" + ) + ) + assert "spm_provenance_schema" not in service._build_options_hash( + options={}, model_version="test", dataset="default" + ) options = service._setup_sim_options("us", {}, {}, "us", "2026", spm=SELECTION) assert options.model_dump(mode="json")["spm"] == SELECTION @@ -403,20 +443,9 @@ def test_worker_cache_receipts_are_required_and_json_roundtrip(): import json from policyengine_api.worker_spm import validate_worker_result - receipt = dict( - forecast_id="test-only", - forecast_sha256=SELECTION["forecast_content_sha256"], - scenario="ce_trend", - geography_kind="national", - runtime_versions={}, - years={"2026": {}}, - geographies=[], - composition_method="classified-inputs", - storage_method="formula", - ) output = { "spm_config": SELECTION, - "spm_provenance": {"baseline": [receipt], "reform": [receipt]}, + "spm_provenance": worker_comparison(), } validate_worker_result(json.loads(json.dumps(output)), SELECTION) validate_worker_result( @@ -457,32 +486,46 @@ def test_worker_receipt_cannot_inherit_nonnull_settings(missing): validate_worker_result({"spm_config": config}, SELECTION) -@pytest.mark.parametrize("side", ["baseline", "reform"]) -def test_worker_receipt_must_cover_requested_year(side): +def test_worker_receipt_must_cover_requested_year(): from policyengine_api.worker_spm import validate_worker_result - receipt = { - "forecast_id": "test-only", - "forecast_sha256": SELECTION["forecast_content_sha256"], - "scenario": "ce_trend", - "geography_kind": "national", - "runtime_versions": {}, - "years": {"2026": {}}, - "geographies": [], - "composition_method": "classified", - "storage_method": "formula", + result = { + "spm_config": SELECTION, + "spm_provenance": worker_comparison(), + } + validate_worker_result(result, SELECTION, expected_year="2026") + result["spm_provenance"] = worker_comparison( + baseline=worker_receipt(years=("2025",)), + reform=worker_receipt(years=("2025",)), + ) + with pytest.raises(SPMValidationError, match="requested year"): + validate_worker_result(result, SELECTION, expected_year="2026") + + +def test_worker_receipt_rejects_baseline_reform_mismatch(): + result = { + "spm_config": SELECTION, + "spm_provenance": worker_comparison( + reform=worker_receipt(years=("2025",)), + ), } + + with pytest.raises(SPMValidationError, match="baseline and reform"): + validate_worker_result(result, SELECTION, expected_year="2026") + + +def test_worker_receipt_rejects_removed_segment_list_shape(): result = { "spm_config": SELECTION, "spm_provenance": { - "baseline": [dict(receipt)], - "reform": [dict(receipt)], + "baseline": [worker_receipt()], + "reform": [worker_receipt()], }, } - validate_worker_result(result, SELECTION, expected_year="2026") - result["spm_provenance"][side][0]["years"] = {"2025": {}} - with pytest.raises(SPMValidationError, match="requested year"): + + with pytest.raises(SPMValidationError) as caught: validate_worker_result(result, SELECTION, expected_year="2026") + assert caught.value.code == "SPM_CONFIGURATION_UNAVAILABLE" @pytest.mark.parametrize( @@ -565,21 +608,10 @@ def test_budget_result_cannot_nest_another_budget_window(): def annual_shape_result(year="2026"): - receipt = { - "forecast_id": "test-only", - "forecast_sha256": SELECTION["forecast_content_sha256"], - "scenario": SELECTION["scenario"], - "geography_kind": SELECTION["geography_kind"], - "runtime_versions": {}, - "years": {"2026": {}}, - "geographies": [], - "composition_method": "classified", - "storage_method": "formula", - } return { "year": year, "spm_config": SELECTION, - "spm_provenance": {"baseline": [receipt], "reform": [receipt]}, + "spm_provenance": worker_comparison(), } @@ -613,17 +645,7 @@ def test_worker_valid_annual_and_window_shapes_cover_requested_year(year): PYDANTIC_INTERNALS = ("errors.pydantic.dev", "input_value", "validation error for") -WORKER_RECEIPT = { - "forecast_id": "test-artifact", - "forecast_sha256": SELECTION["forecast_content_sha256"], - "scenario": SELECTION["scenario"], - "geography_kind": SELECTION["geography_kind"], - "runtime_versions": {"policyengine-us": "test"}, - "years": {"2026": {}}, - "geographies": [], - "composition_method": "test composition", - "storage_method": "test storage", -} +WORKER_RECEIPT = worker_receipt() @pytest.mark.parametrize( @@ -668,10 +690,7 @@ def test_a_worker_receipt_failure_quotes_no_validator_internals(receipt): validate_worker_result( { "spm_config": SELECTION, - "spm_provenance": { - "baseline": [WORKER_RECEIPT], - "reform": [receipt], - }, + "spm_provenance": worker_comparison(reform=receipt), }, SELECTION, expected_year="2026", From 1eb36c2a554841c6599b8cf95baa72a7551977c6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:22:41 +0400 Subject: [PATCH 4/9] Publish compact SPM provenance schemas --- changelog.d/3862.breaking.md | 1 + docs/canonical-spm.md | 67 ++++++++++++----- policyengine_api/openapi_spec.yaml | 25 ++----- policyengine_api/specification.py | 22 +++++- policyengine_api/spm.py | 9 +-- tests/integration/test_runtime_cache_redis.py | 5 +- tests/unit/routes/test_canonical_spm.py | 27 ++----- .../routes/test_household_numeric_identity.py | 11 ++- tests/unit/routes/test_simulation_spm.py | 10 +-- .../test_household_calculations.py | 11 +-- .../services/test_spm_household_identity.py | 3 +- tests/unit/services/test_worker_spm.py | 15 ++-- tests/unit/test_openapi_simulation.py | 25 +++++-- tests/unit/test_openapi_spm.py | 73 +++++++++++++++---- tests/unit/test_spm_settings.py | 12 ++- 15 files changed, 195 insertions(+), 121 deletions(-) create mode 100644 changelog.d/3862.breaking.md diff --git a/changelog.d/3862.breaking.md b/changelog.d/3862.breaking.md new file mode 100644 index 000000000..9c9b795a8 --- /dev/null +++ b/changelog.d/3862.breaking.md @@ -0,0 +1 @@ +Replace large SPM diagnostic provenance payloads with strict compact v2 receipts for household and society-wide results. Older cached or stored SPM output is recalculated instead of translated. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md index 042e3be71..01b2ce313 100644 --- a/docs/canonical-spm.md +++ b/docs/canonical-spm.md @@ -160,11 +160,36 @@ Successful canonical calculations add `spm_config` and `spm_provenance` beside with, not a guarantee that every SPM-dependent variable produced a value: a calculation that never chose a measurement still carries the inherited one's receipt beside its null cells. Read the values to learn which of them a -measurement produced. Provenance comes from the actual simulation and includes artifact, -scenario, years, geography, composition/storage methods and runtime versions. -It remains in JSON form through stored replay and cache hits. A tax-only receipt -may have empty `years` and `geographies` because no SPM measurement was requested. -Clients saving simulation outputs must retain this full response envelope. +measurement produced. Provenance comes from the actual simulation and uses the +required compact `canonical-spm-provenance-v2` schema: + +```json +{ + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "canonical-spm-forecast", + "forecast_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "scenario": "baseline", + "geography_kind": "national", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": ["2026"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0" + } +} +``` + +All fields are required. `years` is sorted and unique, and it is empty only +when no SPM dependency ran. Certified deployments populate every runtime +version. Large diagnostic, mapping, request, effective-setting and source +objects from the country package are intentionally excluded from HTTP and +cache payloads. The compact receipt remains in JSON form through stored replay +and cache hits. Clients saving simulation outputs must retain this response +envelope. `POST /us/simulation` takes `population_id`, `population_type` (`household` or `geography`) and integer `policy_id`. It creates a pending record (HTTP 201) or @@ -183,10 +208,11 @@ country, certification, or whether the household has saved `spm`. update field must be non-null, and `complete` requires non-null `output`. Store the full calculation envelope inside `output`, including its `spm_config` and `spm_provenance`. A JSON-encoded output string is also accepted. This endpoint -stores the supplied output; it does not validate SPM receipt integrity. Null -update fields are ignored. The legacy `api_version` input is ignored; writes -record the installed country model version. Top-level `spm`, even null, returns -HTTP 400 `SPM_SETTINGS_UNSUPPORTED` on POST and PATCH. +validates compact SPM receipt integrity and agreement with the adjacent, +fully-resolved six-field `spm_config` before storage. Old or rich receipts are +not translated. Null update fields are ignored. The legacy `api_version` input +is ignored; writes record the installed country model version. Top-level `spm`, +even null, returns HTTP 400 `SPM_SETTINGS_UNSUPPORTED` on POST and PATCH. POST, PATCH and `GET /us/simulation/{id}` return the simulation record inside `result`. Non-string JSON `output` and `simulation_spec_json` are returned as @@ -201,9 +227,10 @@ Flask and the native specification route. HTTP response cache identity includes the normalized selection and model/bundle versions, and validates certification before reading the cache. The stored -calculated-household cache uses schema version 2, includes the selection in +calculated-household cache uses schema version 3, includes the selection in identity, and stores settings/provenance atomically with the result and its -warnings. Missing or mismatched canonical receipts are cache misses. +warnings. Missing, old, rich or mismatched canonical receipts are cache misses +and are recalculated rather than translated. ## Errors @@ -261,11 +288,15 @@ from `/versions`, including agreement with the bundle's independently pinned artifact hash. An old or uncertified worker returns `SPM_CONFIGURATION_UNAVAILABLE`. Normalized settings are sent to both annual and budget-window jobs and participate -in economy cache identity. Results carry `spm_config` and `spm_provenance` with -`baseline` and `reform` receipt lists (one per executed regional segment). Each -budget-window annual row carries these fields; annual results expose them inside -`result`, and budget-window results inside `result.annualImpacts`. Stored results require matching -settings and valid receipts. Typed SPM input errors remain structured 400s +in economy cache identity. Results carry `spm_config` and `spm_provenance` using +`canonical-spm-comparison-v2`. `baseline` and `reform` each contain one compact +`receipt` plus an `execution_count`; the receipt collapses identical child +execution receipts without transporting a repeated list. Baseline and reform +receipts must be identical. Each budget-window annual row carries these fields; +annual results expose them inside `result`, and budget-window results inside +`result.annualImpacts`. Stored results require matching settings and valid +receipts. Economy cache identity includes the comparison schema version, so old +entries are not selected and are recomputed rather than translated. Typed SPM input errors remain structured 400s through asynchronous submission and polling. Poll-time typed failures retain their code and message for the existing cache lifetime. Later reads replay that failure after API service restarts without polling or resubmitting the failed @@ -278,5 +309,5 @@ for the implemented worker paths and remaining coordinated release gates. Partial selections preserve omitted fields in JSON; omissions inherit the certified bundle defaults. The selection schema deliberately supplies no wire defaults, so generated clients preserve that distinction. Resolved settings freeze all six -fields. Responses may omit null geography_id/as_of values, but must include every -non-null resolved field; receipt replay never substitutes current defaults. +fields. Responses include all six fields, including explicit null values for +`geography_id` or `as_of`; receipt replay never substitutes current defaults. diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index 4e0d0f8b2..b03698ab0 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -899,7 +899,7 @@ paths: spm_config: $ref: '#/components/schemas/SPMSelection' spm_provenance: - $ref: '#/components/schemas/SPMWorkerProvenance' + $ref: '#/components/schemas/SPMComparisonProvenance' 400: $ref: '#/components/responses/SPMValidationError' 404: @@ -972,7 +972,7 @@ paths: spm_config: $ref: '#/components/schemas/SPMSelection' spm_provenance: - $ref: '#/components/schemas/SPMWorkerProvenance' + $ref: '#/components/schemas/SPMComparisonProvenance' progress: type: integer nullable: true @@ -1270,25 +1270,16 @@ components: description: JSON Schema for SPMSelection, preserving omitted fields. defaults: $ref: '#/components/schemas/SPMSelection' - SPMWorkerProvenance: - type: object - additionalProperties: false - required: [baseline, reform] - properties: - baseline: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/SPMProvenance' - reform: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/SPMProvenance' SPMSelection: description: Generated from policyengine_api.spm.SPMSelection. + SPMRuntimeVersions: + description: Generated from policyengine_api.spm.SPMRuntimeVersions. SPMProvenance: description: Generated from policyengine_api.spm.SPMProvenance. + SPMExecutionProvenance: + description: Generated from policyengine_api.spm.SPMExecutionProvenance. + SPMComparisonProvenance: + description: Generated from policyengine_api.spm.SPMComparisonProvenance. responses: SPMValidationError: description: >- diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index 11b4b3c8f..e2c9296b6 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -13,7 +13,13 @@ AnnualEconomyQuery, BudgetWindowEconomyQuery, ) -from policyengine_api.spm import SPMProvenance, SPMSelection +from policyengine_api.spm import ( + SPMComparisonProvenance, + SPMExecutionProvenance, + SPMProvenance, + SPMRuntimeVersions, + SPMSelection, +) DEFAULT_SPECIFICATION_PATH = Path(__file__).with_name("openapi_spec.yaml") @@ -62,6 +68,15 @@ def _economy_query_parameters(model): return result +def _component_schema(model): + """Generate one OpenAPI component and publish nested models separately.""" + schema = model.model_json_schema( + ref_template="#/components/schemas/{model}", + ) + schema.pop("$defs", None) + return _openapi_30_schema(schema) + + def load_specification( path: Path = DEFAULT_SPECIFICATION_PATH, version: str = VERSION, @@ -74,10 +89,13 @@ def load_specification( schemas = document.get("components", {}).get("schemas", {}) for name, model in { "SPMSelection": SPMSelection, + "SPMRuntimeVersions": SPMRuntimeVersions, "SPMProvenance": SPMProvenance, + "SPMExecutionProvenance": SPMExecutionProvenance, + "SPMComparisonProvenance": SPMComparisonProvenance, }.items(): if name in schemas: - schemas[name] = _openapi_30_schema(model.model_json_schema()) + schemas[name] = _component_schema(model) economy_path = "/{country_id}/economy/{policy_id}/over/{baseline_policy_id}" for suffix, query_model in ( ("", AnnualEconomyQuery), diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 419a565c7..9cc181f08 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -386,12 +386,9 @@ def simulation_supports_spm(simulation_type) -> bool: ) -# A resolved selection freezes all six fields, but output transports may omit -# null values. Only those omissions are safe: a receipt must never inherit a -# non-null setting from today's bundle defaults. -REQUIRED_RESOLVED_SPM_FIELDS = frozenset( - {"forecast_content_sha256", "scenario", "geography_kind", "county_vintage"} -) +# A transported resolved selection freezes all six fields, including explicit +# null values. Receipt validation never fills output from current defaults. +REQUIRED_RESOLVED_SPM_FIELDS = frozenset(SPMSelection.model_fields) def resolved_spm_settings(settings: object) -> dict | None: diff --git a/tests/integration/test_runtime_cache_redis.py b/tests/integration/test_runtime_cache_redis.py index 9c107da22..2d7c571cc 100644 --- a/tests/integration/test_runtime_cache_redis.py +++ b/tests/integration/test_runtime_cache_redis.py @@ -120,10 +120,7 @@ def test_cached_calculation_receipts_survive_a_real_round_trip(redis_pair) -> No value = CachedHouseholdCalculation( household={"people": {"you": {}}}, warnings=(), - # A canonical receipt may omit its null settings; that is the same choice. - spm_config={ - name: setting for name, setting in selection.items() if setting is not None - }, + spm_config=selection, spm_provenance={ "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", diff --git a/tests/unit/routes/test_canonical_spm.py b/tests/unit/routes/test_canonical_spm.py index c90e744d6..95f51cb83 100644 --- a/tests/unit/routes/test_canonical_spm.py +++ b/tests/unit/routes/test_canonical_spm.py @@ -544,14 +544,8 @@ def test_valid_spm_database_timeout_keeps_stage11_safe_persistence_response( copy_event.assert_not_called() -def test_stored_replay_hits_the_calculation_cache_when_a_receipt_omits_nulls( - certified, harness -): - """A canonical country may omit null receipt settings; replay must still hit. - - Requiring exact JSON equality between the receipt and the resolved identity - made every stored replay recompute. - """ +def test_stored_replay_rejects_a_receipt_config_that_omits_nulls(certified, harness): + """Country output must state every field in its resolved configuration.""" client, country = harness calculate = country.calculate @@ -574,20 +568,9 @@ def omitting_null_settings(household, policy, **kwargs): ) assert created.status_code == 201, created.json url = f"/us/household/{created.json['result']['household_id']}/policy/2" - first = client.get(url) - assert first.status_code == 200, first.json - assert first.json["spm_config"] == { - "forecast_content_sha256": FORECAST_HASH, - "scenario": "baseline", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - } - cached = client.get(url) - assert cached.status_code == 200 - assert cached.json == first.json - assert len(country.calls) == 1 + assert client.get(url).status_code == 500 + assert client.get(url).status_code == 500 + assert len(country.calls) == 2 @pytest.mark.parametrize("version", ["5.2.1", "5.4.0", "6.0.0"]) diff --git a/tests/unit/routes/test_household_numeric_identity.py b/tests/unit/routes/test_household_numeric_identity.py index 524cf4e5b..e65e4b13b 100644 --- a/tests/unit/routes/test_household_numeric_identity.py +++ b/tests/unit/routes/test_household_numeric_identity.py @@ -219,9 +219,12 @@ def test_numeric_link_preserves_immutable_household_and_saved_simulation( assert saved["population_id"] == saved_spelling assert json.loads(saved["simulation_spec_json"])["population_id"] == saved_spelling if reference == "historical-padded": - assert saved["status"] == "complete" - assert json.loads(saved["output"]) == output + assert saved["status"] == "pending" + assert saved["output"] is None + assert saved["latest_successful_run_id"] is None + assert saved["active_run_id"] is not None with orm_session_factory() as session: - run = session.get(SimulationRun, saved["latest_successful_run_id"]) + run = session.get(SimulationRun, saved["active_run_id"]) assert run.simulation_spec_snapshot_json["population_id"] == saved_spelling - assert run.output == output + assert run.status == "pending" + assert run.output is None diff --git a/tests/unit/routes/test_simulation_spm.py b/tests/unit/routes/test_simulation_spm.py index 59b591a5a..22a151696 100644 --- a/tests/unit/routes/test_simulation_spm.py +++ b/tests/unit/routes/test_simulation_spm.py @@ -46,7 +46,7 @@ def test_simulation_records_reject_spm_overrides_before_read_or_write( @pytest.mark.parametrize("population_id", ["00001", "1", "0000001", 1]) -def test_repost_historical_numeric_household_alias_replays_completed_output( +def test_repost_historical_numeric_household_alias_recalculates_old_spm_output( orm_session_factory, population_id ): output = {"household": {"people": {}}, "spm_config": {"geography_kind": "national"}} @@ -81,8 +81,8 @@ def test_repost_historical_numeric_household_alias_replays_completed_output( assert response.status_code == 200 result = response.get_json()["result"] assert result["id"] == historical_id - assert result["status"] == "complete" - assert json.loads(result["output"]) == output + assert result["status"] == "pending" + assert result["output"] is None assert result["population_id"] == "00001" assert ( json.loads(result["simulation_spec_json"])["population_id"] == "00001" @@ -91,6 +91,6 @@ def test_repost_historical_numeric_household_alias_replays_completed_output( with orm_session_factory() as session: assert session.scalar(select(func.count()).select_from(Simulation)) == 1 run = session.scalar(select(SimulationRun)) - assert run.status == "complete" - assert run.output == output + assert run.status == "pending" + assert run.output is None assert run.simulation_spec_snapshot_json["population_id"] == "00001" diff --git a/tests/unit/runtime_cache/test_household_calculations.py b/tests/unit/runtime_cache/test_household_calculations.py index d14f645c2..570a9e3f6 100644 --- a/tests/unit/runtime_cache/test_household_calculations.py +++ b/tests/unit/runtime_cache/test_household_calculations.py @@ -218,8 +218,8 @@ def test_real_country_provider_receipt_round_trips_household_cache(calculate_spm } -def test_household_cache_hits_when_receipt_omits_null_settings(): - """A canonical receipt may omit its null values; that is still the same selection.""" +def test_household_cache_rejects_receipt_omitting_null_settings(): + """A compact receipt's adjacent config must contain all six fields.""" cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) identity = _identity(spm=SPM_CONFIG) value = CachedHouseholdCalculation( @@ -227,11 +227,8 @@ def test_household_cache_hits_when_receipt_omits_null_settings(): spm_config=OMITTED_NULL_CONFIG, spm_provenance=SPM_RECEIPT, ) - assert cache.set(identity, value) is True - cached = cache.get(identity) - assert cached is not None - assert cached.spm_config == SPM_CONFIG - assert cached.spm_provenance == SPM_RECEIPT + assert cache.set(identity, value) is False + assert cache.get(identity) is None @pytest.mark.parametrize("omitted", sorted(OMITTED_NULL_CONFIG)) diff --git a/tests/unit/services/test_spm_household_identity.py b/tests/unit/services/test_spm_household_identity.py index 2ed2572f1..661d3ec03 100644 --- a/tests/unit/services/test_spm_household_identity.py +++ b/tests/unit/services/test_spm_household_identity.py @@ -158,7 +158,8 @@ def test_replacement_household_preserves_historical_zero_padded_reference( ) assert replay.created is False assert replay.simulation.population_id == str(household.id).zfill(5) - assert replay.simulation.output == {"spm_config": NATIONAL} + assert replay.simulation.status == "pending" + assert replay.simulation.output is None saved = service.get_household("us", household.id) assert saved.household_json == original diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py index 9c406cb62..4cb17b7a6 100644 --- a/tests/unit/services/test_worker_spm.py +++ b/tests/unit/services/test_worker_spm.py @@ -448,15 +448,6 @@ def test_worker_cache_receipts_are_required_and_json_roundtrip(): "spm_provenance": worker_comparison(), } validate_worker_result(json.loads(json.dumps(output)), SELECTION) - validate_worker_result( - { - **output, - "spm_config": { - key: value for key, value in SELECTION.items() if value is not None - }, - }, - SELECTION, - ) validate_worker_result( { "kind": "budgetWindow", @@ -468,6 +459,12 @@ def test_worker_cache_receipts_are_required_and_json_roundtrip(): for incomplete in ( {}, {"spm_config": SELECTION}, + { + **output, + "spm_config": { + key: value for key, value in SELECTION.items() if value is not None + }, + }, {**output, "spm_config": {**SELECTION, "scenario": "zero_real"}}, ): with pytest.raises(SPMValidationError): diff --git a/tests/unit/test_openapi_simulation.py b/tests/unit/test_openapi_simulation.py index ff4fd6c38..d80320eb5 100644 --- a/tests/unit/test_openapi_simulation.py +++ b/tests/unit/test_openapi_simulation.py @@ -147,17 +147,30 @@ def test_simulation_persistence_responses_preserve_spm_output_and_run_fields( "status": "ok", "message": None, "result": {"people": {}}, - "spm_config": {"geography_kind": "national"}, + "spm_config": { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + }, "spm_provenance": { + "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-forecast", "forecast_sha256": "a" * 64, "scenario": "baseline", "geography_kind": "national", - "runtime_versions": {"policyengine-us": "test"}, - "years": {}, - "geographies": [], - "composition_method": "test", - "storage_method": "test", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, + "years": ["2026"], }, } update_payload = { diff --git a/tests/unit/test_openapi_spm.py b/tests/unit/test_openapi_spm.py index 75a6e6d46..38da38748 100644 --- a/tests/unit/test_openapi_spm.py +++ b/tests/unit/test_openapi_spm.py @@ -5,7 +5,13 @@ from flask import Flask from policyengine_api.routes.system_routes import system_bp -from policyengine_api.spm import SPMProvenance, SPMSelection +from policyengine_api.spm import ( + SPMComparisonProvenance, + SPMExecutionProvenance, + SPMProvenance, + SPMRuntimeVersions, + SPMSelection, +) from policyengine_api.query_parameters import ( AnnualEconomyQuery, BudgetWindowEconomyQuery, @@ -50,15 +56,23 @@ def test_economy_query_and_receipt_schemas_match_public_http_contract(): "$ref": "#/components/schemas/SPMSelection" } assert result["properties"]["spm_provenance"] == { - "$ref": "#/components/schemas/SPMWorkerProvenance" + "$ref": "#/components/schemas/SPMComparisonProvenance" } assert operation["responses"]["400"]["$ref"].endswith("SPMValidationError") - receipt = spec["components"]["schemas"]["SPMWorkerProvenance"] - assert receipt["required"] == ["baseline", "reform"] - for side in receipt["required"]: - assert receipt["properties"][side]["items"] == { - "$ref": "#/components/schemas/SPMProvenance" + comparison = spec["components"]["schemas"]["SPMComparisonProvenance"] + assert comparison["required"] == ["schema_version", "baseline", "reform"] + assert comparison["properties"]["schema_version"]["enum"] == [ + "canonical-spm-comparison-v2" + ] + for side in ("baseline", "reform"): + assert comparison["properties"][side] == { + "$ref": "#/components/schemas/SPMExecutionProvenance" } + execution = spec["components"]["schemas"]["SPMExecutionProvenance"] + assert execution["required"] == ["receipt", "execution_count"] + assert execution["properties"]["receipt"] == { + "$ref": "#/components/schemas/SPMProvenance" + } def test_metadata_discovery_and_selection_constraints_are_published(): @@ -106,10 +120,17 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): for name, model in { "SPMSelection": SPMSelection, + "SPMRuntimeVersions": SPMRuntimeVersions, "SPMProvenance": SPMProvenance, + "SPMExecutionProvenance": SPMExecutionProvenance, + "SPMComparisonProvenance": SPMComparisonProvenance, }.items(): schema = schemas[name] - assert set(schema["properties"]) == set(model.model_fields) + expected_fields = { + field.alias or field_name + for field_name, field in model.model_fields.items() + } + assert set(schema["properties"]) == expected_fields assert schema["additionalProperties"] is False assert '"type": "null"' not in json.dumps(schema) @@ -119,12 +140,38 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): for field in ("forecast_content_sha256", "scenario", "geography_id", "as_of"): assert selection[field]["type"] == "string" assert selection[field]["nullable"] is True - assert schemas["SPMProvenance"]["properties"]["runtime_versions"][ - "additionalProperties" - ] == { - "type": "string", - "nullable": True, + provenance = schemas["SPMProvenance"] + assert provenance["required"] == [ + "schema_version", + "forecast_id", + "forecast_sha256", + "scenario", + "geography_kind", + "geography_id", + "county_vintage", + "as_of", + "years", + "runtime_versions", + ] + assert provenance["properties"]["runtime_versions"] == { + "$ref": "#/components/schemas/SPMRuntimeVersions" + } + assert set(schemas["SPMRuntimeVersions"]["properties"]) == { + "policyengine", + "policyengine-core", + "policyengine-us", + "spm-calculator", + } + removed_rich_fields = { + "geographies", + "requested", + "effective", + "execution_count", + "sources", + "diagnostics", + "mappings", } + assert not removed_rich_fields.intersection(provenance["properties"]) for route in ("calculate", "calculate-full"): operation = paths[f"/{{country_id}}/{route}"]["post"] diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index f22b3b75e..33e18936b 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -403,13 +403,13 @@ def test_canonical_country_cannot_inherit_the_legacy_exception(monkeypatch, vers } -def test_resolved_settings_recover_receipt_omitted_null_values(certified_bundle): - """A receipt that omits its null values still names the same selection.""" +def test_resolved_settings_require_explicit_null_values(certified_bundle): + """A transported resolved config includes all six selection fields.""" assert spm.resolved_spm_settings(RESOLVED_SELECTION) == RESOLVED_SELECTION omitted = { key: value for key, value in RESOLVED_SELECTION.items() if value is not None } - assert spm.resolved_spm_settings(omitted) == RESOLVED_SELECTION + assert spm.resolved_spm_settings(omitted) is None assert spm.resolved_spm_settings(spm.normalize_spm_selection("us", None)) == ( RESOLVED_SELECTION ) @@ -434,11 +434,9 @@ def test_resolved_settings_reject_unreadable_receipt_settings(settings): def test_receipt_expands_config_and_reduces_country_diagnostics(certified_bundle): """The public receipt contains one resolved config and compact provenance.""" - omitted = { - key: value for key, value in RESOLVED_SELECTION.items() if value is not None - } simulation = SimpleNamespace( - spm_config=omitted, spm_provenance=lambda: deepcopy(COUNTRY_RECEIPT) + spm_config=RESOLVED_SELECTION, + spm_provenance=lambda: deepcopy(COUNTRY_RECEIPT), ) receipt = spm.calculation_spm_receipt(simulation) assert receipt["spm_config"] == RESOLVED_SELECTION From 42dc1d9de572558775f059a03e94df2eba071a9b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:42:23 +0400 Subject: [PATCH 5/9] Close compact SPM persistence review gaps --- changelog.d/3862.breaking.md | 2 +- docs/canonical-spm.md | 13 +- docs/migration/stage-12-worker-contract.md | 4 + policyengine_api/constants.py | 2 +- policyengine_api/openapi_spec.yaml | 17 +- .../services/report_output_service.py | 97 +++++++++++ .../services/v2/simulations/types.py | 3 +- policyengine_api/specification.py | 2 + policyengine_api/spm.py | 63 ++++--- policyengine_api/worker_spm.py | 10 -- .../services/test_report_output_service.py | 159 +++++++++++++++++- tests/unit/test_openapi_spm.py | 19 ++- .../unit/test_simulation_and_report_routes.py | 85 +++++++++- tests/unit/test_spm_provenance.py | 32 +++- .../unit/v2/test_stage12_worker_contracts.py | 87 +++++++++- 15 files changed, 538 insertions(+), 57 deletions(-) diff --git a/changelog.d/3862.breaking.md b/changelog.d/3862.breaking.md index 9c9b795a8..b878b5aab 100644 --- a/changelog.d/3862.breaking.md +++ b/changelog.d/3862.breaking.md @@ -1 +1 @@ -Replace large SPM diagnostic provenance payloads with strict compact v2 receipts for household and society-wide results. Older cached or stored SPM output is recalculated instead of translated. +Replace large SPM diagnostic provenance payloads with strict compact v2 receipts for household, society-wide, report, and Stage 12 artifact results. Older cached or stored SPM output is recalculated instead of translated, and runtime version values are required. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md index 01b2ce313..c7b5fe0a3 100644 --- a/docs/canonical-spm.md +++ b/docs/canonical-spm.md @@ -183,9 +183,9 @@ required compact `canonical-spm-provenance-v2` schema: } ``` -All fields are required. `years` is sorted and unique, and it is empty only -when no SPM dependency ran. Certified deployments populate every runtime -version. Large diagnostic, mapping, request, effective-setting and source +All fields are required. The four runtime-version values are non-null strings. +`years` is sorted and unique, and it is empty only when no SPM dependency ran. +Large diagnostic, mapping, request, effective-setting and source objects from the country package are intentionally excluded from HTTP and cache payloads. The compact receipt remains in JSON form through stored replay and cache hits. Clients saving simulation outputs must retain this response @@ -302,6 +302,13 @@ their code and message for the existing cache lifetime. Later reads replay that failure after API service restarts without polling or resubmitting the failed job. Canonical cache identity and runtime-bundle refresh rules still apply. +Completed US economy report outputs pass the same compact comparison validation +before `report_outputs` or `report_output_runs` persistence. Their report cache +identity uses schema version 2. Reads of schema-version-1 report records resolve +to a new pending record with no copied output, preventing another client from +receiving a previously stored rich receipt. Household and non-US report output +shapes retain their existing behavior. + See the [canonical SPM worker PR](https://github.com/PolicyEngine/policyengine-sim-api/pull/677) for the implemented worker paths and remaining coordinated release gates. diff --git a/docs/migration/stage-12-worker-contract.md b/docs/migration/stage-12-worker-contract.md index dbb7a8aa2..f26c315fa 100644 --- a/docs/migration/stage-12-worker-contract.md +++ b/docs/migration/stage-12-worker-contract.md @@ -171,6 +171,10 @@ SHA-256 content digests, schema versions, row-identity metadata, and bounded provenance. When an option such as SPM produces a detached calculation receipt, the simulation descriptor and Parquet metadata retain it so the coordinator can validate the receipt before reproducing the existing aggregate response. +`SimulationArtifactDescriptor.calculation_provenance` is either null or the +exact typed compact SPM calculation contract: a complete six-field resolved +configuration plus `canonical-spm-provenance-v2`. It rejects partial, rich, and +legacy receipt shapes rather than preserving or normalizing them. Only the Simulation Entrypoint, the versioned worker application, the bucket's object-lifecycle process, and explicitly authorized operators may access the diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index 683fb4aa9..dffe76ca6 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -177,7 +177,7 @@ def get_bundle_default_dataset_option(country_id: str) -> dict: RUNTIME_CACHE_SCHEMA_VERSIONS = { "economy_impact": 2, - "report_output": 1, + "report_output": 2, } diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index b03698ab0..21f43d240 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -440,7 +440,7 @@ paths: result: type: object spm_config: - $ref: '#/components/schemas/SPMSelection' + $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMProvenance' 404: @@ -532,7 +532,7 @@ paths: result: type: object spm_config: - $ref: '#/components/schemas/SPMSelection' + $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMProvenance' 500: @@ -760,7 +760,10 @@ paths: patch: summary: Update a report output operationId: update_report_output - description: Update a report output status, result, or error message. + description: >- + Update a report output status, result, or error message. Completed US + economy outputs carrying SPM fields require compact v2 comparison + provenance and a matching complete resolved configuration. parameters: - name: country_id in: path @@ -897,7 +900,7 @@ paths: nullable: true properties: spm_config: - $ref: '#/components/schemas/SPMSelection' + $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMComparisonProvenance' 400: @@ -970,7 +973,7 @@ paths: year: type: string spm_config: - $ref: '#/components/schemas/SPMSelection' + $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMComparisonProvenance' progress: @@ -1229,7 +1232,7 @@ components: type: object nullable: true spm_config: - $ref: '#/components/schemas/SPMSelection' + $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMProvenance' SimulationError: @@ -1272,6 +1275,8 @@ components: $ref: '#/components/schemas/SPMSelection' SPMSelection: description: Generated from policyengine_api.spm.SPMSelection. + SPMResolvedConfiguration: + description: Generated from policyengine_api.spm.SPMResolvedConfiguration. SPMRuntimeVersions: description: Generated from policyengine_api.spm.SPMRuntimeVersions. SPMProvenance: diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index 28bdd3e9e..abc184f73 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -1,6 +1,7 @@ import json from dataclasses import dataclass from datetime import datetime, timezone +from typing import cast from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker @@ -8,6 +9,7 @@ from policyengine_api.constants import get_report_output_cache_version from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import ReportOutput, ReportOutputRun, Simulation +from policyengine_api.json_types import JSONObject, JSONValue from policyengine_api.services.report_run_service import ReportRunService from policyengine_api.services.report_spec_service import ( ECONOMY_REPORT_KINDS, @@ -15,6 +17,84 @@ ReportSpecService, ) from policyengine_api.services.simulation_service import SimulationService +from policyengine_api.spm import ( + SPMComparisonProvenance, + error_message, + validate_spm_calculation_provenance, +) + + +def _canonicalize_annual_spm_output( + output: JSONObject, + *, + expected_year: str | None, +) -> JSONObject: + """Validate and serialize the compact SPM fields on one economy result.""" + + has_config = "spm_config" in output + has_provenance = "spm_provenance" in output + if not has_config and not has_provenance: + return output + if not has_config or not has_provenance: + raise ValueError("SPM configuration and provenance must be supplied together") + + comparison = SPMComparisonProvenance.model_validate(output["spm_provenance"]) + calculations = tuple( + validate_spm_calculation_provenance( + output["spm_config"], + execution.receipt, + ) + for execution in (comparison.baseline, comparison.reform) + ) + if expected_year is not None and any( + expected_year not in calculation.spm_provenance.years + for calculation in calculations + ): + raise ValueError("SPM receipt does not cover the report year") + return { + **output, + "spm_config": cast( + JSONValue, + calculations[0].spm_config.model_dump(mode="json"), + ), + "spm_provenance": cast( + JSONValue, + comparison.model_dump(mode="json", by_alias=True), + ), + } + + +def _canonicalize_society_wide_spm_output( + output: object, + *, + expected_year: str | None, +) -> object: + """Validate compact annual or budget-window society-wide SPM output.""" + + if not isinstance(output, dict): + return output + typed_output = cast(JSONObject, output) + if output.get("kind") != "budgetWindow": + return _canonicalize_annual_spm_output( + typed_output, + expected_year=expected_year, + ) + + annual_impacts = output.get("annualImpacts") + if not isinstance(annual_impacts, list): + raise ValueError("SPM budget-window output requires annual impact objects") + canonical_rows: list[JSONValue] = [] + for row in annual_impacts: + if not isinstance(row, dict): + raise ValueError("SPM budget-window output requires annual impact objects") + row_year = row.get("year") + canonical_rows.append( + _canonicalize_annual_spm_output( + cast(JSONObject, row), + expected_year=str(row_year) if row_year is not None else None, + ) + ) + return {**typed_output, "annualImpacts": canonical_rows} @dataclass(frozen=True) @@ -648,6 +728,23 @@ def _update_report_output( "Cannot mark report output running without an active pending " "or running report run" ) + effective_status = values.get("status", report_output.status) + effective_output = values.get("output", report_output.output) + if ( + effective_status == "complete" + and report_output.country_id == "us" + and report_output.report_kind in ECONOMY_REPORT_KINDS + ): + try: + values["output"] = _canonicalize_society_wide_spm_output( + effective_output, + expected_year=report_output.year, + ) + except (ValueError, TypeError, KeyError) as error: + raise ValueError( + "Completed US economy report has invalid SPM provenance: " + f"{error_message(error)}" + ) from error for field, value in values.items(): setattr(report_output, field, value) self._ensure_report_output_dual_write_state( diff --git a/policyengine_api/services/v2/simulations/types.py b/policyengine_api/services/v2/simulations/types.py index cc9b0a723..6e9605102 100644 --- a/policyengine_api/services/v2/simulations/types.py +++ b/policyengine_api/services/v2/simulations/types.py @@ -16,6 +16,7 @@ ) from policyengine_api.query_parameters import CountryId +from policyengine_api.spm import SPMCalculationProvenance ContractText = Annotated[str, Field(min_length=1, max_length=255)] ContractVersion = Literal[1] @@ -205,4 +206,4 @@ class SimulationArtifactDescriptor(StrictContractModel): output_schema_version: ContractVersion = 1 row_identity: RowIdentity bundle: BundleProvenance - calculation_provenance: dict[str, JsonValue] | None = None + calculation_provenance: SPMCalculationProvenance | None = None diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index e2c9296b6..2830a359c 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -17,6 +17,7 @@ SPMComparisonProvenance, SPMExecutionProvenance, SPMProvenance, + SPMResolvedConfiguration, SPMRuntimeVersions, SPMSelection, ) @@ -89,6 +90,7 @@ def load_specification( schemas = document.get("components", {}).get("schemas", {}) for name, model in { "SPMSelection": SPMSelection, + "SPMResolvedConfiguration": SPMResolvedConfiguration, "SPMRuntimeVersions": SPMRuntimeVersions, "SPMProvenance": SPMProvenance, "SPMExecutionProvenance": SPMExecutionProvenance, diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 9cc181f08..7ea5ba638 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -97,6 +97,34 @@ def validate_location(self): return self +class SPMResolvedConfiguration(BaseModel): + """The complete six-field SPM selection recorded beside a receipt.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + forecast_content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + scenario: str = Field(min_length=1, pattern=r"^\S+$") + geography_kind: Literal["county", "national", "metro"] + geography_id: str | None = Field(min_length=1) + county_vintage: str = Field(pattern=r"^[0-9]{4}$") + as_of: str | None + + @field_validator("as_of") + @classmethod + def validate_as_of(cls, value: str | None) -> str | None: + if value is not None and date.fromisoformat(value).isoformat() != value: + raise ValueError("as_of must be an ISO calendar date (YYYY-MM-DD)") + return value + + @model_validator(mode="after") + def validate_location(self) -> "SPMResolvedConfiguration": + if self.geography_kind == "metro": + if self.geography_id is None or not self.geography_id.strip(): + raise ValueError("An SPM area selection requires geography_id") + elif self.geography_id is not None: + raise ValueError("Only an SPM area selection accepts geography_id") + return self + + class SPMProvenance(BaseModel): """Compact public receipt for one canonical SPM execution.""" @@ -137,10 +165,10 @@ class SPMRuntimeVersions(BaseModel): """Package versions required to reproduce one certified SPM execution.""" model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True) - policyengine: str | None - policyengine_core: str | None = Field(alias="policyengine-core") - policyengine_us: str | None = Field(alias="policyengine-us") - spm_calculator: str | None = Field(alias="spm-calculator") + policyengine: str = Field(min_length=1) + policyengine_core: str = Field(min_length=1, alias="policyengine-core") + policyengine_us: str = Field(min_length=1, alias="policyengine-us") + spm_calculator: str = Field(min_length=1, alias="spm-calculator") SPMProvenance.model_rebuild() @@ -150,7 +178,7 @@ class SPMCalculationProvenance(BaseModel): """A resolved SPM configuration and its matching compact receipt.""" model_config = ConfigDict(frozen=True, extra="forbid") - spm_config: SPMSelection + spm_config: SPMResolvedConfiguration spm_provenance: SPMProvenance @model_validator(mode="after") @@ -257,17 +285,6 @@ def build_spm_calculation_provenance( if not isinstance(source_versions, Mapping): raise ValueError("Country SPM receipt requires runtime_versions") runtime_versions = SPMRuntimeVersions.model_validate(source_versions) - if any( - version is None - for version in ( - runtime_versions.policyengine, - runtime_versions.policyengine_core, - runtime_versions.policyengine_us, - runtime_versions.spm_calculator, - ) - ): - raise ValueError("Certified SPM receipts require every runtime version") - compact = build_spm_provenance( forecast_id=_required_string(receipt, "forecast_id"), forecast_sha256=forecast_sha256, @@ -276,7 +293,14 @@ def build_spm_calculation_provenance( runtime_versions=runtime_versions, ) return SPMCalculationProvenance( - spm_config=config, + spm_config=SPMResolvedConfiguration( + forecast_content_sha256=forecast_sha256, + scenario=scenario, + geography_kind=config.geography_kind, + geography_id=config.geography_id, + county_vintage=config.county_vintage, + as_of=config.as_of, + ), spm_provenance=compact, ) @@ -413,11 +437,8 @@ def validate_spm_calculation_provenance( ) -> SPMCalculationProvenance: """Validate a transported compact receipt against its resolved selection.""" - resolved = resolved_spm_settings(config) - if resolved is None: - raise ValueError("SPM configuration is incomplete") return SPMCalculationProvenance( - spm_config=SPMSelection.model_validate(resolved), + spm_config=SPMResolvedConfiguration.model_validate(config), spm_provenance=SPMProvenance.model_validate(provenance), ) diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index bcf059c01..e4941a237 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -112,16 +112,6 @@ def validate_worker_result( ).spm_provenance if expected_year is not None and str(expected_year) not in receipt.years: raise ValueError("Worker SPM receipt does not cover requested year") - if any( - version is None - for version in ( - receipt.runtime_versions.policyengine, - receipt.runtime_versions.policyengine_core, - receipt.runtime_versions.policyengine_us, - receipt.runtime_versions.spm_calculator, - ) - ): - raise ValueError("Worker SPM receipt has incomplete runtime versions") except (ValueError, TypeError, KeyError) as exc: raise SPMValidationError( "SPM_CONFIGURATION_UNAVAILABLE", error_message(exc) diff --git a/tests/unit/services/test_report_output_service.py b/tests/unit/services/test_report_output_service.py index f4e6d67ea..93520634d 100644 --- a/tests/unit/services/test_report_output_service.py +++ b/tests/unit/services/test_report_output_service.py @@ -1,6 +1,7 @@ import pytest from sqlalchemy import func, select +from policyengine_api import constants from policyengine_api.constants import get_report_output_cache_version from policyengine_api.data.v1_models import ReportOutput, ReportOutputRun from policyengine_api.services.report_output_service import ReportOutputService @@ -9,6 +10,43 @@ run_service = ReportRunService() +SPM_CONFIG = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +SPM_RECEIPT = { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "canonical-forecast", + "forecast_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2025"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + }, +} + + +def compact_economy_output() -> dict: + return { + "spm_config": SPM_CONFIG, + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": SPM_RECEIPT, "execution_count": 2}, + "reform": {"receipt": SPM_RECEIPT, "execution_count": 2}, + }, + "budgetary_impact": 42, + } @pytest.fixture @@ -21,13 +59,14 @@ def create_simulation( *, policy_id=1, population_id="household-1", + population_type="household", ): return ( SimulationService(orm_session_factory) .get_or_create_simulation( country_id="us", population_id=population_id, - population_type="household", + population_type=population_type, policy_id=policy_id, ) .simulation @@ -182,6 +221,124 @@ def test_update_accepts_existing_v1_json_string_boundary( assert view.report_output.output == {"ok": True} +def test_update_complete_persists_only_compact_society_wide_spm_output( + service, + orm_session_factory, +): + baseline = create_simulation( + orm_session_factory, + policy_id=1, + population_id="us", + population_type="geography", + ) + reform = create_simulation( + orm_session_factory, + policy_id=2, + population_id="us", + population_type="geography", + ) + report = service.create_or_reuse_report_output( + "us", baseline.id, reform.id, "2025" + ).view.report_output + + view = service.update_report_output( + "us", + report.id, + status="complete", + output=compact_economy_output(), + ) + + assert view.report_output.output == compact_economy_output() + assert view.display_run.output == compact_economy_output() + + +def test_update_complete_rejects_legacy_society_wide_spm_output( + service, + orm_session_factory, +): + baseline = create_simulation( + orm_session_factory, + policy_id=1, + population_id="us", + population_type="geography", + ) + reform = create_simulation( + orm_session_factory, + policy_id=2, + population_id="us", + population_type="geography", + ) + report = service.create_or_reuse_report_output( + "us", baseline.id, reform.id, "2025" + ).view.report_output + legacy = compact_economy_output() + legacy["spm_provenance"] = { + "baseline": [SPM_RECEIPT], + "reform": [SPM_RECEIPT], + } + + with pytest.raises(ValueError, match="SPM"): + service.update_report_output("us", report.id, status="complete", output=legacy) + + with orm_session_factory() as session: + stored = session.get(ReportOutput, report.id) + assert stored.status == "pending" + assert stored.output is None + run = session.get(ReportOutputRun, stored.active_run_id) + assert run.status == "pending" + assert run.output is None + + +def test_resolve_legacy_spm_report_returns_new_empty_cache_record( + service, + orm_session_factory, + monkeypatch, +): + baseline = create_simulation( + orm_session_factory, + policy_id=1, + population_id="us", + population_type="geography", + ) + reform = create_simulation( + orm_session_factory, + policy_id=2, + population_id="us", + population_type="geography", + ) + monkeypatch.setitem(constants.RUNTIME_CACHE_SCHEMA_VERSIONS, "report_output", 1) + legacy_cache_version = get_report_output_cache_version("us") + monkeypatch.setitem(constants.RUNTIME_CACHE_SCHEMA_VERSIONS, "report_output", 2) + assert legacy_cache_version != get_report_output_cache_version("us") + with orm_session_factory.begin() as session: + legacy = ReportOutput( + country_id="us", + simulation_1_id=baseline.id, + simulation_2_id=reform.id, + api_version=legacy_cache_version, + status="complete", + output={ + "spm_config": SPM_CONFIG, + "spm_provenance": { + "baseline": [SPM_RECEIPT], + "reform": [SPM_RECEIPT], + }, + }, + year="2025", + ) + session.add(legacy) + session.flush() + legacy_id = legacy.id + + view = service.resolve_report_output("us", legacy_id) + + assert view.response_id == legacy_id + assert view.report_output.id != legacy_id + assert view.report_output.api_version == get_report_output_cache_version("us") + assert view.report_output.status == "pending" + assert view.report_output.output is None + + def test_update_running_requires_mutable_run(service, orm_session_factory): simulation = create_simulation(orm_session_factory) report = create_report(service, simulation.id) diff --git a/tests/unit/test_openapi_spm.py b/tests/unit/test_openapi_spm.py index 38da38748..222b056e7 100644 --- a/tests/unit/test_openapi_spm.py +++ b/tests/unit/test_openapi_spm.py @@ -9,6 +9,7 @@ SPMComparisonProvenance, SPMExecutionProvenance, SPMProvenance, + SPMResolvedConfiguration, SPMRuntimeVersions, SPMSelection, ) @@ -53,7 +54,7 @@ def test_economy_query_and_receipt_schemas_match_public_http_contract(): if suffix: result = result["properties"]["annualImpacts"]["items"] assert result["properties"]["spm_config"] == { - "$ref": "#/components/schemas/SPMSelection" + "$ref": "#/components/schemas/SPMResolvedConfiguration" } assert result["properties"]["spm_provenance"] == { "$ref": "#/components/schemas/SPMComparisonProvenance" @@ -116,10 +117,12 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): paths = spec["paths"] schemas = spec["components"]["schemas"] selection_ref = {"$ref": "#/components/schemas/SPMSelection"} + resolved_ref = {"$ref": "#/components/schemas/SPMResolvedConfiguration"} provenance_ref = {"$ref": "#/components/schemas/SPMProvenance"} for name, model in { "SPMSelection": SPMSelection, + "SPMResolvedConfiguration": SPMResolvedConfiguration, "SPMRuntimeVersions": SPMRuntimeVersions, "SPMProvenance": SPMProvenance, "SPMExecutionProvenance": SPMExecutionProvenance, @@ -162,6 +165,18 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): "policyengine-us", "spm-calculator", } + assert schemas["SPMRuntimeVersions"]["required"] == [ + "policyengine", + "policyengine-core", + "policyengine-us", + "spm-calculator", + ] + assert all( + "nullable" not in field + for field in schemas["SPMRuntimeVersions"]["properties"].values() + ) + resolved = schemas["SPMResolvedConfiguration"] + assert set(resolved["required"]) == set(resolved["properties"]) removed_rich_fields = { "geographies", "requested", @@ -193,7 +208,7 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): "properties" ] assert {"status", "message", "result"} <= set(result) - assert result["spm_config"] == selection_ref + assert result["spm_config"] == resolved_ref assert result["spm_provenance"] == provenance_ref assert ( operation["responses"]["400"]["$ref"] diff --git a/tests/unit/test_simulation_and_report_routes.py b/tests/unit/test_simulation_and_report_routes.py index 2d2502f7c..3c0b2c892 100644 --- a/tests/unit/test_simulation_and_report_routes.py +++ b/tests/unit/test_simulation_and_report_routes.py @@ -27,15 +27,57 @@ def create_test_client() -> Flask: return app.test_client() -def create_simulation(factory, *, population_id="household-1", policy_id=1): +def create_simulation( + factory, + *, + population_id="household-1", + policy_id=1, + population_type="household", +): simulation = ( SimulationService(factory) - .get_or_create_simulation("us", population_id, "household", policy_id) + .get_or_create_simulation("us", population_id, population_type, policy_id) .simulation ) return simulation.id +def compact_spm_report_output() -> dict: + config = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + receipt = { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "canonical-forecast", + "forecast_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2025"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + }, + } + return { + "spm_config": config, + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": receipt, "execution_count": 1}, + "reform": {"receipt": receipt, "execution_count": 1}, + }, + } + + def create_report(factory, simulation_id): report = ( ReportOutputService(factory) @@ -261,6 +303,45 @@ def test_report_patch_complete_promotes_active_rerun(orm_session_factory): assert report.latest_successful_run_id == rerun_id +def test_report_patch_rejects_legacy_spm_output_before_persistence( + orm_session_factory, +): + baseline_id = create_simulation( + orm_session_factory, + population_id="us", + policy_id=50, + population_type="geography", + ) + reform_id = create_simulation( + orm_session_factory, + population_id="us", + policy_id=51, + population_type="geography", + ) + report_id = ( + ReportOutputService(orm_session_factory) + .create_or_reuse_report_output("us", baseline_id, reform_id, "2025") + .view.report_output.id + ) + legacy = compact_spm_report_output() + receipt = legacy["spm_provenance"]["baseline"]["receipt"] + legacy["spm_provenance"] = { + "baseline": [receipt], + "reform": [receipt], + } + + response = create_test_client().patch( + "/us/report", + json={"id": report_id, "status": "complete", "output": legacy}, + ) + + assert response.status_code == 400 + with orm_session_factory() as session: + report = session.get(ReportOutput, report_id) + assert report.status == "pending" + assert report.output is None + + def test_simulation_v1_routes_keep_json_fields_as_strings(orm_session_factory): simulation_id = create_simulation(orm_session_factory, policy_id=49) output = {"result": "ok", "values": [1, 2, 3]} diff --git a/tests/unit/test_spm_provenance.py b/tests/unit/test_spm_provenance.py index 2a7147df2..e4f02fa58 100644 --- a/tests/unit/test_spm_provenance.py +++ b/tests/unit/test_spm_provenance.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from policyengine_api.spm import ( + SPMCalculationProvenance, SPMComparisonProvenance, SPMProvenance, SPMRuntimeVersions, @@ -88,6 +89,33 @@ def test_compact_receipt_rejects_noncanonical_values( _receipt(**changes) +@pytest.mark.parametrize( + "invalid_version", + ["policyengine", "policyengine-core", "policyengine-us", "spm-calculator"], +) +@pytest.mark.parametrize("value", [None, ""]) +def test_runtime_versions_require_four_nonempty_strings( + invalid_version: str, + value: object, +) -> None: + versions = VERSIONS.model_dump(mode="json", by_alias=True) + versions[invalid_version] = value + + with pytest.raises(ValidationError): + SPMRuntimeVersions.model_validate(versions) + + +def test_calculation_contract_requires_every_resolved_configuration_field() -> None: + receipt = _receipt().model_dump(mode="json", by_alias=True) + partial = SELECTION.model_dump(mode="json") + partial.pop("geography_id") + + with pytest.raises(ValidationError): + SPMCalculationProvenance.model_validate( + {"spm_config": partial, "spm_provenance": receipt} + ) + + def test_compact_receipt_rejects_the_previous_diagnostic_shape() -> None: previous = { "forecast_id": "spm-rolling-2026-09-09", @@ -120,7 +148,9 @@ def test_country_receipt_adapter_extracts_only_compact_scalars() -> None: calculation = build_spm_calculation_provenance(SELECTION, country_receipt) - assert calculation.spm_config == SELECTION + assert calculation.spm_config.model_dump(mode="json") == SELECTION.model_dump( + mode="json" + ) assert calculation.spm_provenance.years == ("2026", "2027") encoded = calculation.model_dump_json(by_alias=True) assert "diagnostic" not in encoded diff --git a/tests/unit/v2/test_stage12_worker_contracts.py b/tests/unit/v2/test_stage12_worker_contracts.py index df177f792..9e537996d 100644 --- a/tests/unit/v2/test_stage12_worker_contracts.py +++ b/tests/unit/v2/test_stage12_worker_contracts.py @@ -27,6 +27,36 @@ DIGEST_C = "c" * 64 +def _calculation_provenance() -> dict[str, object]: + return { + "spm_config": { + "forecast_content_sha256": DIGEST_A, + "scenario": "official", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + }, + "spm_provenance": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "canonical-forecast", + "forecast_sha256": DIGEST_A, + "scenario": "official", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2026"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + }, + }, + } + + def _bundle() -> dict[str, object]: return { "policyengine_version": "5.2.0", @@ -173,10 +203,7 @@ def test_artifact_descriptors_retain_identity_and_bundle_provenance() -> None: "identity_sha256": DIGEST_B, }, "bundle": _bundle(), - "calculation_provenance": { - "spm_config": {"scenario": "official"}, - "spm_provenance": {"forecast_sha256": DIGEST_A}, - }, + "calculation_provenance": _calculation_provenance(), } ) report_artifact = AggregateReportArtifactDescriptor.model_validate( @@ -197,16 +224,60 @@ def test_artifact_descriptors_retain_identity_and_bundle_provenance() -> None: ) assert simulation_artifact.row_identity.identifier_columns == ("household_id",) - assert simulation_artifact.calculation_provenance == { - "spm_config": {"scenario": "official"}, - "spm_provenance": {"forecast_sha256": DIGEST_A}, - } + assert simulation_artifact.calculation_provenance is not None + assert ( + simulation_artifact.calculation_provenance.model_dump( + mode="json", by_alias=True + ) + == _calculation_provenance() + ) assert ( report_artifact.baseline_artifact_sha256 == simulation_artifact.artifact.content_sha256 ) +@pytest.mark.parametrize( + "provenance", + [ + { + "spm_config": {"scenario": "official"}, + "spm_provenance": {"forecast_sha256": DIGEST_A}, + }, + { + **_calculation_provenance(), + "spm_provenance": { + **_calculation_provenance()["spm_provenance"], + "geographies": [], + }, + }, + ], + ids=["partial", "legacy-rich"], +) +def test_simulation_artifact_rejects_noncompact_calculation_provenance( + provenance: dict[str, object], +) -> None: + descriptor = { + "contract_version": 1, + "evaluation_id": str(EVALUATION_ID), + "simulation_execution_id": str(BASELINE_ID), + "role": "baseline", + "artifact": {**_population_artifact(), "content_sha256": DIGEST_C}, + "output_schema_version": 1, + "row_identity": { + "schema_version": 1, + "identifier_columns": ["household_id"], + "row_count": 100, + "identity_sha256": DIGEST_B, + }, + "bundle": _bundle(), + "calculation_provenance": provenance, + } + + with pytest.raises(ValidationError): + SimulationArtifactDescriptor.model_validate(descriptor) + + def test_artifact_payload_contracts_cover_parquet_and_aggregate_json() -> None: assert SIMULATION_PARQUET_PAYLOAD_CONTRACT.model_dump(mode="json") == { "payload_schema_version": 1, From 552eee0fb2d37880b0df02866f84812287c5df88 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:48:23 +0400 Subject: [PATCH 6/9] Require SPM fields on completed US economy reports --- .../services/report_output_service.py | 6 +- .../services/test_report_output_service.py | 76 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index abc184f73..49cb50051 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -33,10 +33,10 @@ def _canonicalize_annual_spm_output( has_config = "spm_config" in output has_provenance = "spm_provenance" in output - if not has_config and not has_provenance: - return output if not has_config or not has_provenance: - raise ValueError("SPM configuration and provenance must be supplied together") + raise ValueError( + "Completed US economy output requires both SPM configuration and provenance" + ) comparison = SPMComparisonProvenance.model_validate(output["spm_provenance"]) calculations = tuple( diff --git a/tests/unit/services/test_report_output_service.py b/tests/unit/services/test_report_output_service.py index 93520634d..b264a8129 100644 --- a/tests/unit/services/test_report_output_service.py +++ b/tests/unit/services/test_report_output_service.py @@ -57,6 +57,7 @@ def service(orm_session_factory): def create_simulation( orm_session_factory, *, + country_id="us", policy_id=1, population_id="household-1", population_type="household", @@ -64,7 +65,7 @@ def create_simulation( return ( SimulationService(orm_session_factory) .get_or_create_simulation( - country_id="us", + country_id=country_id, population_id=population_id, population_type=population_type, policy_id=policy_id, @@ -252,6 +253,79 @@ def test_update_complete_persists_only_compact_society_wide_spm_output( assert view.display_run.output == compact_economy_output() +@pytest.mark.parametrize( + "output", + [ + {"budgetary_impact": 42}, + {"budgetary_impact": 42, "spm_config": SPM_CONFIG}, + { + "budgetary_impact": 42, + "spm_provenance": compact_economy_output()["spm_provenance"], + }, + ], + ids=["absent", "config-only", "provenance-only"], +) +def test_update_complete_requires_both_society_wide_spm_fields( + service, + orm_session_factory, + output, +): + baseline = create_simulation( + orm_session_factory, + policy_id=1, + population_id="us", + population_type="geography", + ) + reform = create_simulation( + orm_session_factory, + policy_id=2, + population_id="us", + population_type="geography", + ) + report = service.create_or_reuse_report_output( + "us", baseline.id, reform.id, "2025" + ).view.report_output + + with pytest.raises(ValueError, match="requires both SPM"): + service.update_report_output("us", report.id, status="complete", output=output) + + with orm_session_factory() as session: + stored = session.get(ReportOutput, report.id) + assert stored.status == "pending" + assert stored.output is None + + +@pytest.mark.parametrize( + ("country_id", "population_type"), + [("us", "household"), ("uk", "geography")], + ids=["us-household", "non-us-economy"], +) +def test_update_complete_preserves_optional_spm_for_other_report_kinds( + service, + orm_session_factory, + country_id, + population_type, +): + simulation = create_simulation( + orm_session_factory, + country_id=country_id, + population_id="household-1" if population_type == "household" else country_id, + population_type=population_type, + ) + report = service.create_or_reuse_report_output( + country_id, simulation.id, year="2025" + ).view.report_output + + view = service.update_report_output( + country_id, + report.id, + status="complete", + output={"ok": True}, + ) + + assert view.report_output.output == {"ok": True} + + def test_update_complete_rejects_legacy_society_wide_spm_output( service, orm_session_factory, From 91fd453dd22ed6987cd53cbdb9a1d5ee6ff03de8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:48 +0400 Subject: [PATCH 7/9] Use SPM provenance as completed-result configuration --- changelog.d/3862.breaking.md | 2 +- docs/canonical-spm.md | 30 ++++---- docs/migration/stage-12-worker-contract.md | 10 ++- policyengine_api/constants.py | 4 +- policyengine_api/openapi_spec.yaml | 30 +++----- policyengine_api/routes/household_routes.py | 6 +- .../runtime_cache/household_calculations.py | 44 +++++------ .../runtime_cache/reform_impacts.py | 2 +- .../services/budget_window_cache.py | 2 +- .../services/household_calculation_service.py | 36 +++------ .../services/report_output_service.py | 26 ++----- .../services/simulation_service.py | 28 +++---- policyengine_api/services/v2/reports/types.py | 11 +++ .../services/v2/simulations/types.py | 4 +- policyengine_api/specification.py | 2 - policyengine_api/spm.py | 64 +++++----------- policyengine_api/worker_spm.py | 17 ++--- tests/integration/test_runtime_cache_redis.py | 2 - tests/unit/routes/test_canonical_spm.py | 19 +++-- .../routes/test_spm_year_worker_polling.py | 1 - .../test_household_calculations.py | 71 ++++++++++-------- .../unit/runtime_cache/test_reform_impacts.py | 8 +- .../unit/services/test_budget_window_cache.py | 8 +- .../services/test_report_output_service.py | 29 +++---- .../unit/services/test_simulation_service.py | 29 ++++--- tests/unit/services/test_worker_spm.py | 33 ++++---- tests/unit/test_country_spm.py | 14 ++-- tests/unit/test_openapi_simulation.py | 8 -- tests/unit/test_openapi_spm.py | 12 +-- .../unit/test_simulation_and_report_routes.py | 9 --- tests/unit/test_spm_provenance.py | 20 ++--- tests/unit/test_spm_settings.py | 8 +- .../unit/v2/test_stage12_worker_contracts.py | 75 +++++++++++-------- 33 files changed, 311 insertions(+), 353 deletions(-) diff --git a/changelog.d/3862.breaking.md b/changelog.d/3862.breaking.md index b878b5aab..4a719cf50 100644 --- a/changelog.d/3862.breaking.md +++ b/changelog.d/3862.breaking.md @@ -1 +1 @@ -Replace large SPM diagnostic provenance payloads with strict compact v2 receipts for household, society-wide, report, and Stage 12 artifact results. Older cached or stored SPM output is recalculated instead of translated, and runtime version values are required. +Replace large SPM diagnostic provenance payloads and duplicate completed-result SPM configuration with strict compact v2 receipts for household, society-wide, report, and Stage 12 artifact results. Older cached or stored SPM output is recalculated instead of translated, and runtime version values are required. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md index c7b5fe0a3..590f9fe07 100644 --- a/docs/canonical-spm.md +++ b/docs/canonical-spm.md @@ -155,9 +155,12 @@ storage; no additional schema migration is required. selected policy. No independent simulation-level override is supported. Top-level `spm` on simulation POST/PATCH is rejected instead of silently ignored. -Successful canonical calculations add `spm_config` and `spm_provenance` beside -`result`. A receipt describes the measurement the simulation was constructed -with, not a guarantee that every SPM-dependent variable produced a value: a +Successful canonical calculations add only `spm_provenance` beside `result`. +Its forecast, scenario, geography, county-vintage and as-of fields are the +complete resolved calculation configuration; completed results do not repeat +that configuration in a sibling `spm_config` field. A receipt describes the +measurement the simulation was constructed with, not a guarantee that every +SPM-dependent variable produced a value: a calculation that never chose a measurement still carries the inherited one's receipt beside its null cells. Read the values to learn which of them a measurement produced. Provenance comes from the actual simulation and uses the @@ -206,11 +209,11 @@ country, certification, or whether the household has saved `spm`. `PATCH /us/simulation` identifies the record with body `id` and accepts `status` (`pending`, `complete` or `error`), `output` and `error_message`. At least one update field must be non-null, and `complete` requires non-null `output`. Store -the full calculation envelope inside `output`, including its `spm_config` and +the full calculation envelope inside `output`, including its `spm_provenance`. A JSON-encoded output string is also accepted. This endpoint -validates compact SPM receipt integrity and agreement with the adjacent, -fully-resolved six-field `spm_config` before storage. Old or rich receipts are -not translated. Null update fields are ignored. The legacy `api_version` input +validates the one compact SPM receipt strictly before storage. Completed output +that includes the former sibling `spm_config`, an old receipt or a rich receipt +is rejected rather than translated. Null update fields are ignored. The legacy `api_version` input is ignored; writes record the installed country model version. Top-level `spm`, even null, returns HTTP 400 `SPM_SETTINGS_UNSUPPORTED` on POST and PATCH. @@ -227,8 +230,8 @@ Flask and the native specification route. HTTP response cache identity includes the normalized selection and model/bundle versions, and validates certification before reading the cache. The stored -calculated-household cache uses schema version 3, includes the selection in -identity, and stores settings/provenance atomically with the result and its +calculated-household cache uses schema version 4, includes the selection in +identity, and stores provenance atomically with the result and its warnings. Missing, old, rich or mismatched canonical receipts are cache misses and are recalculated rather than translated. @@ -288,12 +291,13 @@ from `/versions`, including agreement with the bundle's independently pinned artifact hash. An old or uncertified worker returns `SPM_CONFIGURATION_UNAVAILABLE`. Normalized settings are sent to both annual and budget-window jobs and participate -in economy cache identity. Results carry `spm_config` and `spm_provenance` using +in economy cache identity. Results carry only `spm_provenance` using `canonical-spm-comparison-v2`. `baseline` and `reform` each contain one compact `receipt` plus an `execution_count`; the receipt collapses identical child execution receipts without transporting a repeated list. Baseline and reform -receipts must be identical. Each budget-window annual row carries these fields; -annual results expose them inside `result`, and budget-window results inside +receipts must be identical, and their receipt fields are the sole resolved SPM +configuration. Each budget-window annual row carries this field; +annual results expose it inside `result`, and budget-window results inside `result.annualImpacts`. Stored results require matching settings and valid receipts. Economy cache identity includes the comparison schema version, so old entries are not selected and are recomputed rather than translated. Typed SPM input errors remain structured 400s @@ -304,7 +308,7 @@ job. Canonical cache identity and runtime-bundle refresh rules still apply. Completed US economy report outputs pass the same compact comparison validation before `report_outputs` or `report_output_runs` persistence. Their report cache -identity uses schema version 2. Reads of schema-version-1 report records resolve +identity uses schema version 3. Reads of earlier report records resolve to a new pending record with no copied output, preventing another client from receiving a previously stored rich receipt. Household and non-US report output shapes retain their existing behavior. diff --git a/docs/migration/stage-12-worker-contract.md b/docs/migration/stage-12-worker-contract.md index f26c315fa..1e830a360 100644 --- a/docs/migration/stage-12-worker-contract.md +++ b/docs/migration/stage-12-worker-contract.md @@ -172,9 +172,13 @@ provenance. When an option such as SPM produces a detached calculation receipt, the simulation descriptor and Parquet metadata retain it so the coordinator can validate the receipt before reproducing the existing aggregate response. `SimulationArtifactDescriptor.calculation_provenance` is either null or the -exact typed compact SPM calculation contract: a complete six-field resolved -configuration plus `canonical-spm-provenance-v2`. It rejects partial, rich, and -legacy receipt shapes rather than preserving or normalizing them. +exact typed `canonical-spm-provenance-v2` receipt. The receipt's forecast, +scenario, geography, county-vintage and as-of fields are the complete resolved +configuration; the descriptor does not transport a duplicate `spm_config`. +It rejects partial, rich, legacy and dual-field receipt shapes rather than +preserving or normalizing them. +`AggregateReportArtifactPayload.result` applies the same rule to comparison +provenance and rejects a sibling `spm_config` before artifact storage. Only the Simulation Entrypoint, the versioned worker application, the bucket's object-lifecycle process, and explicitly authorized operators may access the diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index dffe76ca6..7056574cb 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -176,8 +176,8 @@ def get_bundle_default_dataset_option(country_id: str) -> dict: ) or _resolve_distribution_version(_dist_versions, "policyengine-core", "policyengine") RUNTIME_CACHE_SCHEMA_VERSIONS = { - "economy_impact": 2, - "report_output": 2, + "economy_impact": 3, + "report_output": 3, } diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index 21f43d240..3155fe341 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -439,8 +439,6 @@ paths: nullable: true result: type: object - spm_config: - $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMProvenance' 404: @@ -531,8 +529,6 @@ paths: nullable: true result: type: object - spm_config: - $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMProvenance' 500: @@ -623,9 +619,9 @@ paths: non-null output. Null update fields are ignored, and omitted fields retain their values. The installed country model version and current run state are updated with the record. Preserve the full household calculation - envelope, including spm_config and spm_provenance, inside output. Its SPM - selection belongs to the linked household; this operation does not select - a measurement or validate stored receipt integrity. Top-level spm is + envelope, including spm_provenance, inside output. Its SPM selection is + derived from that receipt and belongs to the linked household; this + operation does not select a measurement. Top-level spm is rejected, including null, with HTTP 400 SPM_SETTINGS_UNSUPPORTED. parameters: - name: country_id @@ -658,7 +654,7 @@ paths: Object and array outputs and simulation_spec_json use the legacy JSON-encoded string representation, or null when absent. Stored scalar strings are returned unchanged. Decode a saved household output envelope - to read its spm_config and spm_provenance. Run identifiers and + to read its spm_provenance. Run identifiers and specification metadata may be null on historical records. parameters: - name: country_id @@ -762,8 +758,8 @@ paths: operationId: update_report_output description: >- Update a report output status, result, or error message. Completed US - economy outputs carrying SPM fields require compact v2 comparison - provenance and a matching complete resolved configuration. + economy outputs require compact v2 comparison provenance. Each receipt + contains the complete resolved configuration; a sibling spm_config is rejected. parameters: - name: country_id in: path @@ -883,7 +879,7 @@ paths: # Query parameters are generated from policyengine_api.query_parameters. responses: 200: - description: Annual economic impact, progress, or persisted error state. Canonical completed results include the selected SPM configuration and actual baseline/reform receipts. + description: Annual economic impact, progress, or persisted error state. Canonical completed results include actual baseline/reform receipts containing the selected SPM configuration. content: application/json: schema: @@ -899,8 +895,6 @@ paths: type: object nullable: true properties: - spm_config: - $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMComparisonProvenance' 400: @@ -972,8 +966,6 @@ paths: properties: year: type: string - spm_config: - $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMComparisonProvenance' progress: @@ -1191,7 +1183,7 @@ components: description: >- Non-string JSON output is JSON-encoded, or null when absent. Stored scalar strings are returned unchanged. Decode a saved household - calculation envelope once to recover spm_config and spm_provenance + calculation envelope once to recover spm_provenance beside its result, not beside this record. x-household-envelope-schema: $ref: '#/components/schemas/StoredHouseholdOutputEnvelope' @@ -1220,7 +1212,7 @@ components: type: object description: >- Household calculation envelope saved as simulation output. Keep result - and both SPM fields together. Legacy calculations omit SPM fields; this + and SPM provenance together. Legacy calculations omit SPM provenance; this descriptive schema does not add validation to the persistence endpoint. properties: status: @@ -1231,8 +1223,6 @@ components: result: type: object nullable: true - spm_config: - $ref: '#/components/schemas/SPMResolvedConfiguration' spm_provenance: $ref: '#/components/schemas/SPMProvenance' SimulationError: @@ -1275,8 +1265,6 @@ components: $ref: '#/components/schemas/SPMSelection' SPMSelection: description: Generated from policyengine_api.spm.SPMSelection. - SPMResolvedConfiguration: - description: Generated from policyengine_api.spm.SPMResolvedConfiguration. SPMRuntimeVersions: description: Generated from policyengine_api.spm.SPMRuntimeVersions. SPMProvenance: diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index 071102b4c..300f2d9d3 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -137,10 +137,8 @@ def _calculation_response(calculation) -> dict: result = dict(status="ok", message=None, result=calculation.household) if calculation.warnings: result["warnings"] = list(calculation.warnings) - for field in ("spm_config", "spm_provenance"): - value = getattr(calculation, field, None) - if value is not None: - result[field] = value + if calculation.spm_provenance is not None: + result["spm_provenance"] = calculation.spm_provenance return result diff --git a/policyengine_api/runtime_cache/household_calculations.py b/policyengine_api/runtime_cache/household_calculations.py index 966afa329..4ab7ceaf7 100644 --- a/policyengine_api/runtime_cache/household_calculations.py +++ b/policyengine_api/runtime_cache/household_calculations.py @@ -10,10 +10,13 @@ CacheNamespace, RecoverableJSONCache, ) -from policyengine_api.spm import validate_spm_calculation_provenance +from policyengine_api.spm import ( + resolved_spm_configuration, + validate_spm_calculation_provenance, +) -HOUSEHOLD_CALCULATION_SCHEMA_VERSION = 3 +HOUSEHOLD_CALCULATION_SCHEMA_VERSION = 4 HOUSEHOLD_CALCULATION_TTL_SECONDS = 86_400 @@ -33,7 +36,6 @@ class HouseholdCalculationIdentity: class CachedHouseholdCalculation: household: dict[str, Any] warnings: tuple[str, ...] = () - spm_config: dict[str, Any] | None = None spm_provenance: dict[str, Any] | None = None @@ -65,30 +67,28 @@ def get( return None if not all(isinstance(warning, str) for warning in warnings): return None - spm_config = payload.get("spm_config") + if "spm_config" in payload: + return None spm_provenance = payload.get("spm_provenance") - if any( - value is not None and not isinstance(value, dict) - for value in (spm_config, spm_provenance) - ): + if spm_provenance is not None and not isinstance(spm_provenance, dict): return None if identity.spm is None: - if spm_config is not None or spm_provenance is not None: + if spm_provenance is not None: return None else: try: - calculation = validate_spm_calculation_provenance( - spm_config, - spm_provenance, - ) + receipt = validate_spm_calculation_provenance(spm_provenance) except (ValidationError, ValueError): return None - if calculation.spm_config.model_dump(mode="json") != identity.spm: + if ( + resolved_spm_configuration(receipt).model_dump(mode="json") + != identity.spm + ): return None + spm_provenance = receipt.model_dump(mode="json", by_alias=True) return CachedHouseholdCalculation( household=household, warnings=tuple(warnings), - spm_config=spm_config, spm_provenance=spm_provenance, ) @@ -98,21 +98,15 @@ def set( value: CachedHouseholdCalculation, ) -> bool: if identity.spm is None: - if value.spm_config is not None or value.spm_provenance is not None: + if value.spm_provenance is not None: return False return self._cache.set(asdict(identity), asdict(value)) try: - calculation = validate_spm_calculation_provenance( - value.spm_config, - value.spm_provenance, - ) + receipt = validate_spm_calculation_provenance(value.spm_provenance) except (ValidationError, ValueError): return False - if calculation.spm_config.model_dump(mode="json") != identity.spm: + if resolved_spm_configuration(receipt).model_dump(mode="json") != identity.spm: return False payload = asdict(value) - payload["spm_config"] = calculation.spm_config.model_dump(mode="json") - payload["spm_provenance"] = calculation.spm_provenance.model_dump( - mode="json", by_alias=True - ) + payload["spm_provenance"] = receipt.model_dump(mode="json", by_alias=True) return self._cache.set(asdict(identity), payload) diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index 09c96c0f0..8a7ed5dc8 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -19,7 +19,7 @@ record_cache_event, ) -REFORM_IMPACT_SCHEMA_VERSION = 2 +REFORM_IMPACT_SCHEMA_VERSION = 3 REFORM_IMPACT_TTL_SECONDS = 2_592_000 REFORM_IMPACT_INDEX_LIMIT = 1_000 REFORM_IMPACT_START_CLAIM_TTL_SECONDS = 300 diff --git a/policyengine_api/services/budget_window_cache.py b/policyengine_api/services/budget_window_cache.py index 9da6475ac..b6190341a 100644 --- a/policyengine_api/services/budget_window_cache.py +++ b/policyengine_api/services/budget_window_cache.py @@ -20,7 +20,7 @@ from policyengine_api.runtime_cache.dependencies import get_runtime_cache_context BUDGET_WINDOW_CACHE_FAMILY = "budget-window" -BUDGET_WINDOW_CACHE_SCHEMA_VERSION = 3 +BUDGET_WINDOW_CACHE_SCHEMA_VERSION = 4 BUDGET_WINDOW_STARTING_TTL_SECONDS = 300 BUDGET_WINDOW_BATCH_TTL_SECONDS = 86_400 BUDGET_WINDOW_RESULT_TTL_SECONDS = 2_592_000 diff --git a/policyengine_api/services/household_calculation_service.py b/policyengine_api/services/household_calculation_service.py index ff8f90845..bce5743c7 100644 --- a/policyengine_api/services/household_calculation_service.py +++ b/policyengine_api/services/household_calculation_service.py @@ -28,6 +28,7 @@ from policyengine_api.utils.input_validation import find_unrecognized_inputs from policyengine_api.spm import ( normalize_spm_selection, + resolved_spm_configuration, validate_spm_calculation_provenance, ) @@ -36,7 +37,6 @@ class CalculationResult: household: dict warnings: tuple[str, ...] = () - spm_config: dict | None = None spm_provenance: dict | None = None @@ -45,7 +45,6 @@ class HouseholdCalculationResult: household: dict warnings: tuple[str, ...] = () cached: bool = False - spm_config: dict | None = None spm_provenance: dict | None = None @@ -76,26 +75,19 @@ def __init__(self, invalid_inputs: list[Any]) -> None: super().__init__("Household or policy contains unrecognized inputs") -def _validated_spm_fields( - spm_config: object, +def _validated_spm_provenance( spm_provenance: object, expected_selection: dict | None, -) -> tuple[dict | None, dict | None]: +) -> dict | None: if expected_selection is None: - if spm_config is None and spm_provenance is None: - return None, None + if spm_provenance is None: + return None raise ValueError("Unexpected SPM provenance for a calculation without SPM") - calculation = validate_spm_calculation_provenance( - spm_config, - spm_provenance, - ) - canonical_config = calculation.spm_config.model_dump(mode="json") + receipt = validate_spm_calculation_provenance(spm_provenance) + canonical_config = resolved_spm_configuration(receipt).model_dump(mode="json") if canonical_config != expected_selection: raise ValueError("SPM calculation configuration differs from the request") - return ( - canonical_config, - calculation.spm_provenance.model_dump(mode="json", by_alias=True), - ) + return receipt.model_dump(mode="json", by_alias=True) def get_household_year(household: dict) -> int | str: @@ -217,7 +209,6 @@ def _store_result( CachedHouseholdCalculation( household=calculation.household, warnings=warnings, - spm_config=calculation.spm_config, spm_provenance=calculation.spm_provenance, ), ) @@ -269,7 +260,6 @@ def calculate_stored_household( household=cached.household, warnings=cached.warnings, cached=True, - spm_config=cached.spm_config, spm_provenance=cached.spm_provenance, ) @@ -321,7 +311,6 @@ def calculate_stored_household( calculation = CalculationResult( household=raw_calculation.household, warnings=tuple(getattr(raw_calculation, "warnings", ())), - spm_config=getattr(raw_calculation, "spm_config", None), spm_provenance=getattr(raw_calculation, "spm_provenance", None), ) else: @@ -330,14 +319,12 @@ def calculate_stored_household( calculation = CalculationResult( household=raw_calculation, ) - spm_config, spm_provenance = _validated_spm_fields( - calculation.spm_config, + spm_provenance = _validated_spm_provenance( calculation.spm_provenance, spm, ) calculation = replace( calculation, - spm_config=spm_config, spm_provenance=spm_provenance, ) record_cache_event( @@ -357,7 +344,6 @@ def calculate_stored_household( return HouseholdCalculationResult( household=calculation.household, warnings=response_warnings, - spm_config=calculation.spm_config, spm_provenance=calculation.spm_provenance, ) @@ -433,15 +419,13 @@ def calculate_prepared_household( else: household = raw_calculation.household calculation_warnings = tuple(getattr(raw_calculation, "warnings", ())) - spm_config, spm_provenance = _validated_spm_fields( - getattr(raw_calculation, "spm_config", None), + spm_provenance = _validated_spm_provenance( getattr(raw_calculation, "spm_provenance", None), prepared.spm, ) return HouseholdCalculationResult( household=household, warnings=(prepared.warnings + calculation_warnings), - spm_config=spm_config, spm_provenance=spm_provenance, ) diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index 49cb50051..69246e827 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -20,7 +20,6 @@ from policyengine_api.spm import ( SPMComparisonProvenance, error_message, - validate_spm_calculation_provenance, ) @@ -29,34 +28,23 @@ def _canonicalize_annual_spm_output( *, expected_year: str | None, ) -> JSONObject: - """Validate and serialize the compact SPM fields on one economy result.""" + """Validate and serialize compact SPM provenance on one economy result.""" - has_config = "spm_config" in output - has_provenance = "spm_provenance" in output - if not has_config or not has_provenance: + if "spm_config" in output: raise ValueError( - "Completed US economy output requires both SPM configuration and provenance" + "Completed US economy output must not include legacy spm_config" ) + if "spm_provenance" not in output: + raise ValueError("Completed US economy output requires SPM provenance") comparison = SPMComparisonProvenance.model_validate(output["spm_provenance"]) - calculations = tuple( - validate_spm_calculation_provenance( - output["spm_config"], - execution.receipt, - ) - for execution in (comparison.baseline, comparison.reform) - ) if expected_year is not None and any( - expected_year not in calculation.spm_provenance.years - for calculation in calculations + expected_year not in execution.receipt.years + for execution in (comparison.baseline, comparison.reform) ): raise ValueError("SPM receipt does not cover the report year") return { **output, - "spm_config": cast( - JSONValue, - calculations[0].spm_config.model_dump(mode="json"), - ), "spm_provenance": cast( JSONValue, comparison.model_dump(mode="json", by_alias=True), diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index 64ac114eb..943b0fb00 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -55,15 +55,14 @@ def _latest_successful_run_id(runs: list[SimulationRun]) -> str | None: @staticmethod def _has_stale_spm_output(output: object) -> bool: - if not isinstance(output, dict) or not ( - "spm_config" in output or "spm_provenance" in output - ): + if not isinstance(output, dict): + return False + if "spm_config" in output: + return True + if "spm_provenance" not in output: return False try: - validate_spm_calculation_provenance( - output.get("spm_config"), - output.get("spm_provenance"), - ) + validate_spm_calculation_provenance(output["spm_provenance"]) except ValueError: return True return False @@ -274,16 +273,17 @@ def update_simulation( if isinstance(values.get("output"), str): values["output"] = json.loads(values["output"]) output_value = values.get("output") - if isinstance(output_value, dict) and ( - "spm_config" in output_value or "spm_provenance" in output_value - ): - calculation = validate_spm_calculation_provenance( - output_value.get("spm_config"), - output_value.get("spm_provenance"), + if isinstance(output_value, dict) and "spm_config" in output_value: + raise ValueError( + "Completed simulation output must not include legacy spm_config" + ) + if isinstance(output_value, dict) and "spm_provenance" in output_value: + receipt = validate_spm_calculation_provenance( + output_value["spm_provenance"] ) values["output"] = { **output_value, - **calculation.model_dump(mode="json", by_alias=True), + "spm_provenance": receipt.model_dump(mode="json", by_alias=True), } with self._sessions.begin() as session: simulation = self._select_simulation( diff --git a/policyengine_api/services/v2/reports/types.py b/policyengine_api/services/v2/reports/types.py index 9d0298f43..ef4d997f4 100644 --- a/policyengine_api/services/v2/reports/types.py +++ b/policyengine_api/services/v2/reports/types.py @@ -16,6 +16,7 @@ SimulationRole, StrictContractModel, ) +from policyengine_api.spm import SPMComparisonProvenance ContractVersion = Literal[1] @@ -102,3 +103,13 @@ def require_unique_aggregates( if len(value) != len(set(value)): raise ValueError("requested aggregates must be unique") return value + + @model_validator(mode="after") + def validate_spm_provenance(self) -> AggregateReportArtifactPayload: + """Reject duplicate or noncanonical SPM data in aggregate artifacts.""" + + if "spm_config" in self.result: + raise ValueError("aggregate result must not include legacy spm_config") + if "spm_provenance" in self.result: + SPMComparisonProvenance.model_validate(self.result["spm_provenance"]) + return self diff --git a/policyengine_api/services/v2/simulations/types.py b/policyengine_api/services/v2/simulations/types.py index 6e9605102..d1e466b13 100644 --- a/policyengine_api/services/v2/simulations/types.py +++ b/policyengine_api/services/v2/simulations/types.py @@ -16,7 +16,7 @@ ) from policyengine_api.query_parameters import CountryId -from policyengine_api.spm import SPMCalculationProvenance +from policyengine_api.spm import SPMProvenance ContractText = Annotated[str, Field(min_length=1, max_length=255)] ContractVersion = Literal[1] @@ -206,4 +206,4 @@ class SimulationArtifactDescriptor(StrictContractModel): output_schema_version: ContractVersion = 1 row_identity: RowIdentity bundle: BundleProvenance - calculation_provenance: SPMCalculationProvenance | None = None + calculation_provenance: SPMProvenance | None = None diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index 2830a359c..e2c9296b6 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -17,7 +17,6 @@ SPMComparisonProvenance, SPMExecutionProvenance, SPMProvenance, - SPMResolvedConfiguration, SPMRuntimeVersions, SPMSelection, ) @@ -90,7 +89,6 @@ def load_specification( schemas = document.get("components", {}).get("schemas", {}) for name, model in { "SPMSelection": SPMSelection, - "SPMResolvedConfiguration": SPMResolvedConfiguration, "SPMRuntimeVersions": SPMRuntimeVersions, "SPMProvenance": SPMProvenance, "SPMExecutionProvenance": SPMExecutionProvenance, diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 7ea5ba638..e48bde68d 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -174,30 +174,6 @@ class SPMRuntimeVersions(BaseModel): SPMProvenance.model_rebuild() -class SPMCalculationProvenance(BaseModel): - """A resolved SPM configuration and its matching compact receipt.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - spm_config: SPMResolvedConfiguration - spm_provenance: SPMProvenance - - @model_validator(mode="after") - def validate_matching_receipt(self) -> "SPMCalculationProvenance": - receipt = self.spm_provenance - config = self.spm_config - receipt_as_of = receipt.as_of.isoformat() if receipt.as_of is not None else None - if ( - receipt.forecast_sha256 != config.forecast_content_sha256 - or receipt.scenario != config.scenario - or receipt.geography_kind != config.geography_kind - or receipt.geography_id != config.geography_id - or receipt.county_vintage != config.county_vintage - or receipt_as_of != config.as_of - ): - raise ValueError("SPM receipt differs from the SPM configuration") - return self - - class SPMExecutionProvenance(BaseModel): """One shared compact receipt and the executions that produced it.""" @@ -259,7 +235,7 @@ def _required_string(source: Mapping[str, object], field: str) -> str: def build_spm_calculation_provenance( config: SPMSelection, receipt: Mapping[str, object], -) -> SPMCalculationProvenance: +) -> SPMProvenance: """Reduce the country-owned rich receipt to the public compact contract.""" forecast_sha256 = _required_string(receipt, "forecast_sha256") @@ -285,23 +261,27 @@ def build_spm_calculation_provenance( if not isinstance(source_versions, Mapping): raise ValueError("Country SPM receipt requires runtime_versions") runtime_versions = SPMRuntimeVersions.model_validate(source_versions) - compact = build_spm_provenance( + return build_spm_provenance( forecast_id=_required_string(receipt, "forecast_id"), forecast_sha256=forecast_sha256, selection=config, years=tuple(sorted(years)), runtime_versions=runtime_versions, ) - return SPMCalculationProvenance( - spm_config=SPMResolvedConfiguration( - forecast_content_sha256=forecast_sha256, - scenario=scenario, - geography_kind=config.geography_kind, - geography_id=config.geography_id, - county_vintage=config.county_vintage, - as_of=config.as_of, - ), - spm_provenance=compact, + + +def resolved_spm_configuration( + provenance: SPMProvenance, +) -> SPMResolvedConfiguration: + """Derive the complete resolved selection from its compact receipt.""" + + return SPMResolvedConfiguration( + forecast_content_sha256=provenance.forecast_sha256, + scenario=provenance.scenario, + geography_kind=provenance.geography_kind, + geography_id=provenance.geography_id, + county_vintage=provenance.county_vintage, + as_of=provenance.as_of.isoformat() if provenance.as_of is not None else None, ) @@ -432,15 +412,11 @@ def resolved_spm_settings(settings: object) -> dict | None: def validate_spm_calculation_provenance( - config: object, provenance: object, -) -> SPMCalculationProvenance: - """Validate a transported compact receipt against its resolved selection.""" +) -> SPMProvenance: + """Validate the sole transported compact receipt for a completed result.""" - return SPMCalculationProvenance( - spm_config=SPMResolvedConfiguration.model_validate(config), - spm_provenance=SPMProvenance.model_validate(provenance), - ) + return SPMProvenance.model_validate(provenance) def _current_bundle() -> dict: @@ -656,7 +632,7 @@ def calculation_spm_receipt(simulation) -> dict: SPMSelection.model_validate(resolved), source, ) - return calculation.model_dump(mode="json", by_alias=True) + return {"spm_provenance": calculation.model_dump(mode="json", by_alias=True)} except (ValidationError, ValueError) as error: raise SPMValidationError( "SPM_CONFIGURATION_UNAVAILABLE", diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index e4941a237..2d9977d90 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -6,7 +6,7 @@ SPMValidationError, error_message, normalize_spm_selection, - resolved_spm_settings, + resolved_spm_configuration, validate_spm_calculation_provenance, ) @@ -96,20 +96,17 @@ def validate_worker_result( return if expected_years is not None: raise ValueError("Expected a budget window SPM result") - resolved = resolved_spm_settings(result.get("spm_config")) - if resolved is None: - raise ValueError("Worker result has incomplete resolved SPM settings") - if resolved != selection: - raise ValueError("Worker result SPM settings differ from the request") + if "spm_config" in result: + raise ValueError("Worker result must not include legacy spm_config") comparison = SPMComparisonProvenance.model_validate( result.get("spm_provenance") ) for side in ("baseline", "reform"): execution = getattr(comparison, side) - receipt = validate_spm_calculation_provenance( - resolved, - execution.receipt, - ).spm_provenance + receipt = validate_spm_calculation_provenance(execution.receipt) + resolved = resolved_spm_configuration(receipt).model_dump(mode="json") + if resolved != selection: + raise ValueError("Worker result SPM settings differ from the request") if expected_year is not None and str(expected_year) not in receipt.years: raise ValueError("Worker SPM receipt does not cover requested year") except (ValueError, TypeError, KeyError) as exc: diff --git a/tests/integration/test_runtime_cache_redis.py b/tests/integration/test_runtime_cache_redis.py index 2d7c571cc..e12b04575 100644 --- a/tests/integration/test_runtime_cache_redis.py +++ b/tests/integration/test_runtime_cache_redis.py @@ -120,7 +120,6 @@ def test_cached_calculation_receipts_survive_a_real_round_trip(redis_pair) -> No value = CachedHouseholdCalculation( household={"people": {"you": {}}}, warnings=(), - spm_config=selection, spm_provenance={ "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", @@ -143,7 +142,6 @@ def test_cached_calculation_receipts_survive_a_real_round_trip(redis_pair) -> No assert HouseholdCalculationCache(first, namespace).set(identity, value) cached = HouseholdCalculationCache(second, namespace).get(identity) assert cached is not None - assert cached.spm_config == selection assert cached.spm_provenance == value.spm_provenance other = HouseholdCalculationIdentity( **{**vars(identity), "spm": {**selection, "geography_kind": "county"}} diff --git a/tests/unit/routes/test_canonical_spm.py b/tests/unit/routes/test_canonical_spm.py index 95f51cb83..767de092a 100644 --- a/tests/unit/routes/test_canonical_spm.py +++ b/tests/unit/routes/test_canonical_spm.py @@ -86,7 +86,7 @@ def calculate(self, household, policy, **kwargs): "years": ["2026"], } ) - return CalculationResult(household, (), config, receipt) + return CalculationResult(household, (), receipt) @pytest.fixture @@ -221,7 +221,7 @@ def test_settings_and_provenance_survive_storage_reform_replay_and_replacement( cached = client.get(url + "/policy/2") assert first.status_code == cached.status_code == 200 assert first.json == cached.json - assert first.json["spm_config"] == stored["spm"] + assert "spm_config" not in first.json assert first.json["spm_provenance"]["forecast_sha256"] == FORECAST_HASH assert len(country.calls) == 1 assert "spm" not in country.calls[0][0] @@ -257,7 +257,7 @@ def test_settings_and_provenance_survive_storage_reform_replay_and_replacement( latest = client.get(latest_url).json["result"] assert latest["household_hash"] != edited["household_hash"] assert ( - client.get(latest_url + "/policy/2").json["spm_config"]["geography_kind"] + client.get(latest_url + "/policy/2").json["spm_provenance"]["geography_kind"] == "county" ) assert len(country.calls) == 4 @@ -286,7 +286,7 @@ def test_http_cache_varies_with_measurement_settings(certified, harness, selecti second = client.post("/us/calculate", json=payload) assert second.status_code == 200 assert len(country.calls) == 2 - assert first.json["spm_config"] != second.json["spm_config"] + assert first.json["spm_provenance"] != second.json["spm_provenance"] def test_successful_http_cache_hit_still_starts_observability(certified, harness): @@ -336,7 +336,7 @@ def test_http_cache_default_artifact_hash_is_part_of_identity(certified, harness second = client.post("/us/calculate", json=payload) assert first.status_code == second.status_code == 200 assert len(country.calls) == 2 - assert first.json["spm_config"] != second.json["spm_config"] + assert first.json["spm_provenance"] != second.json["spm_provenance"] def test_linked_household_measurement_is_immutable_for_saved_reports( @@ -544,8 +544,8 @@ def test_valid_spm_database_timeout_keeps_stage11_safe_persistence_response( copy_event.assert_not_called() -def test_stored_replay_rejects_a_receipt_config_that_omits_nulls(certified, harness): - """Country output must state every field in its resolved configuration.""" +def test_stored_replay_rejects_a_receipt_that_omits_null_settings(certified, harness): + """The sole receipt must state every field in its resolved configuration.""" client, country = harness calculate = country.calculate @@ -554,12 +554,11 @@ def omitting_null_settings(household, policy, **kwargs): return CalculationResult( household=result.household, warnings=result.warnings, - spm_config={ + spm_provenance={ key: value - for key, value in result.spm_config.items() + for key, value in result.spm_provenance.items() if value is not None }, - spm_provenance=result.spm_provenance, ) country.calculate = omitting_null_settings diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index d68b32d38..3faa0ab02 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -49,7 +49,6 @@ def segmented_result(year, invalid_side=None): } result = { "year": year, - "spm_config": SELECTION, "spm_provenance": { "schema_version": "canonical-spm-comparison-v2", "baseline": {"receipt": dict(receipt), "execution_count": 2}, diff --git a/tests/unit/runtime_cache/test_household_calculations.py b/tests/unit/runtime_cache/test_household_calculations.py index 570a9e3f6..cfe422efe 100644 --- a/tests/unit/runtime_cache/test_household_calculations.py +++ b/tests/unit/runtime_cache/test_household_calculations.py @@ -129,7 +129,6 @@ def test_household_cache_rejects_invalid_or_mismatched_spm_receipts(receipt): identity = _identity(spm=SPM_CONFIG) value = CachedHouseholdCalculation( household={"people": {}}, - spm_config=SPM_CONFIG, spm_provenance=receipt, ) assert cache.set(identity, value) is False @@ -157,7 +156,6 @@ def test_household_cache_rejects_previous_diagnostic_receipt_before_write(): identity, CachedHouseholdCalculation( household={"people": {}}, - spm_config=SPM_CONFIG, spm_provenance=previous_receipt, ), ) @@ -172,13 +170,11 @@ def test_household_cache_keeps_valid_receipts_including_lazy_tax_only(years): identity = _identity(spm=SPM_CONFIG) value = CachedHouseholdCalculation( household={"people": {}}, - spm_config=SPM_CONFIG, spm_provenance={**SPM_RECEIPT, "years": years}, ) assert cache.set(identity, value) is True cached = cache.get(identity) assert cached is not None - assert cached.spm_config == SPM_CONFIG assert cached.spm_provenance == {**SPM_RECEIPT, "years": years} @@ -196,52 +192,41 @@ def test_real_country_provider_receipt_round_trips_household_cache(calculate_spm config = country_spm.spm_config(provider) receipt = build_spm_calculation_provenance( SPMSelection.model_validate(config), provider.provenance() - ).spm_provenance.model_dump(mode="json", by_alias=True) + ).model_dump(mode="json", by_alias=True) assert bool(receipt["years"]) is calculate_spm cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) identity = _identity(spm=config) value = CachedHouseholdCalculation( household={"people": {}}, - spm_config=config, spm_provenance=receipt, ) assert cache.set(identity, value) is True cached = cache.get(identity) assert cached is not None - assert cached.spm_config == config assert cached.spm_provenance == receipt -OMITTED_NULL_CONFIG = { - key: value for key, value in SPM_CONFIG.items() if value is not None -} - - -def test_household_cache_rejects_receipt_omitting_null_settings(): - """A compact receipt's adjacent config must contain all six fields.""" - cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) - identity = _identity(spm=SPM_CONFIG) - value = CachedHouseholdCalculation( - household={"people": {}}, - spm_config=OMITTED_NULL_CONFIG, - spm_provenance=SPM_RECEIPT, - ) - assert cache.set(identity, value) is False - assert cache.get(identity) is None - - -@pytest.mark.parametrize("omitted", sorted(OMITTED_NULL_CONFIG)) -def test_household_cache_rejects_receipt_omitting_a_nonnull_setting(omitted): - """An omitted non-null setting must never inherit today's resolved default.""" +@pytest.mark.parametrize( + "omitted", + [ + "forecast_sha256", + "scenario", + "geography_kind", + "geography_id", + "county_vintage", + "as_of", + ], +) +def test_household_cache_rejects_receipt_omitting_resolved_setting(omitted): + """Every resolved setting is required on the sole completed-result receipt.""" cache = HouseholdCalculationCache(InMemoryCacheBackend(), _namespace()) identity = _identity(spm=SPM_CONFIG) value = CachedHouseholdCalculation( household={"people": {}}, - spm_config={ - key: item for key, item in OMITTED_NULL_CONFIG.items() if key != omitted + spm_provenance={ + key: item for key, item in SPM_RECEIPT.items() if key != omitted }, - spm_provenance=SPM_RECEIPT, ) assert cache.set(identity, value) is False assert cache.get(identity) is None @@ -263,8 +248,28 @@ def test_household_cache_rejects_receipt_with_different_settings(changed): identity = _identity(spm=SPM_CONFIG) value = CachedHouseholdCalculation( household={"people": {}}, - spm_config={**OMITTED_NULL_CONFIG, **changed}, - spm_provenance=SPM_RECEIPT, + spm_provenance={**SPM_RECEIPT, **changed}, ) assert cache.set(identity, value) is False assert cache.get(identity) is None + + +def test_household_cache_rejects_legacy_dual_field_payload() -> None: + backend = InMemoryCacheBackend() + cache = HouseholdCalculationCache(backend, _namespace()) + identity = _identity(spm=SPM_CONFIG) + backend.set( + cache.cache_key(identity), + encode_envelope( + "household-calculation", + HOUSEHOLD_CALCULATION_SCHEMA_VERSION, + { + "household": {"people": {}}, + "warnings": [], + "spm_config": SPM_CONFIG, + "spm_provenance": SPM_RECEIPT, + }, + ), + ) + + assert cache.get(identity) is None diff --git a/tests/unit/runtime_cache/test_reform_impacts.py b/tests/unit/runtime_cache/test_reform_impacts.py index c7ef0877d..26a02cca7 100644 --- a/tests/unit/runtime_cache/test_reform_impacts.py +++ b/tests/unit/runtime_cache/test_reform_impacts.py @@ -11,6 +11,7 @@ ) from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.reform_impacts import ( + REFORM_IMPACT_SCHEMA_VERSION, REFORM_IMPACT_START_CLAIM_TTL_SECONDS, CachedReformImpact, ReformImpactCache, @@ -198,12 +199,15 @@ def test_previous_schema_reform_impact_is_not_read() -> None: namespace = _namespace() cache = ReformImpactCache(backend, namespace) legacy = _impact("legacy", "[spm=canonical]", 1) - legacy_key = namespace.key("reform-impact", 1, {"execution_id": "legacy"}) + previous_version = REFORM_IMPACT_SCHEMA_VERSION - 1 + legacy_key = namespace.key( + "reform-impact", previous_version, {"execution_id": "legacy"} + ) backend.set( legacy_key, encode_envelope( "reform-impact", - 1, + previous_version, { **legacy.__dict__, "start_time": legacy.start_time.isoformat(), diff --git a/tests/unit/services/test_budget_window_cache.py b/tests/unit/services/test_budget_window_cache.py index 24e3896c8..ff344e924 100644 --- a/tests/unit/services/test_budget_window_cache.py +++ b/tests/unit/services/test_budget_window_cache.py @@ -7,6 +7,7 @@ from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.services.budget_window_cache import ( BUDGET_WINDOW_BATCH_TTL_SECONDS, + BUDGET_WINDOW_CACHE_SCHEMA_VERSION, BUDGET_WINDOW_STARTING_TTL_SECONDS, BudgetWindowCache, BudgetWindowCacheState, @@ -81,7 +82,7 @@ def test_build_key_is_stable_for_request_identity(): ) assert first == second - assert first.startswith("policyengine:test:api:budget-window:v3:") + assert first.startswith("policyengine:test:api:budget-window:v4:") def test_previous_schema_cache_identity_is_not_read(): @@ -98,12 +99,13 @@ def test_previous_schema_cache_identity_is_not_read(): "region": "us", "time_period": "budget_window:2026:2", } - old_key = namespace.key("budget-window", 2, identity) + previous_version = BUDGET_WINDOW_CACHE_SCHEMA_VERSION - 1 + old_key = namespace.key("budget-window", previous_version, identity) backend.set( f"{old_key}:state", encode_envelope( "budget-window", - 2, + previous_version, {"status": "completed", "result": {"legacy": True}}, ), ) diff --git a/tests/unit/services/test_report_output_service.py b/tests/unit/services/test_report_output_service.py index b264a8129..92344b68e 100644 --- a/tests/unit/services/test_report_output_service.py +++ b/tests/unit/services/test_report_output_service.py @@ -39,7 +39,6 @@ def compact_economy_output() -> dict: return { - "spm_config": SPM_CONFIG, "spm_provenance": { "schema_version": "canonical-spm-comparison-v2", "baseline": {"receipt": SPM_RECEIPT, "execution_count": 2}, @@ -254,21 +253,25 @@ def test_update_complete_persists_only_compact_society_wide_spm_output( @pytest.mark.parametrize( - "output", + ("output", "error"), [ - {"budgetary_impact": 42}, - {"budgetary_impact": 42, "spm_config": SPM_CONFIG}, - { - "budgetary_impact": 42, - "spm_provenance": compact_economy_output()["spm_provenance"], - }, + ({"budgetary_impact": 42}, "requires SPM provenance"), + ( + {"budgetary_impact": 42, "spm_config": SPM_CONFIG}, + "legacy spm_config", + ), + ( + {**compact_economy_output(), "spm_config": SPM_CONFIG}, + "legacy spm_config", + ), ], - ids=["absent", "config-only", "provenance-only"], + ids=["absent", "config-only", "dual-field"], ) -def test_update_complete_requires_both_society_wide_spm_fields( +def test_update_complete_requires_only_compact_society_wide_spm_provenance( service, orm_session_factory, output, + error, ): baseline = create_simulation( orm_session_factory, @@ -286,7 +289,7 @@ def test_update_complete_requires_both_society_wide_spm_fields( "us", baseline.id, reform.id, "2025" ).view.report_output - with pytest.raises(ValueError, match="requires both SPM"): + with pytest.raises(ValueError, match=error): service.update_report_output("us", report.id, status="complete", output=output) with orm_session_factory() as session: @@ -380,9 +383,9 @@ def test_resolve_legacy_spm_report_returns_new_empty_cache_record( population_id="us", population_type="geography", ) - monkeypatch.setitem(constants.RUNTIME_CACHE_SCHEMA_VERSIONS, "report_output", 1) - legacy_cache_version = get_report_output_cache_version("us") monkeypatch.setitem(constants.RUNTIME_CACHE_SCHEMA_VERSIONS, "report_output", 2) + legacy_cache_version = get_report_output_cache_version("us") + monkeypatch.setitem(constants.RUNTIME_CACHE_SCHEMA_VERSIONS, "report_output", 3) assert legacy_cache_version != get_report_output_cache_version("us") with orm_session_factory.begin() as session: legacy = ReportOutput( diff --git a/tests/unit/services/test_simulation_service.py b/tests/unit/services/test_simulation_service.py index 5e7b224bb..464ae50b9 100644 --- a/tests/unit/services/test_simulation_service.py +++ b/tests/unit/services/test_simulation_service.py @@ -162,12 +162,12 @@ def test_update_simulation_accepts_legacy_json_text_at_wire_boundary(service): assert updated.output == {"result": 42} -def test_update_simulation_rejects_incomplete_spm_provenance(service): +def test_update_simulation_rejects_legacy_spm_config(service): simulation = service.get_or_create_simulation( "us", "household-1", "household", 1 ).simulation - with pytest.raises(ValueError, match="SPM"): + with pytest.raises(ValueError, match="legacy spm_config"): service.update_simulation( "us", simulation.id, @@ -176,18 +176,24 @@ def test_update_simulation_rejects_incomplete_spm_provenance(service): ) +def test_update_simulation_rejects_incomplete_spm_provenance(service): + simulation = service.get_or_create_simulation( + "us", "household-1", "household", 1 + ).simulation + + with pytest.raises(ValueError, match="Field required"): + service.update_simulation( + "us", + simulation.id, + status="complete", + output={"spm_provenance": {"geography_kind": "national"}}, + ) + + def test_update_simulation_persists_only_compact_spm_provenance(service): simulation = service.get_or_create_simulation( "us", "household-1", "household", 1 ).simulation - config = { - "forecast_content_sha256": "a" * 64, - "scenario": "ce_trend", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - } provenance = { "schema_version": "canonical-spm-provenance-v2", "forecast_id": "spm-rolling-2026-09-09", @@ -210,12 +216,11 @@ def test_update_simulation_persists_only_compact_spm_provenance(service): "us", simulation.id, status="complete", - output={"result": 42, "spm_config": config, "spm_provenance": provenance}, + output={"result": 42, "spm_provenance": provenance}, ) assert updated.output == { "result": 42, - "spm_config": config, "spm_provenance": provenance, } diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py index 4cb17b7a6..e3b35f8d3 100644 --- a/tests/unit/services/test_worker_spm.py +++ b/tests/unit/services/test_worker_spm.py @@ -444,7 +444,6 @@ def test_worker_cache_receipts_are_required_and_json_roundtrip(): from policyengine_api.worker_spm import validate_worker_result output = { - "spm_config": SELECTION, "spm_provenance": worker_comparison(), } validate_worker_result(json.loads(json.dumps(output)), SELECTION) @@ -461,11 +460,17 @@ def test_worker_cache_receipts_are_required_and_json_roundtrip(): {"spm_config": SELECTION}, { **output, - "spm_config": { - key: value for key, value in SELECTION.items() if value is not None - }, + "spm_config": SELECTION, + }, + { + "spm_provenance": worker_comparison( + baseline={ + key: value + for key, value in worker_receipt().items() + if key != "scenario" + } + ) }, - {**output, "spm_config": {**SELECTION, "scenario": "zero_real"}}, ): with pytest.raises(SPMValidationError): validate_worker_result(incomplete, SELECTION) @@ -478,16 +483,22 @@ def test_worker_cache_receipts_are_required_and_json_roundtrip(): def test_worker_receipt_cannot_inherit_nonnull_settings(missing): from policyengine_api.worker_spm import validate_worker_result - config = {key: value for key, value in SELECTION.items() if key != missing} - with pytest.raises(SPMValidationError, match="incomplete resolved"): - validate_worker_result({"spm_config": config}, SELECTION) + receipt_field = ( + "forecast_sha256" if missing == "forecast_content_sha256" else missing + ) + receipt = { + key: value for key, value in worker_receipt().items() if key != receipt_field + } + with pytest.raises(SPMValidationError): + validate_worker_result( + {"spm_provenance": worker_comparison(baseline=receipt)}, SELECTION + ) def test_worker_receipt_must_cover_requested_year(): from policyengine_api.worker_spm import validate_worker_result result = { - "spm_config": SELECTION, "spm_provenance": worker_comparison(), } validate_worker_result(result, SELECTION, expected_year="2026") @@ -501,7 +512,6 @@ def test_worker_receipt_must_cover_requested_year(): def test_worker_receipt_rejects_baseline_reform_mismatch(): result = { - "spm_config": SELECTION, "spm_provenance": worker_comparison( reform=worker_receipt(years=("2025",)), ), @@ -513,7 +523,6 @@ def test_worker_receipt_rejects_baseline_reform_mismatch(): def test_worker_receipt_rejects_removed_segment_list_shape(): result = { - "spm_config": SELECTION, "spm_provenance": { "baseline": [worker_receipt()], "reform": [worker_receipt()], @@ -607,7 +616,6 @@ def test_budget_result_cannot_nest_another_budget_window(): def annual_shape_result(year="2026"): return { "year": year, - "spm_config": SELECTION, "spm_provenance": worker_comparison(), } @@ -686,7 +694,6 @@ def test_a_worker_receipt_failure_quotes_no_validator_internals(receipt): with pytest.raises(SPMValidationError) as caught: validate_worker_result( { - "spm_config": SELECTION, "spm_provenance": worker_comparison(reform=receipt), }, SELECTION, diff --git a/tests/unit/test_country_spm.py b/tests/unit/test_country_spm.py index f392df43e..4b525bb35 100644 --- a/tests/unit/test_country_spm.py +++ b/tests/unit/test_country_spm.py @@ -240,7 +240,6 @@ def calculate(variable, period): assert ( result.household["spm_units"]["spm_unit"]["spm_unit_federal_tax"]["2024"] == 123 ) - assert json.loads(json.dumps(result.spm_config)) == selection assert result.spm_provenance == { "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", @@ -330,7 +329,7 @@ def test_real_country_state_only_dependency_is_null_when_nothing_was_chosen( """ result = real_canonical_country.calculate(requested_household(variable), None) assert result.household["spm_units"]["spm_unit"][variable]["2024"] is None - assert result.spm_config["geography_kind"] == "county" + assert result.spm_provenance["geography_kind"] == "county" @pytest.mark.parametrize("county", ["99999", "malformed"]) @@ -375,6 +374,7 @@ def test_real_country_explicit_geography_yields_json_receipt( resolved = spm.normalize_spm_selection("us", None) forecast = spm._selected_forecast(resolved["forecast_content_sha256"]) selection["geography_id"] = forecast.resolve_county(2024, "06037")["area_id"] + resolved_selection = spm.normalize_spm_selection("us", selection) original = deepcopy(household) result = real_canonical_country.calculate( household, None, spm=selection, spm_requested=True @@ -383,10 +383,10 @@ def test_real_country_explicit_geography_yields_json_receipt( assert ( result.household["spm_units"]["spm_unit"]["spm_unit_spm_threshold"]["2024"] > 0 ) - assert result.spm_config["geography_kind"] == geography + assert result.spm_provenance["geography_kind"] == geography assert ( result.spm_provenance["forecast_sha256"] - == result.spm_config["forecast_content_sha256"] + == resolved_selection["forecast_content_sha256"] ) assert "2024" in result.spm_provenance["years"] json.dumps(result.spm_provenance) @@ -495,7 +495,7 @@ def test_real_http_missing_primitives_are_null_when_nothing_was_chosen( response = real_http_client.post("/us/calculate", json={"household": household}) assert response.status_code == 200, response.json assert response.json["status"] == "ok" - assert response.json["spm_config"]["geography_kind"] == "county" + assert response.json["spm_provenance"]["geography_kind"] == "county" if kind != "axes": threshold = response.json["result"]["spm_units"]["spm_unit"][ "spm_unit_spm_threshold" @@ -513,7 +513,7 @@ def test_real_http_national_result_and_receipt_survive_response_cache(real_http_ assert response.status_code == 200, response.json assert cached.status_code == 200 assert cached.json == response.json - assert response.json["spm_config"]["geography_kind"] == "national" + assert response.json["spm_provenance"]["geography_kind"] == "national" assert "2024" in response.json["spm_provenance"]["years"] @@ -534,7 +534,7 @@ def test_real_http_full_national_calculation_keeps_threshold_and_provenance( ] > 0 ) - assert response.json["spm_config"]["geography_kind"] == "national" + assert response.json["spm_provenance"]["geography_kind"] == "national" assert "2024" in response.json["spm_provenance"]["years"] diff --git a/tests/unit/test_openapi_simulation.py b/tests/unit/test_openapi_simulation.py index d80320eb5..3d9ca1f76 100644 --- a/tests/unit/test_openapi_simulation.py +++ b/tests/unit/test_openapi_simulation.py @@ -147,14 +147,6 @@ def test_simulation_persistence_responses_preserve_spm_output_and_run_fields( "status": "ok", "message": None, "result": {"people": {}}, - "spm_config": { - "forecast_content_sha256": "a" * 64, - "scenario": "baseline", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - }, "spm_provenance": { "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-forecast", diff --git a/tests/unit/test_openapi_spm.py b/tests/unit/test_openapi_spm.py index 222b056e7..a7d709a60 100644 --- a/tests/unit/test_openapi_spm.py +++ b/tests/unit/test_openapi_spm.py @@ -9,7 +9,6 @@ SPMComparisonProvenance, SPMExecutionProvenance, SPMProvenance, - SPMResolvedConfiguration, SPMRuntimeVersions, SPMSelection, ) @@ -53,9 +52,7 @@ def test_economy_query_and_receipt_schemas_match_public_http_contract(): ]["result"] if suffix: result = result["properties"]["annualImpacts"]["items"] - assert result["properties"]["spm_config"] == { - "$ref": "#/components/schemas/SPMResolvedConfiguration" - } + assert "spm_config" not in result["properties"] assert result["properties"]["spm_provenance"] == { "$ref": "#/components/schemas/SPMComparisonProvenance" } @@ -117,12 +114,10 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): paths = spec["paths"] schemas = spec["components"]["schemas"] selection_ref = {"$ref": "#/components/schemas/SPMSelection"} - resolved_ref = {"$ref": "#/components/schemas/SPMResolvedConfiguration"} provenance_ref = {"$ref": "#/components/schemas/SPMProvenance"} for name, model in { "SPMSelection": SPMSelection, - "SPMResolvedConfiguration": SPMResolvedConfiguration, "SPMRuntimeVersions": SPMRuntimeVersions, "SPMProvenance": SPMProvenance, "SPMExecutionProvenance": SPMExecutionProvenance, @@ -175,8 +170,7 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): "nullable" not in field for field in schemas["SPMRuntimeVersions"]["properties"].values() ) - resolved = schemas["SPMResolvedConfiguration"] - assert set(resolved["required"]) == set(resolved["properties"]) + assert "SPMResolvedConfiguration" not in schemas removed_rich_fields = { "geographies", "requested", @@ -208,7 +202,7 @@ def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): "properties" ] assert {"status", "message", "result"} <= set(result) - assert result["spm_config"] == resolved_ref + assert "spm_config" not in result assert result["spm_provenance"] == provenance_ref assert ( operation["responses"]["400"]["$ref"] diff --git a/tests/unit/test_simulation_and_report_routes.py b/tests/unit/test_simulation_and_report_routes.py index 3c0b2c892..23aa8c76f 100644 --- a/tests/unit/test_simulation_and_report_routes.py +++ b/tests/unit/test_simulation_and_report_routes.py @@ -43,14 +43,6 @@ def create_simulation( def compact_spm_report_output() -> dict: - config = { - "forecast_content_sha256": "a" * 64, - "scenario": "ce_trend", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - } receipt = { "schema_version": "canonical-spm-provenance-v2", "forecast_id": "canonical-forecast", @@ -69,7 +61,6 @@ def compact_spm_report_output() -> dict: }, } return { - "spm_config": config, "spm_provenance": { "schema_version": "canonical-spm-comparison-v2", "baseline": {"receipt": receipt, "execution_count": 1}, diff --git a/tests/unit/test_spm_provenance.py b/tests/unit/test_spm_provenance.py index e4f02fa58..28745e900 100644 --- a/tests/unit/test_spm_provenance.py +++ b/tests/unit/test_spm_provenance.py @@ -6,7 +6,6 @@ from pydantic import ValidationError from policyengine_api.spm import ( - SPMCalculationProvenance, SPMComparisonProvenance, SPMProvenance, SPMRuntimeVersions, @@ -14,6 +13,8 @@ build_spm_calculation_provenance, build_spm_comparison_provenance, build_spm_provenance, + resolved_spm_configuration, + validate_spm_calculation_provenance, ) @@ -105,14 +106,15 @@ def test_runtime_versions_require_four_nonempty_strings( SPMRuntimeVersions.model_validate(versions) -def test_calculation_contract_requires_every_resolved_configuration_field() -> None: +def test_completed_calculation_contract_rejects_legacy_dual_field_shape() -> None: receipt = _receipt().model_dump(mode="json", by_alias=True) - partial = SELECTION.model_dump(mode="json") - partial.pop("geography_id") with pytest.raises(ValidationError): - SPMCalculationProvenance.model_validate( - {"spm_config": partial, "spm_provenance": receipt} + validate_spm_calculation_provenance( + { + "spm_config": SELECTION.model_dump(mode="json"), + "spm_provenance": receipt, + } ) @@ -148,10 +150,10 @@ def test_country_receipt_adapter_extracts_only_compact_scalars() -> None: calculation = build_spm_calculation_provenance(SELECTION, country_receipt) - assert calculation.spm_config.model_dump(mode="json") == SELECTION.model_dump( + assert resolved_spm_configuration(calculation).model_dump( mode="json" - ) - assert calculation.spm_provenance.years == ("2026", "2027") + ) == SELECTION.model_dump(mode="json") + assert calculation.years == ("2026", "2027") encoded = calculation.model_dump_json(by_alias=True) assert "diagnostic" not in encoded assert "mapping" not in encoded diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index 33e18936b..c4aa91a04 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -432,14 +432,16 @@ def test_resolved_settings_reject_unreadable_receipt_settings(settings): assert spm.resolved_spm_settings(settings) is None -def test_receipt_expands_config_and_reduces_country_diagnostics(certified_bundle): - """The public receipt contains one resolved config and compact provenance.""" +def test_receipt_reduces_country_diagnostics_to_one_compact_provenance( + certified_bundle, +): + """The public receipt itself contains every resolved configuration field.""" simulation = SimpleNamespace( spm_config=RESOLVED_SELECTION, spm_provenance=lambda: deepcopy(COUNTRY_RECEIPT), ) receipt = spm.calculation_spm_receipt(simulation) - assert receipt["spm_config"] == RESOLVED_SELECTION + assert set(receipt) == {"spm_provenance"} assert receipt["spm_provenance"] == { "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-artifact", diff --git a/tests/unit/v2/test_stage12_worker_contracts.py b/tests/unit/v2/test_stage12_worker_contracts.py index 9e537996d..330af0483 100644 --- a/tests/unit/v2/test_stage12_worker_contracts.py +++ b/tests/unit/v2/test_stage12_worker_contracts.py @@ -29,34 +29,33 @@ def _calculation_provenance() -> dict[str, object]: return { - "spm_config": { - "forecast_content_sha256": DIGEST_A, - "scenario": "official", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - }, - "spm_provenance": { - "schema_version": "canonical-spm-provenance-v2", - "forecast_id": "canonical-forecast", - "forecast_sha256": DIGEST_A, - "scenario": "official", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - "years": ["2026"], - "runtime_versions": { - "policyengine": "6.2.1", - "policyengine-core": "3.32.10", - "policyengine-us": "2.2.1", - "spm-calculator": "1.0.0", - }, + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "canonical-forecast", + "forecast_sha256": DIGEST_A, + "scenario": "official", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2026"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", }, } +def _comparison_provenance() -> dict[str, object]: + receipt = _calculation_provenance() + return { + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": receipt, "execution_count": 1}, + "reform": {"receipt": receipt, "execution_count": 1}, + } + + def _bundle() -> dict[str, object]: return { "policyengine_version": "5.2.0", @@ -240,19 +239,17 @@ def test_artifact_descriptors_retain_identity_and_bundle_provenance() -> None: @pytest.mark.parametrize( "provenance", [ + {"scenario": "official"}, { "spm_config": {"scenario": "official"}, - "spm_provenance": {"forecast_sha256": DIGEST_A}, + "spm_provenance": _calculation_provenance(), }, { **_calculation_provenance(), - "spm_provenance": { - **_calculation_provenance()["spm_provenance"], - "geographies": [], - }, + "geographies": [], }, ], - ids=["partial", "legacy-rich"], + ids=["partial", "legacy-dual-field", "legacy-rich"], ) def test_simulation_artifact_rejects_noncompact_calculation_provenance( provenance: dict[str, object], @@ -301,7 +298,10 @@ def test_artifact_payload_contracts_cover_parquet_and_aggregate_json() -> None: "evaluation_id": str(EVALUATION_ID), "requested_aggregates": ["budget"], "bundle": _bundle(), - "result": {"budget": {"change": 100.0}}, + "result": { + "budget": {"change": 100.0}, + "spm_provenance": _comparison_provenance(), + }, } ) @@ -314,6 +314,19 @@ def test_artifact_payload_contracts_cover_parquet_and_aggregate_json() -> None: } ) + with pytest.raises(ValidationError, match="legacy spm_config"): + AggregateReportArtifactPayload.model_validate( + { + **payload.model_dump(mode="json"), + "result": { + **payload.result, + "spm_config": { + "forecast_content_sha256": DIGEST_A, + }, + }, + } + ) + def test_contract_models_are_immutable_and_reject_duplicate_lists() -> None: report = ReportExecutionInput.model_validate(_report()) From d9905550648b4dafb74f47cab87a3b449d12a2c0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:24:05 +0400 Subject: [PATCH 8/9] Clarify internal SPM configuration model --- policyengine_api/spm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index e48bde68d..4f92a3b96 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -9,8 +9,8 @@ import importlib import inspect from collections.abc import Mapping, Sequence -from functools import lru_cache from datetime import date +from functools import lru_cache from typing import Literal from pydantic import ( @@ -98,7 +98,7 @@ def validate_location(self): class SPMResolvedConfiguration(BaseModel): - """The complete six-field SPM selection recorded beside a receipt.""" + """An internal complete SPM selection derived from a compact receipt.""" model_config = ConfigDict(frozen=True, extra="forbid") forecast_content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") From 5f35197f0a52a60daa8bcdc24a3a377178587ecf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:50:10 +0400 Subject: [PATCH 9/9] Clarify synthetic SPM version fixture --- tests/unit/test_spm_provenance.py | 32 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/unit/test_spm_provenance.py b/tests/unit/test_spm_provenance.py index 28745e900..08521a1cf 100644 --- a/tests/unit/test_spm_provenance.py +++ b/tests/unit/test_spm_provenance.py @@ -29,12 +29,14 @@ "as_of": None, } ) -VERSIONS = SPMRuntimeVersions.model_validate( +# Synthetic fixture values used only to exercise the public receipt contract. +# Deployed workers obtain real package versions from the PolicyEngine.py bundle. +TEST_RUNTIME_VERSIONS = SPMRuntimeVersions.model_validate( { - "policyengine": "6.2.1", - "policyengine-core": "3.32.10", - "policyengine-us": "2.2.1", - "spm-calculator": "1.0.0", + "policyengine": "0.0.0-test-policyengine", + "policyengine-core": "0.0.0-test-policyengine-core", + "policyengine-us": "0.0.0-test-policyengine-us", + "spm-calculator": "0.0.0-test-spm-calculator", } ) @@ -45,7 +47,7 @@ def _receipt(**changes: object) -> SPMProvenance: "forecast_sha256": SHA256, "selection": SELECTION, "years": ("2026",), - "runtime_versions": VERSIONS, + "runtime_versions": TEST_RUNTIME_VERSIONS, } values.update(changes) return build_spm_provenance(**values) @@ -65,10 +67,10 @@ def test_compact_receipt_has_only_the_public_v2_fields() -> None: "as_of": None, "years": ["2026"], "runtime_versions": { - "policyengine": "6.2.1", - "policyengine-core": "3.32.10", - "policyengine-us": "2.2.1", - "spm-calculator": "1.0.0", + "policyengine": "0.0.0-test-policyengine", + "policyengine-core": "0.0.0-test-policyengine-core", + "policyengine-us": "0.0.0-test-policyengine-us", + "spm-calculator": "0.0.0-test-spm-calculator", }, } assert len(receipt.model_dump_json(by_alias=True).encode()) < 1_024 @@ -99,7 +101,7 @@ def test_runtime_versions_require_four_nonempty_strings( invalid_version: str, value: object, ) -> None: - versions = VERSIONS.model_dump(mode="json", by_alias=True) + versions = TEST_RUNTIME_VERSIONS.model_dump(mode="json", by_alias=True) versions[invalid_version] = value with pytest.raises(ValidationError): @@ -141,7 +143,9 @@ def test_country_receipt_adapter_extracts_only_compact_scalars() -> None: "forecast_sha256": SHA256, "scenario": "ce_trend", "geography_kind": "national", - "runtime_versions": VERSIONS.model_dump(mode="json", by_alias=True), + "runtime_versions": TEST_RUNTIME_VERSIONS.model_dump( + mode="json", by_alias=True + ), "years": {"2027": {"large": "diagnostic"}, "2026": {}}, "geographies": [{"large": "mapping"}], "composition_method": "classified inputs", @@ -166,7 +170,9 @@ def test_country_receipt_must_agree_with_adjacent_configuration() -> None: "forecast_sha256": SHA256, "scenario": "different", "geography_kind": "national", - "runtime_versions": VERSIONS.model_dump(mode="json", by_alias=True), + "runtime_versions": TEST_RUNTIME_VERSIONS.model_dump( + mode="json", by_alias=True + ), "years": {}, }