diff --git a/.github/release-lock.md b/.github/release-lock.md index 6a862f89c0e..4dda3650af8 100644 --- a/.github/release-lock.md +++ b/.github/release-lock.md @@ -41,13 +41,34 @@ the same prerequisite, and `Publish` checks its own committed checkout again before installation or build. Model environments use locked synchronization; subsequent commands use that environment without synchronizing again. +The PR `CandidateWheel` matrix starts from the reviewed head in two separate +clean checkouts. Both run the actual release version-bump helper; the `rc1` +entry appends `rc1` to that computed next version before both perform the +root-only lock refresh. Each uses the same Ubuntu version, Python, uv, locked +development environment, and `make` build as `Publish`. Each uploads its +unpublished wheel and `release-build/receipt.json`; the job has read-only +repository access and no publication step. The receipt rejects changed or +untracked policy source, checks the wheel's version and backend, and records source, lock, tool, and +wheel identities. Download the `release-wheel-rc1-` and +`release-wheel-final-` artifacts to qualify their actual bytes. +A change to the head or changelog fragments requires a new candidate; the +candidate does not reserve a registry version. +GitHub updates its hosted Ubuntu images even within `ubuntu-24.04`, so receipts +also record `ImageOS` and `ImageVersion` for release-environment comparison. + +`pyproject.toml` pins Hatchling and its complete non-optional dependency closure +because `python -m build` creates an isolated backend environment outside the +runtime lock. Both builds use those same pins and the frontend version in +`uv.lock`. `Publish` verifies its own receipt before publishing. Registry wheel +comparison and downstream qualification remain separate release checks. + `ReleaseLockTests` has no dependency on a model job or the country lock check. It uses only the Python standard library and uv, so it can test the guard while the country dependency registry is incomplete: ```sh -python -m unittest discover -s .github/tests -p test_release_lock.py -v -RELEASE_LOCK_REAL_UV=1 python -m unittest discover -s .github/tests -p test_release_lock.py -v +python -m unittest discover -s .github/tests -p 'test_release_*.py' -v +RELEASE_LOCK_REAL_UV=1 python -m unittest discover -s .github/tests -p 'test_release_*.py' -v ``` The opt-in probe creates an isolated, minimal project with a standard PyPI diff --git a/.github/release_build.py b/.github/release_build.py new file mode 100644 index 00000000000..6508fc32d1e --- /dev/null +++ b/.github/release_build.py @@ -0,0 +1,161 @@ +"""Verify clean policy source and record a locally built release wheel. + +The PR matrix can prepare either candidate using the actual release bump helper. +Both candidates and Publish record a receipt after the same make command. +This script never resolves dependencies or publishes. +""" + +from __future__ import annotations + +import hashlib +from importlib.metadata import version +import json +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +import tomllib +from zipfile import ZipFile + + +ROOT = Path(__file__).resolve().parents[1] + + +def prepare_candidate(root: Path, kind: str) -> None: + """Use the actual release helper, optionally preparing its rc1 counterpart.""" + if kind not in {"rc1", "final"}: + raise ValueError("Candidate kind must be rc1 or final") + subprocess.run( + [sys.executable, str(root / ".github/bump_version.py")], cwd=root, check=True + ) + if kind == "rc1": + path = root / "pyproject.toml" + project = path.read_text() + final_version = tomllib.loads(project)["project"]["version"] + prepared, count = re.subn( + rf'^(version\s*=\s*"){re.escape(final_version)}("\s*)$', + rf"\g<1>{final_version}rc1\g<2>", + project, + flags=re.MULTILINE, + ) + if count != 1: + raise ValueError("Expected exactly one release version assignment") + path.write_text(prepared) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def checked_build_requirements(project: dict) -> dict[str, str]: + """Require exact pins for every declared isolated-build requirement.""" + if project["build-system"]["build-backend"] != "hatchling.build": + raise ValueError("Release backend must match the reviewed Hatchling build") + requirements = {} + for requirement in project["build-system"]["requires"]: + match = re.fullmatch(r"([A-Za-z0-9_.-]+)==([0-9][A-Za-z0-9_.]*)", requirement) + if match is None or match[1] in requirements: + raise ValueError("Release build requirements must be unique exact pins") + requirements[match[1]] = match[2] + if "hatchling" not in requirements: + raise ValueError("Release build requires an exact Hatchling pin") + return requirements + + +def wheel_identity( + path: Path, *, expected_version: str, hatchling_version: str +) -> dict: + """Bind wheel bytes to the requested release and its declared backend.""" + with ZipFile(path) as wheel: + bad_member = wheel.testzip() + if bad_member is not None: + raise ValueError(f"Invalid wheel member: {bad_member}") + infos = [name for name in wheel.namelist() if name.endswith(".dist-info/WHEEL")] + if len(infos) != 1: + raise ValueError("Expected exactly one wheel metadata directory") + prefix = infos[0].removesuffix("WHEEL") + metadata = wheel.read(prefix + "METADATA").decode() + wheel_metadata = wheel.read(infos[0]).decode() + if re.findall(r"^Version: (.+)$", metadata, re.MULTILINE) != [expected_version]: + raise ValueError("Wheel version differs from the prepared release") + if re.findall(r"^Generator: (.+)$", wheel_metadata, re.MULTILINE) != [ + f"hatchling {hatchling_version}" + ]: + raise ValueError("Wheel generator differs from the pinned backend") + return { + "filename": path.name, + "sha256": _sha256(path), + "size_bytes": path.stat().st_size, + } + + +def write_build_receipt(root: Path = ROOT) -> Path: + """Fail if tests changed package inputs, then retain only public build facts.""" + subprocess.run( + ["git", "diff", "--exit-code", "HEAD", "--", "policyengine_us"], + cwd=root, + check=True, + ) + untracked = subprocess.check_output( + ["git", "ls-files", "--others", "--exclude-standard", "--", "policyengine_us"], + cwd=root, + ) + if untracked.strip(): + raise ValueError("Untracked policy source would enter the release wheel") + project = tomllib.loads((root / "pyproject.toml").read_text()) + lock = tomllib.loads((root / "uv.lock").read_text()) + requirements = checked_build_requirements(project) + frontend = version("build") + locked_frontends = [ + item["version"] for item in lock["package"] if item["name"] == "build" + ] + if locked_frontends != [frontend]: + raise ValueError("Installed build frontend differs from the registry lock") + wheels = sorted((root / "dist").glob("*.whl")) + if len(wheels) != 1: + raise ValueError("Expected exactly one freshly built release wheel") + receipt = { + "source_head": subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=root, text=True + ).strip(), + "policy_tree": subprocess.check_output( + ["git", "rev-parse", "HEAD:policyengine_us"], cwd=root, text=True + ).strip(), + "policy_source_matches_head": True, + "prepared_version": project["project"]["version"], + "pyproject_sha256": _sha256(root / "pyproject.toml"), + "uv_lock_sha256": _sha256(root / "uv.lock"), + "python": platform.python_version(), + "platform": platform.system(), + "machine": platform.machine(), + "runner_image": { + "os": os.environ.get("ImageOS"), + "version": os.environ.get("ImageVersion"), + }, + "uv": subprocess.check_output(["uv", "--version"], text=True).strip(), + "frontend": {"name": "build", "version": frontend}, + "isolated_build_requirements": requirements, + "wheel": wheel_identity( + wheels[0], + expected_version=project["project"]["version"], + hatchling_version=requirements["hatchling"], + ), + } + output = root / "release-build" / "receipt.json" + output.parent.mkdir(exist_ok=True) + output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n") + return output + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prepare", choices=("rc1", "final")) + args = parser.parse_args() + if args.prepare: + prepare_candidate(ROOT, args.prepare) + else: + print(write_build_receipt().read_text(), end="") diff --git a/.github/tests/test_release_build.py b/.github/tests/test_release_build.py new file mode 100644 index 00000000000..2eb27d5416b --- /dev/null +++ b/.github/tests/test_release_build.py @@ -0,0 +1,209 @@ +"""Release artifact contracts without importing the country model.""" + +import importlib.util +import json +from pathlib import Path +import re +import subprocess +import tempfile +import tomllib +import unittest +from unittest.mock import patch +from zipfile import ZipFile + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "release_build", ROOT / ".github/release_build.py" +) +release_build = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release_build) + + +def job(text, name): + return re.search( + rf"^ {name}:\n(.*?)(?=^ [A-Za-z]|\Z)", text, re.MULTILINE | re.DOTALL + )[1] + + +class WorkflowTests(unittest.TestCase): + def test_candidate_build_matches_publish_without_publication_or_policy_mutations( + self, + ): + pr = (ROOT / ".github/workflows/pr.yaml").read_text() + candidate = job(pr, "CandidateWheel") + publish = job((ROOT / ".github/workflows/push.yaml").read_text(), "Publish") + commands = ( + "python .github/release_lock.py --committed", + 'python .github/release_build.py --prepare "${{ matrix.kind }}"', + "python .github/release_lock.py --refresh", + "uv sync --locked --extra dev", + "uv run --no-sync make", + "uv run --no-sync python .github/release_build.py", + "actions/upload-artifact@", + ) + self.assertEqual( + [candidate.index(value) for value in commands], + sorted(candidate.index(value) for value in commands), + ) + self.assertIn("github.event.pull_request.head.sha", candidate) + self.assertIn("kind: [rc1, final]", candidate) + self.assertIn("release-wheel-${{ matrix.kind }}-", candidate) + for expected in ( + "runs-on: ubuntu-24.04", + 'python-version: "3.14.7"', + 'version: "0.12.13"', + "uv sync --locked --extra dev", + "uv run --no-sync make", + "python .github/release_build.py", + ): + self.assertIn(expected, candidate) + self.assertIn(expected, publish) + for prohibited in ( + "update_itemization", + "pypi-publish", + "secrets.", + "git push", + "contents: write", + ): + self.assertNotIn(prohibited, candidate) + self.assertIn("dist/*.whl", candidate) + self.assertIn("release-build/receipt.json", candidate) + self.assertNotIn("run: uv build", job(pr, "Quick-Feedback")) + self.assertLess( + publish.index("python .github/release_build.py"), + publish.index("pypa/gh-action-pypi-publish"), + ) + + def test_isolated_backend_closure_is_exactly_pinned(self): + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + pins = release_build.checked_build_requirements(project) + self.assertEqual( + set(pins), + { + "hatchling", + "packaging", + "pathspec", + "pluggy", + "tomlkit", + "trove-classifiers", + }, + ) + project["build-system"]["requires"][0] = "hatchling>=1.32" + with self.assertRaises(ValueError): + release_build.checked_build_requirements(project) + + +class PreparationTests(unittest.TestCase): + def test_each_candidate_uses_actual_fragment_version_helper(self): + for category, next_version in ( + ("fixed", "4.2.4"), + ("added", "4.3.0"), + ("breaking", "5.0.0"), + ): + for kind in ("rc1", "final"): + with self.subTest(category=category, kind=kind): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / ".github").mkdir() + (root / ".github/bump_version.py").write_bytes( + (ROOT / ".github/bump_version.py").read_bytes() + ) + (root / "changelog.d").mkdir() + (root / f"changelog.d/test.{category}.md").write_text( + "Change\n" + ) + project = root / "pyproject.toml" + project.write_text('[project]\nversion = "4.2.3"\n') + release_build.prepare_candidate(root, kind) + expected = next_version + ("rc1" if kind == "rc1" else "") + self.assertEqual( + tomllib.loads(project.read_text())["project"]["version"], + expected, + ) + + +class ReceiptTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="release-artifact-test-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.policy = self.root / "policyengine_us" / "parameter.yaml" + self.policy.parent.mkdir() + self.policy.write_text("branching: true\n") + (self.root / "pyproject.toml").write_text( + '[project]\nversion = "1.0.0"\n[build-system]\nrequires = ["hatchling==1.32.0"]\nbuild-backend = "hatchling.build"\n' + ) + (self.root / "uv.lock").write_text( + '[[package]]\nname = "build"\nversion = "1.4.0"\n' + ) + for args in ( + ("init", "-q"), + ("add", "."), + ( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "fixture", + ), + ): + subprocess.run( + ["git", *args], cwd=self.root, check=True, capture_output=True + ) + (self.root / "dist").mkdir() + self.wheel = self.root / "dist" / "policyengine_us-1.0.0-py3-none-any.whl" + self.write_wheel() + + def write_wheel(self, version="1.0.0", generator="hatchling 1.32.0"): + with ZipFile(self.wheel, "w") as wheel: + wheel.writestr("policyengine_us/parameter.yaml", self.policy.read_bytes()) + wheel.writestr( + "policyengine_us-1.0.0.dist-info/METADATA", + f"Name: policyengine-us\nVersion: {version}\n", + ) + wheel.writestr( + "policyengine_us-1.0.0.dist-info/WHEEL", + f"Wheel-Version: 1.0\nGenerator: {generator}\n", + ) + + def test_receipt_binds_actual_wheel_and_permits_only_root_version_preparation(self): + project_path = self.root / "pyproject.toml" + project_path.write_text(project_path.read_text().replace('"1.0.0"', '"1.0.1"')) + self.write_wheel(version="1.0.1") + with patch.object(release_build, "version", return_value="1.4.0"): + output = release_build.write_build_receipt(self.root) + receipt = json.loads(output.read_text()) + self.assertEqual(receipt["prepared_version"], "1.0.1") + self.assertEqual(receipt["wheel"]["sha256"], release_build._sha256(self.wheel)) + self.assertTrue(receipt["policy_source_matches_head"]) + self.assertEqual(receipt["frontend"]["version"], "1.4.0") + + def test_changed_policy_parameter_is_rejected_before_receipt(self): + self.policy.write_text("branching: false\n") + with self.assertRaises(subprocess.CalledProcessError): + release_build.write_build_receipt(self.root) + self.assertFalse((self.root / "release-build/receipt.json").exists()) + + def test_untracked_policy_source_is_rejected(self): + (self.policy.parent / "extra.py").write_text("pass\n") + with self.assertRaisesRegex(ValueError, "Untracked policy source"): + release_build.write_build_receipt(self.root) + + def test_wrong_generator_or_version_is_rejected(self): + for version, generator in ( + ("1.0.1", "hatchling 1.32.0"), + ("1.0.0", "hatchling 1.31.0"), + ): + self.write_wheel(version=version, generator=generator) + with self.assertRaises(ValueError): + release_build.wheel_identity( + self.wheel, expected_version="1.0.0", hatchling_version="1.32.0" + ) + + def test_frontend_drift_is_rejected(self): + with patch.object(release_build, "version", return_value="1.6.1"): + with self.assertRaisesRegex(ValueError, "frontend differs"): + release_build.write_build_receipt(self.root) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 08446d9bcba..3e3a91d52d2 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -38,7 +38,51 @@ jobs: - name: Test guard without importing the country model env: RELEASE_LOCK_REAL_UV: "1" - run: python -m unittest discover -s .github/tests -p test_release_lock.py -v + run: python -m unittest discover -s .github/tests -p 'test_release_*.py' -v + CandidateWheel: + name: Unpublished release wheel (${{ matrix.kind }}) + needs: [ReleaseLock, ReleaseLockTests] + permissions: + contents: read + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + kind: [rc1, final] + steps: + - name: Checkout reviewed PR head + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14.7" + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.13" + - name: Check committed registry lock + run: python .github/release_lock.py --committed + - name: Predict release version with the release helper + run: python .github/release_build.py --prepare "${{ matrix.kind }}" + - name: Refresh only the release version in the registry lock + run: python .github/release_lock.py --refresh + - name: Install package + run: uv sync --locked --extra dev + - name: Build package from unchanged policy source + run: uv run --no-sync make + - name: Record and verify release build + run: uv run --no-sync python .github/release_build.py + - name: Retain unpublished wheel and build receipt + uses: actions/upload-artifact@v4 + with: + name: release-wheel-${{ matrix.kind }}-${{ github.event.pull_request.head.sha }} + path: | + dist/*.whl + release-build/receipt.json + if-no-files-found: error + retention-days: 14 Lint: needs: ReleaseLock runs-on: ubuntu-latest @@ -245,8 +289,6 @@ jobs: name: codecov-umbrella token: ${{ secrets.CODECOV_TOKEN }} verbose: true - - name: Build package - run: uv build Baseline: needs: ReleaseLock name: Full Suite - Baseline (${{ matrix.group }}) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 3353b150b53..62636db7d47 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -38,7 +38,7 @@ jobs: - name: Test guard without importing the country model env: RELEASE_LOCK_REAL_UV: "1" - run: python -m unittest discover -s .github/tests -p test_release_lock.py -v + run: python -m unittest discover -s .github/tests -p 'test_release_*.py' -v Lint: needs: ReleaseLock runs-on: ubuntu-latest @@ -328,7 +328,7 @@ jobs: - name: Run microsimulation tests run: uv run --no-sync make test-microsimulation Publish: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: | (github.repository == 'PolicyEngine/policyengine-us') && (github.event.head_commit.message == 'Update PolicyEngine US') @@ -339,7 +339,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.14 + python-version: "3.14.7" - name: Install uv uses: astral-sh/setup-uv@v8.1.0 with: @@ -350,6 +350,8 @@ jobs: run: uv sync --locked --extra dev - name: Build package run: uv run --no-sync make + - name: Record and verify release build + run: uv run --no-sync python .github/release_build.py - name: Publish a Python distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/changelog.d/care-housing-income.fixed.md b/changelog.d/care-housing-income.fixed.md new file mode 100644 index 00000000000..3ad06524d59 --- /dev/null +++ b/changelog.d/care-housing-income.fixed.md @@ -0,0 +1 @@ +Keep SPM housing subsidy valuation within SPM resources and use actual housing assistance for household benefits and CBO transfers. Exclude housing subsidies from California CARE/FERA income from the August 14, 2014 parameter date (2015 for annual calculations). Preserve earlier modeled housing inclusion using actual assistance; its historical policy basis was not revalidated. diff --git a/changelog.d/release-wheel-candidate.fixed.md b/changelog.d/release-wheel-candidate.fixed.md new file mode 100644 index 00000000000..e6436a61eb2 --- /dev/null +++ b/changelog.d/release-wheel-candidate.fixed.md @@ -0,0 +1,4 @@ +Retain unpublished wheels for the predicted release and its rc1 version in +pull-request CI, built from unchanged policy source with the same pinned build +tools as publication. Record their source, package version, generator, lock, and file hash +for verification before release. diff --git a/changelog.d/spm-batch-evidence.fixed.md b/changelog.d/spm-batch-evidence.fixed.md new file mode 100644 index 00000000000..422572d6800 --- /dev/null +++ b/changelog.d/spm-batch-evidence.fixed.md @@ -0,0 +1 @@ +Make batch test results follow subprocess exit codes, include both YAML file extensions in batching, and correct population-count and legacy-data documentation. diff --git a/changelog.d/spm-housing-allocation.fixed.md b/changelog.d/spm-housing-allocation.fixed.md new file mode 100644 index 00000000000..c8543a7dbe2 --- /dev/null +++ b/changelog.d/spm-housing-allocation.fixed.md @@ -0,0 +1 @@ +Prorate household housing assistance and the tenant contributions of awarded families across SPM units by member share before applying each unit's SPM resource cap, while preserving program awards and general benefit income. Document the tenant-payment allocation as a modeling assumption. diff --git a/changelog.d/spm-simulation-isolation.fixed.md b/changelog.d/spm-simulation-isolation.fixed.md new file mode 100644 index 00000000000..48af40e8d76 --- /dev/null +++ b/changelog.d/spm-simulation-isolation.fixed.md @@ -0,0 +1 @@ +Isolate simulation reform state, entities, clone shortcuts and parameter traces; synchronize deliberately shared policy branches, reject saved poverty outputs in datasets and verify the default Microcosm dataset's content hash. diff --git a/changelog.d/spm-supplied-policy-caches.fixed.md b/changelog.d/spm-supplied-policy-caches.fixed.md new file mode 100644 index 00000000000..8b95f51c96d --- /dev/null +++ b/changelog.d/spm-supplied-policy-caches.fixed.md @@ -0,0 +1 @@ +Preserve warm parameter caches for prepared policy roots, clones, and variable-only reform wrappers at the same structural start, while defensively cloning before structural reevaluation or parameter changes and binding formula parameter traces to the active simulation. diff --git a/docs/spm.md b/docs/spm.md index 231dc38b197..cf198fa5a48 100644 --- a/docs/spm.md +++ b/docs/spm.md @@ -5,9 +5,47 @@ measurement amounts. It does not extrapolate thresholds with country CPI or inco parameters. Each supported year and scenario comes from the verified artifact. The installed 1.0.0 artifact covers 2022 through 2035; unavailable years fail. Taxes, benefits, resources and the housing-assistance cap -remain country-model formulas. The cap uses the canonical housing amount before +remain country-model formulas. General household benefits and CBO transfer +aggregates count actual `housing_assistance`; only SPM resources count +`spm_unit_capped_housing_subsidy`. The cap uses the canonical housing amount before the final model storage conversion. +## Housing allocation and its assumptions + +The country sums modeled `housing_assistance` within each household, then +allocates the total to SPM units in proportion to their member counts. Each +unit's cap applies after this allocation. This follows the subsidy proration in +[Census's technical documentation, page 14](https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf#page=14). +The allocated amount is an SPM resource valuation; general benefits still count +the original program awards. Multiple actual awards supplied in a household +are summed, not discarded. + +The associated Microcosm producer uses independently reported household +public-housing or reduced-rent status, with these declared assumptions: + +- **A1, assisted family:** the householder's SPM unit represents the assisted + program family when the survey cannot identify its membership. This does not + imply that housing law permits only one assisted family per household. +- **A2, timing:** interview-time housing receipt represents full-year receipt + for the income year; it is not an observed twelve-month payment history. +- **A3, program coverage:** public housing and reported reduced rent are modeled + through the existing HUD-family calculation, although reduced rent may include + programs outside HUD. +- **A4, tenant contribution:** for SPM valuation, sum `hud_ttp` only for units + with positive modeled housing assistance, then allocate that sum using the + same member shares. This preserves the contribution associated with actual + awards and excludes nonrecipients' hypothetical tenant payments. Census + describes a household contribution but does not specify this multi-unit + allocation; A4 is a consistency assumption, not verified Census parity. + +The model retains its existing HUD program-family approximation, including +family income and utility treatment. These allocation variables do not alter +HUD eligibility, payments or utility allowances. Zero-award households need no +SPM geography for their zero housing resource. In assisted households, every +SPM unit receiving an allocated share needs a valid county and adult composition. + +## Measurement configuration + `Simulation`, `Microsimulation` and `CountryTaxBenefitSystem` accept a serializable `spm` mapping. Its fields are `forecast_content_sha256`, `scenario`, `geography_kind`, `geography_id`, `county_vintage` and `as_of`. The hash, when given, @@ -21,12 +59,26 @@ counties raise `SPM_GEOGRAPHY_UNAVAILABLE`. There is no first-county, congressio district or national fallback in SPM measurement. These geography errors occur only when calculating an SPM measurement or a -dependent resource, such as the housing-assistance cap for units with housing -assistance; units with none are capped at zero without consulting the +dependent SPM resource, such as the housing-assistance cap for units allocated +housing assistance; units with no allocation are capped at zero without consulting the measurement. A state-only tax request can still run. SPM reads the input-only `county_fips` variable and ignores any county inferred or cached by other tax or benefit formulas. Geography and composition errors are `SPMInputError` instances with `code` and `to_dict()`. +California CARE income excludes housing subsidies under +[CPUC Decision 14-08-030, Section 6.2](https://liob.cpuc.ca.gov/wp-content/uploads/sites/14/2020/12/ACF22B3.pdf#page=75) +and [Ordering Paragraph 40(3)](https://liob.cpuc.ca.gov/wp-content/uploads/sites/14/2020/12/ACF22B3.pdf#page=124). +[PG&E's June 2026 reply brief](https://docs.cpuc.ca.gov/PublishedDocs/Efile/G000/M608/K305/608305628.PDF#page=10) +confirms that its CARE/FERA income determination excludes housing subsidies, +despite contrary wording in its application. The shared CARE/FERA parameter +records the decision's effective date, August 14, 2014. Annual calculations +select the list at January 1, so calendar 2014 retains the earlier modeled +inclusion and calendar 2015 first applies the exclusion. Before that transition, +the model retains housing-subsidy inclusion using actual assistance; this update +does not validate the earlier income rule's historical policy basis. CARE/FERA +income never consults the SPM housing cap. The SPM resource calculation retains +its housing cap. + A caller can consciously select national measurement: ```python @@ -58,7 +110,11 @@ household head/spouse primitives support household scenarios; the model never guesses those roles from age ordering. A person counts as an SPM adult at age 18, or at age 15 or above with `is_spm_independent_minor_role`. That role defaults to the input-only `is_household_head | is_household_spouse` and can be supplied from -source data. A unit with no classified adult raises +source data. `policyengine_us.spm.DATASET_SOURCE_INPUTS` explicitly declares +this role as source-owned despite its household fallback formula. Population +producers must supply the observed boolean; the declaration does not authorize +filling missing source roles with the model's default value. +A unit with no classified adult raises `SPM_COMPOSITION_REQUIRED`. Generic age-based adult/child counts and benefit eligibility are unchanged. The dataset loader rejects stored formula-owned SPM outputs; observed source results should use report-only column names. It does not diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index 95db177874a..03984aa1102 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -110,12 +110,15 @@ formula-owned SPM outputs such as `spm_unit_spm_threshold`. Any observed Census measurement is retained under a separate report-only name. The legacy files under `hf://policyengine/policyengine-us-data/` predate that -contract, so resource and poverty outputs are not available over them: +contract. Their available calculations depend on the inputs each file supplies: - `cps_2023.h5` stores `spm_unit_spm_threshold`, so the loader rejects it. - `enhanced_cps_2024.h5` loads and computes tax variables, but carries no SPM - independence roles, so SPM units consisting of one 15-to-17-year-old classify - no measurement adult and every resource output over the file fails closed. + independence roles. SPM measurements fail for units without a classified + measurement adult, including units consisting of one 15-to-17-year-old. + Other tax, benefit and resource requests can compute if their formulas do not + require those measurements; loading the file does not establish that all SPM + outputs are supported. A household simulation that has no county input can select an SPM area explicitly instead: @@ -205,13 +208,13 @@ When running microsimulations, verify that weights produce sensible population t ```python sim = Microsimulation() -# Check population -person_weight = sim.calc("person_weight", map_to="person") -print(f"Total population: {person_weight.sum():,.0f}") +# Count people using MicroSeries' automatic weights +person_ids = sim.calc("person_id", map_to="person") +print(f"Total population: {person_ids.count():,.0f}") # Check household count -household_weight = sim.calc("household_weight") -print(f"Total households: {household_weight.sum():,.0f}") +household_ids = sim.calc("household_id", map_to="household") +print(f"Total households: {household_ids.count():,.0f}") # Verify key aggregates against published statistics total_earnings = sim.calc("employment_income").sum() diff --git a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml index c988f66ede8..f8081bc5a9d 100644 --- a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml +++ b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml @@ -4,7 +4,7 @@ values: - medicaid - chip - snap - - spm_unit_capped_housing_subsidy + - housing_assistance - ssi - tanf - free_school_meals @@ -16,3 +16,6 @@ metadata: unit: list period: year label: CBO means-tested transfers + reference: + - title: Congressional Budget Office, Reconciling the Official Poverty Measure and CBO’s Distributional Analysis of Household Income, Including In-Kind Transfers + href: https://www.cbo.gov/publication/61157 diff --git a/policyengine_us/parameters/gov/household/household_benefits.yaml b/policyengine_us/parameters/gov/household/household_benefits.yaml index be0a6e69cf3..4f7023ff818 100644 --- a/policyengine_us/parameters/gov/household/household_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_benefits.yaml @@ -27,7 +27,7 @@ values: # Contributed. - basic_income - trump_dividend - - spm_unit_capped_housing_subsidy + - housing_assistance - household_state_benefits - household_health_benefits 2024-01-01: @@ -53,7 +53,7 @@ values: # Contributed. - basic_income - trump_dividend - - spm_unit_capped_housing_subsidy + - housing_assistance - household_state_benefits - commodity_supplemental_food_program - household_health_benefits diff --git a/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml b/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml index 2f43cdfca8a..6728fbb331a 100644 --- a/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/ca/cpuc/income_sources.yaml @@ -1,14 +1,16 @@ description: >- - Income sources counted toward CA CPUC CARE/FERA eligibility. - Total gross annual household income includes all taxable and - nontaxable revenues from all people living in the home, from - whatever sources derived, including, but not limited to, wages, - salaries, interest, dividends, spousal and child support payments, - public assistance payments, Social Security and pensions, housing - and military subsidies, rental income, income from self-employment - and all employment-related, non-cash income. -# PG&E Form 01-9077, CARE/FERA Program Application. + California counts these income sources under the California Alternate + Rates for Energy and Family Electric Rate Assistance programs. +# Housing subsidies are excluded by D.14-08-030, effective August 14, 2014. +# PG&E confirmed in its June 2026 reply brief that its contrary application +# wording does not describe its actual CARE/FERA income determination. +# The decision names CARE/ESA; PG&E's filed CARE/FERA practice supports +# applying this housing exclusion to the shared income definition. +# Annual income calculations select the list at January 1: calendar 2014 +# retains the earlier list, and calendar 2015 first applies the exclusion. values: + # Preserve the earlier model's inclusion before this decision, using the + # actual benefit. The pre-2014 income rule has not been revalidated here. 0000-01-01: # Wages, salaries - employment_income @@ -36,8 +38,49 @@ values: - pension_income - retirement_distributions # Housing subsidies - - spm_unit_capped_housing_subsidy - # Military subsidies + - housing_assistance + # Veterans benefits and military service income. These broad inputs do not + # identify the separately excluded military subsidies. + - veterans_benefits + - military_service_income + # Rental income + - rental_income + - farm_rent_income + # Other income sources (from whatever sources derived) + - capital_gains + - unemployment_compensation + - miscellaneous_income + - gi_cash_assistance + - debt_relief + - illicit_income + 2014-08-14: + # Wages, salaries + - employment_income + # Self-employment + - self_employment_income + - sstb_self_employment_income + - partnership_s_corp_income + - farm_operations_income + # Interest + - interest_income + # Dividends + - dividend_income + # Spousal support + - alimony_income + # Child support + - child_support_received + # Public assistance payments + - tanf + - ssi + - snap + - wic + # Social Security + - social_security + # Pensions + - pension_income + - retirement_distributions + # Veterans benefits and military service income. These broad inputs do not + # identify the separately excluded military subsidies. - veterans_benefits - military_service_income # Rental income @@ -50,15 +93,26 @@ values: - gi_cash_assistance - debt_relief - illicit_income - metadata: unit: list period: year label: CA CPUC CARE/FERA income sources reference: - - title: PG&E Form 01-9077, CARE/FERA Program Application - href: https://bha.berkeleyca.gov/sites/default/files/2022-04/PGE%20CARE%20FERA%20Application.pdf + - title: Statewide ESA Program Policy and Procedures Manual v1.4, Section 2.2.2 + href: https://www.cpuc.ca.gov/-/media/cpuc-website/consumer-support/documents/20250418-statewide-esa-program-pp-manualv14.pdf#page=12 + - title: Statewide ESA Program Policy and Procedures Manual v1.4, Table E-1 + href: https://www.cpuc.ca.gov/-/media/cpuc-website/consumer-support/documents/20250418-statewide-esa-program-pp-manualv14.pdf#page=81 + # Retain the form's general income categories; its housing/military + # subsidy wording is corrected by the decision and PG&E filing below. + - title: PG&E Form 01-9077, CARE/FERA Program Application, Income Definition + href: https://www.pge.com/tariffs/assets/pdf/tariffbook/GAS_FORMS_01-9077.pdf#page=2 + - title: CPUC Decision 14-08-030, Section 6.2, Income Definition + href: https://liob.cpuc.ca.gov/wp-content/uploads/sites/14/2020/12/ACF22B3.pdf#page=75 + - title: CPUC Decision 14-08-030, Ordering Paragraph 40(3) + href: https://liob.cpuc.ca.gov/wp-content/uploads/sites/14/2020/12/ACF22B3.pdf#page=124 + - title: PG&E Reply Brief, June 5, 2026, Section B + href: https://docs.cpuc.ca.gov/PublishedDocs/Efile/G000/M608/K305/608305628.PDF#page=10 - title: CA PUC 739.1 (CARE) href: https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=PUC§ionNum=739.1 - title: CA PUC 739.12 (FERA) diff --git a/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py index f56b87f3bc9..39d1fa1a645 100644 --- a/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py +++ b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py @@ -72,15 +72,13 @@ def formula(household, period, parameters): "unemployment_compensation", # Contributed. "basic_income", - "spm_unit_capped_housing_subsidy", + "housing_assistance", "household_state_benefits", "household_head_start_benefits", ] if parameters(period).gov.hud.abolition: BENEFITS = [ - benefit - for benefit in BENEFITS - if benefit != "spm_unit_capped_housing_subsidy" + benefit for benefit in BENEFITS if benefit != "housing_assistance" ] previous_benefits = add(household, period, BENEFITS) middle_class_credit = add( diff --git a/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py b/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py index 728643b9908..9b28bed81db 100644 --- a/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py @@ -98,9 +98,7 @@ def formula(household, period, parameters): BENEFITS = list(parameters(period).gov.household.household_benefits) if parameters(period).gov.hud.abolition: BENEFITS = [ - benefit - for benefit in BENEFITS - if benefit != "spm_unit_capped_housing_subsidy" + benefit for benefit in BENEFITS if benefit != "housing_assistance" ] # Add ECPA child benefit diff --git a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py index f24d8e49ffc..c15c853aaaa 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -108,16 +108,14 @@ def formula(household, period, parameters): "unemployment_compensation", # Contributed. "basic_income", - "spm_unit_capped_housing_subsidy", + "housing_assistance", "household_state_benefits", "household_head_start_benefits", "ecpa_child_benefit", ] if parameters(period).gov.hud.abolition: BENEFITS = [ - benefit - for benefit in BENEFITS - if benefit != "spm_unit_capped_housing_subsidy" + benefit for benefit in BENEFITS if benefit != "housing_assistance" ] return add(household, period, BENEFITS) diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 3d7234830d3..4a24838b166 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -713,6 +713,8 @@ def create_structural_reforms_from_parameters(parameters, period): refundable_credit_conversion, ] reforms = tuple(filter(lambda x: x is not None, reforms)) + if not reforms: + return None class combined_reform(Reform): def apply(self): diff --git a/policyengine_us/spm.py b/policyengine_us/spm.py index bedc086632e..ca23a54b816 100644 --- a/policyengine_us/spm.py +++ b/policyengine_us/spm.py @@ -10,7 +10,11 @@ from copy import copy, deepcopy from functools import lru_cache from inspect import signature +from weakref import WeakSet, ref +from policyengine_core.parameters import ParameterNode, ParameterNodeAtInstant +from policyengine_core.reforms import Reform +from policyengine_core.tracers import TracingParameterNodeAtInstant from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_core.taxbenefitsystems import TaxBenefitSystem from spm_calculator.errors import SPMInputError @@ -32,6 +36,25 @@ } ) +# Public aliases belong to the country model, so the calculator cannot list +# them. Reject these saved outputs alongside the calculator-owned measurements. +DATASET_FORMULA_OWNED_INPUTS = FORMULA_OWNED_INPUTS | frozenset( + { + "in_poverty", + "deep_poverty_line", + "deep_poverty_gap", + "in_deep_poverty", + "spm_unit_allocated_housing_subsidy", + "spm_unit_allocated_tenant_payment", + } +) + +# This source role has a head/spouse fallback for household situations. A +# population producer must retain its observed boolean instead of treating the +# fallback formula as ownership of the input. This declaration permits source +# delivery; it does not permit synthesizing a default value when data are absent. +DATASET_SOURCE_INPUTS = frozenset({"is_spm_independent_minor_role"}) + COUNTY_FIPS_PATTERN = re.compile(r"[0-9]{5}") COUNTY_INPUT_FIX = ( @@ -181,30 +204,102 @@ def spm_config(provider): } +class _SimulationParameters(ParameterNode): + """Reuse child value caches without caching a simulation's root or tracer. + + Each simulation owns this root. Unreformed simulations share the authored + children and their warm value caches; applying a reform first clones them. + Core may set trace/tracer/branch_name on the root during formula execution. + A fresh tracing wrapper binds every lookup to that requesting simulation. + """ + + def _get_at_instant(self, instant): + # Child updates clear caches along their authored parent chain, which + # does not include this private view. Rebuild the small root each time + # so in-place updates are visible; expensive child nodes stay cached. + node = ParameterNodeAtInstant(self.name, self, instant) + if self.trace: + return TracingParameterNodeAtInstant(node, self.tracer, self.branch_name) + return node + + def clone(self): + cloned = super().clone() + # Core clones this view for supplied Reform wrappers too. Child updates + # now reach the cloned root, never the original view's authored source. + cloned._spm_parameter_source = cloned + cloned._spm_cloned_from = ref(_parameter_source(self)) + return cloned + + +def _parameter_source(parameters): + return getattr(parameters, "_spm_parameter_source", parameters) + + +def _has_prepared_structure(system, start_instant): + parameters = system.parameters + source = _parameter_source(parameters) + if ( + start_instant != getattr(system, "_spm_structure_start_instant", None) + or parameters.modified + or source.modified + ): + return False + prepared = getattr(system, "_spm_structure_parameters", None) + if source is _parameter_source(prepared): + return True + # A variable-only Reform clones the prepared baseline's parameter view. + # Recognize that actual clone operation, not an arbitrary replacement root. + cloned_from = getattr(source, "_spm_cloned_from", None) + return ( + isinstance(system, Reform) + and cloned_from is not None + and cloned_from() is _parameter_source(system.baseline.parameters) + and _has_prepared_structure(system.baseline, start_instant) + ) + + +def _parameter_view(parameters): + view = object.__new__(_SimulationParameters) + view.__dict__ = parameters.__dict__.copy() + view._spm_parameter_source = _parameter_source(parameters) + view._at_instant_cache = {} + view.trace = False + view.tracer = None + return view + + +def _bind_private_entities(policy, source): + policy.entities = [copy(entity) for entity in source.entities] + policy.person_entity = next( + entity for entity in policy.entities if entity.is_person + ) + policy.group_entities = [ + entity for entity in policy.entities if not entity.is_person + ] + for entity in policy.entities: + entity.set_tax_benefit_system(policy) + + def share_spm_policy(system): - """Isolate receipts and variable registration without rebuilding policy. - - An ordinary simulation applies no user reform, so it needs private receipts - and a private variable registry, not a private copy of the policy itself. - It is also new rather than cloned, so no previous receipt belongs to it. - Core's TaxBenefitSystem.clone() rebuilds the whole parameter tree node by - node and empties both at-instant caches, and this country then deep-copies - every variable object on top; doing that per household simulation throws - away the shared instance's warm parameter caches and lands on household API - request latency. - - Share the parameter tree and its at-instant caches, and reuse the variable - objects. Every core operation that a reform performs on a variable - (add_variable, replace_variable, update_variable, neutralize_variable, - annualize_variable) rebinds ``variables[name]`` to a newly constructed - object rather than mutating the registered one, so a private dict is enough - to keep this simulation's registration - including the structural reform - re-applied at its own start instant - out of the shared instance. - ``test_ordinary_simulation_shares_default_policy_state`` enforces that - invariant against the shared instance itself. + """Give each request private registration, entities and trace metadata. + + Unreformed policy children retain warm caches. The public apply_reform path + detaches authored parameters before mutation; a parameter lookup never + stores a request's tracer on the shared source root. """ + # An existing detached simulation may itself become a shared source. Its + # whole policy family must detach before mutating these children again. + for owner in getattr(system, "_spm_policy_owners", ()): + if owner.tax_benefit_system.variables is system.variables: + owner.tax_benefit_system._spm_shared_parameters = True + system._spm_shared_parameters = True policy = copy(system) policy.variables = dict(system.variables) + _bind_private_entities(policy, system) + policy.parameters = _parameter_view(system.parameters) + policy._parameters_at_instant_cache = {} + policy._spm_shared_parameters = True + policy._spm_policy_owners = WeakSet() policy.spm_forecast_provider = system.spm_forecast_provider.snapshot( copy_receipts=False ) @@ -228,6 +323,13 @@ def clone_spm_system(system, *, copy_receipts=True): for variable in system.variables.values(): memo[id(variable.entity)] = by_key[variable.entity.key] cloned.variables = deepcopy(system.variables, memo) + cloned.parameters = _parameter_view(cloned.parameters) + if _has_prepared_structure( + system, getattr(system, "_spm_structure_start_instant", None) + ): + cloned._spm_structure_parameters = _parameter_source(cloned.parameters) + cloned._spm_shared_parameters = False + cloned._spm_policy_owners = WeakSet() cloned.spm_forecast_provider = system.spm_forecast_provider.snapshot( copy_receipts=copy_receipts ) @@ -239,11 +341,18 @@ class SPMSimulationMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Core applies constructor reforms directly to the system, including + # modifiers that replace the whole parameter root. + if not isinstance(self.tax_benefit_system.parameters, _SimulationParameters): + self.tax_benefit_system.parameters = _parameter_view( + self.tax_benefit_system.parameters + ) # Core switches the baseline system after cloning its populations. self._rebind_holders() def _rebind_holders(self): """Bind populations and cached holders to their branch's policy state.""" + self._register_policy_owner() self.tax_benefit_system.simulation = self variables = self.tax_benefit_system.variables for entity in self.tax_benefit_system.entities: @@ -266,9 +375,74 @@ def _rebind_holders(self): for branch in self.branches.values(): branch._rebind_holders() + def _register_policy_owner(self): + policy = self.tax_benefit_system + owners = getattr(policy, "_spm_policy_owners", None) + if owners is None: + owners = policy._spm_policy_owners = WeakSet() + owners.add(self) + return owners + + def _calculate(self, variable_name, period=None): + # Core reads abolition parameters before running a formula, including + # on cached/input returns. Bind the current tracer before that lookup. + parameters = self.tax_benefit_system.parameters + parameters.trace = self.trace + parameters.tracer = self.tracer + parameters.branch_name = self.branch_name + # Supplied Reform instances retain Core's extra system-level cache. + # Its results can contain a previous tracer; child value caches remain warm. + self.tax_benefit_system._parameters_at_instant_cache.clear() + return super()._calculate(variable_name, period) + def apply_reform(self, reform): - super().apply_reform(reform) - self._rebind_holders() + policy = self.tax_benefit_system + # Core's parent_branch describes inherited inputs, not policy ownership: + # standalone clones retain that ancestry and need not appear in branches. + # Capture actual policy owners before a reform can replace the registry. + related = [ + owner + for owner in self._register_policy_owner() + if owner.tax_benefit_system.variables is policy.variables + ] + if any( + getattr(owner.tax_benefit_system, "_spm_shared_parameters", False) + for owner in related + ): + parameters = policy.parameters.clone() + for simulation in related: + branch_policy = simulation.tax_benefit_system + branch_policy.parameters = _parameter_view(parameters) + branch_policy._spm_shared_parameters = False + try: + super().apply_reform(reform) + finally: + # A modifier may return another root instead of editing in place. + # Publish its resulting policy to every deliberately shared branch, + # while keeping each root's caches and tracing context private. + parameters = policy.parameters + for simulation in related: + branch_policy = simulation.tax_benefit_system + branch_policy.parameters = _parameter_view(parameters) + branch_policy.variables = policy.variables + branch_policy._parameters_at_instant_cache = {} + # Core invalidates only the caller and its descendants. Synchronize + # every owner, including detached clones, without changing the input + # ancestry. Avoid repeating Core's recursive work for owned branches. + covered = set() + + def cover_branches(owner): + for branch in owner.branches.values(): + if branch not in covered: + covered.add(branch) + cover_branches(branch) + + for owner in related: + cover_branches(owner) + for owner in related: + if owner not in covered: + owner._rebind_holders() + owner._invalidate_all_caches() @property def spm_config(self): @@ -285,6 +459,18 @@ def _prepare_spm_system(self, args, kwargs, config, start_instant): ) supplied = arguments.arguments.get("tax_benefit_system") reform = arguments.arguments.get("reform") + # A prepared country system already applied structural reforms at its + # recorded instant. Reuse that work for pristine supplied systems too; + # modified/replaced parameters, user reforms and other instants still + # run detection. + source = ( + supplied + if supplied is not None + else self.default_tax_benefit_system_instance + ) + self._spm_default_structure_ready = reform is None and _has_prepared_structure( + source, start_instant + ) if supplied is None: if reform is not None: chosen = self.default_tax_benefit_system( @@ -304,6 +490,8 @@ def _prepare_spm_system(self, args, kwargs, config, start_instant): # it - the household API among them - keeps that system's warm # caches; only receipts and variable registration are private. chosen = share_spm_policy(supplied) + if not isinstance(chosen.parameters, _SimulationParameters): + chosen.parameters = _parameter_view(chosen.parameters) # This is a new simulation, unlike clone() of an already calculated # simulation, so no previous calculation receipt belongs to it. if config is not None: @@ -325,6 +513,7 @@ def _prepare_spm_system(self, args, kwargs, config, start_instant): def clone(self, debug=False, trace=False, clone_tax_benefit_system=True): """Retain cached-result receipts while isolating future calculations.""" + self._register_policy_owner() cloned = super().clone(debug=debug, trace=trace, clone_tax_benefit_system=False) if clone_tax_benefit_system: cloned.tax_benefit_system = clone_spm_system(self.tax_benefit_system) @@ -332,11 +521,18 @@ def clone(self, debug=False, trace=False, clone_tax_benefit_system=True): # Core branches may share policy state, but each calculation's # receipt belongs to that simulation's provider. cloned.tax_benefit_system = copy(self.tax_benefit_system) + _bind_private_entities(cloned.tax_benefit_system, self.tax_benefit_system) + cloned.tax_benefit_system.parameters = _parameter_view( + self.tax_benefit_system.parameters + ) + cloned.tax_benefit_system._parameters_at_instant_cache = {} cloned.tax_benefit_system.spm_forecast_provider = ( self.tax_benefit_system.spm_forecast_provider.snapshot( copy_receipts=True ) ) + cloned.calc = cloned.calculate + cloned.df = cloned.calculate_dataframe cloned._rebind_holders() return cloned @@ -347,7 +543,7 @@ def set_input(self, variable_name, period, value): # public consumers validate their household input contract separately. if ( getattr(self, "is_over_dataset", False) - and variable_name in FORMULA_OWNED_INPUTS + and variable_name in DATASET_FORMULA_OWNED_INPUTS ): raise ValueError( f"Dataset supplies formula-owned SPM output {variable_name}. " diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 6b83b8b3acd..759ca0ccc1a 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -1,3 +1,4 @@ +import hashlib from pathlib import Path from policyengine_core.taxbenefitsystems import TaxBenefitSystem from policyengine_us.entities import * @@ -40,6 +41,7 @@ from spm_calculator.policyengine_adapter import build_policyengine_variables from policyengine_us.spm import ( SPMSimulationMixin, + _parameter_view, clone_spm_system, create_spm_provider, ) @@ -49,10 +51,12 @@ CURRENT_YEAR = 2024 DEFAULT_START_DATE = str(CURRENT_YEAR) + "-01-01" -# Certified Populace build (primary-source US microdata), pinned by build id. -# Populace ships from a Hugging Face *dataset* repo, hence the `hf://datasets/` -# prefix handled in `_resolve_dataset_path`. +# Microcosm primary-source microdata. Verify content as well as the historical +# storage tag; model/runtime certification belongs to the release bundle. DEFAULT_DATASET = "hf://datasets/policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260909" +DEFAULT_DATASET_SHA256 = ( + "6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84" +) class CountryTaxBenefitSystem(TaxBenefitSystem): @@ -133,6 +137,17 @@ def __init__( self.apply_reform_set(reform) self.add_variables(*create_50_state_variables()) + self._spm_structure_start_instant = start_instant + self.parameters.modified = False + # Core's Reform constructor clones this root. Retain the actual clone + # origin so a variable-only wrapper can reuse prepared structure. + self.parameters = _parameter_view(self.parameters) + self._spm_structure_parameters = self.parameters + + def get_parameters_at_instant(self, instant): + # The parameter root already caches plain values. Caching its returned + # tracing wrapper again here would preserve the previous request tracer. + return self.parameters(instant) if self.parameters is not None else None def clone(self): return clone_spm_system(self) @@ -201,11 +216,15 @@ def __init__(self, *args, **kwargs): ) super().__init__(*args, **kwargs) - reform = create_structural_reforms_from_parameters( - self.tax_benefit_system.parameters, start_instant - ) - if reform is not None: - self.apply_reform(reform) + if not self._spm_default_structure_ready: + reform = create_structural_reforms_from_parameters( + self.tax_benefit_system.parameters, start_instant + ) + if reform is not None: + # Re-evaluation at a different structural start may activate + # parameter mutations. Detach before any reform callback; + # preserving warm child caches is not safe on this path. + self.apply_reform(reform) # Labor supply responses @@ -282,7 +301,7 @@ def _resolve_dataset_path(dataset_str): version = None if "@" in repo_filename: repo_filename, version = repo_filename.rsplit("@", 1) - return _download_or_explain( + local_path = _download_or_explain( dataset_str, lambda: hf_hub_download( repo_id=f"{owner}/{repo}", @@ -291,6 +310,15 @@ def _resolve_dataset_path(dataset_str): revision=version, ), ) + if dataset_str == DEFAULT_DATASET: + with Path(local_path).open("rb") as source: + actual = hashlib.file_digest(source, "sha256").hexdigest() + if actual != DEFAULT_DATASET_SHA256: + raise ValueError( + f"Default Microcosm dataset content mismatch for {dataset_str!r}: " + f"expected SHA256 {DEFAULT_DATASET_SHA256}, got {actual}." + ) + return local_path if "hf://" in dataset_str: from policyengine_core.tools.hugging_face import ( parse_hf_url, @@ -401,11 +429,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - reform = create_structural_reforms_from_parameters( - self.tax_benefit_system.parameters, start_instant - ) - if reform is not None: - self.apply_reform(reform) + if not self._spm_default_structure_ready: + reform = create_structural_reforms_from_parameters( + self.tax_benefit_system.parameters, start_instant + ) + if reform is not None: + self.apply_reform(reform) # Labor supply responses diff --git a/policyengine_us/tests/code_health/test_batched_per_subdir_budget.py b/policyengine_us/tests/code_health/test_batched_per_subdir_budget.py index 28436103caa..d02bfb5179d 100644 --- a/policyengine_us/tests/code_health/test_batched_per_subdir_budget.py +++ b/policyengine_us/tests/code_health/test_batched_per_subdir_budget.py @@ -12,6 +12,7 @@ from pathlib import Path import yaml +import pytest BATCHER = Path(__file__).resolve().parents[1] / "test_batched.py" SPEC = importlib.util.spec_from_file_location("test_batched", BATCHER) @@ -74,3 +75,52 @@ def test_per_subdir_mode_splits_only_heavy_folders(tmp_path): [str(tmp_path / "light")], [str(tmp_path / "root.yaml")], ] + + +def test_heavy_subdir_keeps_yml_files(tmp_path): + heavy = tmp_path / "heavy" + _write_cases(heavy / "a.yaml", ["r1", "r2", "r3", "r4"]) + _write_cases(heavy / "b.yaml", ["r5", "r6"]) + _write_cases(heavy / "nested" / "must_run.yml", ["r7"]) + batches = batched.subdir_batches(heavy) + files = [file for batch in batches for file in batch] + assert files == [ + str(heavy / "a.yaml"), + str(heavy / "b.yaml"), + str(heavy / "nested" / "must_run.yml"), + ] + + +@pytest.mark.parametrize("mode", ["per-file", "per-subdir", "auto"]) +def test_explicit_batches_keep_both_yaml_extensions(tmp_path, mode): + _write_cases(tmp_path / "a.yaml", ["r1"]) + _write_cases(tmp_path / "b.yml", ["r2"]) + batches = batched.split_into_batches(tmp_path, 2, mode=mode) + assert sorted(file for batch in batches for file in batch) == [ + str(tmp_path / "a.yaml"), + str(tmp_path / "b.yml"), + ] + assert batched.count_yaml_files(tmp_path) == 2 + + +@pytest.mark.parametrize( + "relative", + ["policy/contrib", "contrib/states", "reform", "gov/states", "arbitrary"], +) +def test_auto_path_routes_keep_root_yml_files(tmp_path, relative): + root = tmp_path / relative + _write_cases(root / "a.yaml", ["r1"]) + _write_cases(root / "b.yml", ["r2"]) + batches = batched.split_into_batches(root, 2) + assert sorted(file for batch in batches for file in batch) == [ + str(root / "a.yaml"), + str(root / "b.yml"), + ] + + +def test_exclusion_preserves_yml_outside_excluded_directory(tmp_path): + _write_cases(tmp_path / "exclude_me" / "a.yml", ["r1"]) + _write_cases(tmp_path / "b.yml", ["r2"]) + assert batched.split_into_batches(tmp_path, 2, exclude=["exclude_me"]) == [ + [str(tmp_path / "b.yml")] + ] diff --git a/policyengine_us/tests/code_health/test_batched_process_results.py b/policyengine_us/tests/code_health/test_batched_process_results.py new file mode 100644 index 00000000000..6b27696e2ba --- /dev/null +++ b/policyengine_us/tests/code_health/test_batched_process_results.py @@ -0,0 +1,116 @@ +"""Batch status must follow the child process, including pytest setup errors.""" + +import importlib.util +from pathlib import Path +import subprocess +import sys +import signal + +import pytest + + +SPEC = importlib.util.spec_from_file_location( + "batch_result_runner", Path(__file__).resolve().parents[1] / "test_batched.py" +) +batched = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(batched) + + +def _run_child(monkeypatch, command, **batch_options): + """Replace only the model command; retain real pipes, polling and exit codes.""" + popen = subprocess.Popen + children = [] + + def launch(_command, **kwargs): + child = popen(command, **kwargs) + children.append(child) + return child + + monkeypatch.setattr(batched.subprocess, "Popen", launch) + result = batched.run_batch( + ["unused-by-this-child"], "process regression", stream=False, **batch_options + ) + assert len(children) == 1 + assert children[0].poll() is not None + return result, children[0].returncode + + +def test_real_pytest_setup_error_fails_batch(tmp_path, monkeypatch): + test_file = tmp_path / "test_setup_error.py" + test_file.write_text( + "import pytest\n" + "def test_pass(): pass\n" + "@pytest.fixture\n" + "def broken(): raise RuntimeError('setup failed')\n" + "def test_setup(broken): pass\n" + ) + result, returncode = _run_child( + monkeypatch, + [sys.executable, "-m", "pytest", "-o", "addopts=", str(test_file)], + ) + assert returncode == 1 + assert "1 passed, 1 error" in result["output"] + assert result["status"] == "failed" + + +@pytest.mark.parametrize("returncode", [0, 1, 2, 3, 4, 5]) +def test_exit_status_is_authoritative_after_summary(monkeypatch, returncode): + result, actual = _run_child( + monkeypatch, + [ + sys.executable, + "-c", + "import sys; print('===== 1 passed in 0.01s ====='); " + f"sys.exit({returncode})", + ], + ) + assert actual == returncode + assert result["status"] == ("passed" if returncode == 0 else "failed") + assert result["returncode"] == returncode + + +def test_summary_does_not_terminate_running_child(monkeypatch): + result, returncode = _run_child( + monkeypatch, + [ + sys.executable, + "-c", + "import sys, time; print('===== 1 passed in 0.01s =====', flush=True); " + "time.sleep(1.2); print('cleanup complete'); sys.exit(3)", + ], + ) + assert returncode == 3 + assert "cleanup complete" in result["output"] + assert result["status"] == "failed" + + +def test_clean_exit_without_summary_passes(monkeypatch): + result, returncode = _run_child(monkeypatch, [sys.executable, "-c", "pass"]) + assert returncode == 0 + assert result["status"] == "passed" + + +def test_signal_exit_is_failure(monkeypatch): + result, returncode = _run_child( + monkeypatch, + [ + sys.executable, + "-c", + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)", + ], + ) + assert returncode == -signal.SIGTERM + assert result["status"] == "failed" + + +@pytest.mark.parametrize( + "output", ["", "print('===== 1 passed in 0.01s =====', flush=True);"] +) +def test_timeout_is_enforced_without_inventing_success(monkeypatch, output): + result, returncode = _run_child( + monkeypatch, + [sys.executable, "-c", f"import time; {output} time.sleep(60)"], + timeout_seconds=0.1, + ) + assert returncode == -signal.SIGTERM + assert result["status"] == "timeout" diff --git a/policyengine_us/tests/code_health/test_reform_combo_budget.py b/policyengine_us/tests/code_health/test_reform_combo_budget.py index 369725ea272..af502fe4c4f 100644 --- a/policyengine_us/tests/code_health/test_reform_combo_budget.py +++ b/policyengine_us/tests/code_health/test_reform_combo_budget.py @@ -17,8 +17,9 @@ """ import importlib.util +from pathlib import Path -from policyengine_us.model_api import REPO +REPO = Path(__file__).resolve().parents[2] _spec = importlib.util.spec_from_file_location( "test_batched", REPO / "tests" / "test_batched.py" @@ -31,7 +32,7 @@ def test_no_test_file_exceeds_reform_combo_budget(): over_budget = [] - for yaml_file in sorted(POLICY_TESTS_ROOT.rglob("*.yaml")): + for yaml_file in _test_batched.yaml_files(POLICY_TESTS_ROOT): combos = _test_batched.file_reform_combos(yaml_file) weight = _test_batched.combo_weight(combos) if weight > _test_batched.MAX_BATCH_COMBO_WEIGHT: diff --git a/policyengine_us/tests/core/test_spm_policy_family.py b/policyengine_us/tests/core/test_spm_policy_family.py new file mode 100644 index 00000000000..521d7ae8c6c --- /dev/null +++ b/policyengine_us/tests/core/test_spm_policy_family.py @@ -0,0 +1,445 @@ +"""Reforms and tracing must respect intentional policy sharing boundaries.""" + +import importlib + +import numpy as np +import pytest +from policyengine_core.reforms import Reform +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import YEAR, period +from policyengine_core.tracers import FullTracer +from policyengine_core.variables import Variable + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us.entities import Person +from policyengine_us.system import system + + +def earning_household(): + return { + "people": { + "person": { + "age": {2024: 40}, + "employment_income": {2024: 50_000}, + } + }, + "households": { + "household": { + "members": ["person"], + "county_fips": {2024: "06037"}, + } + }, + } + + +class ReplaceParameterRoot(Reform): + def apply(self): + def replace(parameters): + parameters = parameters.clone() + parameters.gov.irs.deductions.standard.amount.SINGLE.update( + period="2024", value=100_000 + ) + # A public modifier may return an ordinary core root, without the + # country adapter's per-simulation root subclass. + parameters.__class__ = ParameterNode + return parameters + + self.modify_parameters(replace) + + +@pytest.mark.parametrize("replace_root", [False, True]) +def test_child_reform_updates_warm_parent_and_sibling(replace_root): + parent = Simulation(situation=earning_household()) + child = parent.get_branch("child") + sibling = parent.get_branch("sibling") + independent = parent.get_branch("independent", clone_system=True) + for member in (parent, child, sibling, independent): + assert member.calculate("income_tax", 2024)[0] > 0 + independent_before = independent.calculate("income_tax", 2024).copy() + reform = ( + ReplaceParameterRoot + if replace_root + else {"gov.irs.deductions.standard.amount.SINGLE": {"2024": 100_000}} + ) + child.apply_reform(reform) + expected = Simulation(situation=earning_household(), reform=reform).calculate( + "income_tax", 2024 + ) + assert expected[0] < independent_before[0] + for member in (parent, child, sibling): + np.testing.assert_array_equal(member.calculate("income_tax", 2024), expected) + assert member.calculate("employment_income", 2024)[0] == 50_000 + np.testing.assert_array_equal( + independent.calculate("income_tax", 2024), independent_before + ) + + +class VariableOnlyReform(Reform): + def apply(self): + self.neutralize_variable("income_tax") + + +@pytest.mark.parametrize("initial_trace", [False, True]) +def test_supplied_reform_system_lookup_binds_current_formula_tracer(initial_trace): + class parameter_lookup(Variable): + entity = Person + value_type = float + definition_period = YEAR + label = "Parameter lookup through supplied policy system" + + def formula(person, period, parameters): + # A formula can use Core's public system lookup; a supplied Reform + # retains that method even though CountryTaxBenefitSystem overrides it. + policy = person.simulation.tax_benefit_system + amount = policy.get_parameters_at_instant( + period.start + ).gov.irs.deductions.standard.amount.SINGLE + return person.filled_array(amount) + + source = VariableOnlyReform(system.clone()) + source.add_variable(parameter_lookup) + simulation = Simulation( + tax_benefit_system=source, + situation=earning_household(), + trace=initial_trace, + ) + before = simulation.calculate("parameter_lookup", 2024) + old_tracer = simulation.tracer + old_count = len(old_tracer.trees) if initial_trace else None + simulation.trace = True + simulation.tracer = FullTracer() + simulation.delete_arrays("parameter_lookup") + np.testing.assert_array_equal( + simulation.calculate("parameter_lookup", 2024), before + ) + assert any( + parameter.name.endswith("gov.irs.deductions.standard.amount.SINGLE") + for parameter in simulation.tracer.trees[-1].parameters + ) + if initial_trace: + assert len(old_tracer.trees) == old_count + + +@pytest.mark.parametrize("reform_wrapper", [False, True]) +def test_cached_formula_trace_binds_replacement_tracer(reform_wrapper): + supplied = ( + {"tax_benefit_system": VariableOnlyReform(system.clone())} + if reform_wrapper + else {} + ) + simulation = Simulation(situation=earning_household(), trace=True, **supplied) + simulation.calculate("spm_unit_fpg", 2024) + old_tracer = simulation.tracer + old_count = len(old_tracer.trees) + simulation.tracer = FullTracer() + simulation.calculate("spm_unit_fpg", 2024) + assert any( + parameter.name.endswith("gov.abolitions.spm_unit_fpg") + for parameter in simulation.tracer.trees[-1].parameters + ) + assert len(old_tracer.trees) == old_count + + +def test_constructor_replaced_root_keeps_tracing_private(): + simulation = Simulation( + situation=earning_household(), reform=ReplaceParameterRoot, trace=True + ) + simulation.calculate("spm_unit_fpg", 2024) + first_tracer = simulation.tracer + simulation.tracer = FullTracer() + simulation.delete_arrays("spm_unit_fpg") + simulation.calculate("spm_unit_fpg", 2024) + assert simulation.tracer.trees[-1].parameters + assert len(first_tracer.trees) == 1 + + +def test_system_parameter_lookup_does_not_cache_tracer(): + source = system.clone() + source.parameters.trace = True + source.parameters.branch_name = "default" + tracers = [FullTracer(), FullTracer()] + for tracer in tracers: + source.parameters.tracer = tracer + tracer.record_calculation_start("probe", period(2024), "default") + value = source.get_parameters_at_instant( + 2024 + ).gov.irs.deductions.standard.amount.SINGLE + tracer.record_calculation_result(np.array([value])) + tracer.record_calculation_end() + assert len(tracer.trees[-1].parameters) == 1 + assert len(tracers[0].trees[-1].parameters) == 1 + + +@pytest.mark.parametrize("replace_root", [False, True]) +def test_detached_branch_clone_preserves_inherited_inputs_and_private_reform( + replace_root, +): + original = Simulation(situation=earning_household()) + middle = original.get_branch("middle") + middle.set_input("employment_income_before_lsr", 2024, [70_000]) + leaf = middle.get_branch("leaf") + before = [ + member.calculate("income_tax", 2024).copy() + for member in (original, middle, leaf) + ] + detached = leaf.clone() + detached.calculate("income_tax", 2024) + reform = ( + ReplaceParameterRoot + if replace_root + else {"gov.irs.deductions.standard.amount.SINGLE": {"2024": 100_000}} + ) + detached.apply_reform(reform) + reference_inputs = earning_household() + reference_inputs["people"]["person"]["employment_income"] = {2024: 70_000} + reference = Simulation(situation=reference_inputs, reform=reform) + assert detached.calculate("employment_income", 2024)[0] == 70_000 + np.testing.assert_array_equal( + detached.calculate("income_tax", 2024), reference.calculate("income_tax", 2024) + ) + for member, expected in zip((original, middle, leaf), before): + np.testing.assert_array_equal(member.calculate("income_tax", 2024), expected) + + +def test_explicit_shared_clones_participate_without_branch_registration(): + original = Simulation(situation=earning_household()) + first = original.clone(clone_tax_benefit_system=False) + second = original.clone(clone_tax_benefit_system=False) + for owner in (original, first, second): + owner.calculate("income_tax", 2024) + first.apply_reform(ReplaceParameterRoot) + reference = Simulation(situation=earning_household(), reform=ReplaceParameterRoot) + expected = reference.calculate("income_tax", 2024) + for owner in (original, first, second): + np.testing.assert_array_equal(owner.calculate("income_tax", 2024), expected) + + +def test_reusing_detached_source_marks_all_shared_owners_for_copy_on_write(): + from policyengine_us.spm import share_spm_policy + + original = Simulation(situation=earning_household()).clone() + sibling = original.get_branch("sibling") + separate = share_spm_policy(original.tax_benefit_system) + before = separate.parameters.gov.irs.deductions.standard.amount.SINGLE(2024) + sibling.apply_reform( + {"gov.irs.deductions.standard.amount.SINGLE": {"2024": 100_000}} + ) + assert separate.parameters.gov.irs.deductions.standard.amount.SINGLE(2024) == before + for owner in (original, sibling): + assert ( + owner.tax_benefit_system.parameters.gov.irs.deductions.standard.amount.SINGLE( + 2024 + ) + == 100_000 + ) + + +@pytest.mark.parametrize("clone_source", [False, True]) +def test_modified_replacement_default_applies_new_structure(monkeypatch, clone_source): + from policyengine_us.spm import _parameter_source + + source = system.clone() if clone_source else CountryTaxBenefitSystem() + if not clone_source: + assert source.parameters is source._spm_structure_parameters + assert not source.parameters.modified + source.parameters.gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax.update( + period="2024", value=True + ) + if not clone_source: + assert _parameter_source(source.parameters).modified + module = importlib.import_module("policyengine_us.system") + calls = [] + original = module.create_structural_reforms_from_parameters + + def record(parameters, instant): + calls.append(parameters) + return original(parameters, instant) + + monkeypatch.setattr(module, "create_structural_reforms_from_parameters", record) + baseline = Simulation(situation=earning_household()) + before = baseline.calculate("household_tax_before_refundable_credits", 2024) + explicit = Simulation(tax_benefit_system=source, situation=earning_household()) + expected = explicit.calculate("household_tax_before_refundable_credits", 2024) + assert expected[0] < before[0] + monkeypatch.setattr(Simulation, "default_tax_benefit_system_instance", source) + simulation = Simulation(situation=earning_household()) + np.testing.assert_allclose( + simulation.calculate("household_tax_before_refundable_credits", 2024), + expected, + ) + assert len(calls) == 2 + + +@pytest.mark.parametrize("start_instant", ["2024-01-01", "2026-01-01"]) +def test_structural_detection_reuses_only_matching_default(monkeypatch, start_instant): + module = importlib.import_module("policyengine_us.system") + calls = [] + original = module.create_structural_reforms_from_parameters + + def record(parameters, instant): + calls.append(instant) + return original(parameters, instant) + + monkeypatch.setattr(module, "create_structural_reforms_from_parameters", record) + system.parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + children = system.parameters.children + cached = dict(system.parameters.gov._at_instant_cache) + simulation = Simulation(situation=earning_household(), start_instant=start_instant) + assert calls == ([] if start_instant == "2024-01-01" else [start_instant]) + if start_instant == "2024-01-01": + assert simulation.tax_benefit_system.parameters.children is children + else: + # A changed structural start can activate parameter-mutating callbacks. + # Defensive cloning is intentional even when this instance only rebinds + # MI variables; the shared source and its warm caches remain intact. + assert simulation.tax_benefit_system.parameters.children is not children + assert system.parameters.children is children + for instant, node in cached.items(): + assert system.parameters.gov._at_instant_cache[instant] is node + + +@pytest.mark.parametrize("use_modifier", [False, True]) +def test_changed_structural_start_detaches_before_parameter_mutation( + monkeypatch, use_modifier +): + module = importlib.import_module("policyengine_us.system") + source = system.parameters + children = source.children + before = source("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + cached = dict(source.gov._at_instant_cache) + + def mutate(parameters): + assert parameters.children is not children + parameters.gov.irs.deductions.standard.amount.SINGLE.update( + period="2024", value=100_000 + ) + return parameters + + class StructuralParameterChange(Reform): + def apply(self): + if use_modifier: + self.modify_parameters(mutate) + else: + mutate(self.parameters) + + monkeypatch.setattr( + module, + "create_structural_reforms_from_parameters", + lambda parameters, instant: StructuralParameterChange, + ) + simulation = Simulation(situation=earning_household(), start_instant="2026-01-01") + assert ( + simulation.tax_benefit_system.parameters.gov.irs.deductions.standard.amount.SINGLE( + 2024 + ) + == 100_000 + ) + assert source.gov.irs.deductions.standard.amount.SINGLE(2024) == before + assert source.children is children + for instant, node in cached.items(): + assert source.gov._at_instant_cache[instant] is node + + +@pytest.mark.parametrize( + "clone_count,reform_wrapper", + [(0, False), (1, False), (2, False), (0, True), (1, True)], +) +def test_supplied_prepared_system_retains_warm_policy_children( + clone_count, reform_wrapper +): + source = CountryTaxBenefitSystem() if not clone_count else system + for _ in range(clone_count): + source = source.clone() + if reform_wrapper: + source = VariableOnlyReform(source) + source.parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + cached = dict(source.parameters.gov._at_instant_cache) + children = source.parameters.children + simulation = Simulation(tax_benefit_system=source, situation=earning_household()) + assert simulation.tax_benefit_system.parameters.children is children + assert source.parameters.gov._at_instant_cache + for instant, node in cached.items(): + assert ( + simulation.tax_benefit_system.parameters.gov._at_instant_cache[instant] + is node + ) + + +def test_empty_structural_detection_retains_supplied_warm_children(monkeypatch): + module = importlib.import_module("policyengine_us.reforms.reforms") + # The default MI factory always registers a variable reform. Suppress that + # one factory to exercise the supported no-active-structural-reform result. + monkeypatch.setattr(module, "create_mi_surtax_reform", lambda *args: None) + source = system.clone() + source.parameters.gov.irs.deductions.standard.amount.SINGLE.update( + period="2024", value=100_000 + ) + source.parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + children = source.parameters.children + cached = dict(source.parameters.gov._at_instant_cache) + assert ( + module.create_structural_reforms_from_parameters( + source.parameters, "2024-01-01" + ) + is None + ) + simulation = Simulation(tax_benefit_system=source, situation=earning_household()) + assert simulation.tax_benefit_system.parameters.children is children + assert simulation.calculate("standard_deduction", 2024)[0] == 100_000 + assert simulation.calculate("taxable_income", 2024)[0] == 0 + for instant, node in cached.items(): + assert source.parameters.gov._at_instant_cache[instant] is node + + +def test_supplied_wrapper_replacement_root_requires_structural_detection(monkeypatch): + source = VariableOnlyReform(system.clone()) + # An independently cloned, pristine root has no authenticated origin from + # this wrapper's baseline. A false modified flag alone cannot certify it. + source.parameters = system.parameters.clone() + assert not source.parameters.modified + module = importlib.import_module("policyengine_us.system") + calls = [] + original = module.create_structural_reforms_from_parameters + + def record(parameters, instant): + calls.append(instant) + return original(parameters, instant) + + monkeypatch.setattr(module, "create_structural_reforms_from_parameters", record) + Simulation(tax_benefit_system=source, situation=earning_household()) + assert calls == ["2024-01-01"] + + +def test_parameter_view_refreshes_after_in_place_leaf_update(): + source = system.clone() + assert ( + source.parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + < 100_000 + ) + source.parameters.gov.irs.deductions.standard.amount.SINGLE.update( + period="2024", value=100_000 + ) + assert ( + source.parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + == 100_000 + ) + + +def test_reform_can_read_updated_parameters_before_returning(): + class ReadDuringUpdate(Reform): + def apply(self): + parameters = self.parameters + assert ( + parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + < 100_000 + ) + parameters.gov.irs.deductions.standard.amount.SINGLE.update( + period="2024", value=100_000 + ) + assert ( + parameters("2024-01-01").gov.irs.deductions.standard.amount.SINGLE + == 100_000 + ) + + simulation = Simulation(situation=earning_household()) + simulation.apply_reform(ReadDuringUpdate) diff --git a/policyengine_us/tests/core/test_spm_source_input_contract.py b/policyengine_us/tests/core/test_spm_source_input_contract.py new file mode 100644 index 00000000000..853b5ea5901 --- /dev/null +++ b/policyengine_us/tests/core/test_spm_source_input_contract.py @@ -0,0 +1,42 @@ +"""A household fallback formula must not hide an observed population role.""" + +from policyengine_core.periods import ETERNITY +import pytest + +from policyengine_us import Simulation +from policyengine_us.spm import DATASET_FORMULA_OWNED_INPUTS, DATASET_SOURCE_INPUTS +from policyengine_us.system import system + + +def test_source_contract_is_disjoint_and_matches_registered_role(): + assert DATASET_SOURCE_INPUTS == {"is_spm_independent_minor_role"} + assert not DATASET_SOURCE_INPUTS & DATASET_FORMULA_OWNED_INPUTS + variable = system.variables["is_spm_independent_minor_role"] + assert variable.entity.key == "person" + assert variable.value_type is bool + assert variable.definition_period == ETERNITY + assert variable.formulas # Household fallback remains available. + + +@pytest.mark.parametrize("source_role,adults", [(None, 1), (False, 1), (True, 2)]) +def test_observed_role_can_override_household_fallback(source_role, adults): + head = {"age": {2024: 40}, "is_household_head": True} + minor = {"age": {2024: 17}} + if source_role is not None: + head["is_spm_independent_minor_role"] = True + minor["is_spm_independent_minor_role"] = source_role + simulation = Simulation( + situation={ + "people": { + "head": head, + "minor": minor, + }, + "households": {"household": {"members": ["head", "minor"]}}, + "spm_units": {"unit": {"members": ["head", "minor"]}}, + } + ) + assert simulation.calculate("spm_measurement_adults", 2024).tolist() == [adults] + assert simulation.calculate("is_spm_independent_minor_role", ETERNITY).tolist() == [ + True, + bool(source_role), + ] diff --git a/policyengine_us/tests/core/test_spm_system.py b/policyengine_us/tests/core/test_spm_system.py index 2b60bcd08ed..2f51eebe9ac 100644 --- a/policyengine_us/tests/core/test_spm_system.py +++ b/policyengine_us/tests/core/test_spm_system.py @@ -131,16 +131,7 @@ def _parameter_fingerprint(system): def test_ordinary_simulation_shares_default_policy_state(): - """A plain household simulation must not rebuild the shipped policy. - - Core's TaxBenefitSystem.clone() rebuilds the parameter tree node by node and - empties both at-instant caches, and this country deep-copies every variable - on top, so cloning per request would make each household API call rebuild - the at-instant tree for every period it touches. Nothing distinguishes an - ordinary simulation's policy from the shared instance's, so it shares the - tree and reuses the variable objects, and only its receipts and its own - variable registry are private. - """ + """Ordinary requests reuse authored policy without sharing tracing state.""" parameters_before = _parameter_fingerprint(system) variables_before = {name: id(value) for name, value in system.variables.items()} provider_before = system.spm_forecast_provider @@ -148,12 +139,15 @@ def test_ordinary_simulation_shares_default_policy_state(): simulation = Simulation(situation=single_person_situation()) policy = simulation.tax_benefit_system - # Same parameter tree and the same warm at-instant caches: no clone ran. assert policy is not system - assert policy.parameters is system.parameters - assert policy._parameters_at_instant_cache is system._parameters_at_instant_cache - # Entities stay bound to the shared instance, exactly as before this change. - assert policy.entities is system.entities + # Private roots carry trace state; expensive authored children stay shared. + assert policy.parameters is not system.parameters + assert policy.parameters.children is system.parameters.children + assert ( + policy._parameters_at_instant_cache is not system._parameters_at_instant_cache + ) + assert policy.entities is not system.entities + assert all(entity._tax_benefit_system is policy for entity in policy.entities) # A private registry, holding the shared instance's own variable objects. assert policy.variables is not system.variables assert ( @@ -165,9 +159,8 @@ def test_ordinary_simulation_shares_default_policy_state(): for name, variable in policy.variables.items() if variable is not system.variables[name] } - # Only the structural reform re-applied at this simulation's start instant - # rebinds a name, and it rebinds in the private registry. - assert len(rebound) < 10, sorted(rebound) + # The unchanged default already applied this instant's structural policy. + assert not rebound, sorted(rebound) # Nothing reached the shared instance. assert {name: id(value) for name, value in system.variables.items()} == ( @@ -183,13 +176,93 @@ def test_ordinary_simulation_shares_default_policy_state(): later = Simulation(situation=single_person_situation()) assert later.spm_provenance()["years"] == {} # The second simulation reads the at-instant tree the first one built. - assert system.parameters._at_instant_cache + assert system.parameters.gov._at_instant_cache + assert ( + later.tax_benefit_system.parameters.gov._at_instant_cache + is system.parameters.gov._at_instant_cache + ) + + +def test_later_parameter_reform_keeps_other_simulations_on_baseline(monkeypatch): + # A local source system keeps the red test from mutating the global fixture. + source = system.clone() + monkeypatch.setattr(Simulation, "default_tax_benefit_system_instance", source) + situation = single_person_situation() + situation["people"]["person"]["employment_income"] = {2024: 50_000} + changed = Simulation(situation=situation) + unrelated = Simulation(situation=situation) + before = unrelated.calculate("income_tax", 2024).copy() + parameter_before = _parameter_fingerprint(source) + changed.apply_reform( + {"gov.irs.deductions.standard.amount.SINGLE": {"2024": 100_000}} + ) + assert _parameter_fingerprint(source) == parameter_before + np.testing.assert_array_equal( + Simulation(situation=situation).calculate("income_tax", 2024), before + ) + unrelated._invalidate_all_caches() + np.testing.assert_array_equal(unrelated.calculate("income_tax", 2024), before) + assert not np.array_equal(changed.calculate("income_tax", 2024), before) + + +def test_ordinary_reform_can_register_and_set_its_own_input(): + simulation = Simulation(situation=single_person_situation()) + simulation.apply_reform(AddedInputReform) + simulation.set_input("clone_only_income", 2024, [123]) + assert simulation.calculate("clone_only_income", 2024)[0] == 123 + assert "clone_only_income" not in system.variables + for population in simulation.populations.values(): + assert population.entity._tax_benefit_system is simulation.tax_benefit_system + + +def test_clone_calculation_aliases_use_clone_policy_and_receipts(): + situation = single_person_situation() + situation["people"]["person"]["employment_income"] = {2024: 50_000} + original = Simulation(situation=situation) + clone = original.clone() + clone.apply_reform(UserReform) + assert clone.calc("income_tax", 2024)[0] == 0 + assert original.calc("income_tax", 2024)[0] != 0 + clone.calc("spm_unit_spm_threshold", 2025) + assert set(clone.spm_provenance()["years"]) == {"2025"} + assert original.spm_provenance()["years"] == {} + assert clone.df.__self__ is clone + + +def test_traced_simulations_keep_parameter_accesses_separate(): + first = Simulation(situation=single_person_situation(), trace=True) + second = Simulation(situation=single_person_situation(), trace=True) + first.calculate("spm_unit_fpg", 2024) + first_node = first.tracer.trees[-1] + first_parameters = [(p.name, p.value) for p in first_node.parameters] + assert first_parameters + second.calculate("spm_unit_fpg", 2024) + second_node = second.tracer.trees[-1] + assert [(p.name, p.value) for p in second_node.parameters] == first_parameters + assert [(p.name, p.value) for p in first_node.parameters] == first_parameters assert ( - later.tax_benefit_system.parameters._at_instant_cache - is system.parameters._at_instant_cache + first.tax_benefit_system.parameters is not second.tax_benefit_system.parameters ) +@pytest.mark.parametrize( + "name", + [ + "in_poverty", + "deep_poverty_line", + "deep_poverty_gap", + "in_deep_poverty", + "spm_unit_allocated_housing_subsidy", + "spm_unit_allocated_tenant_payment", + ], +) +def test_dataset_rejects_country_owned_spm_outputs(name): + source = small_dataset() + source.spm_unit[name] = [False, False] if name.startswith("in_") else [0.0, 0.0] + with pytest.raises(ValueError, match="formula-owned SPM output"): + Microsimulation(dataset=source) + + def test_applying_reform_to_calculated_clone_preserves_original_tax(): situation = single_person_situation() situation["people"]["person"]["employment_income"] = {2024: 50_000} @@ -295,7 +368,8 @@ def test_shared_policy_branch_detaches_spm_receipts(): branch.tax_benefit_system.variables is simulation.tax_benefit_system.variables ) assert ( - branch.tax_benefit_system.parameters is simulation.tax_benefit_system.parameters + branch.tax_benefit_system.parameters.children + is simulation.tax_benefit_system.parameters.children ) assert branch.tax_benefit_system.simulation is branch assert branch.spm_provenance() == simulation.spm_provenance() @@ -474,3 +548,72 @@ def test_microsimulation_rejects_stored_measurements_without_mutating_dataset( pd.testing.assert_frame_equal(original, current) if dataset_format == "hdfstore": assert path.read_bytes() == content + + +@pytest.mark.parametrize("reform_at_parent", [False, True]) +def test_parameter_reform_updates_only_intentionally_shared_branches(reform_at_parent): + original = Simulation(situation=single_person_situation()) + branch = original.get_branch("shared") + independent = original.clone() + path = "gov.irs.deductions.standard.amount.SINGLE" + apply_to = original if reform_at_parent else branch + apply_to.apply_reform({path: {"2024": 100_000}}) + for member in (original, branch): + assert ( + member.tax_benefit_system.parameters.gov.irs.deductions.standard.amount.SINGLE( + "2024-01-01" + ) + == 100_000 + ) + assert ( + independent.tax_benefit_system.parameters.gov.irs.deductions.standard.amount.SINGLE( + "2024-01-01" + ) + != 100_000 + ) + assert ( + system.parameters.gov.irs.deductions.standard.amount.SINGLE("2024-01-01") + != 100_000 + ) + + +@pytest.mark.parametrize("reform_wrapper", [False, True]) +def test_trace_toggle_and_shared_branch_bind_current_tracer(reform_wrapper): + supplied = ( + {"tax_benefit_system": UserReform(system.clone())} if reform_wrapper else {} + ) + original = Simulation(situation=single_person_situation(), **supplied) + original.calculate("spm_unit_fpg", 2024) + original.trace = True + original.delete_arrays("spm_unit_fpg") + original.calculate("spm_unit_fpg", 2024) + assert original.tracer.trees[-1].parameters + branch = original.get_branch("traced_branch") + branch.delete_arrays("spm_unit_fpg") + branch.calculate("spm_unit_fpg", 2024) + assert original.tracer.trees[-1].branch_name == "traced_branch" + assert original.tracer.trees[-1].parameters + saved = original.tracer + original.trace = False + original.delete_arrays("spm_unit_fpg") + count = len(saved.trees) + original.calculate("spm_unit_fpg", 2024) + assert len(saved.trees) == count + + +def test_default_dataset_resolution_checks_content_hash(tmp_path, monkeypatch): + import importlib + import huggingface_hub + + module = importlib.import_module("policyengine_us.system") + path = tmp_path / "download.h5" + path.write_bytes(b"expected dataset bytes") + expected = hashlib.sha256(path.read_bytes()).hexdigest() + monkeypatch.setattr(module, "DEFAULT_DATASET_SHA256", expected) + monkeypatch.setattr(huggingface_hub, "hf_hub_download", lambda **kwargs: str(path)) + assert _resolve_dataset_path(DEFAULT_DATASET) == str(path) + path.write_bytes(b"replacement behind the same tag") + with pytest.raises(ValueError, match="Default Microcosm dataset content mismatch"): + _resolve_dataset_path(DEFAULT_DATASET) + assert path.read_bytes() == b"replacement behind the same tag" + assert _resolve_dataset_path(DEFAULT_DATASET + "-explicit-other") == str(path) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml index aa9eda413ec..b95a58c4a11 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/ca_cpuc_countable_income.yaml @@ -40,3 +40,75 @@ state_code: CA output: ca_cpuc_countable_income: 68_000 + +- name: Case 4, the earlier annual income convention uses actual housing assistance. + period: 2014 + input: + age: 40 + state_code: CA + employment_income: 1_000 + self_employment_income: 0 + sstb_self_employment_income: 0 + partnership_s_corp_income: 0 + farm_operations_income: 0 + interest_income: 0 + dividend_income: 0 + alimony_income: 0 + child_support_received: 0 + tanf: 0 + ssi: 0 + snap: 0 + wic: 0 + social_security: 0 + pension_income: 0 + retirement_distributions: 0 + housing_assistance: 500 + veterans_benefits: 0 + military_service_income: 0 + rental_income: 0 + farm_rent_income: 0 + capital_gains: 0 + unemployment_compensation: 0 + miscellaneous_income: 0 + gi_cash_assistance: 0 + debt_relief: 0 + illicit_income: 0 + spm_unit_capped_housing_subsidy: 25 + output: + ca_cpuc_countable_income: 1_500 + +- name: Case 5, annual income excludes housing assistance after the dated decision. + period: 2015 + input: + age: 40 + state_code: CA + employment_income: 1_000 + self_employment_income: 0 + sstb_self_employment_income: 0 + partnership_s_corp_income: 0 + farm_operations_income: 0 + interest_income: 0 + dividend_income: 0 + alimony_income: 0 + child_support_received: 0 + tanf: 0 + ssi: 0 + snap: 0 + wic: 0 + social_security: 0 + pension_income: 0 + retirement_distributions: 0 + housing_assistance: 500 + veterans_benefits: 0 + military_service_income: 0 + rental_income: 0 + farm_rent_income: 0 + capital_gains: 0 + unemployment_compensation: 0 + miscellaneous_income: 0 + gi_cash_assistance: 0 + debt_relief: 0 + illicit_income: 0 + spm_unit_capped_housing_subsidy: 25 + output: + ca_cpuc_countable_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/integration.yaml index dd5dac449b4..55dcbe0cb9d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cpuc/integration.yaml @@ -97,3 +97,75 @@ ca_care_eligible: false ca_fera_eligible: true ca_fera: 18 + +- name: Case 6, housing assistance does not disqualify an income-eligible CARE household. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 40_700 + pre_subsidy_rent: 36_000 + spm_units: + unit: + members: [person1] + spm_unit_tenure_type: RENTER + receives_housing_assistance: true + pre_subsidy_electricity_expense: 1_200 + households: + household: + members: [person1] + state_code: CA + pha_payment_standard: 36_000 + output: + # Tenant payment is 30% of income. The $36,000 payment standard equals + # gross rent, so the HUD benefit is $36,000 minus the tenant payment. + hud_ttp: 12_210 + housing_assistance: 23_790 + # CPUC D.14-08-030 excludes housing subsidies from countable income. + ca_cpuc_countable_income: 40_700 + ca_care_categorically_eligible: false + ca_care_income_eligible: true + ca_care_eligible: true + ca_care: 390 + ca_fera_eligible: false + ca_fera: 0 + +- name: Case 7, housing assistance does not disqualify an income-eligible FERA household. + period: 2024 + input: + people: + person1: + age: 40 + employment_income: 21_000 + pre_subsidy_rent: 36_000 + person2: + age: 40 + employment_income: 21_000 + person3: + age: 40 + employment_income: 21_000 + spm_units: + unit: + members: [person1, person2, person3] + spm_unit_tenure_type: RENTER + receives_housing_assistance: true + pre_subsidy_electricity_expense: 1_200 + households: + household: + members: [person1, person2, person3] + state_code: CA + pha_payment_standard: 36_000 + output: + # Tenant payment = 30% of $63,000; the $36,000 payment standard equals + # gross rent, so the HUD benefit is $36,000 - $18,900 = $17,100. + hud_ttp: 18_900 + housing_assistance: 17_100 + ca_cpuc_countable_income: 63_000 + ca_care_categorically_eligible: false + ca_care_income_eligible: false + ca_care_eligible: false + ca_care: 0 + ca_fera_eligible: true + # FERA discount = 18% of $1,200. + ca_fera: 216 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml index cb61fc7a9a8..275f2797e57 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml @@ -44,7 +44,8 @@ medicaid: 10 chip: 5 snap: 7 - spm_unit_capped_housing_subsidy: 8 + housing_assistance: 8 + spm_unit_capped_housing_subsidy: 1 ssi: 4 tanf: 3 free_school_meals: 2 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml index 545b50217f6..1a3c84f6175 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml @@ -20,7 +20,7 @@ head_start: 0 early_head_start: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 0 household_state_benefits: 0 household_health_benefits: 0 output: @@ -51,7 +51,7 @@ head_start: 0 early_head_start: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 10 + housing_assistance: 10 household_state_benefits: 0 commodity_supplemental_food_program: 0 household_health_benefits: 0 @@ -80,10 +80,33 @@ acp: 0 ebb: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 0 household_state_benefits: 0 commodity_supplemental_food_program: 0 household_health_benefits: 0 output: household_head_start_benefits: 0 household_benefits: 0 + +- name: Case 4, household benefits use the full housing payment rather than its SPM valuation. + period: 2024 + input: + age: 40 + housing_assistance: 12_000 + spm_unit_capped_housing_subsidy: 1_000 + social_security: 0 + ssi: 0 + tanf: 0 + unemployment_compensation: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + acp: 0 + ebb: 0 + household_state_benefits: 0 + household_health_benefits: 0 + output: + household_benefits: 12_000 + # The SPM resource valuation remains separate. + spm_unit_benefits: 1_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml index bad1d7579a7..8ca8a254933 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml @@ -20,7 +20,7 @@ head_start: 0 early_head_start: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 0 household_state_benefits: 0 household_health_benefits: 0 household_refundable_tax_credits: 0 @@ -55,7 +55,7 @@ head_start: 0 early_head_start: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 10 + housing_assistance: 10 household_state_benefits: 0 commodity_supplemental_food_program: 0 household_health_benefits: 0 @@ -88,7 +88,7 @@ acp: 0 ebb: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 0 household_state_benefits: 0 commodity_supplemental_food_program: 0 household_health_benefits: 0 @@ -122,7 +122,7 @@ acp: 0 ebb: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 0 household_state_benefits: 0 commodity_supplemental_food_program: 0 household_health_benefits: 0 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml index ff1a3215a61..b37e8c153b3 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml @@ -173,11 +173,12 @@ residential_efficiency_electrification_rebate: 0 unemployment_compensation: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 12_000 + spm_unit_capped_housing_subsidy: 1_000 household_state_benefits: 0 output: - household_benefits: 1 + 2 + 3 + 4 + 5 - spm_unit_benefits: 1 + 2 + 3 + 4 + 5 + household_benefits: 1 + 2 + 3 + 4 + 5 + 12_000 + spm_unit_benefits: 1 + 2 + 3 + 4 + 5 + 1_000 - name: BOOST household benefits exclude housing subsidy if HUD is abolished. period: 2024 @@ -204,7 +205,9 @@ residential_efficiency_electrification_rebate: 0 unemployment_compensation: 0 basic_income: 0 + housing_assistance: 10 spm_unit_capped_housing_subsidy: 10 household_state_benefits: 0 output: household_benefits: 0 + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml index c58353426fe..cf8c3331d02 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml @@ -221,12 +221,13 @@ residential_efficiency_electrification_rebate: 0 unemployment_compensation: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 12_000 + spm_unit_capped_housing_subsidy: 1_000 household_state_benefits: 0 household_health_benefits: 0 output: - household_benefits: 1 + 2 + 3 + 4 + 5 - spm_unit_benefits: 1 + 2 + 3 + 4 + 5 + household_benefits: 1 + 2 + 3 + 4 + 5 + 12_000 + spm_unit_benefits: 1 + 2 + 3 + 4 + 5 + 1_000 - name: EDAA ECPA household benefits exclude housing subsidy if HUD is abolished. period: 2026 @@ -254,9 +255,11 @@ head_start: 0 early_head_start: 0 basic_income: 0 + housing_assistance: 10 spm_unit_capped_housing_subsidy: 10 household_state_benefits: 0 commodity_supplemental_food_program: 0 household_health_benefits: 0 output: household_benefits: 0 + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml index c4e598fdfda..1d9d3f1e04e 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml @@ -121,11 +121,12 @@ residential_efficiency_electrification_rebate: 0 unemployment_compensation: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 12_000 + spm_unit_capped_housing_subsidy: 1_000 household_state_benefits: 0 output: - household_benefits: 1 + 2 + 3 + 4 + 5 - spm_unit_benefits: 1 + 2 + 3 + 4 + 5 + household_benefits: 1 + 2 + 3 + 4 + 5 + 12_000 + spm_unit_benefits: 1 + 2 + 3 + 4 + 5 + 1_000 - name: End Child Poverty Act household benefits exclude housing subsidy if HUD is abolished. period: 2024 @@ -152,7 +153,9 @@ residential_efficiency_electrification_rebate: 0 unemployment_compensation: 0 basic_income: 0 + housing_assistance: 10 spm_unit_capped_housing_subsidy: 10 household_state_benefits: 0 output: household_benefits: 0 + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml b/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml index 797d3424607..e64a68262af 100644 --- a/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml +++ b/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml @@ -34,7 +34,7 @@ residential_efficiency_electrification_rebate: 0 unemployment_compensation: 0 basic_income: 0 - spm_unit_capped_housing_subsidy: 0 + housing_assistance: 0 household_state_benefits: 0 output: household_head_start_benefits: 29_000 diff --git a/policyengine_us/tests/test_batched.py b/policyengine_us/tests/test_batched.py index bf84fbee3f3..cb2f4237f68 100644 --- a/policyengine_us/tests/test_batched.py +++ b/policyengine_us/tests/test_batched.py @@ -10,7 +10,6 @@ import gc import time import argparse -import re import select import threading from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor @@ -21,11 +20,19 @@ import yaml +def yaml_files(directory: Path, recursive: bool = True) -> List[Path]: + """List both extensions accepted by policyengine-core's YAML runner.""" + collect = directory.rglob if recursive else directory.glob + return sorted( + file for extension in ("yaml", "yml") for file in collect(f"*.{extension}") + ) + + def count_yaml_files(directory: Path) -> int: """Count YAML files in a directory recursively.""" if not directory.exists(): return 0 - return len(list(directory.rglob("*.yaml"))) + return len(yaml_files(directory)) def batch_cost(batch_paths: List[str]) -> int: @@ -178,7 +185,7 @@ def subdir_batches(subdir: Path) -> List[List[str]]: minutes before CI killed it. Pack such a folder's files by combo weight instead. """ - files = sorted(subdir.rglob("*.yaml")) + files = yaml_files(subdir) combos: set = set() for file in files: combos |= set(file_reform_combos(file)) @@ -214,7 +221,7 @@ def split_into_batches( # Explicit modes — bypass the per-path auto heuristics so new files # added to the target auto-route without a Makefile edit. if mode == "per-file": - return [[str(f)] for f in sorted(base_path.rglob("*.yaml"))] + return [[str(f)] for f in yaml_files(base_path)] if mode == "per-subdir": subdirs = sorted( @@ -222,7 +229,7 @@ def split_into_batches( for item in base_path.iterdir() if item.is_dir() and item.name not in exclude ) - root_files = sorted(base_path.glob("*.yaml")) + root_files = yaml_files(base_path, recursive=False) batches = [batch for s in subdirs for batch in subdir_batches(s)] if root_files: batches.append([str(f) for f in root_files]) @@ -264,7 +271,7 @@ def split_into_batches( for item in base_path.iterdir() if item.is_dir() and item.name not in exclude ) - root_files = sorted(base_path.glob("*.yaml")) + root_files = yaml_files(base_path, recursive=False) # One batch per file for per-file folders, then one batch per # heavy subdir (if present). @@ -272,7 +279,7 @@ def split_into_batches( [str(file)] for subdir in subdirs if subdir.name in PER_FILE - for file in sorted(subdir.rglob("*.yaml")) + for file in yaml_files(subdir) ] batches += [[str(subdir)] for subdir in subdirs if subdir.name in HEAVY] @@ -303,10 +310,10 @@ def split_into_batches( subdirs = sorted([item for item in base_path.iterdir() if item.is_dir()]) batches = [] for subdir in subdirs: - batches.extend(pack_files_by_combo_weight(sorted(subdir.rglob("*.yaml")))) + batches.extend(pack_files_by_combo_weight(yaml_files(subdir))) # Also include any root-level YAML files as trailing batches - root_files = sorted(base_path.glob("*.yaml")) + root_files = yaml_files(base_path, recursive=False) if root_files: batches.extend(pack_files_by_combo_weight(root_files)) @@ -319,7 +326,7 @@ def split_into_batches( # shutdown signal". A fresh subprocess per file frees each peak between # files. if "reform" in str(base_path): - return [[str(f)] for f in sorted(base_path.rglob("*.yaml"))] + return [[str(f)] for f in yaml_files(base_path)] # Special handling for states directory - support excluding specific states # and splitting into multiple sequential batches for memory management @@ -334,7 +341,7 @@ def split_into_batches( # Root-level YAML files (e.g. cross-state filing-status test) are # state-agnostic and would be invisible to subdir-based batching # otherwise — collect them into a dedicated trailing batch. - root_files = sorted(base_path.glob("*.yaml")) + root_files = yaml_files(base_path, recursive=False) if not subdirs and not root_files: return [] @@ -395,7 +402,7 @@ def collect_gov_paths(folder_names): if gov_item.is_dir(): if gov_item.name not in heavy_set and gov_item.name != "states": remaining.append(str(gov_item)) - elif gov_item.suffix == ".yaml": + elif gov_item.suffix in {".yaml", ".yml"}: remaining.append(str(gov_item)) # Add non-gov directories and root YAML files @@ -404,7 +411,7 @@ def collect_gov_paths(folder_names): if item.name in ["household", "contrib", "gov"]: continue remaining.append(str(item)) - elif item.suffix == ".yaml": + elif item.suffix in {".yaml", ".yml"}: remaining.append(str(item)) # Build batches (only include non-empty ones) @@ -435,10 +442,11 @@ def collect_gov_paths(folder_names): paths = sorted( str(item) for item in base_path.iterdir() - if (item.is_dir() or item.suffix == ".yaml") and item.name not in exclude + if (item.is_dir() or item.suffix in {".yaml", ".yml"}) + and item.name not in exclude ) else: - paths = sorted(str(f) for f in base_path.rglob("*.yaml")) + paths = sorted(str(f) for f in yaml_files(base_path)) if not paths: return [] @@ -459,7 +467,12 @@ def collect_gov_paths(folder_names): return batches -def run_batch(test_paths: List[str], batch_name: str, stream: bool = True) -> Dict: +def run_batch( + test_paths: List[str], + batch_name: str, + stream: bool = True, + timeout_seconds: float = 1800, +) -> Dict: """Run a batch of tests in an isolated subprocess. With stream=True output is echoed to stdout in real time (sequential @@ -478,7 +491,7 @@ def emit(text: str, end: str = "\n") -> None: else: buf.append(text + end) - start_time = time.time() + start_time = time.monotonic() last_output_time = start_time last_heartbeat_time = start_time heartbeat_interval = 30 @@ -516,16 +529,28 @@ def sample_rss() -> None: rss = _read_vm_hwm_mb(process.pid) if rss is not None: peak_rss_mb = rss - last_rss_sample = time.time() + last_rss_sample = time.monotonic() try: - test_completed = False - test_passed = False - output_lines = [] - output_text = "" - # Monitor output line by line while True: + if time.monotonic() - start_time >= timeout_seconds: + emit("\n ⏱️ Timeout - terminating process...") + sample_rss() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + return { + "elapsed": time.monotonic() - start_time, + "status": "timeout", + "returncode": process.returncode, + "peak_rss_mb": peak_rss_mb, + "output": "".join(buf), + } ready, _, _ = select.select([process.stdout], [], [], 0.1) if not ready: # Sample before poll(): poll() reaps the child, after which @@ -535,7 +560,7 @@ def sample_rss() -> None: if poll_result is not None: # Process terminated break - now = time.time() + now = time.monotonic() # In buffered (concurrent) mode the shared heartbeat in # run_batches_concurrently reports liveness instead. if stream and now - last_heartbeat_time >= heartbeat_interval: @@ -562,103 +587,30 @@ def sample_rss() -> None: # lines such as dots that do not end with newlines. line = chunk.decode(errors="replace") emit(line, end="") - last_output_time = time.time() + last_output_time = time.monotonic() last_heartbeat_time = last_output_time - output_lines.append(line) - output_text += line if last_output_time - last_rss_sample >= 1.0: sample_rss() - # Detect pytest completion - # Look for patterns like "====== 5638 passed in 491.24s ======" - # or "====== 2 failed, 5636 passed in 500s ======" - if re.search( - r"=+.*\d+\s+(passed|failed).*in\s+[\d.]+s.*=+", - output_text, - ): - test_completed = True - # Check if tests passed by parsing actual failure count - failed_match = re.search(r"(\d+) failed", output_text) - if failed_match: - failed_count = int(failed_match.group(1)) - test_passed = failed_count == 0 - else: - # No "X failed" in line means all passed - test_passed = True - - emit(f"\n Tests completed, terminating process...") - - # Give 1 second grace period for cleanup - time.sleep(1) - # Final sample while the pid still exists — captures the - # end-of-run high-water mark. - sample_rss() - - # Terminate the process - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - # Force kill if it won't terminate - emit(f" Force killing process...") - process.kill() - process.wait() - break - - # If we didn't detect completion, wait for process with timeout - if not test_completed: - try: - # Wait up to 30 minutes total - elapsed = time.time() - start_time - remaining_timeout = max(1800 - elapsed, 1) - process.wait(timeout=remaining_timeout) - except subprocess.TimeoutExpired: - emit(f"\n ⏱️ Timeout - terminating process...") - sample_rss() - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - - elapsed = time.time() - start_time - return { - "elapsed": elapsed, - "status": "timeout", - "peak_rss_mb": peak_rss_mb, - "output": "".join(buf), - } - - elapsed = time.time() - start_time - - if test_completed: - emit( - f"\n Batch completed in {elapsed:.1f}s " - f"(peak RSS: {_format_rss(peak_rss_mb)})" - ) - return { - "elapsed": elapsed, - "status": "passed" if test_passed else "failed", - "peak_rss_mb": peak_rss_mb, - "output": "".join(buf), - } - else: - # Process ended without detecting test completion - returncode = process.poll() - emit( - f"\n Batch completed in {elapsed:.1f}s " - f"(peak RSS: {_format_rss(peak_rss_mb)})" - ) - return { - "elapsed": elapsed, - "status": "passed" if returncode == 0 else "failed", - "peak_rss_mb": peak_rss_mb, - "output": "".join(buf), - } + # Pytest's summary is output, not an exit status: setup/teardown errors, + # interrupted runs and failing session hooks may follow passing tests. + # Wait for the actual child exit instead of terminating after a summary. + returncode = process.wait() + elapsed = time.monotonic() - start_time + emit( + f"\n Batch completed in {elapsed:.1f}s " + f"(exit {returncode}; peak RSS: {_format_rss(peak_rss_mb)})" + ) + return { + "elapsed": elapsed, + "status": "passed" if returncode == 0 else "failed", + "returncode": returncode, + "peak_rss_mb": peak_rss_mb, + "output": "".join(buf), + } except Exception as e: - elapsed = time.time() - start_time + elapsed = time.monotonic() - start_time emit(f"\n ❌ Error: {str(e)[:100]}") # Clean up process if still running @@ -678,6 +630,8 @@ def sample_rss() -> None: "peak_rss_mb": peak_rss_mb, "output": "".join(buf), } + finally: + process.stdout.close() def run_batches_concurrently(batches: List[List[str]], workers: int) -> List: diff --git a/policyengine_us/tests/unit/test_cpuc_spm_independence.py b/policyengine_us/tests/unit/test_cpuc_spm_independence.py new file mode 100644 index 00000000000..b723f2d8d44 --- /dev/null +++ b/policyengine_us/tests/unit/test_cpuc_spm_independence.py @@ -0,0 +1,80 @@ +"""CPUC income rules exclude housing subsidies, irrespective of SPM settings. + +Explicit SPM configuration and provenance comparisons require the Python API; +the ordinary policy outcomes are also covered by CPUC YAML integration cases. +""" + +from copy import deepcopy + +import numpy as np +import pytest + +from policyengine_us import Simulation + + +@pytest.mark.parametrize("assisted", [False, True]) +def test_assisted_care_income_is_independent_of_spm_selection(assisted): + year = 2024 + situation = { + "people": { + "person1": { + "age": {year: 40}, + "employment_income": {year: 40_700}, + "pre_subsidy_rent": {year: 36_000}, + } + }, + "spm_units": { + "unit": { + "members": ["person1"], + "spm_unit_tenure_type": {year: "RENTER"}, + "receives_housing_assistance": {year: assisted}, + "takes_up_housing_assistance_if_eligible": {year: assisted}, + "pre_subsidy_electricity_expense": {year: 1_200}, + } + }, + "households": { + "household": { + "members": ["person1"], + "state_code": {year: "CA"}, + "pha_payment_standard": {year: 36_000}, + } + }, + } + outputs = [ + "ca_cpuc_countable_income", + "ca_care_income_eligible", + "ca_care_eligible", + "ca_care", + "ca_fera_eligible", + "ca_fera", + ] + results = [] + for spm, county in [ + (None, None), + ({"geography_kind": "national"}, None), + ({"geography_kind": "county"}, "06037"), + ]: + inputs = deepcopy(situation) + if county is not None: + inputs["households"]["household"]["county_fips"] = {year: county} + simulation = Simulation(situation=inputs, spm=spm) + assistance = simulation.calculate("housing_assistance", year)[0] + assert bool(assistance > 0) is assisted + results.append([simulation.calculate(name, year)[0] for name in outputs]) + assert simulation.spm_provenance()["years"] == {} + + # D.14-08-030 excludes housing subsidies from CARE income. The modeled + # 2024 CARE limit is $40,880 and the discount is 32.5% of $1,200. + for result in results: + np.testing.assert_array_equal(result, [40_700, True, True, 390, False, 0]) + + +def test_cpuc_housing_exclusion_effective_date(): + parameters = Simulation.default_tax_benefit_system_instance.parameters + before = parameters("2014-08-13").gov.states.ca.cpuc.income_sources + after = parameters("2014-08-14").gov.states.ca.cpuc.income_sources + assert "housing_assistance" in before + assert list(after) == [name for name in before if name != "housing_assistance"] + assert list(parameters("2026-01-01").gov.states.ca.cpuc.income_sources) == list( + after + ) diff --git a/policyengine_us/tests/unit/test_spm_housing_allocation.py b/policyengine_us/tests/unit/test_spm_housing_allocation.py new file mode 100644 index 00000000000..5e232d37faf --- /dev/null +++ b/policyengine_us/tests/unit/test_spm_housing_allocation.py @@ -0,0 +1,296 @@ +"""Conserve program awards while allocating housing resources for the SPM.""" + +from copy import deepcopy + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_core.periods import period +from policyengine_core.reforms import Reform +from spm_calculator.errors import SPMInputError + + +YEAR = 2025 + + +def split_households(): + # Unequal unit sizes, interleaved people, and an independent household + # distinguish member-share allocation from duplication or array alignment. + return { + "people": {name: {"age": {YEAR: 40}} for name in ("a", "b", "c", "d", "e")}, + "spm_units": { + "head_family": { + "members": ["a"], + "housing_assistance": {YEAR: 12_000}, + "hud_ttp": {YEAR: 0}, + "spm_unit_tenure_type": {YEAR: "RENTER"}, + }, + "other_household": { + "members": ["b", "d"], + "housing_assistance": {YEAR: 6_000}, + "hud_ttp": {YEAR: 0}, + "spm_unit_tenure_type": {YEAR: "RENTER"}, + }, + "co_residents": { + "members": ["c", "e"], + "housing_assistance": {YEAR: 0}, + "hud_ttp": {YEAR: 0}, + "spm_unit_tenure_type": {YEAR: "RENTER"}, + }, + }, + "households": { + "shared_home": {"members": ["a", "c", "e"]}, + "separate_home": {"members": ["b", "d"]}, + }, + } + + +def test_subsidy_allocation_conserves_each_households_actual_awards(): + sim = Simulation(situation=split_households()) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_housing_subsidy", YEAR), + [4_000, 6_000, 8_000], + ) + np.testing.assert_array_equal( + sim.calculate("housing_assistance", YEAR), [12_000, 6_000, 0] + ) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_housing_subsidy", YEAR, map_to="household"), + [12_000, 6_000], + ) + # Allocation uses no threshold or geography and records no SPM calculation. + assert sim.spm_provenance()["years"] == {} + + +def test_multiple_actual_awards_are_preserved_before_spm_allocation(): + situation = split_households() + situation["spm_units"]["co_residents"]["housing_assistance"] = {YEAR: 3_000} + sim = Simulation(situation=situation) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_housing_subsidy", YEAR), + [5_000, 6_000, 10_000], + ) + np.testing.assert_array_equal( + sim.calculate("housing_assistance", YEAR), [12_000, 6_000, 3_000] + ) + + +def test_allocated_subsidy_reaches_nonrecipient_spm_unit_before_its_cap(): + sim = Simulation(situation=split_households(), spm={"geography_kind": "national"}) + np.testing.assert_array_equal( + sim.calculate("spm_unit_capped_housing_subsidy", YEAR), + [4_000, 6_000, 8_000], + ) + + +@pytest.mark.parametrize("amount", [0, 1, 12_345.67]) +def test_single_unit_allocation_preserves_program_amount(amount): + sim = Simulation( + situation={ + "people": {"a": {"age": {YEAR: 40}}}, + "spm_units": { + "unit": { + "members": ["a"], + "housing_assistance": {YEAR: amount}, + } + }, + } + ) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_housing_subsidy", YEAR), + sim.calculate("housing_assistance", YEAR), + ) + + +def test_zero_awards_need_no_spm_geography(): + situation = deepcopy(split_households()) + for unit in situation["spm_units"].values(): + unit["housing_assistance"] = {YEAR: 0} + sim = Simulation(situation=situation) + np.testing.assert_array_equal( + sim.calculate("spm_unit_capped_housing_subsidy", YEAR), [0, 0, 0] + ) + assert sim.spm_provenance()["years"] == {} + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_tenant_payment", YEAR), [0, 0, 0] + ) + + +@pytest.mark.parametrize("nonrecipient_ttp", [25, 45_000]) +def test_only_awarded_families_contribute_tenant_payment(nonrecipient_ttp): + situation = split_households() + units = situation["spm_units"] + units["head_family"]["hud_ttp"] = {YEAR: 3_600} + units["other_household"]["hud_ttp"] = {YEAR: 900} + units["co_residents"]["hud_ttp"] = {YEAR: nonrecipient_ttp} + sim = Simulation(situation=situation, spm={"geography_kind": "national"}) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_tenant_payment", YEAR), [1_200, 900, 2_400] + ) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_tenant_payment", YEAR, map_to="household"), + [3_600, 900], + ) + # Even the high counterfactual liability must leave a positive resource + # for the co-resident unit that receives part of the household subsidy. + assert sim.calculate("spm_unit_capped_housing_subsidy", YEAR)[2] > 0 + + +def test_independent_unit_caps_do_not_redistribute_unused_housing_value(): + situation = split_households() + situation["spm_units"]["head_family"]["hud_ttp"] = {YEAR: 90_000} + sim = Simulation(situation=situation, spm={"geography_kind": "national"}) + np.testing.assert_array_equal( + sim.calculate("spm_unit_allocated_tenant_payment", YEAR), [30_000, 0, 60_000] + ) + np.testing.assert_array_equal( + sim.calculate("spm_unit_capped_housing_subsidy", YEAR), [0, 6_000, 0] + ) + np.testing.assert_array_equal( + sim.calculate("housing_assistance", YEAR), [12_000, 6_000, 0] + ) + + +def real_hud_household(lodger_income=0, lodger_receipt=False): + return { + "people": { + "head": { + "age": {YEAR: 40}, + "is_household_head": {YEAR: True}, + "employment_income": {YEAR: 12_000}, + "pre_subsidy_rent": {YEAR: 36_000}, + }, + "lodger": { + "age": {YEAR: 30}, + "employment_income": {YEAR: lodger_income}, + }, + "co_resident": {"age": {YEAR: 25}}, + }, + "spm_units": { + "head_family": { + "members": ["head"], + "receives_housing_assistance": {YEAR: True}, + "takes_up_housing_assistance_if_eligible": {YEAR: True}, + "spm_unit_tenure_type": {YEAR: "RENTER"}, + }, + "lodger_family": { + "members": ["lodger", "co_resident"], + "receives_housing_assistance": {YEAR: lodger_receipt}, + "takes_up_housing_assistance_if_eligible": {YEAR: lodger_receipt}, + "spm_unit_tenure_type": {YEAR: "RENTER"}, + }, + }, + "households": { + "shared_home": { + "members": ["head", "lodger", "co_resident"], + "state_code": {YEAR: "CA"}, + "county_fips": {YEAR: "06037"}, + "bedrooms": {YEAR: 2}, + "tenant_pays_utilities": {YEAR: True}, + "tenure_type": {YEAR: "RENTED"}, + } + }, + "tax_units": { + name: {"members": [name]} for name in ("head", "lodger", "co_resident") + }, + "families": { + name: {"members": [name]} for name in ("head", "lodger", "co_resident") + }, + "marital_units": { + name: {"members": [name]} for name in ("head", "lodger", "co_resident") + }, + } + + +@pytest.mark.parametrize( + "lodger_income,lodger_receipt", [(0, False), (150_000, False), (150_000, True)] +) +def test_actual_hud_award_ignores_nonawarded_units_hypothetical_payment( + lodger_income, lodger_receipt +): + sim = Simulation(situation=real_hud_household(lodger_income, lodger_receipt)) + np.testing.assert_allclose( + sim.calculate("housing_assistance", YEAR), [28_392, 0], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("spm_unit_allocated_housing_subsidy", YEAR), + [9_464, 18_928], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("spm_unit_allocated_tenant_payment", YEAR), + [1_200, 2_400], + atol=0.01, + ) + capped = sim.calculate("spm_unit_capped_housing_subsidy", YEAR) + assert (capped > 0).all() + assert ( + sim.calculate("spm_unit_capped_housing_subsidy", YEAR, map_to="household")[0] + <= 28_392 + ) + + +def test_multiple_actual_awards_conserve_subsidy_and_contributions(): + sim = Simulation(situation=real_hud_household(lodger_receipt=True)) + np.testing.assert_allclose( + sim.calculate("housing_assistance", YEAR), [28_392, 2_639], atol=0.01 + ) + for name, expected in [ + ("spm_unit_allocated_housing_subsidy", 31_031), + ("spm_unit_allocated_tenant_payment", 3_625), + ]: + np.testing.assert_allclose( + sim.calculate(name, YEAR, map_to="household"), [expected], atol=0.01 + ) + + +def test_spm_selection_changes_measurement_without_changing_general_benefits(): + outputs = [] + for selection in ({"geography_kind": "county"}, {"geography_kind": "national"}): + sim = Simulation(situation=real_hud_household(), spm=selection) + outputs.append( + { + name: sim.calculate(name, YEAR) + for name in ( + "housing_assistance", + "household_benefits", + "spm_unit_capped_housing_subsidy", + ) + } + ) + for name in ("housing_assistance", "household_benefits"): + np.testing.assert_array_equal(outputs[0][name], outputs[1][name]) + assert not np.array_equal( + outputs[0]["spm_unit_capped_housing_subsidy"], + outputs[1]["spm_unit_capped_housing_subsidy"], + ) + + +def test_hud_abolition_zeroes_allocations_and_needs_no_measurement(): + def abolish(parameters): + parameters.gov.hud.abolition.update(period=period(YEAR), value=True) + return parameters + + class AbolishHUD(Reform): + def apply(self): + self.modify_parameters(abolish) + + sim = Simulation(situation=real_hud_household(), reform=AbolishHUD) + for name in ( + "housing_assistance", + "spm_unit_allocated_housing_subsidy", + "spm_unit_allocated_tenant_payment", + "spm_unit_capped_housing_subsidy", + ): + np.testing.assert_array_equal(sim.calculate(name, YEAR), [0, 0]) + assert sim.spm_provenance()["years"] == {} + + +def test_allocated_unit_with_no_classified_adult_raises_composition_error(): + situation = real_hud_household() + for name in ("lodger", "co_resident"): + situation["people"][name]["age"] = {YEAR: 10} + sim = Simulation(situation=situation) + with pytest.raises(SPMInputError, match="no classified adult"): + sim.calculate("spm_unit_capped_housing_subsidy", YEAR) diff --git a/policyengine_us/tests/unit/test_spm_integration_contract.py b/policyengine_us/tests/unit/test_spm_integration_contract.py index e03e00f18a1..b4109c5ec9c 100644 --- a/policyengine_us/tests/unit/test_spm_integration_contract.py +++ b/policyengine_us/tests/unit/test_spm_integration_contract.py @@ -269,24 +269,38 @@ def test_resource_consumers_without_housing_assistance_never_touch_geography( assert simulation.spm_provenance()["years"] == {} -@pytest.mark.parametrize("variable", ["household_net_income", "marginal_tax_rate"]) -def test_resource_consumers_with_housing_assistance_require_geography(variable): - """Once there is assistance to cap, the county requirement applies. - - Earnings stay low so the tenant payment sits below the housing portion - and the cap binds on the assistance rather than on zero. - """ - situation = household(earnings=6_000) - situation["spm_units"]["spm_unit"]["housing_assistance"] = {YEAR: 5_000} +@pytest.mark.parametrize( + "variable", + [ + "household_benefits", + "household_net_income", + "marginal_tax_rate", + "cbo_household_means_tested_transfers", + ], +) +def test_general_income_with_housing_assistance_is_independent_of_spm(variable): + """Benefit aggregates use the actual HUD payment, not its SPM valuation.""" + situation = household(earnings=24_000) + results = [] + for config in [None, {"geography_kind": "national"}]: + simulation = Simulation(situation=situation, spm=config) + assert simulation.calculate("housing_assistance", YEAR)[0] > 0 + results.append(simulation.calculate(variable, YEAR)) + assert np.all(np.isfinite(results[-1])) + assert simulation.spm_provenance()["years"] == {} + np.testing.assert_array_equal(*results) + + +def test_spm_resources_with_housing_assistance_still_require_geography(): + situation = household(earnings=24_000) with pytest.raises(SPMInputError) as error: - Simulation(situation=situation).calculate(variable, YEAR) + Simulation(situation=situation).calculate("spm_unit_net_income", YEAR) assert error.value.code == "SPM_GEOGRAPHY_REQUIRED" - assert error.value.to_dict()["code"] == "SPM_GEOGRAPHY_REQUIRED" national = Simulation(situation=situation, spm={"geography_kind": "national"}) - result = national.calculate(variable, YEAR) - assert np.all(np.isfinite(result)) + assistance = national.calculate("housing_assistance", YEAR)[0] capped = national.calculate("spm_unit_capped_housing_subsidy", YEAR)[0] - assert 0 < capped <= 5_000 + assert 0 < capped < assistance + assert np.all(np.isfinite(national.calculate("spm_unit_net_income", YEAR))) assert str(YEAR) in national.spm_provenance()["years"] diff --git a/policyengine_us/tests/unit/test_spm_measurement_boundary.py b/policyengine_us/tests/unit/test_spm_measurement_boundary.py new file mode 100644 index 00000000000..2c75172b353 --- /dev/null +++ b/policyengine_us/tests/unit/test_spm_measurement_boundary.py @@ -0,0 +1,104 @@ +"""Keep SPM valuation caps out of policy benefits and general income measures.""" + +import ast +from pathlib import Path + +import yaml + + +def test_spm_caps_have_only_spm_resource_consumers(): + root = Path(__file__).resolve().parents[2] + # New reporting leaves require an explicit update to this allowed graph. + consumers = { + "spm_unit_allocated_housing_subsidy": {"spm_unit_capped_housing_subsidy"}, + "spm_unit_allocated_tenant_payment": {"spm_unit_capped_housing_subsidy"}, + "spm_unit_capped_housing_subsidy": {"spm_unit_benefits"}, + "spm_unit_capped_work_childcare_expenses": {"spm_unit_spm_expenses"}, + "spm_unit_head_spouse_earned_cap": {"spm_unit_capped_work_childcare_expenses"}, + "spm_unit_spm_expenses": {"spm_unit_net_income"}, + "spm_unit_benefits": {"spm_unit_net_income"}, + "spm_unit_net_income": { + "spm_unit_is_in_spm_poverty", + "spm_unit_is_in_deep_spm_poverty", + "poverty_gap", + "deep_poverty_gap", + "spm_unit_oecd_equiv_net_income", + }, + "spm_unit_oecd_equiv_net_income": {"spm_unit_income_decile"}, + "spm_unit_income_decile": set(), + "poverty_gap": {"in_poverty"}, + "deep_poverty_gap": {"in_deep_poverty"}, + "spm_unit_is_in_spm_poverty": set(), + "spm_unit_is_in_deep_spm_poverty": set(), + "in_poverty": {"person_in_poverty"}, + "in_deep_poverty": set(), + "person_in_poverty": set(), + } + violations = [] + + class Visitor(ast.NodeVisitor): + def __init__(self, path): + self.path = path + self.consumer = None + + def visit_ClassDef(self, node): + previous = self.consumer + self.consumer = node.name + self.generic_visit(node) + self.consumer = previous + + def visit_Constant(self, node): + if isinstance(node.value, str) and node.value in consumers: + # References outside a variable class also fail explicitly. + if self.consumer not in consumers[node.value]: + violations.append( + f"{self.path.relative_to(root)}:{node.lineno}: " + f"{self.consumer} consumes {node.value}" + ) + + def visit_Expr(self, node): + # A docstring describes a variable; it does not calculate one. + if not isinstance(node.value, ast.Constant) or not isinstance( + node.value.value, str + ): + self.generic_visit(node) + + def visit_Assign(self, node): + metadata = {"documentation", "label", "reference"} + if not all( + isinstance(target, ast.Name) and target.id in metadata + for target in node.targets + ): + self.generic_visit(node) + + for directory in ("variables", "reforms"): + for path in (root / directory).rglob("*.py"): + Visitor(path).visit(ast.parse(path.read_text(), filename=str(path))) + + def scalar_values(value): + if isinstance(value, dict): + for key, child in value.items(): + if key == "description": + continue + if key == "metadata": + # A breakdown can name a variable; descriptive metadata + # cannot introduce a calculated dependency. + if isinstance(child, dict): + yield from scalar_values(child.get("breakdown", [])) + continue + yield from scalar_values(child) + elif isinstance(value, list): + for child in value: + yield from scalar_values(child) + else: + yield value + + # Include nested values in brackets/breakdowns. BaseLoader preserves + # year-zero date keys without constructing unsupported datetime objects. + for path in (root / "parameters").rglob("*.yaml"): + parameter = yaml.load(path.read_text(), Loader=yaml.BaseLoader) + for value in scalar_values(parameter): + if value in consumers: + violations.append(f"{path.relative_to(root)} names {value}") + + assert not violations, "\n".join(violations) diff --git a/policyengine_us/variables/household/income/household/household_benefits.py b/policyengine_us/variables/household/income/household/household_benefits.py index 274b1892c61..f5e709b7764 100644 --- a/policyengine_us/variables/household/income/household/household_benefits.py +++ b/policyengine_us/variables/household/income/household/household_benefits.py @@ -12,8 +12,6 @@ def formula(household, period, parameters): BENEFITS = list(parameters(period).gov.household.household_benefits) if parameters(period).gov.hud.abolition: BENEFITS = [ - benefit - for benefit in BENEFITS - if benefit != "spm_unit_capped_housing_subsidy" + benefit for benefit in BENEFITS if benefit != "housing_assistance" ] return add(household, period, BENEFITS) diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_allocated_housing_subsidy.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_allocated_housing_subsidy.py new file mode 100644 index 00000000000..992860bc208 --- /dev/null +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_allocated_housing_subsidy.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class spm_unit_allocated_housing_subsidy(Variable): + value_type = float + entity = SPMUnit + label = "Housing subsidy allocated to the SPM unit before its resource cap" + definition_period = YEAR + unit = USD + reference = "https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf#page=14" + + def formula(spm_unit, period, parameters): + # Census prorates the household subsidy by each SPM unit's member + # share. This allocation is a measurement step; it does not move the + # underlying program award or change another family's eligibility. + simulation = spm_unit.simulation + household_subsidy = simulation.calculate( + "housing_assistance", period, map_to="household" + ) + return simulation.map_result(household_subsidy, "household", "spm_unit") diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_allocated_tenant_payment.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_allocated_tenant_payment.py new file mode 100644 index 00000000000..4db1c988620 --- /dev/null +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_allocated_tenant_payment.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class spm_unit_allocated_tenant_payment(Variable): + value_type = float + entity = SPMUnit + label = "Tenant payment allocated to the SPM unit for its housing resource cap" + definition_period = YEAR + unit = USD + reference = "https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf#page=14" + + def formula(spm_unit, period, parameters): + awarded = spm_unit("housing_assistance", period) > 0 + if not awarded.any(): + return np.zeros(spm_unit.count) + # A4: allocate the contribution of actually awarded program families + # alongside their subsidy. Census describes a household contribution + # but does not specify its multi-SPM-unit allocation. This assumption + # conserves that contribution; a nonrecipient's hypothetical HUD TTP + # must not reduce another family's allocated housing resource. + payments = where(awarded, spm_unit("hud_ttp", period), 0).astype("float64") + simulation = spm_unit.simulation + household_payments = simulation.map_result(payments, "spm_unit", "household") + return simulation.map_result(household_payments, "household", "spm_unit") diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_capped_housing_subsidy.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_capped_housing_subsidy.py index b24f0ecf20b..8e123bf641e 100644 --- a/policyengine_us/variables/household/income/spm_unit/spm_unit_capped_housing_subsidy.py +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_capped_housing_subsidy.py @@ -11,8 +11,10 @@ class spm_unit_capped_housing_subsidy(Variable): reference = "https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf" def formula(spm_unit, period, parameters): - housing_assistance = spm_unit("housing_assistance", period).astype("float64") - # Only a unit with housing assistance has anything to cap. Consulting + housing_assistance = spm_unit( + "spm_unit_allocated_housing_subsidy", period + ).astype("float64") + # Only a unit allocated housing assistance has anything to cap. Consulting # the canonical housing portion for the other units would demand SPM # geography and composition from resource and benefit consumers that # never use the measurement, so the cap is evaluated for assisted units @@ -25,6 +27,8 @@ def formula(spm_unit, period, parameters): housing_portion = masked_policyengine_amount( spm_unit, period, "housing_portion", assisted ) - tenant_payment = spm_unit("hud_ttp", period).astype("float64") + tenant_payment = spm_unit("spm_unit_allocated_tenant_payment", period).astype( + "float64" + ) cap = max_(housing_portion - tenant_payment, 0) return where(assisted, min_(housing_assistance, cap), 0) diff --git a/pyproject.toml b/pyproject.toml index 075d3445534..859bb54a502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,16 @@ dependencies = [ policyengine-us = "policyengine_us:main" [build-system] -requires = ["hatchling"] +# Exact build requirements also constrain the isolated backend environment +# created by python -m build; the runtime uv.lock does not pin that environment. +requires = [ + "hatchling==1.32.0", + "packaging==26.3", + "pathspec==1.1.1", + "pluggy==1.6.0", + "tomlkit==0.15.1", + "trove-classifiers==2026.6.1.19", +] build-backend = "hatchling.build" [project.optional-dependencies]