Skip to content

Use canonical SPM measurements and immutable population default - #9428

Merged
MaxGhenis merged 26 commits into
mainfrom
max/spm-canonical-final-20260909
Sep 11, 2026
Merged

Use canonical SPM measurements and immutable population default#9428
MaxGhenis merged 26 commits into
mainfrom
max/spm-canonical-final-20260909

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The country model now uses the canonical calculator artifact for SPM measurement, with observed county assignment by default and an explicit national or fixed-area selection. It preserves country tax/benefit formulas, applies the housing cap to the canonical amount before final storage conversion, and binds cloned/reform/baseline holders to their own policy variables.

Microsimulation() defaults to:

hf://datasets/policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260909

That immutable tag is published. A real download with no offline override resolves it to a file whose SHA256 is 6496cc4393d4d3c6574f76eca231de5898c803b9067645591fd5c4d3e65aee84, matching the published H5 hash. Readback confirms 57,240 households, household.county_fips stored as five-digit strings, and person.is_spm_independent_minor_role present. The no-argument Microsimulation() tests therefore run for real; none of them is guarded or skipped.

County measurement requires observed county_fips; state-only requests can select national measurement explicitly with spm={"geography_kind": "national"}. A request with no usable county raises a country-level SPM_GEOGRAPHY_REQUIRED naming the two fixes a caller has — send county_fips as a five-digit string, or select geography_kind="national" — and that now covers integers, pandas missing values and codes that lost a leading zero, which the calculator alone would have reported as an unavailable county assignment. An unrecognised five-digit code still raises SPM_GEOGRAPHY_UNAVAILABLE. Unsupported measurement years raise SPM_YEAR_UNAVAILABLE; the artifact covers 2022–2035. Tax-only requests remain lazy. There is no consumer extrapolation or implicit geographic fallback.

An unpublished or renamed dataset build id now surfaces as a country-level FileNotFoundError naming the URI, with the Hugging Face error as its cause, instead of a transport error that never says which dataset the model was asked for.

Population data must supply the source-backed is_spm_independent_minor_role primitive and observed county inputs. Formula-owned stored SPM outputs are rejected; source observations belong in report-only columns. The legacy policyengine-us-data CPS files predate that contract and cannot produce resource or poverty outputs:

  • cps_2023.h5 stores the formula-owned spm_unit_spm_threshold column, so the loader rejects it at construction. It also stores county FIPS as the CPS within-state integer code.
  • enhanced_cps_2024.h5 loads and computes tax variables, and its county column does hold five-digit FIPS codes, but 18 of its 43,134 SPM units are a lone 15-to-17-year-old with no source-backed independence role, so those units classify no SPM measurement adult.

