Skip to content

Measure state-only US households nationally instead of raising - #544

Draft
MaxGhenis wants to merge 3 commits into
mainfrom
fix/household-spm-national-fallback
Draft

MaxGhenis wants to merge 3 commits into
mainfrom
fix/household-spm-national-fallback

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #543

Why

Since 6.0.0, pe.us.calculate_household raises SPMInputError: County selection has no county FIPS input ('') for every household that gives a state but no county_fips. Two defaults combine to cause it:

  • the default output columns include spm_unit_is_in_spm_poverty (us/model.py);
  • the bundle's default SPM selection is county measurement (data/bundle/manifest.json: measurements.spm.geography_kind = "county").

So a tax-only question about a state-only household fails. The PolicyEngine skills' own examples do this, and skill-examples in PolicyEngine/policyengine-skills fails on 8 of them (run 36653351748).

What changes

calculate_household now resolves its SPM selection with resolve_household_spm_selection:

Household spm argument Geography provenance["spm_geography_source"]
county_fips given none, or no geography_kind county's SPM estimation area "default"
no county: no county input, or only None, "", NaN or "nan", or a county/county_str of "UNKNOWN" none, or no geography_kind national (no geographic adjustment) "national_fallback"
county named only as county or county_str none county, so it raises SPM_GEOGRAPHY_REQUIRED asking for county_fips n/a
any geography_kind chosen as chosen; explicit county without county_fips still raises "selection"

Households with county_fips are unchanged. For a household without county_fips, existing behavior is kept only where a geography_kind is chosen: a selection without one, such as spm={"scenario": "zero_real"}, used to raise for a state-only household and now falls back too, keeping the caller's other settings. National measurement covers everything that uses the SPM measurement: thresholds and poverty, and, for a unit allocated housing assistance, the capped SPM housing subsidy and the SPM resources built on it. Population simulations (Simulation, decile impacts, dataset runs) still call resolve_spm_selection and are unchanged; their data must supply observed counties.

Docstrings, docs/households.md, docs/countries.md and examples/household_impact_example.py describe the new default. provenance gains one key, spm_geography_source.

Design choices

National, not a state average. A state alone does not identify a Census SPM estimation area. The calculator's geography kinds are county, metro area and national (spm_calculator/policyengine_adapter.py PolicyEngineSPMProvider), so a state-average factor would be a new, synthetic geography. National measurement is already supported and needs no area. It also matches what state-only households got before 6.0, as observed on the production API: api.policyengine.org (policyengine-us 1.764.6) returns the same 2026 spm_unit_spm_threshold, 29,418.32, for 2-person households in CA, MS and NY with no county, and in Los Angeles County, so its household thresholds carried no geographic adjustment. (That model's county variable does default to the state's first county, but its SPM threshold did not depend on it.)

Here, not in policyengine-us. This package owns the household calculator's defaults, including the default columns that pull SPM poverty into every call. The country Simulation keeps its strict contract, which also guards population data. The bundle's US data release is certified for policyengine-us 2.2.1 exactly (certified_for_model_version), so a country-package change would reach calculate_household only after a re-certified repin. This change needs no repin: pins, the bundle manifest and the certified data release are untouched.

Reported, never silent. spm-calculator's PolicyEngine adapter has no location fallback ("there is no consumer extrapolation or location fallback policy", policyengine_adapter.py), and this PR keeps it that way: the adapter and the country model still fail closed, and the fallback is a household-calculator default. The receipt style follows the calculator's release API, whose opt-in geography_factor(..., missing="national") marks its result explicit_national_fallback: here the result's spm_config says national and spm_geography_source says why.

County names without FIPS raise. County measurement reads only county_fips. A household that names its county as county or county_str asked for a county, so it keeps county measurement and gets the error asking for county_fips rather than a national result. Mapping county names to FIPS codes in the wrapper would need policyengine-us's county table, a country-package internal; that can be a follow-up.

Invariants (tests/test_spm_household_geography.py)

For every household that names no county, with no geography chosen:

  1. Totality. The calculation never raises SPMInputError. All 59 state codes are checked: 58 compute. Puerto Rico fails before any SPM formula runs (ValueError from snap_region, which has no Puerto Rico value in policyengine-us 2.2.1 or main); the test asserts it fails the same way with an explicit national selection and not with an SPM error.
  2. Differential. The result equals the explicit spm={"geography_kind": "national"} result output for output, apart from spm_geography_source. Checked in CA, MS, NY, with a chosen scenario, with county_fips of "", None and NaN, with county or county_str of "UNKNOWN", and by the property test.
  3. Bounds. Geographic adjustment is exactly 1, the threshold equals the unadjusted threshold, and the threshold is positive.
  4. Poverty identity. spm_unit_is_in_spm_poverty == (spm_unit_net_income < spm_unit_spm_threshold). This is the country formula, rechecked on fallback results for consistency.

A household with county_fips equals the explicit county result (06037, 28001, 36061), and Los Angeles County's adjustment is above 1 with the same unadjusted threshold as national.

The property test (Hypothesis, 20 derandomized examples) draws state code, earnings 0–250,000, 0–3 children and tenure, and checks 1–4 on each. Unit tests cover every branch of the selection logic against a stub bundle: a bundle whose default is not county; selections without a geography (an SPMSelection instance, hash-only, county_vintage-only); a different artifact hash, which is still rejected under the fallback; and every absent and present county-input form. (pd.NA and bytes also count as no county for the selection, but the country model rejects them as inputs whatever the geography, so they are unit-tested only.)

Test changes

tests/test_spm_household.py pinned the old raise for state-only households under default settings. Those cases now pass spm={"geography_kind": "county"}, so they still test strict county mode, and two cases add a county named without county_fips.

Verification

Local, Python 3.13, the pinned bundle (policyengine-us 2.2.1, core 3.32.5, spm-calculator 1.0.0):

  • tests/test_spm_household_geography.py: 108 passed.
  • tests/test_spm_household.py, test_spm_selection.py, test_spm_selection_schema.py, test_household_calculator_snapshot.py: 92 passed.
  • Mutation check: with the fallback disabled, the regression, differential and every-state tests fail with the original SPMInputError.
  • The issue's repro now returns household net income 46,369, threshold 30,653 (national) and spm_geography_source = "national_fallback"; with county_fips="06037" it returns threshold 37,779 (adjustment 1.2325).
  • ruff format --check . and ruff check . pass.

Review

An independent Opus review (subfleet, read-only) found no path that measures nationally when a county was asked for, and found no leakage into population, replay or bundle paths. Round 1 requested changes, all made in the second commit: a stale SPMSelection docstring; missing values and "UNKNOWN" placeholders counted as naming a county; untested selection shapes; derandomized Hypothesis; one docs sentence; and more precise wording in this description. Round 2 approved and asked for wording fixes (made in the third commit): scope "UNKNOWN" to county/county_str, say that pd.NA and bytes fail as inputs, treat the text "nan" as no county, and reflow a docstring.

Downstream

axiom: n/a: wrapper default for SPM geography selection; no policy rule changes.

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits September 30, 2026 08:43
calculate_household's default outputs include SPM poverty and the bundle's
default SPM selection is county measurement, so every household that gave
a state but no county_fips raised SPM_GEOGRAPHY_REQUIRED, even for tax-only
questions. With no geography chosen, a household that names no county is
now measured nationally (no geographic adjustment), recorded as
provenance["spm_geography_source"] == "national_fallback". Households with
county_fips keep their county's SPM estimation area; a geography chosen
with spm= is used as given; a county named only as county or county_str
keeps county measurement and its error; population simulations are
unchanged.

Fixes #543

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A county input that is None, empty, NaN, pd.NA, or "UNKNOWN" (the
  county enum default, for county and county_str) now counts as no county,
  so a state-only payload carrying placeholders gets the national fallback
  instead of the county error. Real calculations pin None, "", NaN and
  UNKNOWN against the explicit national result.
- SPMSelection's docstring no longer says national geography is always
  explicit.
- Unit tests cover selections without a geography (an SPMSelection
  instance, hash-only, county_vintage-only) and a different artifact hash
  under the fallback; the Hypothesis test is derandomized.
- Docs say the fallback also covers the capped SPM housing subsidy and
  reword the geography_kind row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-2 review: say that "UNKNOWN" counts as no county only for county
and county_str, that pd.NA and bytes are absent for the SPM selection but
still rejected as inputs by the country model, and treat the text "nan"
(a DataFrame converted to strings) as absent, as the country model's
county check does. Reflow the SPMSelection docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

calculate_household raises SPMInputError for households without county_fips

1 participant