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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/spm_release_integration.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions changelog.d/spm-release-household.added.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions examples/us_spm_release.py
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 2 additions & 0 deletions src/policyengine/tax_benefit_models/us/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
managed_microsimulation,
us_latest,
)
from .spm_release import SPMReleaseSelection

model = us_latest
"""The pinned US ``TaxBenefitModelVersion`` for this policyengine release."""
Expand All @@ -78,6 +79,7 @@
"model",
"us_latest",
"calculate_household",
"SPMReleaseSelection",
"economic_impact_analysis",
"calculate_budgetary_impact",
"BudgetaryImpact",
Expand Down
76 changes: 62 additions & 14 deletions src/policyengine/tax_benefit_models/us/household.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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")

Expand All @@ -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))

Expand Down Expand Up @@ -142,6 +145,7 @@ def _group(spec: Mapping[str, Any]) -> dict[str, Any]:
"reform",
"extra_variables",
"axes",
"spm_release",
}
)

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
57 changes: 55 additions & 2 deletions src/policyengine/tax_benefit_models/us/model.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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()
)
65 changes: 65 additions & 0 deletions src/policyengine/tax_benefit_models/us/spm_release.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading