diff --git a/.github/workflows/spm_release_integration.yaml b/.github/workflows/spm_release_integration.yaml new file mode 100644 index 00000000..65adb7bf --- /dev/null +++ b/.github/workflows/spm_release_integration.yaml @@ -0,0 +1,34 @@ +name: SPM release household integration + +on: + pull_request: + paths: + - src/** + - tests/test_spm_release_household.py + - tests/test_household_impact.py + - .github/workflows/spm_release_integration.yaml + workflow_dispatch: + +jobs: + development-households: + runs-on: ubuntu-latest + env: + # Explicitly unbundled household execution; population paths refuse it. + POLICYENGINE_US_HOUSEHOLD_ONLY: "1" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - uses: astral-sh/setup-uv@v8.1.0 + - name: Install exact reviewed development dependencies + # Country commit pins spm-calculator 0.5.0 at 9e6ae4798458771231613b994df2345dd1685214. + # This job intentionally does not install the certified [us] bundle. + run: >- + uv pip install --system -e . pytest pytest-asyncio + "policyengine-core==3.30.2" + "policyengine-us @ git+https://github.com/PolicyEngine/policyengine-us@3a4ff53654c75e4559b7d55374e8f00c0261b392" + - name: Verify package consistency + run: uv pip check --system + - name: Run release acceptance and existing household API tests + run: python -m pytest tests/test_spm_release_household.py tests/test_household_impact.py::TestUSCalculateHousehold -q diff --git a/changelog.d/spm-release-household.added.md b/changelog.d/spm-release-household.added.md new file mode 100644 index 00000000..41f0253b --- /dev/null +++ b/changelog.d/spm-release-household.added.md @@ -0,0 +1 @@ +Accept an explicit, hash-verified SPM release in US household calculations, with simulation-specific threshold and housing-share formulas and JSON provenance; add opt-in household-only registration for unbundled development models without certifying population data. diff --git a/examples/us_spm_release.py b/examples/us_spm_release.py new file mode 100644 index 00000000..87452661 --- /dev/null +++ b/examples/us_spm_release.py @@ -0,0 +1,23 @@ +"""Run with the accompanying spm-calculator and wrapper development changes. + +For an unbundled country checkout, explicitly set +POLICYENGINE_US_HOUSEHOLD_ONLY=1 before launching this script. This mode is +household-only and supplies no population certification. +""" + +import json + +import policyengine as pe + +result = pe.us.calculate_household( + people=[{"age": 40}, {"age": 40}, {"age": 8}, {"age": 5}], + spm_unit={"spm_unit_tenure_type": "RENTER"}, + household={"state_code": "TX"}, + year=2025, + spm_release=pe.us.SPMReleaseSelection(year_policy="error"), + extra_variables=[ + "spm_unit_spm_threshold", + "spm_unit_spm_threshold_housing_portion", + ], +) +print(json.dumps(result.to_dict(), indent=2)) diff --git a/src/policyengine/tax_benefit_models/us/__init__.py b/src/policyengine/tax_benefit_models/us/__init__.py index d98ba089..26e885e7 100644 --- a/src/policyengine/tax_benefit_models/us/__init__.py +++ b/src/policyengine/tax_benefit_models/us/__init__.py @@ -52,6 +52,7 @@ managed_microsimulation, us_latest, ) + from .spm_release import SPMReleaseSelection model = us_latest """The pinned US ``TaxBenefitModelVersion`` for this policyengine release.""" @@ -78,6 +79,7 @@ "model", "us_latest", "calculate_household", + "SPMReleaseSelection", "economic_impact_analysis", "calculate_budgetary_impact", "BudgetaryImpact", diff --git a/src/policyengine/tax_benefit_models/us/household.py b/src/policyengine/tax_benefit_models/us/household.py index 5122bb15..321d456e 100644 --- a/src/policyengine/tax_benefit_models/us/household.py +++ b/src/policyengine/tax_benefit_models/us/household.py @@ -37,7 +37,9 @@ from __future__ import annotations +import warnings from collections.abc import Mapping +from contextlib import nullcontext from typing import Any, Optional from policyengine.tax_benefit_models.common import ( @@ -52,6 +54,7 @@ from policyengine.utils.household_validation import validate_household_input from .model import us_latest +from .spm_release import SPMIntegrationProvenance, SPMReleaseSelection _GROUP_ENTITIES = ("marital_unit", "family", "spm_unit", "tax_unit", "household") @@ -68,7 +71,7 @@ def _raise_unexpected_kwargs(unexpected: Mapping[str, Any]) -> None: lines.append(f" - '{name}'{hint}") lines.append( "Valid kwargs: people, marital_unit, family, spm_unit, tax_unit, " - "household, year, reform, extra_variables, axes." + "household, year, reform, extra_variables, axes, spm_release." ) raise TypeError("\n".join(lines)) @@ -142,6 +145,7 @@ def _group(spec: Mapping[str, Any]) -> dict[str, Any]: "reform", "extra_variables", "axes", + "spm_release", } ) @@ -158,6 +162,7 @@ def calculate_household( reform: Optional[Mapping[str, Any]] = None, extra_variables: Optional[list[str]] = None, axes: Optional[list[Any]] = None, + spm_release: Optional[SPMReleaseSelection] = None, **unexpected: Any, ) -> HouseholdResult: """Compute tax and benefit variables for a single US household. @@ -184,6 +189,10 @@ def calculate_household( shape or a flat list of axis dictionaries. Missing ``period`` values default to ``year``. When axes are present, result values are lists ordered by the axis grid instead of scalars. + spm_release: Optional explicit SPMReleaseSelection (or JSON mapping). + Activates simulation-specific release formulas and adds an SPM + provenance receipt. This developmental integration does not certify + population data. Omission preserves the existing model behavior. Returns: :class:`HouseholdResult` with dot-accessible per-entity @@ -236,19 +245,48 @@ def calculate_household( normalized_axes = normalize_axes(axes=axes, year=year, model_version=us_latest) axes_active = normalized_axes is not None - simulation = Simulation( - situation=_build_situation( - people=people, - marital_unit=entities["marital_unit"], - family=entities["family"], - spm_unit=entities["spm_unit"], - tax_unit=entities["tax_unit"], - household=entities["household"], - year=year, - axes=normalized_axes, - ), - reform=reform_dict, - ) + spm_provider = None + if spm_release is not None: + from spm_calculator.policyengine_adapter import ( + build_policyengine_reform, + validate_policyengine_inputs, + ) + + selection = SPMReleaseSelection.model_validate(spm_release) + validate_policyengine_inputs( + {"person": people, **{name: [value] for name, value in entities.items()}} + ) + if normalized_axes is not None: + validate_policyengine_inputs( + { + "axes": [ + {axis["name"]: None} + for group in normalized_axes + for axis in group + ] + } + ) + spm_reform = build_policyengine_reform(selection.create_provider()) + spm_provider = spm_reform.spm_release_provider + reform_dict = (reform_dict or (), spm_reform) + + # Country-model loading changes global warning filters. Restore the + # caller's filters before evaluating the explicit provider, so its CPI + # extrapolation warning is not silently swallowed by those imports. + with warnings.catch_warnings() if spm_provider is not None else nullcontext(): + simulation = Simulation( + situation=_build_situation( + people=people, + marital_unit=entities["marital_unit"], + family=entities["family"], + spm_unit=entities["spm_unit"], + tax_unit=entities["tax_unit"], + household=entities["household"], + year=year, + axes=normalized_axes, + ), + reform=reform_dict, + ) result = HouseholdResult() for entity, columns in output_columns.items(): @@ -282,4 +320,14 @@ def calculate_household( for variable in columns } ) + if spm_provider is not None: + result["provenance"] = { + "spm": SPMIntegrationProvenance.model_validate( + spm_provider.provenance() + ).model_dump(mode="json"), + "wrapper_model_mode": "household_only_development" + if getattr(us_latest, "household_only", False) + else "installed_country_model", + "certified_population_bundle": False, + } return result diff --git a/src/policyengine/tax_benefit_models/us/model.py b/src/policyengine/tax_benefit_models/us/model.py index c517855f..c13d5f18 100644 --- a/src/policyengine/tax_benefit_models/us/model.py +++ b/src/policyengine/tax_benefit_models/us/model.py @@ -1,13 +1,16 @@ import datetime +import os +from importlib import metadata from typing import TYPE_CHECKING, Optional import pandas as pd from microdf import MicroDataFrame -from policyengine.core import TaxBenefitModel +from policyengine.core import TaxBenefitModel, TaxBenefitModelVersion from policyengine.provenance.dataset_materialization import ( materialize_dataset, ) +from policyengine.provenance.manifest import PackageVersion from policyengine.tax_benefit_models.common import MicrosimulationModelVersion from policyengine.tax_benefit_models.common.model_version import ( build_runtime_dataset_provenance, @@ -420,6 +423,11 @@ def managed_microsimulation( ``allow_unmanaged=True``. """ + if getattr(us_latest, "household_only", False): + raise ValueError( + "The household-only development model cannot run population microsimulations" + ) + from policyengine_us import Microsimulation if "dataset" in kwargs: @@ -445,4 +453,49 @@ def managed_microsimulation( return microsim -us_latest = PolicyEngineUSLatest() +class PolicyEngineUSHouseholdOnly(PolicyEngineUSLatest): + """Explicit development model metadata with no population certification. + + Set POLICYENGINE_US_HOUSEHOLD_ONLY=1 before importing policyengine to use + an unbundled country model through the ordinary household calculator. + Certified manifests remain untouched, and population execution is refused. + """ + + household_only: bool = True + + def __init__(self): + installed = metadata.version(self.package_name) + TaxBenefitModelVersion.__init__( + self, + model=us_model, + version=installed, + description="Household-only development integration; no certified population data", + model_package=PackageVersion(name=self.package_name, version=installed), + ) + self.id = f"{self.model.id}@{installed}:household-only" + self.region_registry = self._load_region_registry() + system = self._load_system() + self._populate_variables(system) + self._populate_parameters(system) + + def run(self, simulation): + raise ValueError( + "The household-only development model cannot run population simulations" + ) + + def save(self, simulation): + raise ValueError( + "The household-only development model cannot save population simulations" + ) + + def load(self, simulation): + raise ValueError( + "The household-only development model cannot load population simulations" + ) + + +us_latest = ( + PolicyEngineUSHouseholdOnly() + if os.environ.get("POLICYENGINE_US_HOUSEHOLD_ONLY") == "1" + else PolicyEngineUSLatest() +) diff --git a/src/policyengine/tax_benefit_models/us/spm_release.py b/src/policyengine/tax_benefit_models/us/spm_release.py new file mode 100644 index 00000000..32b4a119 --- /dev/null +++ b/src/policyengine/tax_benefit_models/us/spm_release.py @@ -0,0 +1,65 @@ +"""Typed, serializable selection of an optional SPM release provider.""" + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class SPMReleaseSelection(BaseModel): + """Pin local release bytes or explicitly select the package's bundled release. + + An external file requires its independently retained expected hash. This + object holds no executable callback and does not certify population data. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + path: Optional[str] = None + expected_sha256: Optional[str] = Field(default=None, pattern=r"^[0-9a-f]{64}$") + release_id: Optional[str] = None + year_policy: Literal["error", "pe_cpi_u"] = "pe_cpi_u" + allow_estimated: bool = False + geography_kind: Literal[ + "national", "metro", "congressional_district", "explicit" + ] = "national" + geography_id: Optional[str] = None + geographic_adjustment: Optional[float] = Field( + default=None, gt=0, allow_inf_nan=False + ) + geography_vintage: Optional[str] = None + missing_geography: Literal["error", "national"] = "error" + as_of: Optional[str] = None + + @model_validator(mode="after") + def require_external_digest(self): + if self.path is not None and self.expected_sha256 is None: + raise ValueError("An explicit release path requires expected_sha256") + return self + + def create_provider(self): + from spm_calculator.policyengine_adapter import PolicyEngineSPMProvider + from spm_calculator.release import load_release + + release = load_release( + self.path, expected_sha256=self.expected_sha256, as_of=self.as_of + ) + if self.release_id is not None and self.release_id != release.release_id: + raise ValueError("Loaded SPM release does not match release_id") + options = self.model_dump(exclude={"path", "expected_sha256", "release_id"}) + return PolicyEngineSPMProvider(release, **options) + + +class SPMIntegrationProvenance(BaseModel): + """JSON-safe receipt of what the SPM formulas actually evaluated.""" + + integration_status: Literal["development_household_integration"] + release_id: str + release_sha256: str + information_date: str + year_policy: str + allow_estimated: bool + missing_geography: str + runtime_versions: dict[str, Optional[str]] + years: dict[str, dict[str, Any]] + geographies: list[dict[str, Any]] + composition_method: str + population_data_certified: Literal[False] diff --git a/tests/test_spm_release_household.py b/tests/test_spm_release_household.py new file mode 100644 index 00000000..9ad58fa8 --- /dev/null +++ b/tests/test_spm_release_household.py @@ -0,0 +1,239 @@ +"""Actual public-wrapper acceptance for explicit, immutable SPM releases. + +Run against a development country model with +POLICYENGINE_US_HOUSEHOLD_ONLY=1; no population certification is implied. +""" + +import json +import os + +import pytest + +if os.environ.get("POLICYENGINE_US_HOUSEHOLD_ONLY") != "1": + pytest.skip( + "Development SPM release acceptance runs in the dedicated integration job", + allow_module_level=True, + ) + +from spm_calculator.release import load_release, seal_release + +import policyengine as pe +from policyengine.tax_benefit_models.us.spm_release import SPMReleaseSelection + +EXTRAS = [ + "spm_unit_reference_spm_threshold", + "spm_unit_unadjusted_spm_threshold", + "spm_unit_spm_threshold", + "spm_unit_spm_threshold_housing_portion", + "spm_unit_capped_housing_subsidy", + "spm_unit_count_adults", + "spm_unit_count_children", + "spm_unit_geographic_adjustment", +] + + +def household(**kwargs): + options = { + "people": [{"age": 40}, {"age": 40}, {"age": 8}, {"age": 5}], + "spm_unit": {"spm_unit_tenure_type": "RENTER"}, + "household": {"state_code": "TX"}, + "year": 2025, + "extra_variables": EXTRAS, + "spm_release": SPMReleaseSelection(), + } + options.update(kwargs) + return pe.us.calculate_household(**options) + + +def synthetic_selection(tmp_path, *, year=2025, amount=40000, share=0.4): + doc = load_release().to_dict() + doc["release_id"] = f"synthetic-{year}-{amount}-{share}" + entry = doc["years"]["2025"].copy() + entry.update(methodology_id="synthetic-integration-test") + entry["thresholds"] = dict.fromkeys(entry["thresholds"], amount) + entry["housing_shares"] = dict.fromkeys(entry["housing_shares"], share) + entry["housing_share_provenance"] = dict( + entry["housing_share_provenance"], + status="assumed", + note="Synthetic fixture, not an official share", + ) + doc["years"] = {str(year): entry} + doc = seal_release(doc) + path = tmp_path / f"{doc['release_id']}.json" + path.write_text(json.dumps(doc)) + return SPMReleaseSelection(path=str(path), expected_sha256=doc["content_sha256"]) + + +def test_published_threshold_runs_through_public_wrapper(): + result = household() + # Full precision, independently sourced BLS 2025 workbook reference. + assert result.spm_unit.spm_unit_spm_threshold == pytest.approx( + 41700.555713, abs=0.01 + ) + assert result.provenance["spm"]["years"]["2025"]["status"] == "published" + assert result.provenance["spm"]["population_data_certified"] is False + assert json.loads(json.dumps(result.to_dict()))["provenance"] == result.provenance + + +def test_extrapolated_threshold_has_actual_cpi_and_carried_share_receipt(): + with pytest.warns(UserWarning, match="consumer extrapolation"): + result = household( + year=2026, + spm_unit={ + "spm_unit_tenure_type": "RENTER", + "housing_assistance": 100000, + "hud_ttp": 0, + }, + ) + receipt = result.provenance["spm"]["years"]["2026"] + assert receipt["status"] == "consumer_extrapolation" + assert receipt["method"] == "pe_cpi_u" + assert receipt["cpi"]["target"]["year"] == 2026 + assert result.spm_unit.spm_unit_reference_spm_threshold == pytest.approx( + 41700.555713 * receipt["cpi"]["ratio"], abs=0.01 + ) + assert receipt["housing_share_provenance"]["carried_from_threshold_year"] == 2025 + assert receipt["housing_share_provenance"]["reference_year"] == 2024 + assert result.spm_unit.spm_unit_capped_housing_subsidy == pytest.approx( + result.spm_unit.spm_unit_reference_spm_threshold + * receipt["housing_shares"]["renter"], + abs=0.01, + ) + + +def test_interleaved_release_and_composition_changes_do_not_leak(tmp_path): + first = synthetic_selection(tmp_path, amount=40000) + second = synthetic_selection(tmp_path, amount=50000) + assert household(spm_release=first).spm_unit.spm_unit_spm_threshold == 40000 + assert household(spm_release=second).spm_unit.spm_unit_spm_threshold == 50000 + assert household(spm_release=first).spm_unit.spm_unit_spm_threshold == 40000 + changed = household( + spm_release=first, people=[{"age": 40}, {"age": 40}, {"age": 18}, {"age": 5}] + ) + assert changed.spm_unit.spm_unit_count_adults == 3 + assert changed.spm_unit.spm_unit_count_children == 1 + assert changed.spm_unit.spm_unit_spm_threshold > 40000 + + +def test_year_specific_share_changes_housing_cap_and_resources(tmp_path): + low = synthetic_selection(tmp_path, year=2025, share=0.3) + high = synthetic_selection(tmp_path, year=2026, share=0.6) + spm = {"spm_unit_tenure_type": "RENTER", "housing_assistance": 100000, "hud_ttp": 0} + before = household(spm_release=low, spm_unit=spm) + after = household(year=2026, spm_release=high, spm_unit=spm) + assert before.spm_unit.spm_unit_spm_threshold_housing_portion == pytest.approx( + 12000, abs=0.01 + ) + assert after.spm_unit.spm_unit_spm_threshold_housing_portion == pytest.approx( + 24000, abs=0.01 + ) + assert before.spm_unit.spm_unit_capped_housing_subsidy == pytest.approx( + 12000, abs=0.01 + ) + assert after.spm_unit.spm_unit_capped_housing_subsidy == pytest.approx( + 24000, abs=0.01 + ) + # Hold the model year fixed to isolate the share's effect on resources. + same_year_low_share = household( + year=2026, + spm_unit=spm, + spm_release=synthetic_selection(tmp_path, year=2026, share=0.3), + ) + assert ( + after.spm_unit.spm_unit_net_income + - same_year_low_share.spm_unit.spm_unit_net_income + == pytest.approx(12000, abs=0.01) + ) + + +def test_baked_threshold_is_rejected_before_calculation(): + with pytest.raises(ValueError, match="computed outputs"): + household(spm_unit={"spm_unit_spm_threshold": 123}) + + +def test_poverty_equality_uses_derived_resources(tmp_path): + people = [ + {"age": 40, "employment_income": 80000}, + {"age": 40}, + {"age": 8}, + {"age": 5}, + ] + reference = household(people=people) + resources = reference.spm_unit.spm_unit_net_income + equal = household( + people=people, spm_release=synthetic_selection(tmp_path, amount=resources) + ) + assert equal.spm_unit.spm_unit_net_income == equal.spm_unit.spm_unit_spm_threshold + assert equal.spm_unit.spm_unit_is_in_spm_poverty == 0 + below = household( + people=people, spm_release=synthetic_selection(tmp_path, amount=resources + 1) + ) + assert below.spm_unit.spm_unit_is_in_spm_poverty == 1 + + +def test_pinned_geography_matches_standalone_and_fallback_is_visible(): + from spm_calculator.release import SPMUnit + + release = load_release() + expected = release.calculate_unit( + SPMUnit( + unit_id="u", + num_adults=2, + num_children=2, + tenure="owner_with_mortgage", + year=2025, + geography_kind="congressional_district", + geography_id="101", + ) + ) + result = household( + spm_unit={"spm_unit_tenure_type": "OWNER_WITH_MORTGAGE"}, + household={"state_code": "AL", "congressional_district_geoid": 101}, + spm_release=SPMReleaseSelection(geography_kind="congressional_district"), + ) + assert result.spm_unit.spm_unit_spm_threshold == pytest.approx( + expected["threshold"], abs=0.01 + ) + receipt = result.provenance["spm"]["geographies"][0] + assert ( + receipt["geography_release_id"] + == expected["provenance"]["geography"]["geography_release_id"] + ) + with pytest.raises(ValueError, match="unavailable"): + household( + spm_release=SPMReleaseSelection(geography_kind="congressional_district") + ) + fallback = household( + spm_release=SPMReleaseSelection( + geography_kind="congressional_district", missing_geography="national" + ) + ) + assert ( + fallback.provenance["spm"]["geographies"][0]["status"] + == "explicit_national_fallback" + ) + + +def test_existing_default_result_has_no_spm_provider_provenance(): + result = pe.us.calculate_household(people=[{"age": 40}], year=2025) + assert "provenance" not in result + + +def test_development_model_has_no_data_certification_and_rejects_population(): + if not getattr(pe.us.model, "household_only", False): + pytest.skip( + "Only applicable to explicit household-only development registration" + ) + assert pe.us.model.release_manifest is None + assert pe.us.model.data_certification is None + with pytest.raises(ValueError, match="household-only"): + pe.us.model.run(None) + with pytest.raises(ValueError, match="household-only"): + pe.us.managed_microsimulation() + with pytest.raises(ValueError, match="manifest"): + _ = pe.us.model.trace_tro + + +def test_minor_only_unit_requires_classification_instead_of_zero_threshold(): + with pytest.raises(ValueError, match="at least one classified SPM adult"): + household(people=[{"age": 12}])