Skip to content

US household location v1: one census block per household, every geography derived - #1047

Draft
MaxGhenis wants to merge 2 commits into
mainfrom
us-location-v1-block-draw
Draft

MaxGhenis wants to merge 2 commits into
mainfrom
us-location-v1-block-draw

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Part of #696. This implements its block-first draw on all three US lines; the predictor-surface and lineage-spec items stay open there.

Summary

Max ruled on 27 September: a household's location is one 2020 census block, drawn with probability proportional to block population within the finest geography its source provides, and every other geography is looked up from that block. Calibration supplies fidelity afterwards. This PR adds that rule as one function, us_runtime/block_location.py, and wires it into every US line behind --location-rule block_v1. The default, legacy, keeps each line's current rule and bytes.

It replaces three rules:

Line Today (legacy) block_v1
base-h5 (build_us_puf_support_base.py, feeds Route A) district drawn by SOI return counts, then a block within the district one block per household within its CPS source geography
stacked pool (build_us_multispine_pool.py) ASEC PUMA drawn within state; district and county drawn independently within the PUMA, so they can contradict ACS: a block within the observed PUMA. ASEC: its CPS source geography
ACS local release (_legacy/build_us_acs_multispine_base.py → build_us_acs_local_release.py) same independent PUMA draws ACS: a block within the observed PUMA; donors keep their base block

The rule

Candidate blocks come from each record's source:

  • ACS PUMS: the blocks of its observed 2020 PUMA.
  • CPS ASEC with GTCO ≠ 0: the blocks of that county.
  • CPS ASEC with GTCO = 0: the blocks of its state, excluding counties that are both on Census's identified-county list for that ASEC year (List 4 of the technical documentation) and coded in that year's file. A year whose List 4 drops the whole-county guarantee (ASEC 2026) excludes nothing.
  • Anything else: the blocks of its state.

Tract, county, place, SLDU/SLDL, CBSA, PUMA and every attached congressional-district plan are then looked up from the block, so a household's geographies cannot contradict each other.

Draws are keyed, not sequential: stable_identity_uniforms("{household_id}:{clone}", seed, salt=rule id) feeds an exact inverse CDF over block population. The draw also supports K clones, each with weight/K. Every line refuses K > 1 for now, because Route A's and the pool's duplicate-copy checks do not yet recognize location clones (follow-up below).

Full write-up: docs/us-location-v1.md.

Why both confirmations for GTCO = 0

Max's 16 August refinement on #696 says a record without a county code does not live in an identified county. Census's list and the files disagree in the same way in every year checked. census_cps_2022/2023/2024.h5 match the raw hhpub25.csv, and the table's ratios are computed on those files:

Counties Evidence
Coded, not listed 06017, 06061, 06077, 06113, 25009, 25021, 48439 Tarrant, 53033 King, 53061 Snohomish coverage (coded weighted persons ÷ 2020 pop., median-normalized): Tarrant 0.12–0.19, King 0.39–0.43, Snohomish 0.09–0.21, so only part of each is coded
Listed, never coded 09011 New London, 18157, 24037, 36045, 48183 Gregg, 48303 Lubbock households there carry GTCO = 0

A data-derived set would rule out Tarrant's uncoded 85%. The list alone would rule out Lubbock, whose households carry GTCO = 0. Requiring both excludes 271 counties in each of ASEC 2023–2025.

The ASEC 2026 documentation drops the "entire county is identified" sentence. Its file also has 49 Delaware households with GTCO = 0, although every Delaware county is listed and coded for its whole population. So for that year GTCO = 0 draws from the whole state. The official lists for 2023–2026 are packaged with each PDF's SHA-256, and the loader verifies the CSV against its provenance.

Evidence

  • Ladder. The block-ladder builder adds a per-block puma array. It is additive to schema 1, so the legacy loader reads the file unchanged. The national rebuild (us_block_ladder_2020_puma.npz, sha 9f2dc986…) has 5,769,942 blocks, 2,462 PUMAs, 436 districts, 3,143 counties and 331,449,281 people.
    • Its seven core arrays are byte-identical to Route A's ladder (7ba39b95…).
    • Its per-PUMA population and its (PUMA, district) and (PUMA, county) overlaps equal the PUMA ladder (39a2ab2a…) cell for cell (2,462, 4,043 and 4,620 cells). Within a PUMA, the new draw's district and county distribution is exactly the legacy one; the two are now consistent with each other.
  • Real data. All 222,196 households in census_cps_2022–2025.h5 were located with the packaged lists and the national ladder:
    • both gates passed on the base-h5 smoke (337,704 households with support copies);
    • every GTCO ≠ 0 record landed in its county;
    • CBSA narrowing was skipped automatically, because the CPS codes an older delineation (20 county/CBSA pairs disagree), and the manifest records this.
  • ACS scale. 1,588,854 households were located in 15.2 s, and the gate ran in 5.5 s.

Invariants, and how each is tested

Hypothesis property tests run over nested synthetic ladders (test_us_block_location.py, test_us_cps_source_geography.py):

  1. Consistency. Every derived geography equals the ladder lookup of the drawn block, and the household's state never changes. us_block_location_gate passes, and fails with the column's name when any single column is tampered with.
  2. Containment. A PUMA record's block is in its PUMA. A county record's block is in its county. A GTCO = 0 record's block is in its state and outside the excluded counties. A CBSA-narrowed record's block is in its CBSA. An empty candidate set fails the draw; it never falls back.
  3. Exactness. On a uniform grid of M points, every block gets its population share of M to within 1.
  4. Convergence (fixed seed). 200,000 draws pass a chi-square test against population shares. County-within-PUMA and district-within-PUMA frequencies match the population overlaps; this is the differential against the PUMA ladder's semantics. The keyed uniforms pass a KS test, and clones are uncorrelated.
  5. Determinism. The same seed gives identical output. Permuting rows changes nothing per household. Clone k's block does not depend on K.
  6. Conservation under cloning. Each household appears K times with clone ids key·K+k, and its clone weights sum to its weight. Frame cloning conserves every weighted entity's mass, keeps linkage valid, and keeps every parent in the same clone.
  7. Both confirmations. The excluded set is exactly listed ∩ coded per year, and empty for a year without the guarantee.

Differential tests:

  • the block ladder's PUMA against assemble_us_puma_ladder on the same synthetic sources;
  • the national artifacts against each other, marked slow and gated on env paths (run locally: pass);
  • an attached plan against the ladder's own copy of the same plan;
  • the ACS pool's new kwargs at their defaults against the old call.

Mutation checks. Running (results added before this leaves draft).

Effect on certified builds

None: legacy is the default on every line, and it is byte-identical.

  • The base-h5 legacy pipeline sha is still 906c9c02…, and its stage lists, run_config and child argv are unchanged.
  • The stacked pool's legacy contract, operator order, seed site, identities and every spec-engine file are untouched.
  • No module in spec_engine/seeds.py's kernel lists changed. The legacy-v1 seed-protocol digest is still 91989ed5…, and a new engine-free test pins it.
  • The ACS tools' legacy summaries, limitations and manifests are byte-identical, and the new keys appear only under block_v1.

The stacked-pool block_v1 branch is source-spine blind (test_us_spine_blindness): a row with an observed PUMA draws within it, and any other row resolves through its CPS source key.

Review

An independent five-dimension review found these, all fixed:

  • a formatter pass had touched seven byte-hashed kernel modules, which moved the seed-protocol pin; they were restored byte-for-byte;
  • the Delaware case above crashed the ASEC 2026 draw;
  • List 4's end detection had no positive stop;
  • the base-h5 line was missing the shared block-ladder attrs;
  • the CSV digest was not verified;
  • the ACS limitation text did not match the donor mode;
  • the ACS release did not check the targets ladder.

A second round verifying these fixes is running.

Not in this PR

axiom: n/a: microsimulation geography assignment, no policy rule changes.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits September 28, 2026 10:16
…aphy derived (#696)

Max's 2026-09-27 ruling: draw one 2020 census block per household with
probability proportional to block population within the finest geography
its source provides (ACS: observed PUMA; CPS ASEC: identified county, else
its state outside the counties that are both on Census's List 4 for that
ASEC year and coded in that year's file; else state), and look every other
geography up from the block. One function, us_runtime/block_location.py,
wired into the base-h5 line, the stacked multispine pool (with h5_io
consumer validation) and the ACS local-release line behind
--location-rule block_v1; the default 'legacy' path stays byte-identical.

- Keyed draws (stable_identity_uniforms over household id and clone), exact
  inverse CDF, optional K clones at weight/K (refused on every line until
  Route A admits location clones).
- Block-ladder builder adds an additive per-block 'puma' array.
- Census's CPS ASEC identified-county lists (List 4, ASEC 2023-2026)
  packaged with PDF digests and a per-year whole-county-guarantee flag.
- Property tests (Hypothesis) for consistency, containment, exactness,
  convergence, determinism, row-order invariance and clone conservation;
  differential tests against the PUMA ladder and the national artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Stacked pool: refuse a household that carries an ACS puma_geoid or PUMA
  without a valid matching puma instead of routing it through a CPS
  source-key join (ACS keys share the CPS 2024 key space).
- The opt-in remainder weighting never applies to a year whose List 4
  lacks the whole-county guarantee (keeps its whole-state draw).
- Tests for the parser's positive end-of-list stop, the CSV-versus-
  provenance refusal and the recorded CSV digest.
- Rebuild command covers ASEC 2026; docs invariant, per-line section and
  changelog describe the no-guarantee rule.

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

Copy link
Copy Markdown
Contributor Author

The Connecticut CBSA defect noted in this PR's body is fixed in #1072. That PR maps each CT 2020 block to its 2022 planning region through its town (the BAF MCD layer plus Census's ct_cou_to_cousub_crosswalk.txt), then looks up the CBSA by planning region. Two things matter for this branch:

  1. Rebase. The conflicts with this branch's head 4324432e5 are additive only: the tract_to_puma and cbsa_county_by_block kwargs, the PUMA and CT URL constants, and the metadata keys. With both sides kept, all 41 ladder-source tests pass. A resolved patch is at ~/PolicyEngine/_build_artifacts/us-ct-cbsa/fix-on-pr1047-4324432e5.patch. It predates Give Connecticut blocks their CBSA through 2022 planning regions #1072's later commits, so rebase onto Give Connecticut blocks their CBSA through 2022 planning regions #1072 rather than applying the patch. I rebuilt the PUMA ladder with this branch's builder plus the fix: ~/PolicyEngine/_build_artifacts/us-ct-cbsa/us_block_ladder_2020_puma.npz, sha f84936637d49…. Every array, puma included, is byte-identical to us_block_ladder_2020_puma.npz (9f2dc986…) except cbsa_code on the 42,008 CT blocks. Verification is in verify_vs_puma.json in the same directory.

  2. cbsa_vintage_agreement. Its county → CBSA lookup takes each 2020 county's first block (np.unique(..., return_index=True)). Once CT blocks carry planning-region CBSAs, 7 of the 8 CT counties span 2–3 CBSAs; only 09007 has one:

    County CBSAs
    09001 14860, 47930
    09003 25540, 45860, 47930
    09005 14860, 45860, 47930
    09009 35300, 47930
    09011 25540, 35980, 39480
    09013 25540, 39480
    09015 35980, 39480

    On the real ASEC 2023, 2024 and 2025 GTCO > 0 pairs (280 per year), disagreements stay at 20 before and after the fix, so narrowing stays off. Within CT, 09001 flips to agree. 09005 flips to disagree, and 09009 disagrees only because of the first-block rule (its first block is 47930 against CPS's 35300). A pair should probably agree only when every ladder block in the county carries that CBSA, or the lookup should use a population-majority rule. Separately, the doc line saying the core arrays are "byte-identical to Route A's ladder (7ba39b95…)" stops holding for cbsa_code after a rebuild.

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.

1 participant