Both failures are now asserted as contract tests. The society-wide coverage those files used to carry — net income across the extended years, in-range income deciles, nonzero earnings — moved onto the shipped default build. The future-dated reform regression (#9075) runs two simulations over one 500-household slice of the shipped file, with a non-vacuity guard proving the reform reaches that population in its first year.

A simulation no longer clones policy state when its policy is the shipped policy. Core's TaxBenefitSystem.clone() rebuilds the whole parameter tree node by node and empties its at-instant caches, and this country deep-copies every one of its 6,157 variable objects on top. Three paths were paying that per simulation, and none of them has any policy of its own to isolate: no reform and no supplied system; a supplied system with no reform, which is how a caller that builds one system and runs many households through it works, including the household API and much of this suite; and the baseline branch core builds for a policy reform. Each now shares the shipped parameter tree and variable objects and keeps only its receipts and its own variable registry private, which is safe because every core operation a reform performs on a variable rebinds variables[name] to a new object rather than mutating the registered one. A supplied system with a reform still gets the full clone, because core applies the reform set to whatever system it is handed and the caller's own system must not be the one it reforms. test_ordinary_simulation_shares_default_policy_state asserts the shared tree is reused and that nothing reaches the shared instance.

Measured on one machine, one process, warm caches. 20 sequential single-household Simulation constructions plus household_net_income (scripts/benchmark_household_simulation.py):

revision total of which construction first iteration mean of the rest
main d8fb269 6.14s 0.50s 1.64s 0.237s
reviewed head f121882 109.81s 70.37s 3.42s 5.600s
this head 4.74s 0.48s 1.24s 0.184s

Net income is bit-identical across all three revisions for that household, so the comparison is like for like.

The same clone also sat behind the reviewed head's Rest job, which hit its 60-minute timeout at 52% of the Python suite where main's Rest job takes 21m22s end to end. Two same-machine measurements of that suite:

suite main d8fb269 reviewed head f121882 this head
174 tests in four tests/core files, itemization branching on 26.12s 545.58s 26.56s
make test-other-python, itemization branching off 4m37s (437 tests) not run locally 5m21s (510 tests)

Receipts for this head:

  • pyproject.toml version is 1.825.1 and the committed uv.lock root matches it; python .github/release_lock.py --committed exits 0.
  • spm-calculator resolves to 1.0.0 from https://pypi.org/simple, sdist SHA256 a99aac8c2c0bf81a9455105bbf872366f1ea9066a99cd793714881cd3db846dc and wheel SHA256 e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305. The exact spm-calculator==1.0.0 pin remains.
  • policyengine-core resolves to 3.30.2 in both the lock and this environment.
  • The bundled forecast is spm-rolling-2026-09-09, content SHA256 3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173, default scenario ce_trend, 14 years covering 2022–2035.
  • The breaking changelog fragment drives the major bump to 2.0.0 from 1.825.1 through the existing version helper; no manual PR bump.

Test coverage on this head:

  • The three canonical SPM modules hold 36 test functions and collect 79 cases: tests/core/test_spm_system.py (16 functions, 30 cases), tests/unit/test_spm_integration_contract.py (9, 24), and tests/policy/baseline/household/income/spm_unit/test_spm_unit_spm_threshold.py (11, 25). Eleven of those cases are new on this head, covering shared policy state, the URI-naming download error, integer county columns in a dataset, and non-string county inputs in a situation.
  • 16 YAML files change, carrying 122 cases. Eleven were repaired earlier in this PR; five more are repaired here — CBO after-tax income, marginal tax rate including health benefits, the Trump dividend benefit aggregates, and CA CPUC countable income and its integration cases — each with the same zero housing subsidy input the earlier repairs use. Every repaired file passes with itemization branching both on and off.

Prepared from authoritative upstream main b8ca61a23e1c7ca9f66181dee36d5d8bee89c916, preserving its Alabama 2025 deduction correction and automated version metadata. A full-index three-way patch transferred the reviewed canonical donor on 42103db1b7d5be74bd28ebfc8e93adb2c7859079, including its fixture repairs.

Related scientific integration history: #9081 (inspected; older draft 0.4.0 proposal). Also inspected #9421, the earlier 0.5.0 Git-pin draft. This is a fresh canonical integration PR; neither prior PR is overwritten or closed.

Automatic release safeguards check the committed PyPI lock before the version bump and before publication. After the existing bump and Towncrier run, a guarded refresh permits only the root version to change; resolver failure or dependency drift restores the reviewed lock bytes. Every dependency artifact must have a canonical PyPI file URL and SHA256 hash. The guard tests, the committed-lock check and the changelog check pass in CI on this branch.

Open, needing a decision before this can go green:

  • tests/policy/baseline/partners/analytics_coverage/edge_cases/state/ca/care.yaml and .../fera.yaml fail with SPM_GEOGRAPHY_REQUIRED. CA CPUC countable income includes spm_unit_capped_housing_subsidy (PG&E Form 01-9077 counts housing subsidies), so CARE and FERA now need an SPM area. Both fixtures do name their county, as county_str: LOS_ANGELES_COUNTY_CA, but the model derives geography one-directionally (county_fips -> county -> county_str), so an explicitly named county never becomes a FIPS code. Three candidate fixes, none applied: add a county_fips input to the two fixtures (a partner contract edit, gated); backfill county_fips from an explicitly supplied county the way _backfill_state_code_from_str handles state (correct, but 328 YAML test files supply county_str, 134 of them partner fixtures, so it would change county from the first-county-in-state fallback to the named county across the partner suite); or drop spm_unit_capped_housing_subsidy from the CPUC income list in favour of housing_assistance (changes partner-visible CARE/FERA amounts for assisted households). This is the same coordinated-rollout item as state-only callers losing resource and poverty outputs, and it belongs with the API and app PRs.

No merge, release, publication, heavy population rerun or partner snapshot rewrite is included in this PR.

Root additions after the fix lane (b4453be21d0b0d)

  • Congress CI shard (b4453be): batch 6 (contrib/congress/tlaib) hung without output until CI killed it. Per-subdir batching gave the whole proposal folder one subprocess regardless of the batcher's own memory model; tlaib carries reform-combo weight 8.25 and peaked at 15.0 GB on the 16 GB runner at main (run 34637166889), and locally the batch peaks at 18.2 GB on main and 18.1 GB on this branch, so the runner had no headroom and swapped instead of failing. test_batched.py now packs any per-subdir folder whose distinct combos exceed MAX_BATCH_COMBO_WEIGHT by combo weight, pinned by tests/code_health/test_batched_per_subdir_budget.py. On this branch the shard now passes in 17 minutes with tlaib as two subprocesses peaking at 8,705 MB and 10,615 MB (run 34648331803); no other congress folder changes.
  • Housing cap consults the measurement only where there is assistance to cap (21d0b0d): the cap asked the calculator for the canonical housing portion for every unit, so net income, benefits, marginal tax rates and state programs whose countable income sums the cap (CA CPUC CARE/FERA) demanded SPM geography and composition from households that never use the measurement, and two partner contract fixtures for LA households failed on that alone. No partner uses SPM outputs. spm_unit_capped_housing_subsidy now evaluates the housing portion for assisted units only through policyengine_us.spm.masked_policyengine_amount (same provider path, so composition and county requirements, typed errors and receipts attach to exactly the units whose result depends on the measurement); unassisted units are 0 with no lookup. Assisted units without a county still fail closed with SPM_GEOGRAPHY_REQUIRED; an explicit national selection computes them. The contract tests pin the new behavior, the two partner fixtures pass unmodified, and the thirteen fixture files that had been given a spm_unit_capped_housing_subsidy: 0 input return to their main-branch content (93 cases pass).

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits September 11, 2026 11:49
Keep spm-calculator==1.0.0 from this branch over the protective
spm-calculator==0.3.1 pin that PR #9427 landed on main; take main's
automatic version bump to 1.825.1 and its lock, changelog and model
changes unchanged. The registry lock is regenerated in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
spm-calculator 1.0.0 is now on PyPI, so the committed country lock no
longer has to stay blocked. Regenerating with the guard's own uv
invocation moves the root package to 1.825.1 and the calculator from
0.3.1 to 1.0.0, changing nothing else in the resolved graph.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis and others added 4 commits September 11, 2026 13:18
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Core's TaxBenefitSystem.clone() rebuilds the parameter tree node by node and
empties its at-instant caches, and this country deep-copies every variable
object on top. Doing that per simulation put a full policy rebuild in front of
every household API request and started each one with cold parameter caches.

An ordinary simulation applies no user reform and supplies no system, so
nothing distinguishes its policy from the shared instance's. Share the
parameter tree and the variable objects, and keep only the receipts and the
variable registry private: every core operation a reform performs on a
variable rebinds variables[name] to a new object rather than mutating the
registered one, so the private dict is enough to keep the structural reform
re-applied at this simulation's start instant out of the shared instance.

20 sequential single-household Simulation constructions plus
household_net_income, one process, warm HF cache: 109.8s before (70.4s of it
in construction), 5.9s after (0.6s in construction). scripts/
benchmark_household_simulation.py is the measurement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…build

The calculator treats only None and the empty string as an absent county, so a
county FIPS input that is an integer, a missing value or a code that lost its
leading zero was reported as an unavailable county assignment - pointing at the
artifact rather than at the input. County FIPS is a string variable, so every
such value reaches the forecast as a string; require a five-digit code and name
the two fixes a caller has, sending county_fips as a five-digit string or
selecting geography_kind="national".

A dataset URI whose build id is not published reached the caller as a Hugging
Face transport error that never mentioned which dataset the model was asked
for. Name the URI and keep the Hub error as the cause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The legacy policyengine-us-data CPS files cannot satisfy the contract this PR
introduces, so the tests that ran the model over them could not pass:

- cps_2023.h5 stores the formula-owned spm_unit_spm_threshold column, so the
  loader rejects it before any geography selection matters. It also stores
  county FIPS as the CPS within-state integer code.
- enhanced_cps_2024.h5 loads and computes tax variables, and its county column
  does hold five-digit FIPS codes, but 18 of its 43,134 SPM units are a lone
  15-to-17-year-old with no source-backed independence role, so those units
  classify no SPM measurement adult.

Assert both of those failures instead, and move the society-wide coverage they
used to carry - net income across the extended years, in-range income deciles,
nonzero earnings - onto the default build, which is the population the model
ships. The default-dataset tests stay real: the build id they resolve is
published, so they download it rather than skipping.

The future-dated reform regression needs two simulations over one population,
which neither the legacy files nor two subsamples of the whole default build
can provide. Slice whole households out of the shipped file instead, and add a
non-vacuity guard so the pre-start equality cannot pass on a population the
reform never reaches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (f9b5549) to head (3e81597).
⚠️ Report is 67 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #9428   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            2         1    -1     
  Lines           20        18    -2     
  Branches         0         1    +1     
=========================================
- Hits            20        18    -2     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

MaxGhenis and others added 12 commits September 11, 2026 14:13
CI on the reviewed head found five more baseline and contrib files whose cases
compute a resource, a benefit aggregate or a marginal tax rate and therefore
reach the canonical housing cap: CBO after-tax income, marginal tax rate
including health benefits, the Trump dividend benefit aggregates, and CA CPUC
countable income and its integration cases. Apply the same repair as the eight
files already in this PR and supply a zero housing subsidy, which also settles
the 2021 marginal-tax-rate case: with no housing aid, the measurement year is
never looked up.

Each file passes with itemization branching on and off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An ordinary simulation is new rather than cloned, so it never inherits a
receipt; the flag had no caller. Branch cloning keeps its own shallow copy,
which must share the variables dict rather than take a private one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings main's Washington working families credit work and its 1.825.2 version
bump, so the committed registry lock root can be refreshed to match the version
CI resolves on the merge commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pull-request checks run on the merge commit, so the committed lock root has
to match main's pyproject version, not the version the branch carried before
the merge. Only the root version changes; the guard's own refresh path verified
the dependency graph is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first pass only covered the path with no reform and no supplied system, and
that left the expensive clone on the two paths the suite and the household API
actually use most: a caller that builds one system and runs many households
through it (Simulation(tax_benefit_system=SYSTEM, ...)), and the baseline branch
core builds for a policy reform. Both hold policy identical to the shared
instance's, so both share it. A supplied system with a reform still gets the
full clone, because core applies the reform set to whatever system it is handed
and the caller's own system must not be the one it reforms.

Same machine, same 174 tests in tests/core/{payroll_contributions,
local_employee_taxes,employer_state_unemployment_tax_jurisdictions,
medicaid_slcsp_cost}, itemization branching on: 26.12s at main, 545.58s before
this commit, 26.56s after. The 20-iteration household benchmark is 4.74s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The congress shard hung in batch 6 on this branch until CI killed it after
39 minutes without output. Per-subdir mode gives each proposal folder one
subprocess regardless of the module's own memory model, and congress/tlaib
carries reform-combo weight 8.25: it peaked at 15.0 GB on the 16 GB runner
at main (run 34637166889) and reaches 18.1 GB on this branch locally, so on
the runner it swaps rather than fails. Pack any folder whose distinct combos
exceed MAX_BATCH_COMBO_WEIGHT by combo weight instead; tlaib becomes two
subprocesses of weight 4.0 and 4.25, and no other congress folder changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es not support

Main's tlaib batch peaks at 18.2 GB locally, the same as this branch, so the
earlier wording that the branch adds per-combo memory is unsupported. What is
supported: the runner has no headroom for this batch at main, and the branch
run produced no output for 39 minutes before CI killed it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e to cap

The capped housing subsidy asked the calculator for the canonical housing
portion for every unit, so every consumer of the cap - household net income,
benefits, marginal tax rates, and state programs whose countable income sums
the cap, such as CA CPUC CARE and FERA - demanded SPM geography and
composition from households that never use the measurement. Partners do not
request SPM outputs, and two partner contract fixtures for LA households
failed with SPM_GEOGRAPHY_REQUIRED for that reason alone.

A unit with no housing assistance has nothing to cap: min(0, cap) is zero for
any cap. Evaluate the housing portion for assisted units only, through the
same provider path (masked_policyengine_amount), so the county and
composition requirements, the typed errors and the receipts attach to
exactly the units whose result depends on the measurement. Assisted units
without a county still fail closed; an explicit national selection still
computes them. The contract tests that pinned the old behavior now pin this
one, and the thirteen fixture files that had been given a zero capped-subsidy
input to sidestep the requirement return to their main-branch content: all
93 of their cases pass unmodified, as do the two partner fixtures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the wording

The Fable review of the delta asked for three changes. The code-health test
repeated a memory claim the main-branch baseline does not support; it now
mirrors the batcher's wording. The test named for a mixed population built a
single unit; two tests now build two households, one assisted with a county
and one with neither, and pin that the assisted unit is capped, the other is
zero, exactly one geography receipt is recorded, and an assisted unit without
a county still fails closed beside an unassisted neighbour. PROGRESS.md was
the fix lane's working record and described a state this branch has moved
past, so it leaves the PR. The batcher docstring, its CLI help, the Makefile
and docs/spm.md now describe per-subdir splitting and the assisted-only cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ct no tests

Quick Feedback failed on this branch with exit code 5 although every selected
test passed: two changed files under tests/, the populace_fixture helper and
a fixtures module named like a test, collect nothing, and pytest reports that
with exit code 5, which the runner counted as a failure. A selected path with
nothing to collect is a support module, not a failing test; the runner now
notes it and moves on, and real failures beside it still fail the run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review September 11, 2026 23:16
@MaxGhenis
MaxGhenis merged commit 2cda664 into main Sep 11, 2026
35 checks passed
@MaxGhenis
MaxGhenis deleted the max/spm-canonical-final-20260909 branch September 11, 2026 23:16
MaxGhenis added a commit that referenced this pull request Sep 12, 2026
…ization (#9444)

The first automatic version bump after #9428 failed closed in the release
lock guard: "Versioning changed the reviewed dependency graph". The runner
installs the latest uv (0.12.13, unpinned) while the committed lock was
written by 0.11.7. A fresh lock is left alone by either version, but the bump
forces a re-resolution, and 0.12.13 then rewrites 37 resolution-marker lines
in its own normalization; the guard compares the whole graph and refuses.
Reproduced locally with uv 0.12.13 at version 2.0.0; under 0.11.7 the same
refresh changes only the root version.

Commit the lock as 0.12.13 writes it after a re-resolution (bump, lock,
restore, lock: 37 marker lines change, no package or version changes), and
pin uv 0.12.13 in every setup-uv step of the push, pull request and weekly
lock workflows so the toolchain that checks the lock is the one that wrote
it. With this lock the 2.0.0 refresh differs from the committed graph in the
root version alone, by the guard's own comparison. uv 0.11.7 still passes
`uv lock --check` on it, so developers need no upgrade. The release-lock
document records how to move the pin.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant