From 0a95202e661e781b166f9a6a19ddcd79c2072481 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 10:16:04 -0400 Subject: [PATCH 1/2] US household location v1: one census block per household, every geography 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 --- .../696-us-location-v1-block-draw.added.md | 9 + docs/us-acs-donor-receipt-qualification.md | 4 +- docs/us-location-v1.md | 106 ++ docs/us-reported-receipt-source-enrichment.md | 2 +- .../build/us_runtime/acs_multispine.py | 152 +- .../microcosm/build/us_runtime/base_pool.py | 518 +++++- .../build/us_runtime/block_ladder_sources.py | 23 +- .../build/us_runtime/block_location.py | 1625 +++++++++++++++++ .../cps_identified_county_sources.py | 133 ++ .../build/us_runtime/cps_source_geography.py | 265 +++ .../data/cps_asec_identified_counties.csv | 1193 ++++++++++++ ...ec_identified_counties.csv.provenance.json | 37 + .../src/microcosm/build/us_runtime/h5_io.py | 719 +++++++- .../shared/test_spec_engine_seeds.py | 11 + .../us/test_us_acs_block_location.py | 1523 +++++++++++++++ .../us/test_us_acs_local_release_tool.py | 293 +++ .../us/test_us_block_ladder_sources.py | 123 ++ .../engine_free/us/test_us_block_location.py | 1071 +++++++++++ .../us/test_us_cps_source_geography.py | 430 +++++ .../test_us_multispine_pool_block_location.py | 963 ++++++++++ .../us/test_us_multispine_pool_h5_io.py | 223 +++ ...test_us_puf_support_base_block_location.py | 618 +++++++ .../engine_free/us/test_us_spine_blindness.py | 22 +- .../microcosm_build/us_block_location.py | 110 ++ tools/_legacy/build_us_acs_multispine_base.py | 650 ++++++- tools/build_us_acs_local_release.py | 383 +++- tools/build_us_block_ladder_artifact.py | 41 +- tools/build_us_cps_identified_counties.py | 186 ++ tools/build_us_multispine_pool.py | 1359 +++++++++++++- tools/build_us_puf_support_base.py | 647 +++++-- 30 files changed, 13169 insertions(+), 270 deletions(-) create mode 100644 changelog.d/696-us-location-v1-block-draw.added.md create mode 100644 docs/us-location-v1.md create mode 100644 packages/microcosm-build/src/microcosm/build/us_runtime/block_location.py create mode 100644 packages/microcosm-build/src/microcosm/build/us_runtime/cps_identified_county_sources.py create mode 100644 packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py create mode 100644 packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv create mode 100644 packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv.provenance.json create mode 100644 packages/microcosm-build/tests/engine_free/us/test_us_acs_block_location.py create mode 100644 packages/microcosm-build/tests/engine_free/us/test_us_block_location.py create mode 100644 packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py create mode 100644 packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py create mode 100644 packages/microcosm-build/tests/engine_free/us/test_us_puf_support_base_block_location.py create mode 100644 test_support/microcosm_build/us_block_location.py create mode 100644 tools/build_us_cps_identified_counties.py diff --git a/changelog.d/696-us-location-v1-block-draw.added.md b/changelog.d/696-us-location-v1-block-draw.added.md new file mode 100644 index 0000000000..7d8426190c --- /dev/null +++ b/changelog.d/696-us-location-v1-block-draw.added.md @@ -0,0 +1,9 @@ +Add US household location v1 (`us_runtime.block_location`), the rule Max set on 27 September 2026. + +Each household draws one 2020 census block, in proportion to block population, within the finest geography its source provides. That is the PUMA for ACS records and the identified county for CPS records. A CPS record with no county code draws within its state, outside the counties that Census's List 4 marks as entirely identified and that the year's file actually codes. + +Every other geography is then looked up from the block: tract, county, place, SLDU/SLDL, CBSA, PUMA, and the district of every attached congressional plan. Draws are keyed by household id, so they do not depend on row order. The draw also supports K clones at weight/K. + +Each US line gains `--location-rule block_v1`. The default, `legacy`, stays byte-identical. + +The block ladder builder now adds a per-block `puma` array; the schema-1 loader ignores it. Census's identified-county lists for ASEC 2023-2025 are packaged with their provenance. diff --git a/docs/us-acs-donor-receipt-qualification.md b/docs/us-acs-donor-receipt-qualification.md index 75939af56c..0b95f493e1 100644 --- a/docs/us-acs-donor-receipt-qualification.md +++ b/docs/us-acs-donor-receipt-qualification.md @@ -25,7 +25,7 @@ declares `receives_wic` on `person` (`acs_transfer.py:426`) and `model_required_boolean` transfer targets. `acs_transfer_donor_requirements` (`acs_transfer.py:844`) folds every declared target into the donor requirement set (`:865-870`), and `_require_dense_donor_coverage` -(`tools/_legacy/build_us_acs_multispine_base.py:349-420`) refuses a donor that +(`tools/_legacy/build_us_acs_multispine_base.py`) refuses a donor that cannot supply them. The certified July Build P donor predates those declarations. It carries the @@ -158,7 +158,7 @@ message. It re-raises the producer's name and exception class only. ## What the staging gate requires `_require_dense_donor_coverage` -(`tools/_legacy/build_us_acs_multispine_base.py:349-420`), in order: +(`tools/_legacy/build_us_acs_multispine_base.py`), in order: 1. **Resolve the donor channel first** (`:362`, through `_coverage_donor_channel`, `:430-437`). `resolve_acs_donor_channel` diff --git a/docs/us-location-v1.md b/docs/us-location-v1.md new file mode 100644 index 0000000000..6f87bea138 --- /dev/null +++ b/docs/us-location-v1.md @@ -0,0 +1,106 @@ +# US household location, version 1 + +Every US line locates a household the same way. It draws one 2020 census block at random, in proportion to block population, within the finest geography the household's source provides. Every other geography is then looked up from that block. Max ruled this on 27 September 2026, and it implements the block-first draw of microcosm#696. Fidelity comes from calibrating weights afterwards, not from the draw. + +The code is `packages/microcosm-build/src/microcosm/build/us_runtime/block_location.py`. The CPS source geography is in `cps_source_geography.py`, and Census's identified-county lists are in `cps_identified_county_sources.py` and `data/cps_asec_identified_counties.csv`. + +## The rule + +A household's candidate blocks depend on what its source publishes: + +| Source record | Candidate blocks | Kind recorded | +|---|---|---| +| ACS PUMS (publishes a 2020 PUMA) | the blocks of that PUMA | `puma` | +| CPS ASEC with `GTCO` ≠ 0 | the blocks of that county | `county` | +| CPS ASEC with `GTCO` = 0 | the blocks of its state outside the counties excluded for its ASEC year | `state_unidentified_counties` | +| Anything else | the blocks of its state | `state` | + +The GTCO = 0 kind can also be narrowed to the record's CBSA, recorded as `state_unidentified_counties_cbsa`. That happens only when the source's CBSA codes agree with the ladder's delineation. + +Within the candidate set, a block's probability is its 2020 P.L. 94-171 population divided by the set's population. The inverse CDF is exact: blocks in geoid order, cumulative population, one uniform per draw. + +Every other geography comes from the block. Tract and county are prefixes of the block geoid. Place, SLDU, SLDL, CBSA, PUMA and the congressional district of every attached plan are block-level lookups in the ladder. A household's geographies therefore cannot contradict one another, and a new district map is a lookup, not a new draw. + +## CPS county identification + +Max, 16 August 2026: county identification in the public file is countywide. A record without a county code does not live in an identified county, so the draw must not leak mass into counties that are positively ruled out. + +Census publishes the list: "List 4: FIPS County Codes" in each year's ASEC technical documentation, which notes that "Counties are only included on this list if the entire county is identified." `tools/build_us_cps_identified_counties.py` parses it for ASEC 2023, 2024 and 2025 (277 counties each) and ASEC 2026 (361). The PDF SHA-256s are recorded in the CSV's provenance file. + +The list and the files disagree in the same way in all three years (`census_cps_2022/2023/2024.h5`, identical in the raw `hhpub25.csv`): + +- **Coded in the file but not on the list (9):** 06017, 06061, 06077, 06113, 25009, 25021, 48439 (Tarrant), 53033 (King) and 53061 (Snohomish). Coverage is weighted CPS persons coded to the county divided by its 2020 population, normalized so the median coded county is 1. Tarrant's coverage is 0.12 to 0.19, King's 0.39 to 0.43 and Snohomish's 0.09 to 0.21: those files code only part of the county. The five CA/MA counties sit at 0.68 to 1.25. +- **On the list but never coded (6):** 09011 (New London), 18157, 24037, 36045, 48183 (Gregg) and 48303 (Lubbock). Households there carry `GTCO = 0`. + +The 2026 list and file disagree too (361 listed, 307 coded). The file still codes Connecticut's old counties, which the list omits. + +So a county is excluded for a `GTCO = 0` record only with two confirmations: it is on that ASEC year's list, and that year's file codes it. That leaves 271 excluded counties in each of 2023 to 2025. If a year has no official list, the data-derived set is the documented fallback. Each source year keeps its own set, and `census_cps_{Y}.h5` pairs with ASEC `Y + 1`. + +**Years without the whole-county guarantee.** The 2026 documentation drops the sentence "Counties are only included on this list if the entire county is identified". The CSV's `entire_county_guarantee` column records, per year, whether a year's preamble carries it. + +The 2026 file also breaks the premise itself. Every Delaware county is listed and coded for about its whole population (coverage 0.90 to 1.04), yet 49 Delaware households carry `GTCO = 0`. So for a year without the guarantee nothing is excluded, and a `GTCO = 0` record draws from its whole state; that year's `basis` is `no_whole_county_guarantee_state_draw`. In 2023 to 2025, Delaware and DC have no `GTCO = 0` households, and every state keeps candidate blocks for its `GTCO = 0` records. An empty candidate set would fail the draw rather than fall back. + +**Opt-in, off by default: `partial_county_remainder`.** A partially coded county is still a full candidate in the GTCO = 0 pool, so in expectation it collects about (1 + coverage) of its population. The option scales such a county's blocks by max(0, 1 − coverage). That makes its expected mass equal its census population; a test checks this against a simulated draw. The ruled default is plain block population, and switching the default is Max's call. + +**CBSA narrowing is automatic but not active today.** It applies only when every coded (county, `GTCBSA`) pair in the files matches the ladder's county-to-CBSA lookup. The CPS codes an older delineation than the ladder's OMB 2023 file (20 pairs disagree), so the check fails and the narrowing is skipped. The result is recorded in the manifest. + +## The ladder + +`tools/build_us_block_ladder_artifact.py` now adds a `puma` array: each block's 2020 PUMA, taken from its tract via Census's 2020 tract-to-PUMA relationship file. The array is additive to block-ladder schema 1. `load_us_block_ladder` ignores arrays it does not require, so legacy builds read the new file unchanged, and `load_us_location_ladder` validates the PUMA. + +The national rebuild is `us_block_ladder_2020_puma.npz` (sha256 `9f2dc986…`): + +- 5,769,942 blocks, 2,462 PUMAs, 436 districts, 3,143 counties, population 331,449,281; +- its seven core arrays are byte-identical to Route A's ladder (`7ba39b95…`); +- its per-PUMA population and its (PUMA, CD) and (PUMA, county) overlaps equal the PUMA ladder (`39a2ab2a…`) cell for cell (2,462, 4,043 and 4,620 cells). + +So within a PUMA, the new draw's distribution of districts and counties is exactly the legacy PUMA ladder's, except that the two are now consistent with each other. + +Congressional-district plans other than the ladder's primary (119th) are attached from a block-to-plan registry with `attach_congressional_district_plans`. Each attached plan becomes a household column `congressional_district_geoid__`. A plan the ladder already carries must agree block for block. + +The registry is PolicyEngine/microcosm#1041, and a follow-up adds a `--cd-plan-registry` flag to each tool. Its plans are the 117th through 120th Congresses. Each plan's recorded sources, including known deviations, are copied into the manifest. For example, the 2020 Block Assignment File's CD layer is the 116th plan, which differs from the 117th in North Carolina, so #1041 builds NC's 117th from the 2019 plan instead. + +## Keyed draws and clones + +Each draw uses `stable_identity_uniforms("{household_id}:{clone}", seed, salt="us_block_location.population_draw.v1")`. A household's block therefore depends only on: + +- the seed; +- its own id; +- its clone index; +- its candidate set. + +It does not depend on row order, on which other households are present, or on how many clones were drawn. + +With `clones = K`, each household is drawn K times. Clone k of household h becomes household h·K + k with weight w/K. `clone_frame_for_location` repeats every entity, remaps ids and membership, and remaps person-reference columns while keeping the 0 sentinel. Weighted mass is unchanged. + +Every line currently refuses K > 1, because Route A's and the stacked pool's duplicate-copy checks do not yet recognize a location clone. That follow-up is tracked separately. + +## Invariants + +Each of these is a property test in `tests/engine_free/us/test_us_block_location.py` (Hypothesis over nested synthetic ladders) or `test_us_cps_source_geography.py`: + +1. **Consistency.** Every derived geography equals the ladder's lookup of the drawn block, the household's state never changes, and `us_block_location_gate` passes. The gate fails, naming the column, when any single derived column is tampered with. +2. **Containment.** A household's block lies in its source geography: + - a PUMA record's block is in its PUMA; + - a coded CPS 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 to a coarser geography. +3. **Exactness and convergence.** + - On an evenly spaced grid of M uniforms, every block receives its population share of M to within 1. + - With a 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. + - The keyed uniforms pass a Kolmogorov–Smirnov test, and clones are uncorrelated. +4. **Determinism.** The same inputs and seed give identical output. Permuting the input rows changes nothing per household. Clone k's block is the same for any K > k. +5. **Conservation.** + - Each household appears K times, with clone indices 0 to K−1 and clone ids key·K + k. + - Its clone weights sum to its weight (relative tolerance 1e-12). + - Frame cloning conserves every weighted entity's mass, keeps linkage valid, and places every parent in the same clone. +6. **Both confirmations.** The excluded set is exactly the intersection of listed and coded counties, per year. + +The differential tests are: + +- the block ladder's PUMA against the PUMA ladder built from the same synthetic sources; +- the national artifacts against each other (marked slow; set `MICROCOSM_US_BLOCK_LADDER_PUMA`, `MICROCOSM_US_BLOCK_LADDER_ROUTE_A` and `MICROCOSM_US_PUMA_LADDER` to run); +- an attached plan against the ladder's own copy of the same plan. diff --git a/docs/us-reported-receipt-source-enrichment.md b/docs/us-reported-receipt-source-enrichment.md index 3a775cb525..f7d1f7aa1c 100644 --- a/docs/us-reported-receipt-source-enrichment.md +++ b/docs/us-reported-receipt-source-enrichment.md @@ -23,7 +23,7 @@ A local-area release names its donor in `us_source_coverage.json` `release_manifest.json`, and it then requires that manifest to carry exactly one microdata artifact whose SHA-256 equals the staging donor H5 (`_donor_release_identity`, -`tools/_legacy/build_us_acs_multispine_base.py:169-216`). The contract refuses +`tools/_legacy/build_us_acs_multispine_base.py`). The contract refuses a local-area release without it (`microcosm/data/contract.py:4468-4486`). The national default does not carry the three receipt inputs the ACS transfer diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/acs_multispine.py b/packages/microcosm-build/src/microcosm/build/us_runtime/acs_multispine.py index c6fe766d3f..3ac5dd0b17 100644 --- a/packages/microcosm-build/src/microcosm/build/us_runtime/acs_multispine.py +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/acs_multispine.py @@ -41,9 +41,18 @@ transfer_acs_inputs, ) from microcosm.build.us_runtime.base_pool import ( + ACS_POOL_BLOCK_LOCATION_METADATA_KEY, preflight_pooled_ladder_geography, + validate_pool_location_options, with_optional_acs_spine, ) +from microcosm.build.us_runtime.block_location import ( + US_BLOCK_LOCATION_RULE_ID, + US_LOCATION_RULE_BLOCK_V1, + US_LOCATION_RULE_LEGACY, + UsLocationLadder, + location_geography_columns, +) from microcosm.build.us_runtime.congressional_district_vintage import ( CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE, ) @@ -93,6 +102,10 @@ def build_optional_acs_multispine( expected_congressional_district_vintage: str | None = ( CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE ), + location_rule: str = US_LOCATION_RULE_LEGACY, + block_ladder: UsLocationLadder | None = None, + location_seed: int = 0, + location_clones: int = 1, ) -> AcsMultispineResult: """Optionally build, transfer, and append the ACS 2024 1-year spine. @@ -107,6 +120,13 @@ def build_optional_acs_multispine( materialized its own frame. This cannot make the final dense pool small, but it avoids retaining the raw, mapped, transferred, and pooled versions together for the rest of the build. + + ``location_rule`` defaults to ``"legacy"``: the PUMA-ladder assignment + above, unchanged. ``"block_v1"`` (microcosm#696) locates every pooled + household on one 2020 block from ``block_ladder`` instead + (:func:`~microcosm.build.us_runtime.base_pool.with_optional_acs_spine`); + its record lands in ``provenance["household_location"]``. The location + options are validated before the ACS spine is loaded. """ if source is None: @@ -114,6 +134,20 @@ def build_optional_acs_multispine( frame=base, provenance={"enabled": False}, ) + block_location = location_rule != US_LOCATION_RULE_LEGACY + if ( + block_location + or block_ladder is not None + or location_seed != 0 + or location_clones != 1 + ): + validate_pool_location_options( + location_rule, + block_ladder=block_ladder, + location_seed=location_seed, + location_clones=location_clones, + geography_seed=geography_seed, + ) raw_acs, loader_metadata = build_acs_pums_unit_frame( source, @@ -136,7 +170,15 @@ def build_optional_acs_multispine( # tract, incoherent preserved geography, or an unknown ACS PUMA must # fail here, not after the QRF fits have run. donor_geography: str | None = None - if puma_ladder is not None: + if block_location: + donor_geography = preflight_pooled_ladder_geography( + base.table("household"), + mapped_frame.table("household"), + puma_ladder, + location_rule=location_rule, + block_ladder=block_ladder, + ) + elif puma_ladder is not None: donor_geography = preflight_pooled_ladder_geography( base.table("household"), mapped_frame.table("household"), @@ -194,7 +236,15 @@ def build_optional_acs_multispine( hours_imputed_inputs + tuple(transferred.imputed_inputs) ) deferred_inputs = tuple(transferred.deferred_inputs) - if puma_ladder is not None: + if block_location: + # The block resolves every geography the transfer defers (block, + # tract, county, congressional district). + assert block_ladder is not None + located = set(location_geography_columns(block_ladder)) + deferred_inputs = tuple( + column for column in deferred_inputs if column not in located + ) + elif puma_ladder is not None: deferred_inputs = tuple( column for column in deferred_inputs @@ -207,7 +257,20 @@ def build_optional_acs_multispine( del transferred pool_options: dict[str, Any] = {"acs_share": acs_share} - if puma_ladder is not None: + if block_location: + pool_options.update( + { + "geography_seed": geography_seed, + "expected_congressional_district_vintage": ( + expected_congressional_district_vintage + ), + "location_rule": location_rule, + "block_ladder": block_ladder, + "location_seed": location_seed, + "location_clones": location_clones, + } + ) + elif puma_ladder is not None: pool_options.update( { "puma_ladder": puma_ladder, @@ -245,7 +308,22 @@ def build_optional_acs_multispine( provenance["local_hours_source"] = hours_source if modeled_hours is not None: provenance["hours_modeled_completion"] = modeled_hours - if puma_ladder is not None: + if block_location: + assert block_ladder is not None + provenance["household_location"] = _json_ready_mapping( + _thawed_metadata(pooled.metadata[ACS_POOL_BLOCK_LOCATION_METADATA_KEY]) + ) + provenance["geography_ladder"] = _block_geography_provenance( + pooled, + block_ladder, + puma_ladder, + location_seed=location_seed, + donor_geography=donor_geography, + expected_congressional_district_vintage=( + expected_congressional_district_vintage + ), + ) + elif puma_ladder is not None: geography = us_puma_ladder_assignment_summary( pooled.table("household"), puma_ladder, @@ -283,6 +361,58 @@ def build_optional_acs_multispine( ) +def _block_geography_provenance( + pooled: Frame, + block_ladder: UsLocationLadder, + puma_ladder: UsPumaLadder | None, + *, + location_seed: int, + donor_geography: str | None, + expected_congressional_district_vintage: str | None, +) -> dict[str, Any]: + """The ``geography_ladder`` provenance of a ``block_v1`` pool. + + It keeps the legacy record's shape (coverage of the PUMA-ladder columns, + which ``block_v1`` still writes, from + :func:`~microcosm.build.us_runtime.puma_ladder.us_puma_ladder_assignment_summary` + when a PUMA ladder is supplied) and states the block rule: every + geography column is resolved from the drawn or preserved block, so no + sub-PUMA input stays unresolved. + """ + + household = pooled.table("household") + columns = list(location_geography_columns(block_ladder)) + if puma_ladder is not None: + geography = us_puma_ladder_assignment_summary( + household, + puma_ladder, + weight_values=pooled.weights_for("household").values, + ) + else: + geography = { + "applied": all(column in household.columns for column in columns), + "household_rows": int(len(household)), + } + geography.update( + { + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "assignment_rule": US_BLOCK_LOCATION_RULE_ID, + "seed": location_seed, + "block_ladder_sha256": block_ladder.sha256, + "block_layer_vintages": block_ladder.layer_vintages, + "expected_congressional_district_vintage": ( + expected_congressional_district_vintage + ), + # ACS rows draw a block within their observed PUMA; donor rows + # keep a block the base already carries, else draw within state. + "donor_geography": donor_geography, + "resolved_model_inputs": columns, + "unresolved_sub_puma_inputs": [], + } + ) + return _json_ready_mapping(geography) + + def _require_recipient_adult_care_structure( frame: Frame, ) -> dict[str, Any] | None: @@ -312,6 +442,20 @@ def _require_recipient_adult_care_structure( return {"passed": True, "details": dict(gate.details)} +def _thawed_metadata(value: Any) -> Any: + """A plain-container copy of frozen :class:`Frame` metadata. + + Frame metadata freezes mappings and sequences into its own immutable + types; the manifest wants plain dicts and lists. + """ + + if isinstance(value, Mapping): + return {key: _thawed_metadata(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_thawed_metadata(item) for item in value] + return value + + def _json_ready_mapping(value: Mapping[str, Any]) -> dict[str, Any]: converted = _json_ready(value) if not isinstance(converted, dict): # pragma: no cover - type contract diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/base_pool.py b/packages/microcosm-build/src/microcosm/build/us_runtime/base_pool.py index eb32242c45..ba28aa0c0c 100644 --- a/packages/microcosm-build/src/microcosm/build/us_runtime/base_pool.py +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/base_pool.py @@ -9,6 +9,21 @@ import pandas as pd from microcosm.build.us_runtime.acs_pums import ACS_2024_1YR_SPINE +from microcosm.build.us_runtime.block_location import ( + LOCATION_CANDIDATE_BLOCKS_COLUMN, + LOCATION_CLONE_INDEX_COLUMN, + LOCATION_SOURCE_GEOGRAPHY_COLUMN, + SOURCE_GEOGRAPHY_PUMA, + SOURCE_GEOGRAPHY_STATE, + US_LOCATION_RULE_BLOCK_V1, + US_LOCATION_RULE_CHOICES, + US_LOCATION_RULE_LEGACY, + UsLocationLadder, + derive_us_block_geography, + draw_us_block_locations, + location_geography_columns, + us_block_location_manifest, +) from microcosm.build.us_runtime.puf_support import ( support_channel_column, support_clone_index_column, @@ -28,13 +43,18 @@ __all__ = [ "ACS_2024_1YR_SPINE", + "ACS_POOL_BLOCK_LOCATION_METADATA_KEY", + "ACS_POOL_LOCATION_CLONES_REFUSAL", "ASEC_PUF_SPINE", "DEFAULT_ACS_POOL_PEAK_LIMIT_BYTES", "DONOR_ASSIGNED_GEOGRAPHY_COLUMNS", + "DONOR_BLOCK_PRESERVED", + "DONOR_BLOCK_STATE_DRAWN", "donor_assigned_geography_complete", "estimate_optional_acs_pool_peak_bytes", "preflight_pooled_ladder_geography", "spine_column", + "validate_pool_location_options", "with_optional_acs_spine", ] @@ -60,6 +80,33 @@ _ID_OVERLAP_CHUNK_ROWS = 65_536 _PUMA_LADDER_ANCHOR_COLUMN = "__microcosm_puma_ladder_anchor" +#: Frame-metadata key of the pooled ``block_v1`` location record (the +#: :func:`~microcosm.build.us_runtime.block_location.us_block_location_manifest` +#: record plus this line's donor-block accounting). +ACS_POOL_BLOCK_LOCATION_METADATA_KEY = "acs_pool_block_location" +#: Donor (ASEC-by-PUF) row outcomes under ``block_v1``: a donor that already +#: carries a ladder block keeps it (no redraw); a donor without one draws a +#: block within its state. +DONOR_BLOCK_PRESERVED = "preserved_donor_block" +DONOR_BLOCK_STATE_DRAWN = "state_drawn_missing_donor_block" +#: Why this line refuses ``location_clones > 1`` today; admitting K > 1 end +#: to end is the follow-up to microcosm#696. +ACS_POOL_LOCATION_CLONES_REFUSAL = ( + "location_clones > 1 is not yet admitted on the ACS local-release line. " + "(1) block_location.clone_frame_for_location adds an " + "'{entity}_location_clone_index' column to every entity table; those " + "names are model-named, are not policyengine-us variables and are not in " + "microcosm.data.stored_inputs.US_STORED_NON_VARIABLE_COLUMNS, so the " + "package stage's stored-inputs gate " + "(tools/build_us_acs_local_release.py:_require_stored_inputs -> " + "stored_inputs.require_h5_stored_inputs) refuses the release. " + "(2) base_pool._assign_pooled_block_location preserves each ASEC-by-PUF " + "donor's already-assigned block rather than redrawing it, so K copies of " + "a preserved donor would be K identical locations, not K draws; no rule " + "for redrawing preserved donor blocks per clone exists yet. " + "Use location_clones=1." +) + def spine_column(entity: str) -> str: """Return the entity-prefixed base-spine metadata column.""" @@ -78,6 +125,10 @@ def with_optional_acs_spine( puma_ladder: UsPumaLadder | None = None, geography_seed: int = 0, expected_congressional_district_vintage: str | None = None, + location_rule: str = US_LOCATION_RULE_LEGACY, + block_ladder: UsLocationLadder | None = None, + location_seed: int = 0, + location_clones: int = 1, ) -> Frame: """Append an ACS frame while conserving the base's household mass. @@ -107,11 +158,39 @@ def with_optional_acs_spine( inputs and the defensive copies made by :class:`~microcosm.frame.Frame`; it intentionally does not claim to account for unrelated objects already in the process. + + ``location_rule`` selects the household location rule. ``"legacy"`` (the + default) is the PUMA-ladder behaviour above, byte for byte, and refuses + ``block_ladder``, a nonzero ``location_seed`` and ``location_clones != 1``. + ``"block_v1"`` (microcosm#696) replaces the PUMA-ladder draw with one 2020 + census block per household from ``block_ladder`` (which must carry the + per-block ``puma``): ACS rows draw a block within their observed PUMA; + ASEC-by-PUF donor rows that already carry a ladder block keep it (no + redraw); donor rows without one draw a block within their state. Every + geography column then derives from the block (see + :func:`_assign_pooled_block_location`), and the location record is stored + in the result's metadata under :data:`ACS_POOL_BLOCK_LOCATION_METADATA_KEY`. + ``puma_ladder`` and ``geography_seed`` play no part in a ``block_v1`` + assignment, so a ``puma_ladder`` or a nonzero ``geography_seed`` is + refused rather than ignored. """ if acs is None: return base + validate_pool_location_options( + location_rule, + block_ladder=block_ladder, + location_seed=location_seed, + location_clones=location_clones, + geography_seed=geography_seed, + ) + if location_rule == US_LOCATION_RULE_BLOCK_V1 and puma_ladder is not None: + raise ValueError( + "puma_ladder assigns geography only under the legacy location " + "rule; a block_v1 pool derives every geography from block_ladder. " + "Refusing puma_ladder rather than ignoring it." + ) share = _validated_acs_share(acs_share) limit = _validated_peak_limit(max_peak_bytes) _require_us_base_frame(base, label="base") @@ -176,7 +255,18 @@ def with_optional_acs_spine( id_offsets=id_offsets, populate_acs_support_metadata=populate_acs_support_metadata, ) - if puma_ladder is not None: + location_record: dict[str, Any] | None = None + if location_rule == US_LOCATION_RULE_BLOCK_V1: + assert block_ladder is not None # validate_pool_location_options + location_record = _assign_pooled_block_location( + tables, + block_ladder, + seed=location_seed, + expected_congressional_district_vintage=( + expected_congressional_district_vintage + ), + ) + elif puma_ladder is not None: _assign_pooled_puma_ladder( tables, puma_ladder, @@ -202,13 +292,23 @@ def with_optional_acs_spine( ], ignore_index=True, ) - result = Frame( - tables, - base.schema, - {"household": household_weights}, - strata, - mass_log=mass_log, - ) + if location_record is None: + result = Frame( + tables, + base.schema, + {"household": household_weights}, + strata, + mass_log=mass_log, + ) + else: + result = Frame( + tables, + base.schema, + {"household": household_weights}, + strata, + mass_log=mass_log, + metadata={ACS_POOL_BLOCK_LOCATION_METADATA_KEY: location_record}, + ) if not np.isclose( result.weights_for("household").total, original_mass, @@ -557,7 +657,10 @@ def _preserved_donor_geography( def preflight_pooled_ladder_geography( base_household: pd.DataFrame, acs_household: pd.DataFrame, - ladder: UsPumaLadder, + ladder: UsPumaLadder | None, + *, + location_rule: str = US_LOCATION_RULE_LEGACY, + block_ladder: UsLocationLadder | None = None, ) -> str: """Validate everything pooled ladder assignment needs, before transfer. @@ -567,8 +670,34 @@ def preflight_pooled_ladder_geography( here, not after the fits have run. Returns the donor geography mode (``"preserved_assigned"`` or ``"ladder_drawn"``); raises ``ValueError`` on any incompatibility, matching the assignment-time checks. + + Under ``location_rule="block_v1"`` the checks are the block assignment's + instead (:func:`_assign_pooled_block_location`), against + ``block_ladder``; ``ladder`` is not consulted. The returned mode is then + :data:`DONOR_BLOCK_PRESERVED` (every donor carries a ladder block), + :data:`DONOR_BLOCK_STATE_DRAWN` (none does) or + ``"preserved_donor_block_with_state_draws"`` (some do). """ + if location_rule != US_LOCATION_RULE_LEGACY: + if location_rule != US_LOCATION_RULE_BLOCK_V1: + raise ValueError( + f"location_rule must be one of {US_LOCATION_RULE_CHOICES}, got " + f"{location_rule!r}." + ) + if block_ladder is None: + raise ValueError("location_rule='block_v1' requires a block_ladder.") + return _preflight_pooled_block_location( + base_household, acs_household, block_ladder + ) + if block_ladder is not None: + raise ValueError( + "block_ladder is only used by location_rule='block_v1'; the legacy " + "rule assigns geography from the PUMA ladder." + ) + if ladder is None: + raise ValueError("The legacy location rule requires a PUMA ladder.") + mode = "ladder_drawn" if len(base_household): donor_rows = pd.Series(True, index=base_household.index) @@ -688,6 +817,377 @@ def _assign_pooled_puma_ladder( tables["household"] = assigned +def validate_pool_location_options( + location_rule: str, + *, + block_ladder: UsLocationLadder | None, + location_seed: int, + location_clones: int, + geography_seed: int, +) -> None: + """Refuse location options the selected rule would silently ignore.""" + + if location_rule not in US_LOCATION_RULE_CHOICES: + raise ValueError( + f"location_rule must be one of {US_LOCATION_RULE_CHOICES}, got " + f"{location_rule!r}." + ) + if isinstance(location_seed, bool) or not isinstance( + location_seed, (int, np.integer) + ): + raise ValueError(f"location_seed must be an integer, got {location_seed!r}.") + if isinstance(location_clones, bool) or not isinstance( + location_clones, (int, np.integer) + ): + raise ValueError( + f"location_clones must be an integer, got {location_clones!r}." + ) + if location_rule == US_LOCATION_RULE_LEGACY: + if block_ladder is not None: + raise ValueError( + "block_ladder is only used by location_rule='block_v1'; the " + "legacy rule assigns geography from the PUMA ladder." + ) + if int(location_seed) != 0 or int(location_clones) != 1: + raise ValueError( + "location_seed and location_clones apply only to " + "location_rule='block_v1'; the legacy rule takes its seed from " + f"geography_seed (got location_seed={location_seed!r}, " + f"location_clones={location_clones!r})." + ) + return + if block_ladder is None: + raise ValueError("location_rule='block_v1' requires a block_ladder.") + if block_ladder.puma is None: + raise ValueError( + "location_rule='block_v1' on the ACS pool needs a block ladder that " + "carries the per-block 'puma' array: ACS rows draw within their " + "observed PUMA." + ) + if int(location_seed) < 0: + raise ValueError(f"location_seed must be non-negative, got {location_seed!r}.") + if int(location_clones) < 1: + raise ValueError(f"location_clones must be >= 1, got {location_clones!r}.") + if int(location_clones) > 1: + raise ValueError(ACS_POOL_LOCATION_CLONES_REFUSAL) + if int(geography_seed) != 0: + raise ValueError( + "geography_seed seeds only the legacy PUMA-ladder draw; under " + "location_rule='block_v1' the draw is seeded by location_seed. " + f"Refusing geography_seed={geography_seed!r} rather than ignoring it." + ) + + +def _acs_block_pumas(puma: pd.Series, ladder: UsLocationLadder) -> np.ndarray: + """Each ACS household's observed 2020 PUMA, validated against the ladder.""" + + parsed = pd.to_numeric(puma, errors="coerce") + invalid = parsed.isna() | ~np.isfinite(parsed) | (parsed <= 0) + if invalid.any(): + examples = puma.loc[invalid].head().tolist() + raise ValueError( + "Every ACS household must carry its observed seven-digit PUMA " + f"geoid before block location; invalid value(s): {examples}." + ) + values = parsed.astype(np.int64).to_numpy() + assert ladder.puma is not None # validate_pool_location_options + known = np.isin(values, ladder.puma) + if not known.all(): + examples = sorted(set(values[~known][:5].tolist())) + raise ValueError( + f"ACS PUMA geoid(s) absent from the block ladder's PUMA layer: {examples}." + ) + return values + + +def _donor_block_index( + donor: pd.DataFrame, ladder: UsLocationLadder +) -> tuple[np.ndarray, np.ndarray]: + """Donor rows' already-assigned blocks as ladder indices. + + Returns ``(has_block, block_index)``: a donor row whose ``block_geoid`` is + missing or blank has no block (``-1``) and will draw within its state. A + present block must be a 15-digit code the ladder carries, in the row's + ``state_fips``; anything else fails loudly, because preserving an + unknown or mis-stated block would publish geography the ladder cannot + derive. + """ + + n = len(donor) + if "block_geoid" not in donor.columns or n == 0: + return np.zeros(n, dtype=bool), np.full(n, -1, dtype=np.int64) + raw = donor["block_geoid"] + if pd.api.types.is_numeric_dtype(raw): + # An integer-typed block geoid loses the leading zero of states 01-09. + text = pd.Series( + ["" if pd.isna(value) else f"{int(value):015d}" for value in raw.tolist()], + index=raw.index, + dtype=object, + ) + else: + text = raw.astype(object).where(raw.notna(), "").astype(str).str.strip() + has_block = (text != "").to_numpy() + index = np.full(n, -1, dtype=np.int64) + if not has_block.any(): + return has_block, index + present = text.to_numpy()[has_block].astype(np.str_) + malformed = ~((np.char.str_len(present) == 15) & np.char.isdigit(present)) + if malformed.any(): + examples = present[malformed][:5].tolist() + raise ValueError( + "Donor block_geoid values must be 15-digit 2020 block codes; " + f"invalid value(s): {examples}." + ) + blocks = present.astype(np.int64) + position = np.clip(np.searchsorted(ladder.block_geoid, blocks), 0, len(ladder) - 1) + unknown = ladder.block_geoid[position] != blocks + if unknown.any(): + examples = sorted(set(blocks[unknown][:5].tolist())) + raise ValueError( + "Donor block_geoid(s) absent from the block ladder: " + f"{[f'{value:015d}' for value in examples]}. The donor's block " + "vintage must match the ladder's 2020 tabulation blocks." + ) + states = pd.to_numeric(donor["state_fips"], errors="coerce").to_numpy()[has_block] + mismatch = (blocks // 10**13) != states + if mismatch.any(): + examples = sorted( + { + (f"{block:015d}", int(state)) + for block, state in zip( + blocks[mismatch][:5].tolist(), + states[mismatch][:5].tolist(), + strict=True, + ) + } + ) + raise ValueError( + f"Donor block_geoid state prefixes disagree with state_fips: {examples}." + ) + index[has_block] = position + return has_block, index + + +def _require_ladder_states(state_fips: pd.Series, ladder: UsLocationLadder) -> None: + states = set(pd.to_numeric(state_fips).astype(np.int64).tolist()) + ladder_states = set((ladder.block_geoid // 10**13).astype(int).tolist()) + missing = sorted(states - ladder_states) + if missing: + raise ValueError( + "Donor state_fips without any ladder block (state-conditional " + f"block draw impossible): {missing}." + ) + + +def _block_donor_mode(has_block: np.ndarray) -> str: + if has_block.all(): + return DONOR_BLOCK_PRESERVED + if not has_block.any(): + return DONOR_BLOCK_STATE_DRAWN + return f"{DONOR_BLOCK_PRESERVED}_with_state_draws" + + +def _preflight_pooled_block_location( + base_household: pd.DataFrame, + acs_household: pd.DataFrame, + ladder: UsLocationLadder, +) -> str: + """The ``block_v1`` half of :func:`preflight_pooled_ladder_geography`.""" + + if ladder.puma is None: + raise ValueError( + "location_rule='block_v1' on the ACS pool needs a block ladder that " + "carries the per-block 'puma' array." + ) + has_block = np.zeros(0, dtype=bool) + if len(base_household): + has_block, _ = _donor_block_index(base_household, ladder) + if (~has_block).any(): + _require_ladder_states(base_household.loc[~has_block, "state_fips"], ladder) + if len(acs_household): + if "puma" not in acs_household.columns: + raise ValueError( + "ACS household table must carry canonical seven-digit puma " + "geoids before block location." + ) + _acs_block_pumas(acs_household["puma"], ladder) + return _block_donor_mode(has_block) + + +def _assign_pooled_block_location( + tables: dict[str, pd.DataFrame], + ladder: UsLocationLadder, + *, + seed: int, + expected_congressional_district_vintage: str | None, +) -> dict[str, Any]: + """Locate every pooled household on one 2020 block (``block_v1``). + + - ACS rows (``household_spine == "acs_2024_1yr"``) draw one block within + their observed 2020 PUMA, proportional to block population + (:func:`~microcosm.build.us_runtime.block_location.draw_us_block_locations`, + keyed by the pooled ``household_id``). + - ASEC-by-PUF donor rows that already carry a ``block_geoid`` (the base + H5 line assigned it, under either rule) keep that block: it is verified + to exist in the ladder and to lie in the row's state, and is never + redrawn. + - Donor rows without a block draw one within their state; the count is + recorded under :data:`DONOR_BLOCK_STATE_DRAWN`. + + Every geography column (:func:`location_geography_columns`: block, + tract, county, place, SLDU/SLDL, CBSA, PUMA, primary CD and any attached + plan) is then written from the ladder's lookup of the block, for every + row, so no household carries a geography its block contradicts. + ``state_fips`` is never rewritten. Returns the manifest record. + """ + + household = tables["household"] + tag = spine_column("household") + if tag not in household: + raise ValueError(f"Pooled household table lacks required spine tag {tag!r}.") + if "puma" not in household: + raise ValueError( + "ACS household table must carry canonical seven-digit puma geoids " + "before block location." + ) + if expected_congressional_district_vintage is not None: + plan = ladder.primary_congressional_district_plan + if plan != expected_congressional_district_vintage: + raise ValueError( + f"US block ladder congressional-district vintage {plan!r} does " + "not match the vintage the caller expects " + f"({expected_congressional_district_vintage!r})." + ) + n = len(household) + acs_rows = household[tag].eq(ACS_2024_1YR_SPINE).to_numpy() + donor_rows = ~acs_rows + household_key = pd.to_numeric(household["household_id"]).to_numpy(np.int64) + state = pd.to_numeric(household["state_fips"]).to_numpy(np.int64) + + block_index = np.full(n, -1, dtype=np.int64) + source_geography = np.full(n, DONOR_BLOCK_PRESERVED, dtype=object) + donor_has_block, donor_index = _donor_block_index(household.loc[donor_rows], ladder) + preserved = np.zeros(n, dtype=bool) + preserved[np.flatnonzero(donor_rows)[donor_has_block]] = True + block_index[preserved] = donor_index[donor_has_block] + + drawn = ~preserved + puma = np.zeros(n, dtype=np.int64) + if acs_rows.any(): + puma[acs_rows] = _acs_block_pumas(household.loc[acs_rows, "puma"], ladder) + if (drawn & donor_rows).any(): + _require_ladder_states(household.loc[drawn & donor_rows, "state_fips"], ladder) + if drawn.any(): + draw = draw_us_block_locations( + ladder, + household_key=household_key[drawn], + state_fips=state[drawn], + seed=seed, + clones=1, + puma=puma[drawn], + ) + block_index[drawn] = draw.block_index + source_geography[drawn] = draw.source_geography + if (block_index < 0).any(): + raise AssertionError("internal error: a pooled household received no block.") + if ((ladder.block_geoid[block_index] // 10**13) != state).any(): + raise AssertionError("internal error: a pooled block left its state.") + + derived = derive_us_block_geography(ladder, block_index) + columns = location_geography_columns(ladder) + changed = _rederived_donor_changes(household, derived, preserved, columns) + for column in columns: + household[column] = derived[column] + if acs_rows.any(): + drawn_puma = pd.to_numeric(household.loc[acs_rows, "puma"]).to_numpy(np.int64) + if not np.array_equal(drawn_puma, puma[acs_rows]): + raise AssertionError("internal error: an ACS block left its PUMA.") + tables["household"] = household + + record = us_block_location_manifest( + pd.DataFrame( + { + LOCATION_CLONE_INDEX_COLUMN: np.zeros(n, dtype=np.int64), + LOCATION_SOURCE_GEOGRAPHY_COLUMN: source_geography, + LOCATION_CANDIDATE_BLOCKS_COLUMN: np.zeros(n, dtype=np.int64), + } + ), + ladder, + seed=seed, + clones=1, + candidate_set_rule={ + ACS_2024_1YR_SPINE: ( + f"{SOURCE_GEOGRAPHY_PUMA}: the blocks of the record's observed " + "2020 PUMA (ACS PUMS ST+PUMA)" + ), + ASEC_PUF_SPINE: ( + f"{DONOR_BLOCK_PRESERVED}: the donor base's already-assigned " + "block, kept (never redrawn) and re-derived through this " + f"ladder; a donor row without a block: {SOURCE_GEOGRAPHY_STATE}" + ), + }, + ) + n_state_drawn = int((drawn & donor_rows).sum()) + record.update( + { + "household_key": "pooled household_id (after ACS id remapping)", + "spine_households": { + ACS_2024_1YR_SPINE: int(acs_rows.sum()), + ASEC_PUF_SPINE: int(donor_rows.sum()), + }, + "donor_blocks": { + "mode": _block_donor_mode(donor_has_block), + DONOR_BLOCK_PRESERVED: int(preserved.sum()), + DONOR_BLOCK_STATE_DRAWN: n_state_drawn, + "state_drawn_reason": ( + "the donor household carries no block_geoid, so its finest " + "known geography is its state" + if n_state_drawn + else None + ), + "rederived_columns_changed": changed, + }, + } + ) + return record + + +def _rederived_donor_changes( + household: pd.DataFrame, + derived: dict[str, np.ndarray], + preserved: np.ndarray, + columns: tuple[str, ...], +) -> dict[str, int]: + """Preserved donor rows whose stored, non-empty column differs from the block. + + Preserving a donor's block re-derives every other geography from this + ladder. A nonzero count means the donor was located against a ladder that + disagrees with this one on that column; it is recorded, not hidden. A + stored value that is missing or blank is filled, not changed, and is not + counted. + """ + + changes: dict[str, int] = {} + if not preserved.any(): + return changes + for column in columns: + if column not in household.columns: + continue + want = derived[column][preserved] + stored = pd.Series(household[column].to_numpy()[preserved], dtype=object) + text = stored.where(stored.notna(), "").astype(str).str.strip() + present = (text != "").to_numpy() + if want.dtype.kind in "iu": + got = pd.to_numeric(stored, errors="coerce").to_numpy(dtype=np.float64) + differs = got != want.astype(np.float64) + else: + differs = text.to_numpy() != want.astype(str) + count = int((differs & present).sum()) + if count: + changes[column] = count + return changes + + def _prepared_source_view( source: pd.DataFrame, *, diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/block_ladder_sources.py b/packages/microcosm-build/src/microcosm/build/us_runtime/block_ladder_sources.py index 000813d96b..47be9cb43b 100644 --- a/packages/microcosm-build/src/microcosm/build/us_runtime/block_ladder_sources.py +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/block_ladder_sources.py @@ -348,6 +348,7 @@ def assemble_us_block_ladder( place_by_block: Mapping[int, int], cbsa_by_county: Mapping[str, int], metadata: Mapping[str, Any], + tract_to_puma: Mapping[int, int] | None = None, ) -> dict[str, np.ndarray]: """Join the parsed sources into the ladder artifact's NPZ payload. @@ -355,6 +356,11 @@ def assemble_us_block_ladder( block the CD BEF does not cover is a source defect, not a skippable row. SLD and place maps may legitimately not cover a block (states without a layer); absent entries mean unassigned. + + With ``tract_to_puma`` (schema 2) every populated block also carries its + 2020 PUMA: 2020 blocks nest in 2020 tracts and 2020 tracts in 2020 PUMAs, + so the PUMA is the block's tract's PUMA. A populated block whose tract + the relationship file omits is a source defect. """ blocks = np.asarray(sorted(block_population), dtype=np.int64) @@ -382,7 +388,7 @@ def assemble_us_block_ladder( [cbsa_by_county.get(f"{block:015d}"[:5], 0) for block in blocks.tolist()], dtype=np.int32, ) - return { + payload = { "block_geoid": blocks, "population": population, "congressional_district_geoid": cd, @@ -392,6 +398,21 @@ def assemble_us_block_ladder( "cbsa_code": cbsa, "metadata_json": np.asarray(json.dumps(dict(metadata), sort_keys=True)), } + if tract_to_puma is not None: + missing_puma = [ + block for block in blocks.tolist() if block // 10**4 not in tract_to_puma + ] + if missing_puma: + examples = [f"{block:015d}" for block in missing_puma[:5]] + raise ValueError( + f"{len(missing_puma)} populated block(s) have a tract absent " + f"from the tract-to-PUMA relationship file; examples: {examples}." + ) + payload["puma"] = np.asarray( + [tract_to_puma[block // 10**4] for block in blocks.tolist()], + dtype=np.int64, + ) + return payload def _five_digit_code(value: str) -> int | None: diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/block_location.py b/packages/microcosm-build/src/microcosm/build/us_runtime/block_location.py new file mode 100644 index 0000000000..60071a9073 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/block_location.py @@ -0,0 +1,1625 @@ +"""US household location, version 1: one 2020 census block per household. + +The rule (Max, 27 September 2026; microcosm#696): for each household, draw one +2020 census tabulation block at random, proportional to block population, +within the finest geography its source provides; then look every other +geography up from that block. One rule and one code path for every US line. +Fidelity comes from calibrating weights afterwards, not from the draw. + +A household's finest source geography, and so its candidate blocks, is: + +- ``puma``: an ACS PUMS record's published 2020 PUMA → the blocks of that PUMA; +- ``county``: a CPS ASEC record that identifies its county (``GTCO`` nonzero) + → the blocks of that county; +- ``state_unidentified_counties``: a CPS ASEC record with ``GTCO = 0`` → the + blocks of its state outside the counties its ASEC vintage identifies. + County identification is a countywide disclosure property (Max, + 2026-08-16), so such a household is known not to live in an identified + county. Optionally narrowed to its CBSA + (``state_unidentified_counties_cbsa``) when the source's CBSA delineation + agrees with the ladder's (:func:`cbsa_vintage_agreement`); +- ``state``: every other record (PUF-lineage records without a CPS source, + or any source that carries nothing finer). + +Every other geography derives from the block through the ladder. Tract and +county are structural prefixes of the block geoid. Place, state legislative +districts, CBSA, PUMA and the district of every attached congressional +plan are block-level lookups. So one household's geographies can never +contradict each other, and a new district map is a re-lookup, not a +re-draw. + +Draws are keyed, not sequential. A household clone's uniform is +``stable_identity_uniforms("{key}:{clone}", seed, salt=rule id)``, mapped +to a block by the exact inverse CDF of block population within its +candidate set (blocks in geoid order). A draw depends only on the seed, the +household key, the clone index and the candidate set. It does not depend +on row order, on other households, or on the order strata are visited, +which the legacy ``rng.choice`` paths cannot promise. Clone ``k`` of a +``K``-clone draw is the same block whatever ``K`` is. + +With ``clones = K > 1`` every household is drawn ``K`` times, each clone +gets ``weight / K``, and weighted household and person mass is unchanged. +Calibration can then move weight between a household's clones, which in +effect chooses its location. With ``K = 1`` a household can only be +rescaled where it landed. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +from collections.abc import Collection, Mapping +from dataclasses import dataclass, field, replace +from importlib.resources import files +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +from microcosm.build.gates import GateResult +from microcosm.build.stochastic_assignment import stable_identity_uniforms +from microcosm.build.us_runtime.congressional_district_geography import ( + CONGRESSIONAL_DISTRICT_GEOID_COLUMN, +) +from microcosm.build.us_runtime.geography_ladder import ( + UsBlockLadder, + load_us_block_ladder, +) +from microcosm.frame import Frame, Weights + +#: The rule id every build records when it locates households this way; also +#: the salt of the keyed uniforms. +US_BLOCK_LOCATION_RULE_ID = "us_block_location.population_draw.v1" + +#: ``--location-rule`` values. ``legacy`` keeps each line's historical rule +#: (and its bytes); ``block_v1`` is this module. +US_LOCATION_RULE_LEGACY = "legacy" +US_LOCATION_RULE_BLOCK_V1 = "block_v1" +US_LOCATION_RULE_CHOICES = (US_LOCATION_RULE_LEGACY, US_LOCATION_RULE_BLOCK_V1) + +#: Source-geography kinds, finest first. +SOURCE_GEOGRAPHY_PUMA = "puma" +SOURCE_GEOGRAPHY_COUNTY = "county" +SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA = "state_unidentified_counties_cbsa" +SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES = "state_unidentified_counties" +SOURCE_GEOGRAPHY_STATE = "state" +SOURCE_GEOGRAPHY_KINDS = ( + SOURCE_GEOGRAPHY_PUMA, + SOURCE_GEOGRAPHY_COUNTY, + SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA, + SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES, + SOURCE_GEOGRAPHY_STATE, +) + +#: Location-table bookkeeping columns. +LOCATION_CLONE_INDEX_COLUMN = "location_clone_index" +LOCATION_CLONE_ID_COLUMN = "location_clone_id" +LOCATION_WEIGHT_COLUMN = "location_weight" +LOCATION_SOURCE_GEOGRAPHY_COLUMN = "location_source_geography" +LOCATION_CANDIDATE_BLOCKS_COLUMN = "location_candidate_blocks" +#: Geography columns every draw writes (``puma`` only from a ladder that +#: carries it). Each attached non-primary CD plan adds +#: ``congressional_district_geoid__``. +US_BLOCK_LOCATION_GEOGRAPHY_COLUMNS = ( + "block_geoid", + "tract_geoid", + "county_fips", + "place_fips", + "sldu", + "sldl", + "cbsa_code", + "puma", + CONGRESSIONAL_DISTRICT_GEOID_COLUMN, +) +#: Household-column prefix of every non-primary CD plan attached to a ladder. +US_CD_PLAN_COLUMN_PREFIX = "congressional_district_geoid__" +#: Ladder array key of the per-block 2020 PUMA. It is additive to block-ladder +#: schema 1: the schema-1 loader ignores arrays it does not require, so a +#: legacy build reads a PUMA-carrying ladder unchanged. +US_BLOCK_LADDER_PUMA_ARRAY = "puma" +#: Frame metadata key of the location receipt. +US_BLOCK_LOCATION_METADATA_KEY = "us_block_location" +#: H5 root attributes every line writes on a ``block_v1`` artifact (never on a +#: legacy one): the rule, its seed and clone count as text, and the block +#: ladder's digest and layer vintages. +POPULACE_LOCATION_RULE_ATTR = "populace_location_rule" +POPULACE_LOCATION_SEED_ATTR = "populace_location_seed" +POPULACE_LOCATION_CLONES_ATTR = "populace_location_clones" +POPULACE_BLOCK_LADDER_SHA256_ATTR = "populace_block_ladder_sha256" +POPULACE_BLOCK_LADDER_VINTAGES_ATTR = "populace_block_ladder_vintages" +#: Packaged official CPS ASEC identified-county lists (Census technical +#: documentation, Appendix F, List 4), keyed by ASEC survey year. +CPS_ASEC_IDENTIFIED_COUNTIES_RESOURCE = "cps_asec_identified_counties.csv" + +_BLOCK_STATE_DIVISOR = 10**13 +_BLOCK_COUNTY_DIVISOR = 10**10 +_PUMA_STATE_DIVISOR = 10**5 +_CBSA_KEY_MULTIPLIER = 100_000 +_EXCLUDED_KEY = -1 + + +# -------------------------------------------------------------------------- +# The ladder +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UsLocationLadder: + """The block ladder plus what the location draw derives beyond it. + + Attributes: + blocks: The validated schema-1 block ladder: block geoid (sorted), + population, primary-plan CD, SLDU/SLDL, place, CBSA. + puma: 2020 PUMA per block (``state_fips * 10**5 + PUMA5CE``), or + ``None`` for an artifact without the PUMA array. A PUMA- + constrained draw refuses such a ladder. + congressional_district_plans: CD geoid array per plan id, aligned to + the blocks. Always carries the primary plan (the ladder's + ``congressional_district`` layer); more are attached from a + block → CD-plan registry by :func:`attach_congressional_district_plans`. + congressional_district_plan_sources: Vintage, source and any known + deviations per plan id. + sha256: SHA-256 of the artifact file, when loaded from one. + """ + + blocks: UsBlockLadder + puma: np.ndarray | None = None + congressional_district_plans: Mapping[str, np.ndarray] = field(default_factory=dict) + congressional_district_plan_sources: Mapping[str, Mapping[str, Any]] = field( + default_factory=dict + ) + sha256: str | None = None + + def __len__(self) -> int: + return len(self.blocks) + + @property + def block_geoid(self) -> np.ndarray: + return self.blocks.block_geoid + + @property + def population(self) -> np.ndarray: + return self.blocks.population + + @property + def cbsa_code(self) -> np.ndarray: + return self.blocks.cbsa_code + + @property + def primary_congressional_district_plan(self) -> str: + """The plan id the ``congressional_district_geoid`` column carries.""" + return self.blocks.layer_vintages["congressional_district"] + + @property + def layer_vintages(self) -> dict[str, str]: + vintages = dict(self.blocks.layer_vintages) + if self.puma is not None: + vintages["puma"] = str(self.blocks.metadata["layers"]["puma"]["vintage"]) + return vintages + + +def us_location_ladder( + blocks: UsBlockLadder, + *, + puma: np.ndarray | None = None, + sha256: str | None = None, +) -> UsLocationLadder: + """Wrap a validated block ladder for location draws. + + Block geoids must be sorted ascending (the artifact builder writes them + so), because block lookups are binary searches. An optional per-block + ``puma`` is validated: one positive 2020 PUMA per block, in the block's + state, constant within each tract, with a recorded ``puma`` layer vintage + and source. + """ + + if (np.diff(blocks.block_geoid) <= 0).any(): + raise ValueError("block ladder block_geoid values must be sorted ascending.") + validated_puma = None + if puma is not None: + layers = blocks.metadata.get("layers", {}) + puma_layer = layers.get("puma") if isinstance(layers, Mapping) else None + if not isinstance(puma_layer, Mapping) or not all( + str(puma_layer.get(key) or "") for key in ("vintage", "source") + ): + raise ValueError( + "a block ladder carrying 'puma' must record a 'puma' layer with " + "a non-empty vintage and source (vintage_policy: error)." + ) + validated_puma = _validated_puma_array(puma, blocks.block_geoid) + primary = blocks.layer_vintages["congressional_district"] + return UsLocationLadder( + blocks=blocks, + puma=validated_puma, + congressional_district_plans={primary: blocks.congressional_district_geoid}, + congressional_district_plan_sources={ + primary: { + **dict(blocks.metadata["layers"]["congressional_district"]), + "primary": True, + } + }, + sha256=sha256, + ) + + +def load_us_location_ladder(path: str | Path) -> UsLocationLadder: + """Load a block ladder and its optional per-block PUMA for location draws. + + The core arrays go through :func:`load_us_block_ladder` unchanged; the + additive ``puma`` array is validated by :func:`us_location_ladder`. + """ + + source = Path(path) + blocks = load_us_block_ladder(source) + with np.load(source, allow_pickle=False) as payload: + puma = ( + np.asarray(payload[US_BLOCK_LADDER_PUMA_ARRAY]) + if US_BLOCK_LADDER_PUMA_ARRAY in payload.files + else None + ) + return us_location_ladder(blocks, puma=puma, sha256=file_sha256(source)) + + +def attach_congressional_district_plans( + ladder: UsLocationLadder, + *, + block_geoid: np.ndarray, + plans: Mapping[str, np.ndarray], + plan_sources: Mapping[str, Mapping[str, Any]], +) -> UsLocationLadder: + """Return ``ladder`` with more congressional-district plans attached. + + ``block_geoid`` and each ``plans[plan]`` array come from a block → CD-plan + registry: one district per 2020 block per plan, in the + ``state_fips * 100 + district`` convention. The registry may cover more + blocks than the ladder but must cover every ladder block, and each + district must lie in its block's state. A plan the ladder already carries + must agree with it block for block: the same map read from two artifacts + is a differential check, never a silent override. ``plan_sources`` must + give each plan a non-empty ``vintage`` and ``source``; any other keys + (source hashes, known deviations) are carried into the manifest verbatim. + """ + + registry_blocks = _int_values(block_geoid, label="registry block_geoid") + if len(registry_blocks) == 0: + raise ValueError("CD-plan registry has no blocks.") + if len(np.unique(registry_blocks)) != len(registry_blocks): + raise ValueError("CD-plan registry block_geoid values must be unique.") + order = np.argsort(registry_blocks, kind="stable") + sorted_blocks = registry_blocks[order] + position = np.clip( + np.searchsorted(sorted_blocks, ladder.block_geoid), 0, len(sorted_blocks) - 1 + ) + uncovered = sorted_blocks[position] != ladder.block_geoid + if uncovered.any(): + examples = [f"{value:015d}" for value in ladder.block_geoid[uncovered][:5]] + raise ValueError( + f"CD-plan registry omits {int(uncovered.sum())} ladder block(s); " + f"examples: {examples}." + ) + aligned = order[position] + merged = dict(ladder.congressional_district_plans) + sources = { + plan: dict(spec) + for plan, spec in ladder.congressional_district_plan_sources.items() + } + for plan, values in plans.items(): + spec = plan_sources.get(plan) + if not isinstance(spec, Mapping) or not all( + str(spec.get(key) or "") for key in ("vintage", "source") + ): + raise ValueError( + f"CD plan {plan!r} must record a non-empty vintage and source." + ) + raw = np.asarray(values) + if len(raw) != len(registry_blocks): + raise ValueError( + f"CD plan {plan!r} has {len(raw)} values for " + f"{len(registry_blocks)} registry blocks." + ) + districts = _validated_cd_array( + raw[aligned], ladder.block_geoid, label=f"CD plan {plan!r}" + ) + if plan in merged: + differs = merged[plan] != districts + if differs.any(): + raise ValueError( + f"CD plan {plan!r} disagrees with the ladder's own copy on " + f"{int(differs.sum())} block(s)." + ) + sources[plan]["registry"] = dict(spec) + continue + merged[str(plan)] = districts + sources[str(plan)] = {**dict(spec), "primary": False} + return replace( + ladder, + congressional_district_plans=merged, + congressional_district_plan_sources=sources, + ) + + +def plan_column(plan: str, ladder: UsLocationLadder) -> str: + """Household column an attached CD plan is written to.""" + + if plan not in ladder.congressional_district_plans: + raise KeyError(f"CD plan {plan!r} is not attached to the ladder.") + if plan == ladder.primary_congressional_district_plan: + return CONGRESSIONAL_DISTRICT_GEOID_COLUMN + return f"{US_CD_PLAN_COLUMN_PREFIX}{plan}" + + +def location_geography_columns(ladder: UsLocationLadder) -> tuple[str, ...]: + """Every geography column a draw against ``ladder`` writes, in order.""" + + base = tuple( + column + for column in US_BLOCK_LOCATION_GEOGRAPHY_COLUMNS + if column != "puma" or ladder.puma is not None + ) + extra = tuple( + plan_column(plan, ladder) + for plan in sorted(ladder.congressional_district_plans) + if plan != ladder.primary_congressional_district_plan + ) + return (*base, *extra) + + +# -------------------------------------------------------------------------- +# The draw +# -------------------------------------------------------------------------- + + +def location_uniforms( + seed: int, household_key: np.ndarray, clone_index: np.ndarray +) -> np.ndarray: + """Keyed uniforms in ``[0, 1]``, one per ``(household key, clone)`` pair. + + ``blake2b("{seed}:{rule id}:{key}:{clone}")`` through the repository's + :func:`~microcosm.build.stochastic_assignment.stable_identity_uniforms`: + a pure function of its arguments, so a household's draw never depends on + where it sits in the table or on which other households are present. + """ + + keys = np.asarray(household_key, dtype=np.int64) + clones = np.asarray(clone_index, dtype=np.int64) + if keys.shape != clones.shape: + raise ValueError("household_key and clone_index must align.") + if int(seed) < 0: + raise ValueError(f"location seed must be non-negative, got {seed!r}.") + return stable_identity_uniforms( + [ + f"{key}:{clone}" + for key, clone in zip(keys.tolist(), clones.tolist(), strict=True) + ], + seed=int(seed), + salt=US_BLOCK_LOCATION_RULE_ID, + ) + + +class _BlockStrata: + """Blocks grouped by an ``int64`` stratum key, each stratum contiguous. + + Blocks keyed ``-1`` belong to no stratum a household may draw from. Within + a stratum blocks are in geoid order, and a uniform ``u`` selects the block + whose cumulative-population interval ``[C_{i-1}, C_i)`` holds + ``C_start + u * P`` (``P`` the stratum population): the exact inverse + CDF, so ``P(block i) = population_i / P``. + """ + + def __init__( + self, block_keys: np.ndarray, block_geoid: np.ndarray, population: np.ndarray + ) -> None: + self.order = np.lexsort((block_geoid, block_keys)) + self.keys = block_keys[self.order] + self.population = population[self.order] + self.cumulative = np.cumsum(self.population) + + def bounds(self, household_keys: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + lo = np.searchsorted(self.keys, household_keys, side="left") + hi = np.searchsorted(self.keys, household_keys, side="right") + return lo, hi + + def draw(self, household_keys: np.ndarray, uniforms: np.ndarray) -> np.ndarray: + lo, hi = self.bounds(household_keys) + if (hi <= lo).any() or (household_keys == _EXCLUDED_KEY).any(): + raise AssertionError("internal error: draw on an empty stratum.") + start = self.cumulative[lo] - self.population[lo] + total = self.cumulative[hi - 1] - start + target = start + uniforms * total + position = np.searchsorted(self.cumulative, target, side="right") + return self.order[np.clip(position, lo, hi - 1)] + + +@dataclass(frozen=True) +class UsBlockLocationDraw: + """One block per ``(household row, clone)``. + + Attributes: + row: Input row of each output row (``np.repeat`` of the rows, so a + household's clones are adjacent, clone 0 first). + clone_index: Clone ``0..K-1`` of each output row. + block_index: Index into the ladder arrays of each drawn block. + source_geography: Source-geography kind of each output row. + candidate_blocks: Size of each output row's candidate set. + seed: The seed the uniforms were keyed with. + clones: ``K``. + """ + + row: np.ndarray + clone_index: np.ndarray + block_index: np.ndarray + source_geography: np.ndarray + candidate_blocks: np.ndarray + seed: int + clones: int + + +def draw_us_block_locations( + ladder: UsLocationLadder, + *, + household_key: np.ndarray, + state_fips: np.ndarray, + seed: int, + clones: int = 1, + puma: np.ndarray | None = None, + county_fips: np.ndarray | None = None, + identification_group: np.ndarray | None = None, + identified_counties: Mapping[int, Collection[int]] | None = None, + cbsa_code: np.ndarray | None = None, + unidentified_county_share: Mapping[int, Mapping[int, float]] | None = None, +) -> UsBlockLocationDraw: + """Draw one block per household (per clone) within its source geography. + + Args: + ladder: The location ladder. PUMA-constrained households need one + that carries a per-block ``puma``. + household_key: Unique non-negative integer key per household (its + stable id). Draws are keyed by it, so keys must be unique. + state_fips: State FIPS per household; every candidate block lies in it. + seed: Location seed, recorded in the build manifest. + clones: ``K >= 1`` independent draws per household. + puma: 2020 PUMA per household (``state * 10**5 + PUMA5CE``); ``<= 0`` + where the source publishes none. + county_fips: 5-digit county FIPS per household as an integer; ``<= 0`` + where the source identifies none. + identification_group: For households with neither PUMA nor county, + the group (CPS ASEC vintage) whose identified-county list their + county was suppressed against; ``< 0`` means none, so the draw + is statewide. + identified_counties: Identified (excluded) county FIPS per group; + required when any household has an identification group. + cbsa_code: Optional CBSA per household, used only by + ``state_unidentified_counties`` households with a positive code. + Pass it only when :func:`cbsa_vintage_agreement` holds. + unidentified_county_share: Optional per-group ``{county: share}`` + multiplying block population in that group's unidentified-county + pool (default 1; a share of 0 removes the county). This is the + opt-in correction for partially coded counties + (:func:`cps_unidentified_county_shares`); the ruled default is + plain block population. + + Raises: + ValueError: On inconsistent inputs, or when any household's candidate + set is empty. The draw never falls back silently to a coarser + geography. + """ + + clones = int(clones) + if clones < 1: + raise ValueError(f"clones must be >= 1, got {clones}.") + keys = _int_values(household_key, label="household_key") + n = len(keys) + if n == 0: + raise ValueError("draw_us_block_locations needs at least one household.") + if (keys < 0).any(): + raise ValueError("household_key values must be non-negative.") + if len(np.unique(keys)) != n: + raise ValueError( + "household_key values must be unique: draws are keyed by them." + ) + if int(keys.max()) > (np.iinfo(np.int64).max - clones) // clones: + raise ValueError("household_key too large to derive clone ids.") + state = _aligned(state_fips, n, label="state_fips") + if ((state < 1) | (state > 99)).any(): + raise ValueError("state_fips must be two-digit state codes.") + puma_values = _optional_aligned(puma, n, label="puma") + county_values = _optional_aligned(county_fips, n, label="county_fips") + group_values = _optional_aligned( + identification_group, n, label="identification_group", missing=-1 + ) + cbsa_values = _optional_aligned(cbsa_code, n, label="cbsa_code") + + has_puma = puma_values > 0 + has_county = county_values > 0 + if (has_puma & has_county).any(): + raise ValueError( + "a household carries at most one of puma and county_fips; " + f"{int((has_puma & has_county).sum())} carry both." + ) + has_group = ~has_puma & ~has_county & (group_values >= 0) + if has_group.any() and identified_counties is None: + raise ValueError( + "identification_group is set but identified_counties is missing." + ) + if has_puma.any() and ladder.puma is None: + raise ValueError( + "PUMA-constrained households need a block ladder that carries a " + "per-block 'puma' array; this ladder has none." + ) + _require_same_state(state, puma_values // _PUMA_STATE_DIVISOR, has_puma, "puma") + _require_same_state(state, county_values // 1000, has_county, "county_fips") + + block_state = ladder.block_geoid // _BLOCK_STATE_DIVISOR + block_county = ladder.block_geoid // _BLOCK_COUNTY_DIVISOR + kind = np.full(n, SOURCE_GEOGRAPHY_STATE, dtype=object) + household_stratum = state.copy() + population = np.asarray(ladder.population, dtype=np.float64) + views: list[tuple[np.ndarray, str, np.ndarray, np.ndarray]] = [] + + if has_puma.any(): + assert ladder.puma is not None + kind[has_puma] = SOURCE_GEOGRAPHY_PUMA + household_stratum[has_puma] = puma_values[has_puma] + views.append((has_puma, SOURCE_GEOGRAPHY_PUMA, ladder.puma, population)) + if has_county.any(): + kind[has_county] = SOURCE_GEOGRAPHY_COUNTY + household_stratum[has_county] = county_values[has_county] + views.append((has_county, SOURCE_GEOGRAPHY_COUNTY, block_county, population)) + if has_group.any(): + assert identified_counties is not None + for group in sorted({int(value) for value in group_values[has_group]}): + if group not in identified_counties: + raise ValueError( + "identified_counties has no entry for identification group " + f"{group}." + ) + identified = np.asarray( + sorted(int(value) for value in identified_counties[group]), + dtype=np.int64, + ) + excluded = np.isin(block_county, identified) + group_population = _county_scaled_population( + ladder.population, + block_county, + (unidentified_county_share or {}).get(group, {}), + ) + excluded = excluded | (group_population <= 0) + in_group = has_group & (group_values == group) + with_cbsa = in_group & (cbsa_values > 0) + without_cbsa = in_group & ~with_cbsa + if without_cbsa.any(): + kind[without_cbsa] = SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES + views.append( + ( + without_cbsa, + f"{SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES}[{group}]", + np.where(excluded, _EXCLUDED_KEY, block_state), + group_population, + ) + ) + if with_cbsa.any(): + kind[with_cbsa] = SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA + household_stratum[with_cbsa] = ( + state[with_cbsa] * _CBSA_KEY_MULTIPLIER + cbsa_values[with_cbsa] + ) + cbsa = ladder.cbsa_code.astype(np.int64) + views.append( + ( + with_cbsa, + f"{SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA}[{group}]", + np.where( + excluded | (cbsa <= 0), + _EXCLUDED_KEY, + block_state * _CBSA_KEY_MULTIPLIER + cbsa, + ), + group_population, + ) + ) + plain_state = kind == SOURCE_GEOGRAPHY_STATE + if plain_state.any(): + views.append((plain_state, SOURCE_GEOGRAPHY_STATE, block_state, population)) + + row = np.repeat(np.arange(n, dtype=np.int64), clones) + clone_index = np.tile(np.arange(clones, dtype=np.int64), n) + uniforms = location_uniforms(seed, keys[row], clone_index) + block_index = np.full(n * clones, -1, dtype=np.int64) + candidates = np.zeros(n * clones, dtype=np.int64) + for mask, label, block_keys, view_population in views: + strata = _BlockStrata( + np.asarray(block_keys, dtype=np.int64), ladder.block_geoid, view_population + ) + rows = np.flatnonzero(mask) + lo, hi = strata.bounds(household_stratum[rows]) + empty = hi <= lo + if empty.any(): + examples = [ + f"household {int(keys[r])} (state {int(state[r]):02d}, " + f"stratum {int(household_stratum[r])})" + for r in rows[empty][:5].tolist() + ] + raise ValueError( + f"{int(empty.sum())} household(s) have no candidate block for " + f"source geography {label}: {examples}. The household geography " + "and the block ladder must share a vintage." + ) + out = np.flatnonzero(mask[row]) + block_index[out] = strata.draw(household_stratum[row[out]], uniforms[out]) + candidates[out] = np.repeat((hi - lo).astype(np.int64), clones) + if (block_index < 0).any(): + raise AssertionError("internal error: some rows received no block.") + if (block_state[block_index] != state[row]).any(): + raise AssertionError("internal error: a drawn block left its state.") + return UsBlockLocationDraw( + row=row, + clone_index=clone_index, + block_index=block_index, + source_geography=kind[row], + candidate_blocks=candidates, + seed=int(seed), + clones=clones, + ) + + +def derive_us_block_geography( + ladder: UsLocationLadder, block_index: np.ndarray +) -> dict[str, np.ndarray]: + """Every geography the block determines, as household-column arrays. + + Tract and county are prefixes of the block geoid; the rest are the + ladder's values at the block. String formats match the legacy ladders: + ``""`` for no place, CBSA or SLD, zero-padded text codes, 7-character + PUMA, integer ``state * 100 + district`` CDs. So downstream readers and + gates see the same shapes. + """ + + index = np.asarray(block_index, dtype=np.int64) + blocks = ladder.blocks + block_str = np.array( + [f"{value:015d}" for value in blocks.block_geoid[index].tolist()] + ) + columns: dict[str, np.ndarray] = { + "block_geoid": block_str, + "tract_geoid": np.array([value[:11] for value in block_str.tolist()]), + "county_fips": np.array([value[:5] for value in block_str.tolist()]), + "place_fips": _fips_strings(blocks.place_fips[index], width=5), + "sldu": blocks.sldu[index].astype(object), + "sldl": blocks.sldl[index].astype(object), + "cbsa_code": _fips_strings(blocks.cbsa_code[index], width=5), + } + if ladder.puma is not None: + columns["puma"] = np.array( + [f"{value:07d}" for value in ladder.puma[index].tolist()], dtype=object + ) + for plan in sorted(ladder.congressional_district_plans): + columns[plan_column(plan, ladder)] = np.asarray( + ladder.congressional_district_plans[plan][index], dtype=np.int64 + ) + return columns + + +def assign_us_block_location( + household: pd.DataFrame, + ladder: UsLocationLadder, + *, + seed: int, + clones: int = 1, + weights: np.ndarray | None = None, + key_column: str = "household_id", + state_fips_column: str = "state_fips", + puma_column: str | None = None, + county_column: str | None = None, + identification_group_column: str | None = None, + identified_counties: Mapping[int, Collection[int]] | None = None, + cbsa_column: str | None = None, + unidentified_county_share: Mapping[int, Mapping[int, float]] | None = None, +) -> pd.DataFrame: + """The location table: one row per household, per clone when ``K > 1``. + + This is the one assignment function every US line calls. Columns: + ``key_column``; :data:`LOCATION_CLONE_INDEX_COLUMN`; the stable + :data:`LOCATION_CLONE_ID_COLUMN` (``key * K + clone``); + :data:`LOCATION_WEIGHT_COLUMN` (``weight / K``, when ``weights`` is + given); :data:`LOCATION_SOURCE_GEOGRAPHY_COLUMN`; + :data:`LOCATION_CANDIDATE_BLOCKS_COLUMN`; ``state_fips``; and every + :func:`location_geography_columns` column. Rows follow the input order, + with each household's clones adjacent. + """ + + for column in (key_column, state_fips_column): + if column not in household.columns: + raise ValueError(f"household table must contain {column!r}.") + keys = _int_values(household[key_column], label=key_column) + draw = draw_us_block_locations( + ladder, + household_key=keys, + state_fips=_int_values(household[state_fips_column], label=state_fips_column), + seed=seed, + clones=clones, + puma=_optional_column(household, puma_column), + county_fips=_optional_column(household, county_column), + identification_group=_optional_column( + household, identification_group_column, missing=-1 + ), + identified_counties=identified_counties, + cbsa_code=_optional_column(household, cbsa_column), + unidentified_county_share=unidentified_county_share, + ) + return location_table( + draw, ladder, household_key=keys, weights=weights, key_column=key_column + ) + + +def location_table( + draw: UsBlockLocationDraw, + ladder: UsLocationLadder, + *, + household_key: np.ndarray, + weights: np.ndarray | None = None, + key_column: str = "household_id", +) -> pd.DataFrame: + """Materialize a draw as the location table (:func:`assign_us_block_location`).""" + + keys = np.asarray(household_key, dtype=np.int64)[draw.row] + table: dict[str, Any] = { + key_column: keys, + LOCATION_CLONE_INDEX_COLUMN: draw.clone_index, + LOCATION_CLONE_ID_COLUMN: keys * draw.clones + draw.clone_index, + } + if weights is not None: + values = np.asarray(weights, dtype=np.float64) + if len(values) * draw.clones != len(draw.row): + raise ValueError("weights must align with household rows.") + table[LOCATION_WEIGHT_COLUMN] = values[draw.row] / draw.clones + table[LOCATION_SOURCE_GEOGRAPHY_COLUMN] = draw.source_geography + table[LOCATION_CANDIDATE_BLOCKS_COLUMN] = draw.candidate_blocks + table["state_fips"] = ladder.block_geoid[draw.block_index] // _BLOCK_STATE_DIVISOR + table.update(derive_us_block_geography(ladder, draw.block_index)) + return pd.DataFrame(table) + + +# -------------------------------------------------------------------------- +# CPS ASEC county identification +# -------------------------------------------------------------------------- + + +def identified_counties_by_group( + state_fips: np.ndarray, + county_code: np.ndarray, + group: np.ndarray, +) -> dict[int, frozenset[int]]: + """Counties each group's records code: ``{group: {state * 1000 + county}}``. + + For CPS ASEC, ``county_code`` is ``GTCO`` (the 3-digit county within the + state, ``0`` when suppressed) and ``group`` the source vintage. Each + vintage keeps its own set, because a county's identification status can + change between years. + """ + + state = _int_values(state_fips, label="state_fips") + county = _int_values(county_code, label="county_code") + groups = _int_values(group, label="group") + if not (len(state) == len(county) == len(groups)): + raise ValueError("state_fips, county_code and group must align.") + if ((county < 0) | (county > 999)).any(): + raise ValueError("county_code must be a 3-digit county code (0 = none).") + identified = county > 0 + result: dict[int, frozenset[int]] = {} + for value in sorted({int(g) for g in groups.tolist()}): + mask = identified & (groups == value) + result[value] = frozenset( + int(code) for code in np.unique(state[mask] * 1000 + county[mask]) + ) + return result + + +@dataclass(frozen=True) +class CpsIdentifiedCountyList: + """Census's official identified-county list for one CPS ASEC year. + + Attributes: + asec_year: ASEC survey year (the ``cpsmar{yy}`` documentation year). + counties: 5-digit county FIPS as integers. + source: URL of the technical documentation it was read from. + source_sha256: SHA-256 of that PDF. + entire_county_guarantee: Whether that year's List 4 preamble says + "Counties are only included on this list if the entire county is + identified" (ASEC 2023-2025 do; the 2026 documentation does not). + """ + + asec_year: int + counties: frozenset[int] + source: str + source_sha256: str + entire_county_guarantee: bool = True + + +def default_cps_asec_identified_counties_path() -> Path: + """Path of the packaged official identified-county CSV.""" + + return Path( + str( + files("microcosm.build.us_runtime.data").joinpath( + CPS_ASEC_IDENTIFIED_COUNTIES_RESOURCE + ) + ) + ) + + +def load_cps_asec_identified_counties( + path: str | Path | None = None, +) -> dict[int, CpsIdentifiedCountyList]: + """Load the official CPS ASEC identified-county lists, keyed by ASEC year. + + The default is the packaged CSV built by + ``tools/build_us_cps_identified_counties.py`` from List 4 ("FIPS County + Codes") of each year's ASEC technical documentation. ASEC 2023-2025 say + "Counties are only included on this list if the entire county is + identified"; the ``entire_county_guarantee`` column records, per year, + whether the preamble says so (a CSV without the column means it does). + """ + + source = ( + Path(path) if path is not None else default_cps_asec_identified_counties_path() + ) + provenance = source.with_name(source.name + ".provenance.json") + if provenance.is_file(): + expected = json.loads(provenance.read_text()).get("csv_sha256") + actual = file_sha256(source) + if expected != actual: + raise ValueError( + f"{source.name} has SHA-256 {actual}, but its provenance records " + f"{expected}; rebuild both with " + "tools/build_us_cps_identified_counties.py." + ) + elif path is None: + raise FileNotFoundError(f"packaged {source.name} lacks {provenance.name}.") + rows: dict[int, dict[str, Any]] = {} + with source.open(newline="") as stream: + for record in csv.DictReader(stream): + year = int(record["asec_year"]) + entry = rows.setdefault( + year, + { + "counties": set(), + "source": record["source_url"], + "source_sha256": record["source_sha256"], + "guarantee": record.get("entire_county_guarantee", "true"), + }, + ) + if ( + entry["source"] != record["source_url"] + or entry["source_sha256"] != record["source_sha256"] + or entry["guarantee"] != record.get("entire_county_guarantee", "true") + ): + raise ValueError(f"ASEC {year} rows disagree on their source.") + if entry["guarantee"] not in ("true", "false"): + raise ValueError( + f"ASEC {year} entire_county_guarantee must be true or false." + ) + county = int(record["state_fips"]) * 1000 + int(record["county_code"]) + if county in entry["counties"]: + raise ValueError(f"ASEC {year} lists county {county:05d} twice.") + entry["counties"].add(county) + if not rows: + raise ValueError(f"no identified-county rows in {source}.") + return { + year: CpsIdentifiedCountyList( + asec_year=year, + counties=frozenset(entry["counties"]), + source=str(entry["source"]), + source_sha256=str(entry["source_sha256"]), + entire_county_guarantee=entry["guarantee"] == "true", + ) + for year, entry in sorted(rows.items()) + } + + +def cps_excluded_county_sets( + data_identified: Mapping[int, Collection[int]], + official: Mapping[int, CpsIdentifiedCountyList] | None, + *, + asec_year_of_group: Mapping[int, int], +) -> tuple[dict[int, frozenset[int]], dict[str, Any]]: + """Counties a ``GTCO = 0`` record is known not to live in, per group. + + A county is excluded only with two confirmations: Census lists it (List 4 + of that ASEC year), *and* the file actually codes it. Either source alone + misleads: + + - the public files code some counties only in part (on ASEC 2023-2025, + Tarrant TX, King WA and Snohomish WA carry codes for well under half + their population), so a data-derived set would wrongly rule out the + uncoded remainder; + - List 4 names counties the files never code (e.g. Lubbock TX, New London + CT), whose households therefore carry ``GTCO = 0``. + + Both confirmations prove a county only while List 4 promises that a + listed county is identified in full. The 2026 documentation drops that + sentence, and its file breaks the premise: every Delaware county is listed + and coded for about its whole population, yet 49 Delaware households carry + ``GTCO = 0``. For a year whose list lacks the guarantee, nothing is + excluded: a ``GTCO = 0`` record draws from its whole state. + + Without an official list for a group's year, the data-derived set is the + documented fallback (microcosm#696). Returns the per-group excluded sets + and a diagnostics record for the manifest. + """ + + excluded: dict[int, frozenset[int]] = {} + per_group: dict[str, Any] = {} + for group in sorted(data_identified): + coded = frozenset(int(value) for value in data_identified[group]) + asec_year = asec_year_of_group.get(group) + listed = ( + None if official is None or asec_year is None else official.get(asec_year) + ) + if listed is None: + excluded[group] = coded + per_group[str(group)] = { + "asec_year": asec_year, + "basis": "data_derived_fallback", + "coded_counties": len(coded), + "excluded_counties": len(coded), + } + continue + both = coded & listed.counties + if listed.entire_county_guarantee: + basis = "official_list_and_coded" + excluded[group] = frozenset(both) + else: + basis = "no_whole_county_guarantee_state_draw" + excluded[group] = frozenset() + per_group[str(group)] = { + "asec_year": asec_year, + "basis": basis, + "official_source": listed.source, + "official_source_sha256": listed.source_sha256, + "entire_county_guarantee": listed.entire_county_guarantee, + "official_counties": len(listed.counties), + "coded_counties": len(coded), + "listed_and_coded_counties": len(both), + "excluded_counties": len(excluded[group]), + "coded_not_listed": [ + f"{value:05d}" for value in sorted(coded - listed.counties) + ], + "listed_not_coded": [ + f"{value:05d}" for value in sorted(listed.counties - coded) + ], + } + union = frozenset().union(*excluded.values()) if excluded else frozenset() + varying = sorted( + county + for county in union + if len({county in members for members in excluded.values()}) > 1 + ) + return excluded, { + "rule": ( + "GTCO=0 records draw from their state's blocks outside counties " + "that are both on Census's identified-county list for the ASEC " + "year and coded in that year's file; a year whose list drops the " + "whole-county guarantee excludes nothing (whole-state draw)" + ), + "groups": per_group, + "counties_whose_exclusion_varies_across_groups": [ + f"{value:05d}" for value in varying + ], + } + + +def cps_county_coverage_ratios( + ladder: UsLocationLadder, + *, + county_fips: np.ndarray, + person_weight: np.ndarray, + group: np.ndarray, +) -> dict[int, dict[int, float]]: + """Coded share of each county's population, per group, median-normalized. + + For every county a group's records code, the weighted persons coded to it + divided by its 2020 census population, rescaled so the group's median + county is 1 (which absorbs the survey's weight scale). About 1 means the + file codes the whole county; well below 1 means it codes only part. + ``person_weight`` is the household weight times household size. + """ + + county = _int_values(county_fips, label="county_fips") + weight = np.asarray(person_weight, dtype=np.float64) + groups = _int_values(group, label="group") + if not (len(county) == len(weight) == len(groups)): + raise ValueError("county_fips, person_weight and group must align.") + census = ( + pd.Series(np.asarray(ladder.population, dtype=np.float64)) + .groupby(ladder.block_geoid // _BLOCK_COUNTY_DIVISOR) + .sum() + ) + result: dict[int, dict[int, float]] = {} + for value in sorted({int(g) for g in groups.tolist()}): + mask = (groups == value) & (county > 0) + coded = pd.Series(weight[mask]).groupby(county[mask]).sum() + if coded.empty: + result[value] = {} + continue + missing = sorted(set(coded.index) - set(census.index)) + if missing: + raise ValueError(f"coded counties absent from the ladder: {missing[:5]}.") + ratio = coded / census.reindex(coded.index) + ratio = ratio / float(np.median(ratio.to_numpy())) + result[value] = {int(k): float(v) for k, v in ratio.items()} + return result + + +def cps_unidentified_county_shares( + coverage: Mapping[int, Mapping[int, float]], + excluded: Mapping[int, Collection[int]], +) -> dict[int, dict[int, float]]: + """Opt-in: weight each coded-but-not-excluded county by its uncoded remainder. + + A county a file codes only in part appears among its own coded records + and again in the ``GTCO = 0`` pool. Weighted by full population, it + collects about ``(1 + coverage)`` of its population in expectation. + Scaling its blocks in the pool by ``max(0, 1 - coverage)`` makes the + expectation its population. Excluded counties already have share 0. + """ + + return { + int(group): { + int(county): max(0.0, 1.0 - float(ratio)) + for county, ratio in sorted(ratios.items()) + if int(county) not in set(excluded.get(group, ())) + } + for group, ratios in sorted(coverage.items()) + } + + +def cbsa_vintage_agreement( + ladder: UsLocationLadder, + county_fips: np.ndarray, + cbsa_code: np.ndarray, +) -> dict[str, Any]: + """Check a source's county → CBSA pairs against the ladder's delineation. + + Narrowing a draw to the source's CBSA is sound only when the source codes + CBSAs on the ladder's delineation. Every distinct pair of (identified + county, CBSA code) in the source must match the ladder's county → CBSA + lookup. Returns ``{"agrees", "pairs", "disagreeing_pairs", "examples"}``. + """ + + county = _int_values(county_fips, label="county_fips") + cbsa = _int_values(cbsa_code, label="cbsa_code") + if len(county) != len(cbsa): + raise ValueError("county_fips and cbsa_code must align.") + block_county = ladder.block_geoid // _BLOCK_COUNTY_DIVISOR + first_county, first_index = np.unique(block_county, return_index=True) + ladder_cbsa = dict( + zip( + first_county.tolist(), + ladder.cbsa_code[first_index].astype(np.int64).tolist(), + strict=True, + ) + ) + mask = county > 0 + pairs = sorted( + {(int(c), int(b)) for c, b in zip(county[mask], cbsa[mask], strict=True)} + ) + disagreeing = [ + {"county_fips": f"{c:05d}", "source_cbsa": b, "ladder_cbsa": ladder_cbsa.get(c)} + for c, b in pairs + if ladder_cbsa.get(c) != b + ] + return { + "agrees": bool(pairs) and not disagreeing, + "pairs": len(pairs), + "disagreeing_pairs": len(disagreeing), + "examples": disagreeing[:20], + } + + +# -------------------------------------------------------------------------- +# Frames +# -------------------------------------------------------------------------- + + +def clone_frame_for_location( + frame: Frame, + clones: int, + *, + person_reference_columns: Collection[str] = (), +) -> Frame: + """Replicate every household ``K`` times with ids ``id * K + k``. + + Every entity table is repeated row by row (so a unit's clones are + adjacent and ascending ids stay ascending). Every entity id column and + person membership column maps ``id → id * K + k``, as do the named + person-reference columns (``0`` stays the no-person sentinel). Each + entity gains ``{entity}_location_clone_index``. Weighted entities split + each weight into ``K`` equal parts, so every total is conserved. Link + tables, strata, the mass log and metadata carry over. ``K = 1`` returns + ``frame`` itself. + """ + + clones = int(clones) + if clones < 1: + raise ValueError(f"clones must be >= 1, got {clones}.") + if clones == 1: + return frame + schema = frame.schema + id_columns = {entity: schema.entity_id_column(entity) for entity in frame.entities} + membership = [schema.membership_column(group) for group in schema.group_entities] + largest = max( + int(np.abs(frame.table(entity)[column].to_numpy(dtype=np.int64)).max()) + for entity, column in id_columns.items() + ) + if largest > (np.iinfo(np.int64).max - clones) // clones: + raise ValueError("entity ids are too large to clone without overflow.") + + def remap( + values: pd.Series, clone: np.ndarray, *, keep_zero: bool = False + ) -> np.ndarray: + raw = values.to_numpy(dtype=np.int64) + mapped = raw * clones + clone + return np.where(raw == 0, 0, mapped) if keep_zero else mapped + + tables: dict[str, pd.DataFrame] = {} + for entity in frame.entities: + original = frame.table(entity) + clone_column = f"{entity}_location_clone_index" + if clone_column in original.columns: + raise ValueError(f"{entity} is already location-cloned ({clone_column}).") + repeated = original.loc[original.index.repeat(clones)].reset_index(drop=True) + clone = np.tile(np.arange(clones, dtype=np.int64), len(original)) + repeated[id_columns[entity]] = remap(repeated[id_columns[entity]], clone) + if entity == schema.person_entity: + for column in membership: + repeated[column] = remap(repeated[column], clone) + for column in person_reference_columns: + if column in repeated.columns: + repeated[column] = remap(repeated[column], clone, keep_zero=True) + repeated[clone_column] = clone + tables[entity] = repeated + for link_name in frame.links: + link = frame.link(link_name) + repeated = link.loc[link.index.repeat(clones)].reset_index(drop=True) + clone = np.tile(np.arange(clones, dtype=np.int64), len(link)) + for column in id_columns.values(): + if column in repeated.columns: + repeated[column] = remap(repeated[column], clone) + tables[link_name] = repeated + weights = {} + for entity in frame.weighted_entities: + stored = frame.weights_for(entity) + split = Weights( + values=np.repeat(stored.values / clones, clones), kind=stored.kind + ) + stored.assert_mass_conserved(split) + weights[entity] = split + strata = pd.Series( + np.repeat(frame.strata.to_numpy(), clones), name=frame.strata.name + ) + return Frame( + tables, + schema, + weights, + strata, + mass_log=frame.mass_log, + metadata=frame.metadata, + ) + + +def with_household_us_block_location( + frame: Frame, + ladder: UsLocationLadder, + *, + seed: int, + clones: int = 1, + puma: np.ndarray | None = None, + county_fips: np.ndarray | None = None, + identification_group: np.ndarray | None = None, + identified_counties: Mapping[int, Collection[int]] | None = None, + cbsa_code: np.ndarray | None = None, + unidentified_county_share: Mapping[int, Mapping[int, float]] | None = None, + person_reference_columns: Collection[str] = (), + receipt: Mapping[str, Any] | None = None, +) -> tuple[Frame, pd.DataFrame]: + """Locate every household of ``frame``; return the new frame and the table. + + Source-geography arrays align with the household table's rows. With + ``K > 1`` the frame is cloned first (:func:`clone_frame_for_location`): + clone ``k`` of household ``h`` becomes household ``h * K + k`` and receives + the block drawn for ``(h, k)``. The household table gains every + :func:`location_geography_columns` column, overwriting same-named inputs + such as an ACS ``puma`` (which the draw reproduces). ``state_fips`` is + never rewritten; the draw asserts each block lies in it. The frame's + metadata gains a :data:`US_BLOCK_LOCATION_METADATA_KEY` receipt. + """ + + household = frame.table("household") + if "state_fips" not in household.columns: + raise ValueError("household table must contain 'state_fips'.") + keys = _int_values(household["household_id"], label="household_id") + draw = draw_us_block_locations( + ladder, + household_key=keys, + state_fips=_int_values(household["state_fips"], label="state_fips"), + seed=seed, + clones=clones, + puma=puma, + county_fips=county_fips, + identification_group=identification_group, + identified_counties=identified_counties, + cbsa_code=cbsa_code, + unidentified_county_share=unidentified_county_share, + ) + table = location_table( + draw, ladder, household_key=keys, weights=frame.weights_for("household").values + ) + located = clone_frame_for_location( + frame, draw.clones, person_reference_columns=person_reference_columns + ) + tables = {entity: located.table(entity).copy() for entity in located.entities} + target = tables["household"] + expected_ids = ( + table["household_id"] if draw.clones == 1 else table[LOCATION_CLONE_ID_COLUMN] + ).to_numpy(dtype=np.int64) + if not np.array_equal( + target["household_id"].to_numpy(dtype=np.int64), expected_ids + ): + raise AssertionError("internal error: cloned household order drifted.") + for column in location_geography_columns(ladder): + target[column] = table[column].to_numpy() + for link_name in located.links: + tables[link_name] = located.link(link_name).copy() + location_receipt = { + "rule": US_BLOCK_LOCATION_RULE_ID, + "seed": int(seed), + "clones": int(draw.clones), + "ladder_sha256": ladder.sha256, + **dict(receipt or {}), + } + result = Frame( + tables, + located.schema, + {entity: located.weights_for(entity) for entity in located.weighted_entities}, + located.strata, + mass_log=located.mass_log, + metadata={**located.metadata, US_BLOCK_LOCATION_METADATA_KEY: location_receipt}, + ) + return result, table + + +# -------------------------------------------------------------------------- +# Manifest and gate +# -------------------------------------------------------------------------- + + +def us_block_location_manifest( + table: pd.DataFrame, + ladder: UsLocationLadder, + *, + seed: int, + clones: int, + candidate_set_rule: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """The build-manifest record of a ``block_v1`` location step.""" + + kinds = table[LOCATION_SOURCE_GEOGRAPHY_COLUMN].astype(str).to_numpy() + first_clone = table[LOCATION_CLONE_INDEX_COLUMN].to_numpy() == 0 + record: dict[str, Any] = { + "rule": US_BLOCK_LOCATION_RULE_ID, + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "seed": int(seed), + "clones": int(clones), + "uniforms": ( + "stable_identity_uniforms('{household_id}:{clone}', seed, " + f"salt={US_BLOCK_LOCATION_RULE_ID!r}) (blake2b-64)" + ), + "sampling_basis": "2020 block population (P.L. 94-171 POP100)", + "block_ladder": { + "sha256": ladder.sha256, + "blocks": int(len(ladder)), + "layer_vintages": ladder.layer_vintages, + "carries_puma": ladder.puma is not None, + "primary_congressional_district_plan": ( + ladder.primary_congressional_district_plan + ), + "congressional_district_plans": { + plan: dict(spec) + for plan, spec in sorted( + ladder.congressional_district_plan_sources.items() + ) + }, + }, + "households": int(first_clone.sum()), + "rows": int(len(table)), + "source_geography_households": { + kind: int(((kinds == kind) & first_clone).sum()) + for kind in SOURCE_GEOGRAPHY_KINDS + }, + "candidate_set_rule": dict(candidate_set_rule or {}), + "geography_columns": list(location_geography_columns(ladder)), + } + if LOCATION_WEIGHT_COLUMN in table.columns: + record["location_weight_total"] = float(table[LOCATION_WEIGHT_COLUMN].sum()) + return record + + +def us_block_location_gate( + household: pd.DataFrame, + ladder: UsLocationLadder, + *, + state_fips_column: str = "state_fips", +) -> GateResult: + """Every written geography must equal the ladder's lookup of its block. + + Fails on a malformed or unknown block, a ``state_fips`` that disagrees + with the block's state, or any derived column that differs from what the + block determines: the consistency invariant, stated as a release gate. + """ + + columns = location_geography_columns(ladder) + missing = [ + column + for column in (*columns, state_fips_column) + if column not in household.columns + ] + if missing: + return _gate([f"household table is missing geography column(s): {missing}"], {}) + text = household["block_geoid"].astype(str).to_numpy().astype(np.str_) + malformed = ~((np.char.str_len(text) == 15) & np.char.isdigit(text)) + if malformed.any(): + return _gate( + [f"block_geoid: {int(malformed.sum())} row(s) are not 15-digit codes"], {} + ) + blocks = text.astype(np.int64) + index = np.clip(np.searchsorted(ladder.block_geoid, blocks), 0, len(ladder) - 1) + unknown = ladder.block_geoid[index] != blocks + if unknown.any(): + return _gate( + [ + f"block_geoid: {int(unknown.sum())} row(s) name a block the " + "ladder does not carry" + ], + {}, + ) + failures: list[str] = [] + expected = derive_us_block_geography(ladder, index) + for column in columns: + want = expected[column] + actual = household[column] + if want.dtype.kind in "iu": + got = pd.to_numeric(actual, errors="coerce").to_numpy(dtype=np.float64) + differs = got != want.astype(np.float64) + else: + differs = actual.astype(str).to_numpy() != want.astype(str) + if differs.any(): + failures.append( + f"{column}: {int(differs.sum())} row(s) disagree with the block's " + "ladder lookup" + ) + state = pd.to_numeric(household[state_fips_column], errors="coerce").to_numpy() + mismatch = state != (blocks // _BLOCK_STATE_DIVISOR) + if mismatch.any(): + failures.append( + f"{state_fips_column}: {int(mismatch.sum())} row(s) disagree with the " + "block's state" + ) + return _gate(failures, {"rows": int(len(household)), "columns": list(columns)}) + + +def file_sha256(path: str | Path) -> str: + """SHA-256 of a file, streamed.""" + + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonical_json_sha256(value: Any) -> str: + """SHA-256 of ``value`` as sorted, compact JSON.""" + + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _gate(failures: list[str], details: Mapping[str, Any]) -> GateResult: + return GateResult( + name="us_block_location", + passed=not failures, + failures=tuple(failures), + details=dict(details), + ) + + +def _validated_cd_array( + values: np.ndarray, block: np.ndarray, *, label: str +) -> np.ndarray: + cd = _int_values(values, label=label) + if ((cd < 100) | (cd > 9999)).any(): + bad = cd[(cd < 100) | (cd > 9999)][:5].tolist() + raise ValueError(f"{label} must be state_fips*100+district; invalid: {bad}.") + mismatched = (block // _BLOCK_STATE_DIVISOR) != (cd // 100) + if mismatched.any(): + examples = [ + f"block {b:015d} -> cd {c}" + for b, c in zip( + block[mismatched][:5].tolist(), + cd[mismatched][:5].tolist(), + strict=True, + ) + ] + raise ValueError( + f"{label} districts must lie in their block's state: {examples}." + ) + return cd + + +def _validated_puma_array(values: np.ndarray, block: np.ndarray) -> np.ndarray: + raw = np.asarray(values) + if raw.dtype.kind not in "iu": + raise ValueError(f"puma must be an integer array, got dtype {raw.dtype}.") + puma = raw.astype(np.int64) + if len(puma) != len(block): + raise ValueError(f"puma has {len(puma)} values for {len(block)} blocks.") + if ((puma <= 0) | (puma % _PUMA_STATE_DIVISOR <= 0)).any(): + raise ValueError("puma must be state_fips*10**5+PUMA5CE for every block.") + mismatched = (block // _BLOCK_STATE_DIVISOR) != (puma // _PUMA_STATE_DIVISOR) + if mismatched.any(): + examples = [ + f"block {b:015d} -> puma {p:07d}" + for b, p in zip( + block[mismatched][:5].tolist(), + puma[mismatched][:5].tolist(), + strict=True, + ) + ] + raise ValueError(f"puma must lie in its block's state: {examples}.") + tract = block // 10**4 + same_tract = tract[1:] == tract[:-1] + if (same_tract & (puma[1:] != puma[:-1])).any(): + raise ValueError("puma must be constant within a 2020 tract (tracts nest).") + return puma + + +def _county_scaled_population( + population: np.ndarray, block_county: np.ndarray, share: Mapping[int, float] +) -> np.ndarray: + scaled = np.asarray(population, dtype=np.float64).copy() + for county, value in share.items(): + value = float(value) + if not np.isfinite(value) or not 0.0 <= value <= 1.0: + raise ValueError( + f"unidentified-county share for {int(county):05d} must be in " + f"[0, 1], got {value!r}." + ) + scaled[block_county == int(county)] *= value + return scaled + + +def _require_same_state( + state: np.ndarray, source_state: np.ndarray, mask: np.ndarray, label: str +) -> None: + mismatched = mask & (source_state != state) + if mismatched.any(): + examples = [ + f"state {int(s):02d} -> {label} state {int(t):02d}" + for s, t in zip( + state[mismatched][:5].tolist(), + source_state[mismatched][:5].tolist(), + strict=True, + ) + ] + raise ValueError(f"household {label} disagrees with state_fips: {examples}.") + + +def _int_values(values: Any, *, label: str) -> np.ndarray: + series = pd.Series(values).reset_index(drop=True) + numeric = pd.to_numeric(series, errors="coerce") + if numeric.isna().any(): + bad = series[numeric.isna()].head(5).tolist() + raise ValueError(f"{label} contains missing or non-numeric value(s): {bad}.") + floats = numeric.to_numpy(dtype=np.float64) + if (floats != np.floor(floats)).any(): + raise ValueError(f"{label} must contain integers.") + return numeric.to_numpy().astype(np.int64) + + +def _aligned(values: Any, n: int, *, label: str) -> np.ndarray: + array = _int_values(values, label=label) + if len(array) != n: + raise ValueError(f"{label} has {len(array)} values for {n} households.") + return array + + +def _optional_aligned( + values: Any, n: int, *, label: str, missing: int = 0 +) -> np.ndarray: + if values is None: + return np.full(n, missing, dtype=np.int64) + numeric = pd.to_numeric(pd.Series(values).reset_index(drop=True), errors="coerce") + return _aligned(numeric.fillna(missing), n, label=label) + + +def _optional_column( + household: pd.DataFrame, column: str | None, *, missing: int = 0 +) -> np.ndarray | None: + if column is None: + return None + if column not in household.columns: + raise ValueError(f"household table has no {column!r} column.") + return ( + pd.to_numeric(household[column], errors="coerce") + .fillna(missing) + .to_numpy(dtype=np.float64) + ) + + +def _fips_strings(values: np.ndarray, *, width: int) -> np.ndarray: + array = np.asarray(values, dtype=np.int64) + return np.array( + [("" if value == 0 else f"{value:0{width}d}") for value in array.tolist()], + dtype=object, + ) + + +__all__ = [ + "CPS_ASEC_IDENTIFIED_COUNTIES_RESOURCE", + "POPULACE_BLOCK_LADDER_SHA256_ATTR", + "POPULACE_BLOCK_LADDER_VINTAGES_ATTR", + "POPULACE_LOCATION_CLONES_ATTR", + "POPULACE_LOCATION_RULE_ATTR", + "POPULACE_LOCATION_SEED_ATTR", + "LOCATION_CANDIDATE_BLOCKS_COLUMN", + "LOCATION_CLONE_ID_COLUMN", + "LOCATION_CLONE_INDEX_COLUMN", + "LOCATION_SOURCE_GEOGRAPHY_COLUMN", + "LOCATION_WEIGHT_COLUMN", + "SOURCE_GEOGRAPHY_COUNTY", + "SOURCE_GEOGRAPHY_KINDS", + "SOURCE_GEOGRAPHY_PUMA", + "SOURCE_GEOGRAPHY_STATE", + "SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES", + "SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA", + "US_BLOCK_LADDER_PUMA_ARRAY", + "US_BLOCK_LOCATION_GEOGRAPHY_COLUMNS", + "US_BLOCK_LOCATION_METADATA_KEY", + "US_BLOCK_LOCATION_RULE_ID", + "US_CD_PLAN_COLUMN_PREFIX", + "US_LOCATION_RULE_BLOCK_V1", + "US_LOCATION_RULE_CHOICES", + "US_LOCATION_RULE_LEGACY", + "CpsIdentifiedCountyList", + "UsBlockLocationDraw", + "UsLocationLadder", + "assign_us_block_location", + "attach_congressional_district_plans", + "canonical_json_sha256", + "cbsa_vintage_agreement", + "clone_frame_for_location", + "cps_county_coverage_ratios", + "cps_excluded_county_sets", + "cps_unidentified_county_shares", + "default_cps_asec_identified_counties_path", + "derive_us_block_geography", + "draw_us_block_locations", + "file_sha256", + "identified_counties_by_group", + "load_cps_asec_identified_counties", + "load_us_location_ladder", + "location_geography_columns", + "location_table", + "location_uniforms", + "plan_column", + "us_block_location_gate", + "us_block_location_manifest", + "us_location_ladder", + "with_household_us_block_location", +] diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/cps_identified_county_sources.py b/packages/microcosm-build/src/microcosm/build/us_runtime/cps_identified_county_sources.py new file mode 100644 index 0000000000..9f349dbb6d --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/cps_identified_county_sources.py @@ -0,0 +1,133 @@ +"""Parse Census's list of counties the CPS ASEC public file identifies. + +Each year's CPS ASEC technical documentation (``cpsmar{yy}.pdf``) carries, in +Appendix F, "List 4: FIPS County Codes": the counties a public-file record can +name through ``GTCO``, grouped under state headings, with the note "Counties +are only included on this list if the entire county is identified." A +trailing asterisk marks a county that is also a single-county micropolitan +area. :mod:`~microcosm.build.us_runtime.block_location` uses the list as one +of the two confirmations that rule a county out for a ``GTCO = 0`` record. + +The parser reads the ``pdftotext -layout`` rendering of the PDF. Download and +text extraction live in ``tools/build_us_cps_identified_counties.py``; +everything here is pure and unit-testable. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass + +from microcosm.build.us_runtime.block_ladder_sources import US_STATES + +LIST_HEADING = "List 4: FIPS County Codes" +#: The preamble sentence that makes List 4 a whole-county guarantee. ASEC +#: 2023-2025 carry it; the 2026 documentation drops it. +ENTIRE_COUNTY_GUARANTEE = ( + "only included on this list if the entire county is identified" +) +_END_MARKERS = ("* Counties marked", "List 5", "APPENDIX") +#: The one kind of non-county line inside the list after its first state +#: heading: the running page header ("SPECIFIC METROPOLITAN AREAS F-18"). +_PAGE_HEADER = re.compile(r"^SPECIFIC METROPOLITAN AREAS\b") +_COUNTY_LINE = re.compile(r"^(\d{3})\s{2,}(.+?)(\*?)$") +_STATE_BY_NAME = {name.replace("_", " "): fips for fips, _, name in US_STATES} + + +@dataclass(frozen=True) +class CpsIdentifiedCounty: + """One county on the ASEC identified-county list.""" + + state_fips: str + county_code: str + county_name: str + single_county_micropolitan: bool + + +def list_carries_entire_county_guarantee(lines: Iterable[str]) -> bool: + """Whether List 4's preamble promises whole-county identification. + + Reads the text between the list heading (its last occurrence) and the + first county line, whitespace-normalized, for + :data:`ENTIRE_COUNTY_GUARANTEE`. + """ + + rendered = [line.rstrip("\n") for line in lines] + starts = [ + index for index, line in enumerate(rendered) if line.strip() == LIST_HEADING + ] + if not starts: + raise ValueError(f"no {LIST_HEADING!r} heading in the rendered text.") + preamble: list[str] = [] + for line in rendered[starts[-1] + 1 :]: + if _COUNTY_LINE.match(line.strip()) or line.strip() in _STATE_BY_NAME: + break + preamble.append(line.strip()) + return ENTIRE_COUNTY_GUARANTEE in " ".join(" ".join(preamble).split()) + + +def parse_cps_identified_county_list( + lines: Iterable[str], +) -> list[CpsIdentifiedCounty]: + """Parse List 4 from a ``pdftotext -layout`` rendering of an ASEC techdoc. + + The list starts at the last line reading exactly ``List 4: FIPS County + Codes`` (earlier matches are the table of contents) and ends at its + asterisk footnote, the next list, or the next appendix. State names head + their counties. Before the first state heading, the preamble and column + labels are skipped. After it, the only lines the list admits are state + headings, county lines and the running page header, so any other + non-blank line ends the list. The 2026 documentation has no footnote + after List 4, and its next section would otherwise be read as counties. + Raises on a county line before any state heading, a repeated county, or + an empty list. + """ + + rendered = [line.rstrip("\n") for line in lines] + starts = [ + index for index, line in enumerate(rendered) if line.strip() == LIST_HEADING + ] + if not starts: + raise ValueError(f"no {LIST_HEADING!r} heading in the rendered text.") + counties: list[CpsIdentifiedCounty] = [] + seen: set[tuple[str, str]] = set() + state: str | None = None + for line in rendered[starts[-1] + 1 :]: + text = line.strip() + if any(text.startswith(marker) for marker in _END_MARKERS): + break + if text in _STATE_BY_NAME: + state = _STATE_BY_NAME[text] + continue + match = _COUNTY_LINE.match(text) + if match is None: + if state is not None and text and not _PAGE_HEADER.match(text): + break + continue + if state is None: + raise ValueError(f"county line {text!r} precedes every state heading.") + key = (state, match.group(1)) + if key in seen: + raise ValueError(f"county {state}{match.group(1)} is listed twice.") + seen.add(key) + counties.append( + CpsIdentifiedCounty( + state_fips=state, + county_code=match.group(1), + county_name=match.group(2).strip(), + single_county_micropolitan=bool(match.group(3)), + ) + ) + if not counties: + raise ValueError(f"{LIST_HEADING!r} parsed to no counties.") + return counties + + +__all__ = [ + "ENTIRE_COUNTY_GUARANTEE", + "LIST_HEADING", + "CpsIdentifiedCounty", + "list_carries_entire_county_guarantee", + "parse_cps_identified_county_list", +] diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py b/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py new file mode 100644 index 0000000000..3d9f82d86a --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py @@ -0,0 +1,265 @@ +"""CPS ASEC household source geography for the block location draw. + +A CPS ASEC record's finest published geography is its county when the public +file identifies it (``GTCO`` nonzero), else its state, with the known +restriction that it does not live in any county the file identifies in full. +This module reads ``GESTFIPS``/``GTCO``/``GTCBSA`` from the processed ASEC H5s +(``census_cps_{income_year}.h5``, household table keyed by ``H_SEQ``), joins +them onto frame households by ``(source_year, source_household_id)``, and +builds the inputs :func:`~microcosm.build.us_runtime.block_location.draw_us_block_locations` +takes, plus the manifest record of the candidate-set rule. + +``census_cps_{Y}.h5`` holds the ASEC fielded in ``Y + 1`` (income year ``Y``): +the microcosm #720 person-column join matches ``census_cps_2024.h5`` to +``pppub25.csv`` exactly. So a record's identification group is its +``source_year`` and its official list is ASEC ``source_year + 1``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +from microcosm.build.us_runtime.block_location import ( + CpsIdentifiedCountyList, + UsLocationLadder, + cbsa_vintage_agreement, + cps_county_coverage_ratios, + cps_excluded_county_sets, + cps_unidentified_county_shares, + default_cps_asec_identified_counties_path, + file_sha256, + identified_counties_by_group, + load_cps_asec_identified_counties, +) +from microcosm.frame import Frame + +#: ``census_cps_{Y}.h5`` is the ASEC fielded in ``Y + 1``. +CPS_ASEC_SURVEY_YEAR_OFFSET = 1 +CPS_HOUSEHOLD_GEOGRAPHY_COLUMNS = ("H_SEQ", "GESTFIPS", "GTCO", "GTCBSA") +_WEIGHT_COLUMNS = ("HSUP_WGT", "H_NUMPER") + + +def load_cps_household_geography( + sources: Mapping[int, str | Path], +) -> pd.DataFrame: + """Read every source year's household geography from its ASEC H5. + + Returns one row per source household with ``source_year``, + ``source_household_id`` (``H_SEQ``), ``state_fips``, ``gtco``, ``gtcbsa`` + and ``person_weight`` (``HSUP_WGT * H_NUMPER``; its scale cancels in the + median-normalized coverage ratios). + """ + + frames: list[pd.DataFrame] = [] + for year, path in sorted(sources.items()): + household = pd.read_hdf(Path(path), "household") + missing = [ + column + for column in (*CPS_HOUSEHOLD_GEOGRAPHY_COLUMNS, *_WEIGHT_COLUMNS) + if column not in household.columns + ] + if missing: + raise ValueError(f"ASEC H5 {path} household table lacks {missing}.") + if household["H_SEQ"].duplicated().any(): + raise ValueError(f"ASEC H5 {path} repeats H_SEQ.") + frames.append( + pd.DataFrame( + { + "source_year": int(year), + "source_household_id": household["H_SEQ"].to_numpy(np.int64), + "state_fips": household["GESTFIPS"].to_numpy(np.int64), + "gtco": household["GTCO"].to_numpy(np.int64), + "gtcbsa": household["GTCBSA"].to_numpy(np.int64), + "person_weight": ( + household["HSUP_WGT"].to_numpy(np.float64) + * household["H_NUMPER"].to_numpy(np.float64) + ), + } + ) + ) + if not frames: + raise ValueError("no ASEC sources given.") + return pd.concat(frames, ignore_index=True) + + +def household_source_keys(frame: Frame) -> tuple[np.ndarray, np.ndarray]: + """``(source_year, source_household_id)`` for each household row. + + Read from the person table: every member of a household must carry the + same pair. Support copies (PUF detail, capital-gains tail) share their + native row's pair, so they inherit its source geography. + """ + + person = frame.table("person") + for column in ("source_year", "source_household_id", "person_household_id"): + if column not in person.columns: + raise ValueError(f"person table lacks {column!r}; no CPS source key.") + keys = person[["person_household_id", "source_year", "source_household_id"]] + distinct = keys.drop_duplicates() + if distinct["person_household_id"].duplicated().any(): + raise ValueError("a household's members disagree on their CPS source key.") + by_household = distinct.set_index("person_household_id") + household_ids = frame.table("household")["household_id"].to_numpy(np.int64) + aligned = by_household.reindex(household_ids) + if aligned.isna().any().any(): + raise ValueError("some households have no member carrying a CPS source key.") + return ( + aligned["source_year"].to_numpy(np.int64), + aligned["source_household_id"].to_numpy(np.int64), + ) + + +@dataclass(frozen=True) +class CpsSourceGeography: + """Draw inputs for CPS-lineage households, aligned to household rows.""" + + county_fips: np.ndarray + identification_group: np.ndarray + identified_counties: dict[int, frozenset[int]] + cbsa_code: np.ndarray | None + unidentified_county_share: dict[int, dict[int, float]] | None + record: dict[str, Any] + + +def cps_source_geography( + ladder: UsLocationLadder, + cps: pd.DataFrame, + *, + source_year: np.ndarray, + source_household_id: np.ndarray, + state_fips: np.ndarray, + official: Mapping[int, CpsIdentifiedCountyList] | None = None, + cbsa_narrowing: bool = True, + partial_county_remainder: bool = False, +) -> CpsSourceGeography: + """Source geography of CPS-lineage households and its manifest record. + + Every household joins its own ASEC row on ``(source_year, + source_household_id)``; its ``state_fips`` must equal ``GESTFIPS``. + County identification (coded sets, coverage ratios, CBSA agreement) is + measured on the whole file of each year, not only the rows the frame + kept. ``official`` defaults to the packaged Census lists. + + ``cbsa_narrowing`` narrows ``GTCO = 0`` households to their CBSA only if + every coded (county, CBSA) pair in the files matches the ladder; the + check's result is recorded either way. ``partial_county_remainder`` + (off by default; the ruled draw is plain block population) scales + partially coded counties in the ``GTCO = 0`` pool by their uncoded + remainder. + """ + + lookup = cps.set_index(["source_year", "source_household_id"]) + if not lookup.index.is_unique: + raise ValueError("CPS household geography repeats a source key.") + index = pd.MultiIndex.from_arrays( + [ + np.asarray(source_year, dtype=np.int64), + np.asarray(source_household_id, dtype=np.int64), + ] + ) + joined = lookup.reindex(index) + if joined["gtco"].isna().any(): + missing = int(joined["gtco"].isna().sum()) + raise ValueError(f"{missing} household(s) have no ASEC source row.") + state = np.asarray(state_fips, dtype=np.int64) + if (joined["state_fips"].to_numpy(np.int64) != state).any(): + raise ValueError("household state_fips disagrees with its ASEC GESTFIPS.") + gtco = joined["gtco"].to_numpy(np.int64) + county = np.where(gtco > 0, state * 1000 + gtco, 0) + group = np.asarray(source_year, dtype=np.int64) + + official_lists = ( + load_cps_asec_identified_counties() if official is None else official + ) + coded = identified_counties_by_group( + cps["state_fips"], cps["gtco"], cps["source_year"] + ) + asec_year_of_group = { + int(year): int(year) + CPS_ASEC_SURVEY_YEAR_OFFSET for year in coded + } + identified_rows = cps["gtco"].to_numpy(np.int64) > 0 + coded_county = np.where( + identified_rows, + cps["state_fips"].to_numpy(np.int64) * 1000 + cps["gtco"].to_numpy(np.int64), + 0, + ) + coverage = cps_county_coverage_ratios( + ladder, + county_fips=coded_county, + person_weight=cps["person_weight"].to_numpy(np.float64), + group=cps["source_year"].to_numpy(np.int64), + ) + excluded, record = cps_excluded_county_sets( + coded, + official_lists, + asec_year_of_group=asec_year_of_group, + ) + agreement = cbsa_vintage_agreement( + ladder, coded_county, cps["gtcbsa"].to_numpy(np.int64) + ) + use_cbsa = bool(cbsa_narrowing and agreement["agrees"]) + cbsa = ( + np.where(gtco == 0, joined["gtcbsa"].to_numpy(np.int64), 0) + if use_cbsa + else None + ) + shares = ( + cps_unidentified_county_shares(coverage, excluded) + if partial_county_remainder + else None + ) + partial = { + str(year): { + f"{county_fips:05d}": round(ratio, 4) + for county_fips, ratio in sorted(ratios.items()) + if county_fips not in excluded.get(year, frozenset()) + } + for year, ratios in coverage.items() + } + record.update( + { + "official_list_csv_sha256": ( + file_sha256(default_cps_asec_identified_counties_path()) + if official is None + else None + ), + "cps_asec_survey_year_offset": CPS_ASEC_SURVEY_YEAR_OFFSET, + "cbsa_narrowing": { + "requested": bool(cbsa_narrowing), + "applied": use_cbsa, + "vintage_agreement": agreement, + }, + "partial_county_remainder": { + "applied": bool(partial_county_remainder), + "coverage_ratio_definition": ( + "coded weighted persons / 2020 census population, " + "median-normalized per source year" + ), + "coded_not_excluded_coverage": partial, + }, + } + ) + return CpsSourceGeography( + county_fips=county, + identification_group=np.where(gtco > 0, -1, group), + identified_counties=excluded, + cbsa_code=cbsa, + unidentified_county_share=shares, + record=record, + ) + + +__all__ = [ + "CPS_ASEC_SURVEY_YEAR_OFFSET", + "CPS_HOUSEHOLD_GEOGRAPHY_COLUMNS", + "CpsSourceGeography", + "cps_source_geography", + "household_source_keys", + "load_cps_household_geography", +] diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv b/packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv new file mode 100644 index 0000000000..f9e3df61da --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv @@ -0,0 +1,1193 @@ +asec_year,state_fips,county_code,county_name,single_county_micropolitan,entire_county_guarantee,source_url,source_sha256 +2023,01,003,Baldwin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,01,081,Lee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,01,097,Mobile,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,04,013,Maricopa,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,04,019,Pima,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,04,021,Pinal,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,04,025,Yavapai,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,04,027,Yuma,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,001,Alameda,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,007,Butte,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,019,Fresno,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,029,Kern,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,031,Kings,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,037,Los Angeles,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,053,Monterey,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,059,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,067,Sacramento,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,073,San Diego,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,075,San Francisco,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,079,San Luis Obispo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,081,San Mateo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,083,Santa Barbara,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,087,Santa Cruz,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,089,Shasta,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,095,Solano,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,097,Sonoma,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,099,Stanislaus,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,107,Tulare,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,06,111,Ventura,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,08,013,Boulder,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,08,031,Denver,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,08,059,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,08,069,Larimer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,08,123,Weld,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,09,001,Fairfield,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,09,005,Litchfield,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,09,009,New Haven,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,09,011,New London,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,09,015,Windham,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,10,001,Kent,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,10,003,New Castle,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,10,005,Sussex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,11,001,District of Columbia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,005,Bay,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,009,Brevard,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,011,Broward,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,019,Clay,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,021,Collier,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,033,Escambia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,053,Hernando,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,057,Hillsborough,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,069,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,071,Lee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,083,Marion,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,085,Martin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,086,Miami-Dade,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,095,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,099,Palm Beach,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,101,Pasco,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,103,Pinellas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,105,Polk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,109,St. Johns,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,111,St. Lucie,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,12,113,Santa Rosa,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,015,Bartow,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,045,Carroll,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,057,Cherokee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,063,Clayton,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,077,Coweta,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,097,Douglas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,113,Fayette,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,117,Forsythe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,135,Gwinnett,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,139,Hall,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,151,Henry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,13,223,Paulding,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,15,003,Honolulu,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,17,097,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,17,111,McHenry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,17,119,Madison,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,17,163,St. Clair,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,17,179,Tazewell,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,019,Clark,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,039,Elkhart,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,063,Hendricks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,081,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,089,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,105,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,141,St. Joseph,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,18,157,Tippecanoe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,19,103,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,19,113,Linn,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,19,163,Scott,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,20,091,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,20,173,Sedgwick,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,21,015,Boone,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,21,067,Fayette,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,21,111,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,21,117,Kenton,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,005,Ascension,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,033,East Baton Rouge,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,051,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,063,Livingston,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,071,Orleans,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,073,Ouachita,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,22,103,St. Tammany,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,23,001,Androscoggin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,23,005,Cumberland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,23,011,Kennebec,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,23,019,Penobscot,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,003,Anne Arundel,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,013,Carroll,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,015,Cecil,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,017,Charles,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,025,Harford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,031,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,033,Prince Georges,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,037,St. Mary’s,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,24,510,Baltimore City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,001,Barnstable,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,005,Bristol,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,013,Hampden,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,015,Hampshire,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,017,Middlesex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,023,Plymouth,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,025,Suffolk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,25,027,Worcester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,005,Allegan,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,021,Berrien,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,025,Calhoun,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,049,Genesee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,075,Jackson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,081,Kent,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,093,Livingston,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,099,Macomb,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,115,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,121,Muskegon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,125,Oakland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,145,Saginaw,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,161,Washtenaw,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,26,163,Wayne,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,27,003,Anoka,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,27,123,Ramsey,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,27,139,Scott,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,27,163,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,27,171,Wright,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,29,071,Franklin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,29,099,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,29,189,St. Louis,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,30,111,Yellowstone,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,31,055,Douglas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,32,003,Clark,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,33,011,Hillsborough,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,33,013,Merrimack,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,33,015,Rockingham,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,33,017,Strafford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,003,Bergen,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,005,Burlington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,007,Camden,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,011,Cumberland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,013,Essex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,017,Hudson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,019,Hunterdon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,021,Mercer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,023,Middlesex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,027,Morris,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,031,Passaic,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,035,Somerset,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,037,Sussex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,34,039,Union,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,35,001,Bernalillo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,35,013,Dona Ana,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,35,045,San Juan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,35,049,Santa Fe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,005,Bronx,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,045,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,047,Kings,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,055,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,059,Nassau,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,061,New York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,067,Onondaga,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,069,Ontario,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,071,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,081,Queens,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,085,Richmond,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,087,Rockland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,091,Saratoga,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,103,Suffolk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,36,119,Westchester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,001,Alamance,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,021,Buncombe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,057,Davidson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,067,Forsyth,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,119,Mecklenburg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,133,Onslow,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,147,Pitt,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,155,Robeson,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,159,Rowan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,179,Union,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,37,191,Wayne,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,025,Clermont,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,057,Greene,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,085,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,089,Licking,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,095,Lucas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,103,Medina,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,109,Miami,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,113,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,133,Portage,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,39,153,Summit,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,41,017,Deschutes,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,41,029,Jackson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,41,039,Lane,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,003,Allegheny,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,007,Beaver,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,011,Berks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,017,Bucks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,019,Butler,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,021,Cambria,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,029,Chester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,043,Dauphin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,045,Delaware,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,049,Erie,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,055,Franklin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,071,Lancaster,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,081,Lycoming,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,085,Mercer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,089,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,091,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,101,Philadelphia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,107,Schuylkill,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,125,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,129,Westmoreland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,42,133,York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,45,041,Florence,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,45,051,Horry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,45,083,Spartanburg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,45,091,York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,47,009,Blount,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,47,093,Knox,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,47,125,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,47,165,Sumner,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,47,189,Wilson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,041,Brazos,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,061,Cameron,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,135,Ector,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,139,Ellis,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,181,Grayson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,183,Gregg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,215,Hidalgo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,251,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,303,Lubbock,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,309,McLennan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,423,Smith,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,441,Taylor,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,479,Webb,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,48,485,Wichita,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,49,053,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,013,Arlington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,041,Chesterfield,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,087,Henrico,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,107,Loudoun,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,153,Prince William,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,177,Spotsylvania,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,179,Stafford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,550,Chesapeake City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,700,Newport News City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,710,Norfolk City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,760,Richmond City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,51,810,Virginia Beach City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,53,057,Skagit,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,54,039,Kanawha,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,55,059,Kenosha,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,55,073,Marathon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,55,101,Racine,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,55,105,Rock,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2023,55,139,Winnebago,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf,54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d +2024,01,003,Baldwin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,01,081,Lee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,01,097,Mobile,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,04,013,Maricopa,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,04,019,Pima,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,04,021,Pinal,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,04,025,Yavapai,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,04,027,Yuma,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,001,Alameda,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,007,Butte,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,019,Fresno,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,029,Kern,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,031,Kings,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,037,Los Angeles,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,053,Monterey,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,059,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,067,Sacramento,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,073,San Diego,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,075,San Francisco,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,079,San Luis Obispo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,081,San Mateo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,083,Santa Barbara,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,087,Santa Cruz,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,089,Shasta,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,095,Solano,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,097,Sonoma,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,099,Stanislaus,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,107,Tulare,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,06,111,Ventura,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,08,013,Boulder,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,08,031,Denver,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,08,059,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,08,069,Larimer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,08,123,Weld,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,09,001,Fairfield,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,09,005,Litchfield,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,09,009,New Haven,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,09,011,New London,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,09,015,Windham,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,10,001,Kent,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,10,003,New Castle,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,10,005,Sussex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,11,001,District of Columbia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,005,Bay,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,009,Brevard,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,011,Broward,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,019,Clay,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,021,Collier,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,033,Escambia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,053,Hernando,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,057,Hillsborough,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,069,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,071,Lee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,083,Marion,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,085,Martin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,086,Miami-Dade,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,095,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,099,Palm Beach,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,101,Pasco,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,103,Pinellas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,105,Polk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,109,St. Johns,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,111,St. Lucie,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,12,113,Santa Rosa,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,015,Bartow,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,045,Carroll,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,057,Cherokee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,063,Clayton,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,077,Coweta,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,097,Douglas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,113,Fayette,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,117,Forsythe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,135,Gwinnett,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,139,Hall,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,151,Henry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,13,223,Paulding,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,15,003,Honolulu,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,17,097,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,17,111,McHenry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,17,119,Madison,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,17,163,St. Clair,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,17,179,Tazewell,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,019,Clark,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,039,Elkhart,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,063,Hendricks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,081,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,089,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,105,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,141,St. Joseph,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,18,157,Tippecanoe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,19,103,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,19,113,Linn,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,19,163,Scott,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,20,091,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,20,173,Sedgwick,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,21,015,Boone,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,21,067,Fayette,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,21,111,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,21,117,Kenton,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,005,Ascension,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,033,East Baton Rouge,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,051,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,063,Livingston,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,071,Orleans,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,073,Ouachita,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,22,103,St. Tammany,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,23,001,Androscoggin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,23,005,Cumberland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,23,011,Kennebec,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,23,019,Penobscot,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,003,Anne Arundel,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,013,Carroll,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,015,Cecil,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,017,Charles,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,025,Harford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,031,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,033,Prince Georges,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,037,St. Mary’s,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,24,510,Baltimore City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,001,Barnstable,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,005,Bristol,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,013,Hampden,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,015,Hampshire,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,017,Middlesex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,023,Plymouth,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,025,Suffolk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,25,027,Worcester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,005,Allegan,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,021,Berrien,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,025,Calhoun,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,049,Genesee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,075,Jackson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,081,Kent,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,093,Livingston,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,099,Macomb,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,115,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,121,Muskegon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,125,Oakland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,145,Saginaw,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,161,Washtenaw,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,26,163,Wayne,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,27,003,Anoka,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,27,123,Ramsey,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,27,139,Scott,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,27,163,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,27,171,Wright,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,29,071,Franklin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,29,099,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,29,189,St. Louis,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,30,111,Yellowstone,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,31,055,Douglas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,32,003,Clark,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,33,011,Hillsborough,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,33,013,Merrimack,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,33,015,Rockingham,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,33,017,Strafford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,003,Bergen,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,005,Burlington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,007,Camden,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,011,Cumberland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,013,Essex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,017,Hudson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,019,Hunterdon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,021,Mercer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,023,Middlesex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,027,Morris,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,031,Passaic,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,035,Somerset,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,037,Sussex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,34,039,Union,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,35,001,Bernalillo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,35,013,Dona Ana,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,35,045,San Juan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,35,049,Santa Fe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,005,Bronx,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,045,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,047,Kings,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,055,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,059,Nassau,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,061,New York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,067,Onondaga,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,069,Ontario,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,071,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,081,Queens,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,085,Richmond,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,087,Rockland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,091,Saratoga,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,103,Suffolk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,36,119,Westchester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,001,Alamance,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,021,Buncombe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,057,Davidson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,067,Forsyth,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,119,Mecklenburg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,133,Onslow,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,147,Pitt,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,155,Robeson,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,159,Rowan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,179,Union,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,37,191,Wayne,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,025,Clermont,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,057,Greene,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,085,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,089,Licking,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,095,Lucas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,103,Medina,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,109,Miami,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,113,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,133,Portage,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,39,153,Summit,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,41,017,Deschutes,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,41,029,Jackson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,41,039,Lane,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,003,Allegheny,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,007,Beaver,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,011,Berks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,017,Bucks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,019,Butler,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,021,Cambria,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,029,Chester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,043,Dauphin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,045,Delaware,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,049,Erie,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,055,Franklin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,071,Lancaster,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,081,Lycoming,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,085,Mercer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,089,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,091,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,101,Philadelphia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,107,Schuylkill,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,125,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,129,Westmoreland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,42,133,York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,45,041,Florence,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,45,051,Horry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,45,083,Spartanburg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,45,091,York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,47,009,Blount,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,47,093,Knox,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,47,125,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,47,165,Sumner,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,47,189,Wilson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,041,Brazos,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,061,Cameron,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,135,Ector,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,139,Ellis,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,181,Grayson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,183,Gregg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,215,Hidalgo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,251,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,303,Lubbock,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,309,McLennan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,423,Smith,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,441,Taylor,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,479,Webb,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,48,485,Wichita,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,49,053,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,013,Arlington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,041,Chesterfield,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,087,Henrico,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,107,Loudoun,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,153,Prince William,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,177,Spotsylvania,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,179,Stafford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,550,Chesapeake City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,700,Newport News City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,710,Norfolk City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,760,Richmond City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,51,810,Virginia Beach City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,53,057,Skagit,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,54,039,Kanawha,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,55,059,Kenosha,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,55,073,Marathon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,55,101,Racine,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,55,105,Rock,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2024,55,139,Winnebago,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf,650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d +2025,01,003,Baldwin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,01,081,Lee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,01,097,Mobile,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,04,013,Maricopa,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,04,019,Pima,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,04,021,Pinal,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,04,025,Yavapai,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,04,027,Yuma,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,001,Alameda,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,007,Butte,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,019,Fresno,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,029,Kern,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,031,Kings,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,037,Los Angeles,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,053,Monterey,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,059,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,067,Sacramento,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,073,San Diego,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,075,San Francisco,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,079,San Luis Obispo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,081,San Mateo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,083,Santa Barbara,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,087,Santa Cruz,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,089,Shasta,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,095,Solano,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,097,Sonoma,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,099,Stanislaus,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,107,Tulare,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,06,111,Ventura,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,08,013,Boulder,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,08,031,Denver,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,08,059,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,08,069,Larimer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,08,123,Weld,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,09,001,Fairfield,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,09,005,Litchfield,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,09,009,New Haven,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,09,011,New London,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,09,015,Windham,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,10,001,Kent,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,10,003,New Castle,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,10,005,Sussex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,11,001,District of Columbia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,005,Bay,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,009,Brevard,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,011,Broward,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,019,Clay,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,021,Collier,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,033,Escambia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,053,Hernando,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,057,Hillsborough,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,069,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,071,Lee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,083,Marion,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,085,Martin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,086,Miami-Dade,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,095,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,099,Palm Beach,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,101,Pasco,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,103,Pinellas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,105,Polk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,109,St. Johns,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,111,St. Lucie,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,12,113,Santa Rosa,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,015,Bartow,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,045,Carroll,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,057,Cherokee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,063,Clayton,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,077,Coweta,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,097,Douglas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,113,Fayette,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,117,Forsythe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,135,Gwinnett,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,139,Hall,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,151,Henry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,13,223,Paulding,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,15,003,Honolulu,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,17,097,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,17,111,McHenry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,17,119,Madison,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,17,163,St. Clair,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,17,179,Tazewell,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,019,Clark,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,039,Elkhart,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,063,Hendricks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,081,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,089,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,105,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,141,St. Joseph,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,18,157,Tippecanoe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,19,103,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,19,113,Linn,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,19,163,Scott,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,20,091,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,20,173,Sedgwick,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,21,015,Boone,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,21,067,Fayette,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,21,111,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,21,117,Kenton,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,005,Ascension,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,033,East Baton Rouge,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,051,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,063,Livingston,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,071,Orleans,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,073,Ouachita,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,22,103,St. Tammany,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,23,001,Androscoggin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,23,005,Cumberland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,23,011,Kennebec,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,23,019,Penobscot,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,003,Anne Arundel,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,013,Carroll,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,015,Cecil,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,017,Charles,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,025,Harford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,031,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,033,Prince Georges,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,037,St. Mary’s,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,24,510,Baltimore City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,001,Barnstable,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,005,Bristol,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,013,Hampden,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,015,Hampshire,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,017,Middlesex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,023,Plymouth,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,025,Suffolk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,25,027,Worcester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,005,Allegan,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,021,Berrien,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,025,Calhoun,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,049,Genesee,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,075,Jackson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,081,Kent,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,093,Livingston,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,099,Macomb,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,115,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,121,Muskegon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,125,Oakland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,145,Saginaw,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,161,Washtenaw,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,26,163,Wayne,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,27,003,Anoka,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,27,123,Ramsey,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,27,139,Scott,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,27,163,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,27,171,Wright,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,29,071,Franklin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,29,099,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,29,189,St. Louis,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,30,111,Yellowstone,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,31,055,Douglas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,32,003,Clark,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,33,011,Hillsborough,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,33,013,Merrimack,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,33,015,Rockingham,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,33,017,Strafford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,003,Bergen,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,005,Burlington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,007,Camden,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,011,Cumberland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,013,Essex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,017,Hudson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,019,Hunterdon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,021,Mercer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,023,Middlesex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,027,Morris,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,031,Passaic,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,035,Somerset,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,037,Sussex,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,34,039,Union,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,35,001,Bernalillo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,35,013,Dona Ana,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,35,045,San Juan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,35,049,Santa Fe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,005,Bronx,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,045,Jefferson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,047,Kings,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,055,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,059,Nassau,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,061,New York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,067,Onondaga,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,069,Ontario,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,071,Orange,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,081,Queens,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,085,Richmond,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,087,Rockland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,091,Saratoga,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,103,Suffolk,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,36,119,Westchester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,001,Alamance,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,021,Buncombe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,057,Davidson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,067,Forsyth,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,119,Mecklenburg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,133,Onslow,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,147,Pitt,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,155,Robeson,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,159,Rowan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,179,Union,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,37,191,Wayne,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,025,Clermont,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,057,Greene,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,085,Lake,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,089,Licking,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,095,Lucas,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,103,Medina,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,109,Miami,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,113,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,133,Portage,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,39,153,Summit,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,41,017,Deschutes,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,41,029,Jackson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,41,039,Lane,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,003,Allegheny,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,007,Beaver,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,011,Berks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,017,Bucks,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,019,Butler,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,021,Cambria,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,029,Chester,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,043,Dauphin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,045,Delaware,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,049,Erie,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,055,Franklin,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,071,Lancaster,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,081,Lycoming,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,085,Mercer,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,089,Monroe,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,091,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,101,Philadelphia,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,107,Schuylkill,true,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,125,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,129,Westmoreland,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,42,133,York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,45,041,Florence,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,45,051,Horry,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,45,083,Spartanburg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,45,091,York,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,47,009,Blount,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,47,093,Knox,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,47,125,Montgomery,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,47,165,Sumner,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,47,189,Wilson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,041,Brazos,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,061,Cameron,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,135,Ector,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,139,Ellis,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,181,Grayson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,183,Gregg,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,215,Hidalgo,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,251,Johnson,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,303,Lubbock,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,309,McLennan,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,423,Smith,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,441,Taylor,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,479,Webb,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,48,485,Wichita,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,49,053,Washington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,013,Arlington,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,041,Chesterfield,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,087,Henrico,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,107,Loudoun,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,153,Prince William,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,177,Spotsylvania,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,179,Stafford,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,550,Chesapeake City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,700,Newport News City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,710,Norfolk City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,760,Richmond City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,51,810,Virginia Beach City,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,53,057,Skagit,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,54,039,Kanawha,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,55,059,Kenosha,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,55,073,Marathon,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,55,101,Racine,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,55,105,Rock,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2025,55,139,Winnebago,false,true,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf,7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4 +2026,01,003,Baldwin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,01,015,Calhoun,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,01,073,Jefferson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,01,081,Lee,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,01,097,Mobile,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,01,117,Shelby,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,003,Cochise,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,013,Maricopa,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,015,Mohave,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,019,Pima,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,021,Pinal,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,025,Yavapai,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,04,027,Yuma,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,05,119,Pulaski,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,001,Alameda,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,007,Butte,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,017,El Dorado,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,019,Fresno,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,025,Imperial,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,029,Kern,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,031,Kings,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,037,Los Angeles,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,039,Madera,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,047,Merced,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,053,Monterey,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,055,Napa,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,059,Orange,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,061,Placer,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,065,Riverside,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,067,Sacramento,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,073,San Diego,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,075,San Francisco,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,077,San Joaquin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,079,San Luis Obispo,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,081,San Mateo,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,083,Santa Barbara,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,087,Santa Cruz,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,089,Shasta,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,095,Solano,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,097,Sonoma,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,099,Stanislaus,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,107,Tulare,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,111,Ventura,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,06,113,Yolo,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,013,Boulder,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,031,Denver,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,035,Douglas,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,059,Jefferson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,069,Larimer,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,101,Pueblo,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,08,123,Weld,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,10,001,Kent,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,10,003,New Castle,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,10,005,Sussex,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,11,001,District of Columbia,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,001,Alachua,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,005,Bay,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,009,Brevard,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,011,Broward,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,015,Charlotte,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,019,Clay,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,021,Collier,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,033,Escambia,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,053,Hernando,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,057,Hillsborough,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,061,Indian River,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,069,Lake,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,071,Lee,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,083,Marion,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,085,Martin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,086,Miami-Dade,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,091,Okaloosa,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,095,Orange,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,099,Palm Beach,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,101,Pasco,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,103,Pinellas,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,105,Polk,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,109,St. Johns,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,111,St. Lucie,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,113,Santa Rosa,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,117,Seminole,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,12,127,Volusia,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,015,Bartow,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,045,Carroll,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,057,Cherokee,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,063,Clayton,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,077,Coweta,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,097,Douglas,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,113,Fayette,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,117,Forsythe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,135,Gwinnett,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,139,Hall,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,151,Henry,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,153,Houston,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,13,223,Paulding,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,15,001,Hawaii,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,15,003,Honolulu,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,16,055,Kootenai,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,091,Kankakee,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,097,Lake,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,099,LaSalle,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,111,McHenry,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,113,McLean,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,115,Macon,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,119,Madison,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,163,St. Clair,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,17,179,Tazewell,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,019,Clark,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,039,Elkhart,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,063,Hendricks,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,081,Johnson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,089,Lake,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,091,LaPorte,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,095,Madison,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,105,Monroe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,141,St. Joseph,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,18,157,Tippecanoe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,19,103,Johnson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,19,113,Linn,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,19,153,Polk,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,19,163,Scott,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,20,045,Douglas,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,20,091,Johnson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,20,173,Sedgwick,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,21,015,Boone,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,21,067,Fayette,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,21,111,Jefferson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,21,117,Kenton,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,005,Ascension,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,019,Calcasieu,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,033,East Baton Rouge,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,051,Jefferson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,063,Livingston,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,071,Orleans,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,073,Ouachita,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,22,103,St. Tammany,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,23,001,Androscoggin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,23,005,Cumberland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,23,011,Kennebec,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,23,019,Penobscot,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,003,Anne Arundel,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,013,Carroll,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,015,Cecil,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,017,Charles,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,025,Harford,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,027,Howard,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,031,Montgomery,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,033,Prince Georges,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,037,St. Mary’s,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,043,Washington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,24,510,Baltimore City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,001,Barnstable,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,005,Bristol,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,009,Essex,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,013,Hampden,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,015,Hampshire,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,017,Middlesex,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,021,Norfolk,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,023,Plymouth,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,025,Suffolk,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,25,027,Worcester,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,005,Allegan,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,021,Berrien,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,025,Calhoun,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,049,Genesee,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,075,Jackson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,081,Kent,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,093,Livingston,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,099,Macomb,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,115,Monroe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,121,Muskegon,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,125,Oakland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,139,Ottawa,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,145,Saginaw,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,147,St. Clair,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,161,Washtenaw,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,26,163,Wayne,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,003,Anoka,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,037,Dakota,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,123,Ramsey,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,137,St. Louis,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,139,Scott,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,163,Washington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,27,171,Wright,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,29,019,Boone,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,29,071,Franklin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,29,099,Jefferson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,29,189,St. Louis,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,30,111,Yellowstone,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,31,055,Douglas,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,31,153,Sarpy,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,32,003,Clark,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,33,011,Hillsborough,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,33,013,Merrimack,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,33,015,Rockingham,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,33,017,Strafford,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,003,Bergen,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,005,Burlington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,007,Camden,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,009,Cape May,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,011,Cumberland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,013,Essex,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,017,Hudson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,019,Hunterdon,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,021,Mercer,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,023,Middlesex,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,025,Monmouth,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,027,Morris,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,029,Ocean,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,031,Passaic,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,035,Somerset,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,037,Sussex,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,039,Union,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,34,041,Warren,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,35,001,Bernalillo,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,35,013,Dona Ana,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,35,045,San Juan,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,35,049,Santa Fe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,005,Bronx,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,013,Chautauqua,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,027,Dutchess,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,047,Kings,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,055,Monroe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,059,Nassau,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,061,New York,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,067,Onondaga,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,069,Ontario,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,071,Orange,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,081,Queens,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,085,Richmond,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,087,Rockland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,091,Saratoga,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,103,Suffolk,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,111,Ulster,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,36,119,Westchester,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,001,Alamance,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,021,Buncombe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,057,Davidson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,067,Forsyth,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,097,Iredell,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,119,Mecklenburg,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,133,Onslow,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,147,Pitt,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,155,Robeson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,159,Rowan,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,179,Union,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,183,Wake,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,37,191,Wayne,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,023,Clark,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,025,Clermont,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,029,Columbiana,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,035,Cuyahoga,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,041,Delaware,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,045,Fairfield,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,049,Franklin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,057,Greene,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,085,Lake,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,089,Licking,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,095,Lucas,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,103,Medina,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,109,Miami,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,113,Montgomery,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,133,Portage,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,153,Summit,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,165,Warren,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,39,169,Wayne,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,40,031,Comanche,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,41,017,Deschutes,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,41,029,Jackson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,41,039,Lane,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,41,041,Linn,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,003,Allegheny,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,007,Beaver,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,011,Berks,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,013,Blair,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,017,Bucks,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,019,Butler,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,021,Cambria,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,029,Chester,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,043,Dauphin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,045,Delaware,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,049,Erie,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,055,Franklin,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,071,Lancaster,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,081,Lycoming,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,085,Mercer,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,089,Monroe,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,091,Montgomery,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,101,Philadelphia,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,107,Schuylkill,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,125,Washington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,129,Westmoreland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,42,133,York,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,007,Anderson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,041,Florence,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,045,Greenville,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,051,Horry,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,063,Lexington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,079,Richland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,083,Spartanburg,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,45,091,York,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,47,009,Blount,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,47,093,Knox,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,47,125,Montgomery,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,47,165,Sumner,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,47,189,Wilson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,029,Bexar,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,039,Brazoria,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,041,Brazos,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,061,Cameron,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,135,Ector,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,139,Ellis,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,141,El Paso,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,181,Grayson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,183,Gregg,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,215,Hidalgo,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,251,Johnson,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,303,Lubbock,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,309,McLennan,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,329,Midland,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,375,Potter,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,381,Randall,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,423,Smith,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,439,Tarrant,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,441,Taylor,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,479,Webb,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,48,485,Wichita,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,49,049,Utah,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,49,053,Washington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,013,Arlington,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,041,Chesterfield,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,059,Fairfax,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,087,Henrico,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,107,Loudoun,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,153,Prince William,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,177,Spotsylvania,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,179,Stafford,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,510,Alexandria City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,550,Chesapeake City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,650,Hampton City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,700,Newport News City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,710,Norfolk City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,740,Portsmouth City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,760,Richmond City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,51,810,Virginia Beach City,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,033,King,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,035,Kitsap,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,053,Pierce,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,057,Skagit,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,061,Snohomish,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,063,Spokane,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,067,Thurston,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,073,Whatcom,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,53,077,Yakima,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,54,039,Kanawha,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,55,059,Kenosha,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,55,063,Lacrosse,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,55,073,Marathon,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,55,101,Racine,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,55,105,Rock,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 +2026,55,139,Winnebago,false,false,https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf,8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107 diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv.provenance.json b/packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv.provenance.json new file mode 100644 index 0000000000..9d7a7c24f0 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/data/cps_asec_identified_counties.csv.provenance.json @@ -0,0 +1,37 @@ +{ + "csv_sha256": "d10fc71c77b59997d7c9cac78e74106cd43bfcf00470bd11e07f528bc6982acc", + "kind": "cps_asec_identified_counties", + "list": "List 4: FIPS County Codes", + "note": "Census (ASEC 2023-2025): 'Counties are only included on this list if the entire county is identified.' The 2026 documentation drops that sentence; entire_county_guarantee records, per year, whether the preamble carries it. An asterisk marks a county that is also a single-county micropolitan area.", + "pdftotext": "pdftotext version 26.09.0", + "years": { + "2023": { + "counties": 277, + "entire_county_guarantee": true, + "sha256": "54750c1c5257947f08a0941abac76803dd72e7db5a15b9883d64ffdd2278b42d", + "states": 41, + "url": "https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar23.pdf" + }, + "2024": { + "counties": 277, + "entire_county_guarantee": true, + "sha256": "650fdf1707cf5f1b8729a52156729f6a495c37421c98372bfc7b0deb3fe2be9d", + "states": 41, + "url": "https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar24.pdf" + }, + "2025": { + "counties": 277, + "entire_county_guarantee": true, + "sha256": "7f0cb9f791f2737ad12fd552b8cb201f4d1de266eac160322597344e73499fb4", + "states": 41, + "url": "https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar25.pdf" + }, + "2026": { + "counties": 361, + "entire_county_guarantee": false, + "sha256": "8f6d4c10ae5e720449ae23fdf739bb7812aff8102218d7c013be8ce737125107", + "states": 43, + "url": "https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar26.pdf" + } + } +} diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/h5_io.py b/packages/microcosm-build/src/microcosm/build/us_runtime/h5_io.py index 48b87cb2bc..73091db0de 100644 --- a/packages/microcosm-build/src/microcosm/build/us_runtime/h5_io.py +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/h5_io.py @@ -28,6 +28,13 @@ canonicalize_table_string_dtypes, ) from microcosm.build.us_runtime import worker_identity as worker_identity_runtime +from microcosm.build.us_runtime.block_location import ( + POPULACE_BLOCK_LADDER_SHA256_ATTR, + POPULACE_BLOCK_LADDER_VINTAGES_ATTR, + POPULACE_LOCATION_CLONES_ATTR, + POPULACE_LOCATION_RULE_ATTR, + POPULACE_LOCATION_SEED_ATTR, +) from microcosm.build.us_runtime.congressional_district_geography import ( CONGRESSIONAL_DISTRICT_GEOID_COLUMN, ) @@ -69,13 +76,25 @@ "refuse_denied_pool_h5", "refuse_denied_pool_h5_digest", "LEGACY_NULLABLE_STAGING_ARTIFACT_KIND", + "POPULACE_BLOCK_LADDER_SHA256_ATTR", + "POPULACE_BLOCK_LADDER_VINTAGES_ATTR", + "POPULACE_LOCATION_CLONES_ATTR", + "POPULACE_LOCATION_RULE_ATTR", + "POPULACE_LOCATION_SEED_ATTR", "US_MULTISPINE_AGREEMENT_DIAGNOSTICS_ARTIFACT_KIND", "US_MULTISPINE_POOL_H5_MATERIALIZER_VERSION", "US_MULTISPINE_POOL_H5_ARTIFACT_KIND", "US_MULTISPINE_POOL_MANIFEST_ARTIFACT_KIND", "US_MULTISPINE_POOL_MANIFEST_SCHEMA_VERSION", "US_STACKED_POOL_OPERATOR_ORDER", + "US_STACKED_BLOCK_LOCATION_ALGORITHM_ID", + "US_STACKED_BLOCK_LOCATION_DIGEST_COLUMNS", + "US_STACKED_BLOCK_LOCATION_ROOT_ATTRIBUTES", + "US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER", + "expected_us_stacked_block_location_contract", "identify_us_multispine_pool_manifest", + "ordered_household_block_location_receipt", + "us_stacked_pool_operator_order", "load_authenticated_us_multispine_pool_for_release", "load_authenticated_us_multispine_pool_for_scoring", "load_legacy_calibrated_us_h5", @@ -135,6 +154,48 @@ "stacked_completeness_gate", "by_origin_battery", ) +# ``--location-rule block_v1`` (microcosm#696) replaces only the geography +# slot: one 2020 census block per household drawn within its finest source +# geography, every other geography looked up from that block. The id is the +# block-location rule id every US line records +# (``block_location.US_BLOCK_LOCATION_RULE_ID``; a test pins the equality). +US_STACKED_BLOCK_LOCATION_ALGORITHM_ID = "us_block_location.population_draw.v1" +US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER = tuple( + "assign_us_block_location" if operator == "assign_us_puma_ladder" else operator + for operator in US_STACKED_POOL_OPERATOR_ORDER +) +# Ordered native-household digest columns of a block_v1 assignment. Every +# other location column is a ladder lookup of ``block_geoid``. +US_STACKED_BLOCK_LOCATION_DIGEST_COLUMNS = ( + "household_id", + "block_geoid", + "puma", + CONGRESSIONAL_DISTRICT_GEOID_COLUMN, + "county_fips", +) +_STACKED_BLOCK_LOCATION_COHERENT_COLUMNS = ( + "block_geoid", + "tract_geoid", + "county_fips", + "place_fips", + "sldu", + "sldl", + "cbsa_code", + "puma", + CONGRESSIONAL_DISTRICT_GEOID_COLUMN, +) +US_STACKED_BLOCK_LOCATION_ROOT_ATTRIBUTES = ( + POPULACE_LOCATION_RULE_ATTR, + POPULACE_LOCATION_SEED_ATTR, + POPULACE_LOCATION_CLONES_ATTR, + POPULACE_BLOCK_LADDER_SHA256_ATTR, + POPULACE_BLOCK_LADDER_VINTAGES_ATTR, +) +_STACKED_BLOCK_LOCATION_RULE = "block_v1" +_STACKED_BLOCK_LADDER_ROLE = "block_ladder" +_STACKED_BLOCK_LADDER_SOURCE_REF = "source:us_block_ladder_2020_puma" +_STACKED_CPS_ASEC_ROLE_PREFIX = "cps_asec_household_geography_" +_STACKED_CD_CROSSWALK_ROLE = "congressional_district_vintage_crosswalk" _LEGACY_POOL_OPERATOR_ORDER = ( "assemble", "clone", @@ -1427,7 +1488,7 @@ def _validate_stacked_late_dag_manifest_binding( if manifest.get("pipeline") != "us-stacked-pool": return - if manifest.get("operator_order") != list(US_STACKED_POOL_OPERATOR_ORDER): + if manifest.get("operator_order") != list(us_stacked_pool_operator_order(manifest)): raise ValueError( f"US stacked pool manifest {manifest_path} does not bind the " "canonical late-DAG operator order." @@ -1481,6 +1542,32 @@ def _validate_stacked_late_dag_manifest_binding( ) +def _stacked_geography_algorithm_id(manifest: Mapping[str, object]) -> object: + """The manifest's geography algorithm id, or ``None`` when absent.""" + + assignment = manifest.get("geography_assignment") + contract = assignment.get("contract") if isinstance(assignment, Mapping) else None + algorithm = contract.get("algorithm") if isinstance(contract, Mapping) else None + return algorithm.get("id") if isinstance(algorithm, Mapping) else None + + +def us_stacked_pool_operator_order( + manifest: Mapping[str, object], +) -> tuple[str, ...]: + """The operator order a stacked manifest must bind, chosen by its rule. + + A ``block_v1`` geography algorithm selects the block-location order; + every other manifest (including one without a geography receipt, which + the geography validator then refuses) expects the legacy order. + """ + + if _stacked_geography_algorithm_id(manifest) == ( + US_STACKED_BLOCK_LOCATION_ALGORITHM_ID + ): + return US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + return US_STACKED_POOL_OPERATOR_ORDER + + def _validate_stacked_geography_assignment_manifest_binding( manifest: Mapping[str, object], *, @@ -1488,6 +1575,14 @@ def _validate_stacked_geography_assignment_manifest_binding( ) -> None: """Authenticate the post-assembly household-CD authority and receipt.""" + if _stacked_geography_algorithm_id(manifest) == ( + US_STACKED_BLOCK_LOCATION_ALGORITHM_ID + ): + _validate_stacked_block_location_manifest_binding( + manifest, + manifest_path=manifest_path, + ) + return assignment = manifest.get("geography_assignment") if not isinstance(assignment, Mapping): raise ValueError( @@ -1735,6 +1830,309 @@ def _validate_stacked_geography_assignment_manifest_binding( ) +def expected_us_stacked_block_location_contract( + *, + seed: int, + clones: int, + block_ladder_sha256: str, + crosswalk_sha256: str, + cps_asec_sha256: Mapping[int, str], +) -> dict[str, object]: + """The closed ``block_v1`` household-location contract a stacked pool binds. + + This is the consumer's independent statement of the contract the stacked + pool tool writes (``_stacked_block_location_contract``); a differential + test pins the two together. Only the seed, the clone count and the + authenticated input digests vary between runs. + """ + + authorities: dict[str, object] = { + _STACKED_BLOCK_LADDER_ROLE: { + "input_role": _STACKED_BLOCK_LADDER_ROLE, + "source_ref": _STACKED_BLOCK_LADDER_SOURCE_REF, + "sha256": block_ladder_sha256, + }, + _STACKED_CD_CROSSWALK_ROLE: { + "input_role": _STACKED_CD_CROSSWALK_ROLE, + "source_ref": ( + "source:us_congressional_district_vintage_crosswalk_117_to_119" + ), + "sha256": crosswalk_sha256, + "source_vintage_ref": "vintage:cd_117", + "source_vintage": "117th_congress", + "target_vintage_ref": "vintage:cd_119", + "target_vintage": CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE, + }, + } + for income_year, sha256 in sorted(cps_asec_sha256.items()): + role = f"{_STACKED_CPS_ASEC_ROLE_PREFIX}{int(income_year)}" + authorities[role] = { + "input_role": role, + "source_ref": f"source:census_cps_{int(income_year)}", + "income_year": int(income_year), + "sha256": sha256, + } + return { + "declaration": { + "anchor": "block", + "order": "before_gap_fill", + "location_rule": _STACKED_BLOCK_LOCATION_RULE, + "kernels": { + "assign": "kernel:assign_us_block_location", + "validate": "kernel:us_block_location_gate", + }, + "draw": { + "unit": "household", + "universe": "census_block_2020", + "weight": "block_population_2020", + "candidate_sets": { + "acs": "blocks_within_observed_puma", + "asec": "cps_asec_source_geography", + }, + "uniforms": "stable_identity_uniforms", + }, + "derive": list(_STACKED_BLOCK_LOCATION_COHERENT_COLUMNS), + "assertions": [ + "observed_acs_puma_preserved", + "block_state_matches_state_fips", + "geography_derived_from_block", + ], + "ladder_source": _STACKED_BLOCK_LADDER_SOURCE_REF, + "congressional_district_vintage_crosswalk": { + "source_ref": ( + "source:us_congressional_district_vintage_crosswalk_117_to_119" + ), + "source_vintage": "vintage:cd_117", + "target_vintage": "vintage:cd_119", + }, + "seed": "stream:household_location_block_v1", + "clones": clones, + "validation": [ + "us_block_location_gate", + "puma_ladder_gate", + "vintage_refusal", + ], + }, + "algorithm": { + "id": US_STACKED_BLOCK_LOCATION_ALGORITHM_ID, + "kernel": "with_household_us_block_location", + "operator": "assign_us_block_location", + "order": "before_gap_fill", + "location_rule": _STACKED_BLOCK_LOCATION_RULE, + "clones": clones, + }, + "authorities": authorities, + "seed": { + "site": "us_block_location", + "stream": "household_location_block_v1", + "value_source": "cli.location_seed", + "value": seed, + }, + } + + +def _is_whole_number(value: object, *, minimum: int) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= minimum + + +def _validate_stacked_block_location_manifest_binding( + manifest: Mapping[str, object], + *, + manifest_path: Path, +) -> None: + """Authenticate a ``block_v1`` household-location authority and receipt.""" + + label = f"US stacked pool manifest {manifest_path}" + assignment = manifest.get("geography_assignment") + if not isinstance(assignment, Mapping): + raise ValueError(f"{label} has no geography assignment receipt.") + stage_receipts = manifest.get("stage_receipts") + stage_assignment = ( + stage_receipts.get("geography_assignment") + if isinstance(stage_receipts, Mapping) + else None + ) + if stage_assignment != assignment: + raise ValueError( + f"{label} geography assignment differs from its assembled-stage receipt." + ) + if ( + assignment.get("artifact_kind") + != "populace_us_stacked_household_geography_assignment" + or assignment.get("schema_version") != 1 + ): + raise ValueError(f"{label} has an unsupported geography assignment receipt.") + contract = _mapping( + assignment.get("contract"), + label=f"{label} geography assignment contract", + ) + seed_block = contract.get("seed") + algorithm = contract.get("algorithm") + authorities = contract.get("authorities") + if ( + not isinstance(seed_block, Mapping) + or not isinstance(algorithm, Mapping) + or not isinstance(authorities, Mapping) + ): + raise ValueError(f"{label} block-location contract is malformed.") + seed = seed_block.get("value") + clones = algorithm.get("clones") + if not _is_whole_number(seed, minimum=0): + raise ValueError(f"{label} block-location seed must be a whole number >= 0.") + if clones != 1 or isinstance(clones, bool): + raise ValueError( + f"{label} binds {clones!r} location clones; the stacked pool admits " + "exactly one location per household." + ) + block_authority = authorities.get(_STACKED_BLOCK_LADDER_ROLE) + crosswalk_authority = authorities.get(_STACKED_CD_CROSSWALK_ROLE) + if not isinstance(block_authority, Mapping) or not isinstance( + crosswalk_authority, Mapping + ): + raise ValueError(f"{label} block-location authorities are incomplete.") + cps_asec_sha256: dict[int, str] = {} + for role, authority in authorities.items(): + if not str(role).startswith(_STACKED_CPS_ASEC_ROLE_PREFIX): + continue + income_year = ( + authority.get("income_year") if isinstance(authority, Mapping) else None + ) + if not _is_whole_number(income_year, minimum=1) or role != ( + f"{_STACKED_CPS_ASEC_ROLE_PREFIX}{income_year}" + ): + raise ValueError(f"{label} CPS ASEC authority {role!r} is malformed.") + cps_asec_sha256[int(income_year)] = str(authority.get("sha256")) + if not cps_asec_sha256: + raise ValueError( + f"{label} block-location contract binds no CPS ASEC source geography." + ) + expected_contract = expected_us_stacked_block_location_contract( + seed=int(seed), + clones=1, + block_ladder_sha256=str(block_authority.get("sha256")), + crosswalk_sha256=str(crosswalk_authority.get("sha256")), + cps_asec_sha256=cps_asec_sha256, + ) + if contract != expected_contract: + raise ValueError(f"{label} block-location contract changed.") + if manifest.get("random_seed") != 0: + raise ValueError(f"{label} model random_seed changed.") + + provenance_pins = manifest.get("provenance_pins") + if not isinstance(provenance_pins, Mapping): + raise ValueError(f"{label} has no provenance pins.") + for role in sorted(authorities): + authority = authorities[role] + pin = provenance_pins.get(role) + sha256 = authority.get("sha256") if isinstance(authority, Mapping) else None + if ( + not isinstance(pin, Mapping) + or not isinstance(sha256, str) + or _LOWERCASE_SHA256.fullmatch(sha256) is None + or sha256 != pin.get("actual_sha256") + or pin.get("expected_sha256") != pin.get("actual_sha256") + ): + raise ValueError( + f"{label} geography authority {role!r} differs from its " + "authenticated input pin." + ) + + location = manifest.get("household_location") + ladder_record = ( + location.get("block_ladder") if isinstance(location, Mapping) else None + ) + if ( + not isinstance(location, Mapping) + or location != assignment.get("household_location") + or location.get("rule") != US_STACKED_BLOCK_LOCATION_ALGORITHM_ID + or location.get("location_rule") != _STACKED_BLOCK_LOCATION_RULE + or location.get("seed") != seed + or location.get("clones") != 1 + or not isinstance(ladder_record, Mapping) + or ladder_record.get("sha256") != block_authority.get("sha256") + ): + raise ValueError( + f"{label} household_location record does not match its " + "block-location contract." + ) + _block_ladder_layer_vintages(manifest, manifest_path=manifest_path) + + output = assignment.get("output") + if not isinstance(output, Mapping): + raise ValueError(f"{label} geography output is missing.") + rows = output.get("household_rows") + positive_rows = output.get("positive_congressional_district_rows") + unique_values = output.get("unique_congressional_district_values") + if ( + not _is_whole_number(rows, minimum=1) + or positive_rows != rows + or not _is_whole_number(unique_values, minimum=1) + or location.get("households") != rows + or location.get("rows") != rows + ): + raise ValueError( + f"{label} does not prove one located block and a positive " + "congressional district for every household." + ) + order = assignment.get("pre_assignment_household_order") + if ( + not isinstance(order, Mapping) + or order.get("column") != "household_id" + or order.get("codec") != "int64_little_endian.v1" + or order.get("row_count") != rows + or not isinstance(order.get("sha256"), str) + or _LOWERCASE_SHA256.fullmatch(str(order["sha256"])) is None + ): + raise ValueError(f"{label} has an invalid seeded household-order receipt.") + assigned_geography = assignment.get("assigned_household_geography") + if ( + not isinstance(assigned_geography, Mapping) + or assigned_geography.get("columns") + != list(US_STACKED_BLOCK_LOCATION_DIGEST_COLUMNS) + or assigned_geography.get("codec") + != "block_location_column_major_int64_little_endian.v1" + or assigned_geography.get("row_count") != rows + or not isinstance(assigned_geography.get("sha256"), str) + or _LOWERCASE_SHA256.fullmatch(str(assigned_geography["sha256"])) is None + ): + raise ValueError( + f"{label} has an invalid ordered household block-location receipt." + ) + summary = assignment.get("summary") + if ( + not isinstance(summary, Mapping) + or summary.get("applied") is not True + or summary.get("household_rows") != rows + ): + raise ValueError(f"{label} has an invalid geography assignment summary.") + gate = assignment.get("gate") + gates = gate.get("gates") if isinstance(gate, Mapping) else None + if ( + not isinstance(gate, Mapping) + or gate.get("passed") is not True + or not isinstance(gates, Mapping) + or set(gates) != {"us_block_location", "us_puma_ladder"} + or any( + not isinstance(gates[name], Mapping) + or gates[name].get("passed") is not True + for name in gates + ) + ): + raise ValueError( + f"{label} does not carry passed block-location and PUMA-ladder gates." + ) + universe = assignment.get("target_universe") + if ( + not isinstance(universe, Mapping) + or not _is_whole_number(universe.get("district_count"), minimum=1) + or not isinstance(universe.get("geoids_sha256"), str) + or _LOWERCASE_SHA256.fullmatch(str(universe["geoids_sha256"])) is None + ): + raise ValueError( + f"{label} has an invalid congressional-district target-universe receipt." + ) + + def _ordered_household_id_receipt( household: pd.DataFrame, *, @@ -1842,6 +2240,300 @@ def fixed_width_values(column: str, width: int) -> np.ndarray: } +def ordered_household_block_location_receipt( + household: pd.DataFrame, + *, + boundary: str, +) -> dict[str, object]: + """Digest ordered native household IDs and their located block geography. + + The producer (stacked pool tool) and every consumer compute this one + function: household id, 15-digit block, 7-digit PUMA, positive integral + congressional district and 5-digit county, column-major little-endian + int64 under their own domain tag. + """ + + missing = [ + column + for column in US_STACKED_BLOCK_LOCATION_DIGEST_COLUMNS + if column not in household + ] + if missing: + raise ValueError(f"{boundary} is missing geography column(s): {missing}.") + household_ids = pd.to_numeric( + household["household_id"], + errors="coerce", + ).to_numpy(dtype=np.float64, na_value=np.nan) + valid_ids = np.isfinite(household_ids) & (household_ids == np.floor(household_ids)) + if not valid_ids.all(): + raise ValueError(f"{boundary} household_id values must be integral.") + + def fixed_width_values(column: str, width: int) -> np.ndarray: + text = household[column].astype(str) + if not text.str.fullmatch(rf"[0-9]{{{width}}}").all(): + raise ValueError( + f"{boundary} {column!r} must contain exactly {width}-digit codes." + ) + return text.astype(np.int64).to_numpy(dtype=" Mapping[str, str]: + """The block ladder's layer vintages a block_v1 manifest records.""" + + location = manifest.get("household_location") + ladder = location.get("block_ladder") if isinstance(location, Mapping) else None + vintages = ladder.get("layer_vintages") if isinstance(ladder, Mapping) else None + if ( + not isinstance(vintages, Mapping) + or not vintages + or not all( + isinstance(key, str) and isinstance(value, str) and key and value + for key, value in vintages.items() + ) + ): + raise ValueError( + f"US stacked pool manifest {manifest_path} household_location does " + "not record its block ladder's layer vintages." + ) + return vintages + + +def _validate_stacked_block_location_h5_binding( + manifest: Mapping[str, object], + household: pd.DataFrame, + root_attributes: Mapping[str, str | None], + *, + manifest_path: Path, + pool_path: Path, +) -> None: + """Bind a ``block_v1`` manifest's location claims to the authenticated H5.""" + + assignment = _mapping( + manifest.get("geography_assignment"), + label=f"US stacked pool manifest {manifest_path}.geography_assignment", + ) + contract = _mapping( + assignment.get("contract"), + label=( + f"US stacked pool manifest {manifest_path}.geography_assignment.contract" + ), + ) + authorities = _mapping( + contract.get("authorities"), + label=( + "US stacked pool manifest " + f"{manifest_path}.geography_assignment.contract.authorities" + ), + ) + crosswalk = _mapping( + authorities.get(_STACKED_CD_CROSSWALK_ROLE), + label=( + "US stacked pool manifest " + f"{manifest_path}.geography_assignment crosswalk authority" + ), + ) + block_ladder = _mapping( + authorities.get(_STACKED_BLOCK_LADDER_ROLE), + label=( + "US stacked pool manifest " + f"{manifest_path}.geography_assignment block-ladder authority" + ), + ) + seed = _mapping( + contract.get("seed"), + label=f"US stacked pool manifest {manifest_path} block-location seed", + ).get("value") + clones = _mapping( + contract.get("algorithm"), + label=f"US stacked pool manifest {manifest_path} block-location algorithm", + ).get("clones") + expected_attributes = { + CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR: crosswalk.get("sha256"), + CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR: crosswalk.get("target_vintage"), + POPULACE_LOCATION_RULE_ATTR: _STACKED_BLOCK_LOCATION_RULE, + POPULACE_LOCATION_SEED_ATTR: str(seed), + POPULACE_LOCATION_CLONES_ATTR: str(clones), + POPULACE_BLOCK_LADDER_SHA256_ATTR: block_ladder.get("sha256"), + POPULACE_BLOCK_LADDER_VINTAGES_ATTR: json.dumps( + dict(_block_ladder_layer_vintages(manifest, manifest_path=manifest_path)), + sort_keys=True, + ), + } + if ( + expected_attributes[CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR] + != CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE + ): + raise ValueError( + f"US stacked pool manifest {manifest_path} does not name the current " + "congressional-district target vintage." + ) + mismatched_attributes = { + key: {"expected": expected, "actual": root_attributes.get(key)} + for key, expected in expected_attributes.items() + if root_attributes.get(key) != expected + } + if mismatched_attributes: + raise ValueError( + f"US stacked pool H5 {pool_path} location root attributes do not " + f"match its manifest: {mismatched_attributes}." + ) + + boundary = f"US stacked pool H5 {pool_path}" + native_household, source_column = _stacked_native_households( + household, + boundary=boundary, + ) + expected_order = assignment.get("pre_assignment_household_order") + actual_order = _ordered_household_id_receipt( + native_household, + boundary=boundary, + ) + if actual_order != expected_order: + raise ValueError( + f"{boundary} native household order differs from its manifest " + "geography assignment receipt." + ) + actual_geography = ordered_household_block_location_receipt( + native_household, + boundary=boundary, + ) + if actual_geography != assignment.get("assigned_household_geography"): + raise ValueError( + f"{boundary} native household block location differs from its " + "manifest assignment output receipt." + ) + native_districts = _positive_integral_district_values( + native_household, + boundary=boundary, + ) + _positive_integral_district_values(household, boundary=boundary) + actual_output = { + "household_rows": len(native_household), + "positive_congressional_district_rows": len(native_districts), + "unique_congressional_district_values": int(len(np.unique(native_districts))), + } + if assignment.get("output") != actual_output: + raise ValueError( + f"{boundary} household geography counts differ from its manifest " + "assignment output receipt." + ) + block = household["block_geoid"].astype(str) + for column, width in (("tract_geoid", 11), ("county_fips", 5)): + if column not in household: + raise ValueError(f"{boundary} is missing geography column {column!r}.") + if not household[column].astype(str).eq(block.str[:width]).all(): + raise ValueError( + f"{boundary} {column!r} is not the prefix of its household's " + "located block." + ) + missing_coherent = [ + column + for column in _STACKED_BLOCK_LOCATION_COHERENT_COLUMNS + if column not in household + ] + if missing_coherent: + raise ValueError( + f"{boundary} is missing block-location column(s): {missing_coherent}." + ) + grouped = household.groupby(source_column, sort=False, dropna=False) + for column in _STACKED_BLOCK_LOCATION_COHERENT_COLUMNS: + incoherent = grouped[column].nunique(dropna=False) > 1 + if bool(incoherent.any()): + raise ValueError( + f"{boundary} cloned household rows disagree on assigned " + f"geography column {column!r}." + ) + + +def _stacked_native_households( + household: pd.DataFrame, + *, + boundary: str, +) -> tuple[pd.DataFrame, str]: + """Validate PUF-support clone lineage; return native rows and source column.""" + + clone_column = support_clone_index_column("household") + source_column = support_source_id_column("household") + missing_lineage = [ + column for column in (source_column, clone_column) if column not in household + ] + if missing_lineage: + raise ValueError( + f"{boundary} is missing household clone-lineage column(s): " + f"{missing_lineage}." + ) + clone_index = pd.to_numeric(household[clone_column], errors="coerce") + clone_numeric = clone_index.to_numpy(dtype=np.float64, na_value=np.nan) + valid_clone_index = ( + np.isfinite(clone_numeric) + & (clone_numeric >= 0) + & (clone_numeric == np.floor(clone_numeric)) + ) + if not valid_clone_index.all(): + raise ValueError(f"{boundary} has invalid {clone_column!r} values.") + source_ids = pd.to_numeric(household[source_column], errors="coerce") + source_numeric = source_ids.to_numpy(dtype=np.float64, na_value=np.nan) + valid_source_ids = np.isfinite(source_numeric) & ( + source_numeric == np.floor(source_numeric) + ) + if not valid_source_ids.all(): + raise ValueError(f"{boundary} has invalid {source_column!r} values.") + lineage = pd.DataFrame( + { + "source_id": source_numeric.astype(np.int64), + "clone_index": clone_numeric.astype(np.int64), + }, + index=household.index, + ) + if lineage.duplicated(["source_id", "clone_index"]).any(): + raise ValueError(f"{boundary} has duplicate household clone-lineage roles.") + native_counts = lineage["clone_index"].eq(0).groupby(lineage["source_id"]).sum() + if not native_counts.eq(1).all(): + raise ValueError( + f"{boundary} requires exactly one native household for every clone lineage." + ) + native_mask = lineage["clone_index"].eq(0) + native_household = household.loc[native_mask] + native_ids = pd.to_numeric( + native_household["household_id"], errors="coerce" + ).to_numpy(dtype=np.float64, na_value=np.nan) + native_source_ids = lineage.loc[native_mask, "source_id"].to_numpy(dtype=np.float64) + if not np.array_equal(native_ids, native_source_ids): + raise ValueError( + f"{boundary} native household IDs differ from their clone-lineage " + "source IDs." + ) + return native_household, source_column + + def _validate_stacked_geography_h5_binding( manifest: Mapping[str, object], household: pd.DataFrame, @@ -1854,6 +2546,30 @@ def _validate_stacked_geography_h5_binding( if manifest.get("pipeline") != _STACKED_PIPELINE: return + if _stacked_geography_algorithm_id(manifest) == ( + US_STACKED_BLOCK_LOCATION_ALGORITHM_ID + ): + _validate_stacked_block_location_h5_binding( + manifest, + household, + root_attributes, + manifest_path=manifest_path, + pool_path=pool_path, + ) + return + # A legacy-rule pool never writes location-rule root attributes; an H5 + # that carries them claims a rule its manifest does not bind. + claimed_location_attributes = sorted( + key + for key in US_STACKED_BLOCK_LOCATION_ROOT_ATTRIBUTES + if root_attributes.get(key) is not None + ) + if claimed_location_attributes: + raise ValueError( + f"US stacked pool H5 {pool_path} carries location-rule root " + f"attribute(s) {claimed_location_attributes} that its legacy-rule " + f"manifest {manifest_path} does not bind." + ) assignment = _mapping( manifest.get("geography_assignment"), label=f"US stacked pool manifest {manifest_path}.geography_assignment", @@ -2191,6 +2907,7 @@ def _load_us_multispine_pool( for key in ( CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR, CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR, + *US_STACKED_BLOCK_LOCATION_ROOT_ATTRIBUTES, ) } diff --git a/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_seeds.py b/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_seeds.py index bba3a43b43..bf12e96255 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_seeds.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_seeds.py @@ -458,3 +458,14 @@ def test_qrf_multi_regime_chain_has_golden_shared_draw_state() -> None: assert ( post_state == "de1c423f97685d93be539e0d8748b61d13251bcb8b04f63ee01ca37072f7ab43" ) + + +def test_legacy_v1_seed_protocol_equals_the_pinned_inventory_digest() -> None: + """Engine-free tripwire: a byte edit to any seed-kernel module (even a + formatter pass) moves this digest; the engine lane's coverage check would + otherwise be the first to notice.""" + + from microcosm.build.spec_engine.inventory_coverage import EXPECTED_HASHES + from microcosm.build.spec_engine.seeds import LEGACY_V1_PROTOCOL + + assert LEGACY_V1_PROTOCOL.implementation_sha256 == EXPECTED_HASHES["seed_protocol"] diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_acs_block_location.py b/packages/microcosm-build/tests/engine_free/us/test_us_acs_block_location.py new file mode 100644 index 0000000000..d60077f174 --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/us/test_us_acs_block_location.py @@ -0,0 +1,1523 @@ +"""The ACS local-release line under ``--location-rule block_v1`` (microcosm#696). + +The pooled ASEC-by-PUF + ACS staging frame locates every household on one +2020 census block: + +- ACS rows draw a block within their observed 2020 PUMA; +- donor rows that already carry a ladder block keep it (never redrawn) and + re-derive every geography from it; +- donor rows without a block draw within their state, counted under an + explicit reason; + +and every geography column then equals the ladder's lookup of the block, so +the block-location release gate passes. The legacy default is untouched +(the existing ACS multispine tests pin it); these tests cover the new +branch, its refusals and its records. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import shlex +from collections.abc import Mapping +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from microcosm.build import FitWeightRecord, GateResult +from microcosm.build.us_runtime import acs_multispine +from microcosm.build.us_runtime.acs_pums import ACS_2024_1YR_SPINE +from microcosm.build.us_runtime.base_pool import ( + ACS_POOL_BLOCK_LOCATION_METADATA_KEY, + ACS_POOL_LOCATION_CLONES_REFUSAL, + DONOR_BLOCK_PRESERVED, + DONOR_BLOCK_STATE_DRAWN, + preflight_pooled_ladder_geography, + spine_column, + with_optional_acs_spine, +) +from microcosm.build.us_runtime.block_location import ( + SOURCE_GEOGRAPHY_PUMA, + SOURCE_GEOGRAPHY_STATE, + US_BLOCK_LOCATION_RULE_ID, + US_LOCATION_RULE_BLOCK_V1, + UsLocationLadder, + derive_us_block_geography, + load_us_location_ladder, + location_geography_columns, + us_block_location_gate, +) +from microcosm.frame import US_SCHEMA, Frame, WeightKind, Weights +from test_support.microcosm_build.us_block_location import ( + synthetic_blocks, + write_location_ladder, +) +from test_support.paths import paths_for + +_TEST_PATHS = paths_for("microcosm-build") +requires_pytables = pytest.mark.skipif( + importlib.util.find_spec("tables") is None, + reason="requires pytables (the build environment)", +) + +STATES = (1, 2, 36) + + +@pytest.fixture(scope="module") +def ladder(tmp_path_factory: pytest.TempPathFactory) -> UsLocationLadder: + path = tmp_path_factory.mktemp("ladder") / "blocks.npz" + write_location_ladder(path, synthetic_blocks(3, states=STATES)) + return load_us_location_ladder(path) + + +@pytest.fixture(scope="module") +def ladder_without_puma(tmp_path_factory: pytest.TempPathFactory) -> UsLocationLadder: + path = tmp_path_factory.mktemp("ladder") / "blocks_no_puma.npz" + write_location_ladder(path, synthetic_blocks(3, states=STATES), with_puma=False) + return load_us_location_ladder(path) + + +def _frame( + household: pd.DataFrame, *, weights: np.ndarray, spine: str | None = None +) -> Frame: + ids = household["household_id"].to_numpy(dtype=np.int64) + person = pd.DataFrame( + { + "person_id": ids, + "person_household_id": ids, + "person_tax_unit_id": ids, + "person_spm_unit_id": ids, + "person_family_id": ids, + "person_marital_unit_id": ids, + } + ) + tables = { + "person": person, + "household": household, + "tax_unit": pd.DataFrame({"tax_unit_id": ids}), + "spm_unit": pd.DataFrame({"spm_unit_id": ids}), + "family": pd.DataFrame({"family_id": ids}), + "marital_unit": pd.DataFrame({"marital_unit_id": ids}), + } + if spine is not None: + for entity, table in tables.items(): + table[spine_column(entity)] = spine + return Frame(tables, US_SCHEMA, {"household": Weights(weights, WeightKind.DESIGN)}) + + +def _ladder_block(ladder: UsLocationLadder, state: int, which: int = 0) -> str: + in_state = ladder.block_geoid[ladder.block_geoid // 10**13 == state] + return f"{int(in_state[which % len(in_state)]):015d}" + + +def _donor_base( + ladder: UsLocationLadder, *, blocks: str = "all", legacy_columns: bool = True +) -> Frame: + """Six donor households; ``blocks`` is ``all``, ``some`` or ``none``.""" + + states = [1, 1, 2, 36, 36, 2] + household = pd.DataFrame( + {"household_id": np.arange(1, 7, dtype=np.int64), "state_fips": states} + ) + if blocks != "none": + geoids = [_ladder_block(ladder, state, i) for i, state in enumerate(states)] + if blocks == "some": + geoids[1] = "" + geoids[4] = None + household["block_geoid"] = geoids + if legacy_columns: + # What a legacy base-h5 donor carries next to its block. + index = np.searchsorted( + ladder.block_geoid, + [int(g) if g else ladder.block_geoid[0] for g in geoids], + ) + derived = derive_us_block_geography(ladder, index) + for column in ("tract_geoid", "county_fips"): + household[column] = derived[column] + household["congressional_district_geoid"] = derived[ + "congressional_district_geoid" + ] + household["puma"] = pd.Series([None] * len(household), dtype=object) + return _frame(household, weights=np.full(6, 50.0), spine="asec_puf") + + +def _acs(ladder: UsLocationLadder, *, n: int = 9) -> Frame: + assert ladder.puma is not None + pumas = np.unique(ladder.puma) + chosen = [int(pumas[i % len(pumas)]) for i in range(n)] + household = pd.DataFrame( + { + "household_id": np.arange(1, n + 1, dtype=np.int64), + "state_fips": [puma // 10**5 for puma in chosen], + "puma": [f"{puma:07d}" for puma in chosen], + } + ) + return _frame(household, weights=np.full(n, 10.0), spine=ACS_2024_1YR_SPINE) + + +def _pool(ladder: UsLocationLadder, base: Frame, acs: Frame, **options) -> Frame: + return with_optional_acs_spine( + base, + acs, + location_rule=US_LOCATION_RULE_BLOCK_V1, + block_ladder=ladder, + max_peak_bytes=None, + **options, + ) + + +def _thaw(value): + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw(item) for item in value] + return value + + +def _record(pooled: Frame) -> dict: + return _thaw(pooled.metadata[ACS_POOL_BLOCK_LOCATION_METADATA_KEY]) + + +def _block_index(ladder: UsLocationLadder, blocks: pd.Series) -> np.ndarray: + return np.searchsorted(ladder.block_geoid, blocks.astype(np.int64).to_numpy()) + + +def test__block_v1__acs_blocks_lie_in_observed_puma(ladder: UsLocationLadder) -> None: + acs = _acs(ladder) + pooled = _pool(ladder, _donor_base(ladder), acs) + household = pooled.table("household") + acs_rows = household[spine_column("household")].eq(ACS_2024_1YR_SPINE) + observed = acs.table("household")["puma"].astype(np.int64).to_numpy() + index = _block_index(ladder, household.loc[acs_rows, "block_geoid"]) + assert ladder.puma is not None + np.testing.assert_array_equal(ladder.puma[index], observed) + np.testing.assert_array_equal( + household.loc[acs_rows, "puma"].astype(np.int64).to_numpy(), observed + ) + + +def test__block_v1__donor_blocks_are_preserved_not_redrawn( + ladder: UsLocationLadder, +) -> None: + base = _donor_base(ladder) + pooled = _pool(ladder, base, _acs(ladder)) + household = pooled.table("household") + donors = household[spine_column("household")].eq("asec_puf") + assert ( + household.loc[donors, "block_geoid"].tolist() + == base.table("household")["block_geoid"].tolist() + ) + record = _record(pooled) + assert record["donor_blocks"]["mode"] == DONOR_BLOCK_PRESERVED + assert record["donor_blocks"][DONOR_BLOCK_PRESERVED] == 6 + assert record["donor_blocks"][DONOR_BLOCK_STATE_DRAWN] == 0 + assert record["donor_blocks"]["state_drawn_reason"] is None + # The fixture's legacy columns came from this ladder: nothing changed. + assert record["donor_blocks"]["rederived_columns_changed"] == {} + + +def test__block_v1__every_geography_derives_from_the_block_and_gate_passes( + ladder: UsLocationLadder, +) -> None: + pooled = _pool(ladder, _donor_base(ladder, blocks="some"), _acs(ladder)) + household = pooled.table("household") + gate = us_block_location_gate(household, ladder) + assert gate.passed, gate.failures + for column in location_geography_columns(ladder): + assert household[column].notna().all(), column + assert household["county_fips"].map(len).eq(5).all() + assert household["puma"].map(len).eq(7).all() + assert ( + household["block_geoid"].str[:2].astype(int).to_numpy() + == household["state_fips"].to_numpy() + ).all() + + +def test__block_v1__donors_without_block_draw_within_state_and_are_counted( + ladder: UsLocationLadder, +) -> None: + base = _donor_base(ladder, blocks="some") + pooled = _pool(ladder, base, _acs(ladder)) + record = _record(pooled) + donor_blocks = record["donor_blocks"] + assert donor_blocks["mode"] == f"{DONOR_BLOCK_PRESERVED}_with_state_draws" + assert donor_blocks[DONOR_BLOCK_PRESERVED] == 4 + assert donor_blocks[DONOR_BLOCK_STATE_DRAWN] == 2 + assert "no block_geoid" in donor_blocks["state_drawn_reason"] + assert record["source_geography_households"][SOURCE_GEOGRAPHY_STATE] == 2 + assert record["source_geography_households"][SOURCE_GEOGRAPHY_PUMA] == 9 + household = pooled.table("household") + donors = household.loc[household[spine_column("household")].eq("asec_puf")] + kept = base.table("household")["block_geoid"] + for position in (0, 2, 3, 5): + assert donors["block_geoid"].iloc[position] == kept.iloc[position] + + +def test__block_v1__donor_without_any_block_column_draws_within_state( + ladder: UsLocationLadder, +) -> None: + pooled = _pool(ladder, _donor_base(ladder, blocks="none"), _acs(ladder)) + record = _record(pooled) + assert record["donor_blocks"]["mode"] == DONOR_BLOCK_STATE_DRAWN + assert record["donor_blocks"][DONOR_BLOCK_STATE_DRAWN] == 6 + assert us_block_location_gate(pooled.table("household"), ladder).passed + + +def test__block_v1__record_carries_rule_seed_and_ladder( + ladder: UsLocationLadder, +) -> None: + pooled = _pool(ladder, _donor_base(ladder), _acs(ladder), location_seed=17) + record = _record(pooled) + assert record["rule"] == US_BLOCK_LOCATION_RULE_ID + assert record["location_rule"] == US_LOCATION_RULE_BLOCK_V1 + assert record["seed"] == 17 + assert record["clones"] == 1 + assert record["block_ladder"]["sha256"] == ladder.sha256 + assert record["block_ladder"]["carries_puma"] is True + assert record["households"] == 15 + assert record["spine_households"] == {ACS_2024_1YR_SPINE: 9, "asec_puf": 6} + assert set(record["candidate_set_rule"]) == {ACS_2024_1YR_SPINE, "asec_puf"} + + +def test__block_v1__is_deterministic_and_seed_sensitive( + ladder: UsLocationLadder, +) -> None: + base = _donor_base(ladder, blocks="none") + acs = _acs(ladder, n=40) + first = _pool(ladder, base, acs, location_seed=3).table("household") + again = _pool(ladder, base, acs, location_seed=3).table("household") + other = _pool(ladder, base, acs, location_seed=4).table("household") + pd.testing.assert_frame_equal(first, again) + assert not first["block_geoid"].equals(other["block_geoid"]) + + +def test__block_v1__conserves_household_mass(ladder: UsLocationLadder) -> None: + base = _donor_base(ladder) + pooled = _pool(ladder, base, _acs(ladder)) + assert pooled.weights_for("household").total == pytest.approx( + base.weights_for("household").total, rel=1e-12 + ) + + +def test__block_v1__refuses_more_than_one_location_clone( + ladder: UsLocationLadder, +) -> None: + with pytest.raises(ValueError) as caught: + _pool(ladder, _donor_base(ladder), _acs(ladder), location_clones=2) + message = str(caught.value) + assert message == ACS_POOL_LOCATION_CLONES_REFUSAL + assert "_require_stored_inputs" in message + assert "US_STORED_NON_VARIABLE_COLUMNS" in message + assert "_assign_pooled_block_location" in message + + +@pytest.mark.parametrize( + ("options", "match"), + [ + ({"location_clones": 0}, "location_clones must be >= 1"), + ({"location_seed": -1}, "location_seed must be non-negative"), + ({"geography_seed": 5}, "geography_seed seeds only the legacy"), + ({"location_seed": 1.5}, "location_seed must be an integer"), + ], +) +def test__block_v1__refuses_invalid_options( + ladder: UsLocationLadder, options: dict, match: str +) -> None: + with pytest.raises(ValueError, match=match): + _pool(ladder, _donor_base(ladder), _acs(ladder), **options) + + +def test__block_v1__refuses_a_puma_ladder_it_would_ignore( + ladder: UsLocationLadder, +) -> None: + with pytest.raises(ValueError, match="Refusing puma_ladder"): + _pool( + ladder, + _donor_base(ladder), + _acs(ladder), + puma_ladder=_legacy_puma_ladder(), + ) + + +def test__block_v1__refuses_missing_or_puma_less_ladder( + ladder: UsLocationLadder, ladder_without_puma: UsLocationLadder +) -> None: + base, acs = _donor_base(ladder), _acs(ladder) + with pytest.raises(ValueError, match="requires a block_ladder"): + with_optional_acs_spine(base, acs, location_rule=US_LOCATION_RULE_BLOCK_V1) + with pytest.raises(ValueError, match="per-block 'puma'"): + _pool(ladder_without_puma, base, acs) + + +@pytest.mark.parametrize( + "options", + [ + {"location_seed": 1}, + {"location_clones": 2}, + {"location_rule": "nope"}, + ], +) +def test__legacy__refuses_block_only_options( + ladder: UsLocationLadder, options: dict +) -> None: + with pytest.raises(ValueError): + with_optional_acs_spine(_donor_base(ladder), _acs(ladder), **options) + + +def test__legacy__refuses_a_block_ladder(ladder: UsLocationLadder) -> None: + with pytest.raises(ValueError, match="only used by location_rule='block_v1'"): + with_optional_acs_spine(_donor_base(ladder), _acs(ladder), block_ladder=ladder) + + +def test__legacy__no_acs_identity_is_unchanged(ladder: UsLocationLadder) -> None: + base = _donor_base(ladder) + assert with_optional_acs_spine(base, None) is base + + +def test__block_v1__refuses_unknown_donor_block(ladder: UsLocationLadder) -> None: + base = _donor_base(ladder, legacy_columns=False) + household = base.table("household").copy() + household.loc[0, "block_geoid"] = "010019999999999" + bad = _frame(household, weights=np.full(6, 50.0), spine="asec_puf") + with pytest.raises(ValueError, match="absent from the block ladder"): + _pool(ladder, bad, _acs(ladder)) + + +def test__block_v1__refuses_donor_block_in_another_state( + ladder: UsLocationLadder, +) -> None: + base = _donor_base(ladder, legacy_columns=False) + household = base.table("household").copy() + household.loc[0, "block_geoid"] = _ladder_block(ladder, 36) + bad = _frame(household, weights=np.full(6, 50.0), spine="asec_puf") + with pytest.raises(ValueError, match="state prefixes disagree"): + _pool(ladder, bad, _acs(ladder)) + + +def test__block_v1__refuses_acs_puma_outside_the_ladder( + ladder: UsLocationLadder, +) -> None: + acs = _acs(ladder) + household = acs.table("household").copy() + household.loc[0, "puma"] = "0199999" + household = household.drop(columns=[spine_column("household")]) + bad = _frame( + household, weights=np.full(len(household), 10.0), spine=ACS_2024_1YR_SPINE + ) + with pytest.raises(ValueError, match="absent from the block ladder's PUMA layer"): + _pool(ladder, _donor_base(ladder), bad) + + +def test__block_v1__integer_donor_blocks_keep_their_leading_zero( + ladder: UsLocationLadder, +) -> None: + base = _donor_base(ladder, legacy_columns=False) + household = base.table("household").copy() + household["block_geoid"] = household["block_geoid"].astype(np.int64) + as_int = _frame(household, weights=np.full(6, 50.0), spine="asec_puf") + pooled = _pool(ladder, as_int, _acs(ladder)) + donors = pooled.table("household").iloc[:6] + assert ( + donors["block_geoid"].tolist() + == base.table("household")["block_geoid"].tolist() + ) + + +def test__block_v1__records_donor_columns_the_ladder_rederives( + ladder: UsLocationLadder, +) -> None: + base = _donor_base(ladder) + household = base.table("household").copy() + household.loc[0, "county_fips"] = "01999" + stale = _frame(household, weights=np.full(6, 50.0), spine="asec_puf") + pooled = _pool(ladder, stale, _acs(ladder)) + record = _record(pooled) + assert record["donor_blocks"]["rederived_columns_changed"] == {"county_fips": 1} + assert us_block_location_gate(pooled.table("household"), ladder).passed + + +def test__preflight__block_v1_reports_donor_mode(ladder: UsLocationLadder) -> None: + acs = _acs(ladder).table("household") + for blocks, expected in ( + ("all", DONOR_BLOCK_PRESERVED), + ("none", DONOR_BLOCK_STATE_DRAWN), + ("some", f"{DONOR_BLOCK_PRESERVED}_with_state_draws"), + ): + base = _donor_base(ladder, blocks=blocks).table("household") + assert ( + preflight_pooled_ladder_geography( + base, + acs, + None, + location_rule=US_LOCATION_RULE_BLOCK_V1, + block_ladder=ladder, + ) + == expected + ) + + +def test__preflight__block_v1_fails_before_transfer_on_bad_inputs( + ladder: UsLocationLadder, +) -> None: + acs = _acs(ladder).table("household").copy() + base = _donor_base(ladder).table("household") + acs.loc[0, "puma"] = "0199999" + with pytest.raises(ValueError, match="PUMA layer"): + preflight_pooled_ladder_geography( + base, + acs, + None, + location_rule=US_LOCATION_RULE_BLOCK_V1, + block_ladder=ladder, + ) + base = base.copy() + base.loc[0, "block_geoid"] = "not-a-block" + with pytest.raises(ValueError, match="15-digit"): + preflight_pooled_ladder_geography( + base, + _acs(ladder).table("household"), + None, + location_rule=US_LOCATION_RULE_BLOCK_V1, + block_ladder=ladder, + ) + + +def test__preflight__legacy_refuses_block_ladder(ladder: UsLocationLadder) -> None: + with pytest.raises(ValueError, match="only used by location_rule='block_v1'"): + preflight_pooled_ladder_geography( + _donor_base(ladder).table("household"), + _acs(ladder).table("household"), + None, + block_ladder=ladder, + ) + + +# --------------------------------------------------------------------------- +# build_optional_acs_multispine threading +# --------------------------------------------------------------------------- + + +def test__multispine__block_v1_threads_through_and_records_location( + monkeypatch: pytest.MonkeyPatch, ladder: UsLocationLadder +) -> None: + base = _donor_base(ladder, blocks="some") + acs = _acs(ladder) + captured: dict[str, object] = {} + + monkeypatch.setattr( + acs_multispine, + "build_acs_pums_unit_frame", + lambda source, *, chunksize: (acs, {"spine": ACS_2024_1YR_SPINE}), + ) + monkeypatch.setattr( + acs_multispine, + "map_acs_native_inputs", + lambda raw: type("Mapped", (), {"frame": raw, "native_inputs": {}})(), + ) + + def fake_transfer(recipient, donor, **kwargs): + captured["transfer"] = kwargs + return type( + "Transfer", + (), + { + "frame": recipient, + "imputed_inputs": (), + "fit_records": (), + "deferred_inputs": ( + "block_geoid", + "congressional_district_geoid", + "county_fips", + "tract_geoid", + ), + "resolved_donor_channel": "asec", + }, + )() + + monkeypatch.setattr(acs_multispine, "transfer_acs_inputs", fake_transfer) + result = acs_multispine.build_optional_acs_multispine( + base, + object(), # type: ignore[arg-type] + location_rule=US_LOCATION_RULE_BLOCK_V1, + block_ladder=ladder, + location_seed=9, + ) + provenance = result.provenance + json.dumps(provenance, allow_nan=False) + assert provenance["deferred_inputs"] == [] + location = provenance["household_location"] + assert location["rule"] == US_BLOCK_LOCATION_RULE_ID + assert location["seed"] == 9 + assert location["donor_blocks"][DONOR_BLOCK_STATE_DRAWN] == 2 + geography = provenance["geography_ladder"] + assert geography["applied"] is True + assert geography["location_rule"] == US_LOCATION_RULE_BLOCK_V1 + assert geography["seed"] == 9 + assert geography["unresolved_sub_puma_inputs"] == [] + assert geography["block_ladder_sha256"] == ladder.sha256 + assert geography["resolved_model_inputs"] == list( + location_geography_columns(ladder) + ) + assert us_block_location_gate(result.frame.table("household"), ladder).passed + + +def test__multispine__refuses_invalid_location_options_before_loading( + monkeypatch: pytest.MonkeyPatch, ladder: UsLocationLadder +) -> None: + def must_not_load(*_args, **_kwargs): + raise AssertionError("location options must be validated first") + + monkeypatch.setattr(acs_multispine, "build_acs_pums_unit_frame", must_not_load) + for options in ( + {"location_seed": 3}, + {"location_rule": US_LOCATION_RULE_BLOCK_V1}, + { + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "block_ladder": ladder, + "location_clones": 2, + }, + ): + with pytest.raises(ValueError): + acs_multispine.build_optional_acs_multispine( + _donor_base(ladder), + object(), # type: ignore[arg-type] + **options, + ) + + +# --------------------------------------------------------------------------- +# tools/_legacy/build_us_acs_multispine_base.py +# --------------------------------------------------------------------------- + + +def _load_legacy_builder(): + path = ( + _TEST_PATHS.repository + / "tools" + / "_legacy" + / ("build_us_acs_multispine_base.py") + ) + spec = importlib.util.spec_from_file_location( + "legacy_build_us_acs_multispine_base_block_v1", path + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _load_release_tool(): + path = _TEST_PATHS.repository / "tools" / "build_us_acs_local_release.py" + spec = importlib.util.spec_from_file_location( + "build_us_acs_local_release_block_v1", path + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test__legacy_tool__parser_defaults_are_legacy() -> None: + builder = _load_legacy_builder() + args = builder._parse_args(["--base-h5", "a.h5", "--out-h5", "b.h5"]) + assert args.location_rule == "legacy" + assert args.location_seed == 0 + assert args.location_clones == 1 + assert args.block_ladder is None + builder._validate_location_arguments(args) + + +@pytest.mark.parametrize( + ("extra", "match"), + [ + (["--location-seed", "1"], "apply only to --location-rule block_v1"), + (["--location-clones", "2"], "apply only to --location-rule block_v1"), + (["--block-ladder", "x.npz"], "only used by --location-rule block_v1"), + (["--location-rule", "block_v1"], "requires --block-ladder"), + ( + ["--location-rule", "block_v1", "--block-ladder", "x.npz"] + + ["--location-clones", "2"], + "_require_stored_inputs", + ), + ( + ["--location-rule", "block_v1", "--block-ladder", "x.npz"] + + ["--geography-seed", "3"], + "--geography-seed seeds only the legacy", + ), + ], +) +def test__legacy_tool__refuses_inconsistent_location_flags( + extra: list[str], match: str +) -> None: + builder = _load_legacy_builder() + args = builder._parse_args(["--base-h5", "a.h5", "--out-h5", "b.h5", *extra]) + with pytest.raises(SystemExit, match=match): + builder._validate_location_arguments(args) + + +def test__legacy_tool__refuses_zero_location_clones_at_parse() -> None: + builder = _load_legacy_builder() + with pytest.raises(SystemExit): + builder._parse_args( + ["--base-h5", "a.h5", "--out-h5", "b.h5", "--location-clones", "0"] + ) + + +def _with_person_inputs(frame: Frame) -> Frame: + tables = {entity: frame.table(entity).copy() for entity in frame.entities} + tables["person"]["takes_up_snap_if_eligible"] = True + return Frame( + tables, + US_SCHEMA, + {"household": frame.weights_for("household")}, + frame.strata, + ) + + +def _puma_ladder_stub(builder, ladder: UsLocationLadder): + """The PUMA ladder of ``ladder``'s own blocks (the same 2020 geography).""" + + assert ladder.puma is not None + frame = pd.DataFrame( + { + "puma": ladder.puma, + "cd": ladder.congressional_district_plans[ + ladder.primary_congressional_district_plan + ], + "county": ladder.block_geoid // 10**10, + "tract": ladder.block_geoid // 10**4, + "population": ladder.population.astype(np.float64), + } + ) + + def overlap(column: str): + grouped = frame.groupby(["puma", column], sort=True)["population"].sum() + return ( + grouped.index.get_level_values(0).to_numpy(np.int64), + grouped.index.get_level_values(1).to_numpy(np.int64), + grouped.to_numpy(np.float64), + ) + + anchor = frame.groupby("puma", sort=True)["population"].sum() + cd_puma, cd, cd_population = overlap("cd") + county_puma, county, county_population = overlap("county") + tract_puma, tract, tract_population = overlap("tract") + return builder.UsPumaLadder( + puma=anchor.index.to_numpy(np.int64), + puma_population=anchor.to_numpy(np.float64), + cd_overlap_puma=cd_puma, + cd_overlap_cd=cd, + cd_overlap_population=cd_population, + county_overlap_puma=county_puma, + county_overlap_county=county.astype(np.int32), + county_overlap_population=county_population, + tract_overlap_puma=tract_puma, + tract_overlap_tract=tract, + tract_overlap_population=tract_population, + metadata={ + "schema_version": 1, + "kind": "us_puma_ladder", + "puma_vintage": "2020_puma", + "sampling_basis": "population", + "layers": { + "congressional_district": { + "vintage": ladder.primary_congressional_district_plan + }, + "county": {"vintage": "2020_census"}, + "tract": {"vintage": "2020_census"}, + }, + }, + ) + + +def _run_block_v1_main( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + tamper: bool = False, + seed: int = 5, + puma_ladder=None, +): + builder = _load_legacy_builder() + ladder_path = write_location_ladder( + tmp_path / "blocks.npz", synthetic_blocks(3, states=STATES) + ) + ladder = load_us_location_ladder(ladder_path) + base = _with_person_inputs(_donor_base(ladder, blocks="some")) + acs_frame = _acs(ladder) + acs_tables = {e: acs_frame.table(e).copy() for e in acs_frame.entities} + acs_tables["person"]["takes_up_snap_if_eligible"] = False + acs_tables["household"]["TYPEHUGQ"] = 1.0 + acs_frame = Frame( + acs_tables, US_SCHEMA, {"household": acs_frame.weights_for("household")} + ) + combined = with_optional_acs_spine( + base, + acs_frame, + location_rule=US_LOCATION_RULE_BLOCK_V1, + block_ladder=ladder, + location_seed=seed, + max_peak_bytes=None, + ) + if tamper: + tables = {e: combined.table(e).copy() for e in combined.entities} + tables["household"].loc[0, "county_fips"] = "99999" + combined = Frame( + tables, + US_SCHEMA, + {"household": combined.weights_for("household")}, + combined.strata, + metadata=combined.metadata, + ) + if puma_ladder is None: + puma_ladder = _puma_ladder_stub(builder, ladder) + donor_mode = _record(combined)["donor_blocks"]["mode"] + provenance = { + "enabled": True, + "deferred_inputs": [], + "household_location": _record(combined), + "geography_ladder": acs_multispine._block_geography_provenance( + combined, + ladder, + puma_ladder, + location_seed=seed, + donor_geography=donor_mode, + expected_congressional_district_vintage="119th_congress", + ), + "imputed_inputs": [ + { + "column": "takes_up_snap_if_eligible", + "family": "benefit_participation", + "unmodeled_recipient_rows": 0, + } + ], + } + base_h5 = tmp_path / "dense.h5" + base_h5.write_bytes(b"dense-base") + puma_ladder_path = tmp_path / "us_puma_ladder_2020.npz" + puma_ladder_path.write_bytes(b"puma-ladder") + manifest_path = tmp_path / "acs_sources.json" + manifest_path.write_text("{}", encoding="utf-8") + output_h5 = tmp_path / "combined.h5" + summary_path = tmp_path / "combined.summary.json" + captured: dict[str, object] = {} + transfer_plan = { + "person": {"benefit_participation": ("takes_up_snap_if_eligible",)} + } + + def fake_build(actual_base, actual_source, **kwargs): + captured["build"] = kwargs + return builder.AcsMultispineResult( + frame=combined, + fit_records=(FitWeightRecord("acs_transfer:person:benefits", "design"),), + provenance=provenance, + ) + + def fake_write(frame, path, **kwargs): + captured["write"] = kwargs + path.write_bytes(b"combined-output") + + manifest = type("Manifest", (), {})() + monkeypatch.setattr(builder, "_load_base_frame", lambda path: base) + monkeypatch.setattr( + builder, + "_base_location_rule", + lambda path: { + "location_rule": "block_v1", + "recorded": True, + "attributes": {"populace_location_rule": "block_v1"}, + }, + ) + monkeypatch.setattr(builder, "_require_dense_donor_coverage", lambda *a, **k: None) + monkeypatch.setattr( + builder, "acs_local_transfer_target_families", lambda: transfer_plan + ) + monkeypatch.setattr( + builder, "declared_acs_transfer_target_families", lambda: transfer_plan + ) + monkeypatch.setattr( + builder.acs_sources, "load_acs_source_manifest", lambda p: manifest + ) + monkeypatch.setattr( + builder.acs_sources, + "fetch_acs_pums_sources", + lambda cache, *, manifest: builder.AcsPumsSource( + cache / "h.zip", cache / "p.zip" + ), + ) + monkeypatch.setattr(builder, "_source_provenance", lambda *a, **k: {}) + monkeypatch.setattr(builder, "build_optional_acs_multispine", fake_build) + monkeypatch.setattr(builder, "load_us_puma_ladder", lambda path: puma_ladder) + monkeypatch.setattr(builder, "_engine_input_null_audit", lambda frame: []) + monkeypatch.setattr(builder, "_preflight_staging_export", lambda frame: 1) + monkeypatch.setattr(builder, "_write_dataset", fake_write) + monkeypatch.setattr( + builder, + "acs_local_hours_signal_gate", + lambda frame, *, source_null_audit: GateResult( + name="acs_local_hours_signal", passed=True, failures=(), details={} + ), + ) + arguments = [ + "--base-h5", + str(base_h5), + "--out-h5", + str(output_h5), + "--summary", + str(summary_path), + "--source-manifest", + str(manifest_path), + "--inputs-dir", + str(tmp_path / "inputs"), + "--puma-ladder", + str(puma_ladder_path), + "--location-rule", + "block_v1", + "--location-seed", + str(seed), + "--block-ladder", + str(ladder_path), + ] + exit_code = builder.main(arguments) + summary = json.loads(summary_path.read_text(encoding="utf-8")) + return builder, ladder, exit_code, captured, summary + + +def test__legacy_tool__block_v1_main_records_rule_seed_gate_and_attributes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + builder, ladder, exit_code, captured, summary = _run_block_v1_main( + monkeypatch, tmp_path + ) + assert exit_code == 0 + build_options = dict(captured["build"]) + block_ladder = build_options.pop("block_ladder") + assert block_ladder.sha256 == ladder.sha256 + build_options.pop("puma_ladder") + build_options.pop("hours_donor_factory") + build_options.pop("target_families") + assert build_options == { + "chunksize": builder.DEFAULT_CHUNKSIZE, + "acs_share": 0.5, + "hours_under15_policy": None, + "donor_channel": builder.ACS_DONOR_CHANNEL_AUTO, + "seed": 0, + "n_estimators": 32, + "max_targets_per_fit": 8, + "geography_seed": 0, + "location_rule": "block_v1", + "location_seed": 5, + "location_clones": 1, + } + + location = summary["household_location"] + assert location["rule"] == US_BLOCK_LOCATION_RULE_ID + assert location["location_rule"] == "block_v1" + assert location["seed"] == 5 + assert location["clones"] == 1 + assert location["block_ladder"]["sha256"] == ladder.sha256 + assert location["block_ladder_path"] == str((tmp_path / "blocks.npz").resolve()) + assert ( + location["puma_ladder"]["sha256"] == hashlib.sha256(b"puma-ladder").hexdigest() + ) + assert location["donor_base_location"]["location_rule"] == "block_v1" + assert location["gate"]["passed"] is True + agreement = location["ladder_agreement"] + assert agreement["congressional_district_plan"] == "119th_congress" + for layer in ("pumas", "states", "congressional_districts"): + assert agreement[layer]["only_in_block_ladder"] == [] + assert agreement[layer]["only_in_puma_ladder"] == [] + assert agreement[layer]["max_abs_population_difference"] == 0.0 + assert location["donor_blocks"][DONOR_BLOCK_STATE_DRAWN] == 2 + + orchestration = summary["orchestration"] + assert orchestration["location_rule"] == "block_v1" + assert orchestration["location_seed"] == 5 + assert orchestration["location_clones"] == 1 + assert orchestration["block_ladder_sha256"] == ladder.sha256 + assert orchestration["geography_seed"] == 0 + + geography = summary["geography_ladder"] + assert geography["sha256"] == hashlib.sha256(b"puma-ladder").hexdigest() + assert geography["block_ladder"]["sha256"] == ladder.sha256 + assert geography["seed"] == 5 + assert geography["location_rule"] == "block_v1" + + limitation = summary["reviewed_limitations"][2] + assert limitation["id"] == "sub_puma_geographic_precision" + assert limitation["location_rule"] == "block_v1" + assert limitation["unavailable_exact_geography"] == [] + assert limitation["assignment_seed"] == 5 + assert "Do not synthesize" not in limitation["treatment"] + assert "block/tract/place/SLD/CBSA" in limitation["treatment"] + + attributes = captured["write"]["root_attributes"] + assert attributes["populace_location_rule"] == "block_v1" + assert attributes["populace_location_seed"] == "5" + assert attributes["populace_location_clones"] == "1" + assert attributes["populace_block_ladder_sha256"] == ladder.sha256 + assert json.loads(attributes["populace_block_ladder_vintages"]) == ( + ladder.layer_vintages + ) + assert attributes["populace_puma_ladder_artifact_sha256"] == ( + hashlib.sha256(b"puma-ladder").hexdigest() + ) + assert json.loads(attributes["populace_puma_ladder_vintages"]) == ( + _puma_ladder_stub(builder, ladder).layer_vintages + ) + + +def test__legacy_tool__block_v1_gate_failure_is_hard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + with pytest.raises(SystemExit, match="Block location gate failed.*county_fips"): + _run_block_v1_main(monkeypatch, tmp_path, tamper=True) + assert not (tmp_path / "combined.h5").exists() + + +def test__legacy_tool__refuses_a_puma_ladder_of_another_geography( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + with pytest.raises(SystemExit, match="not the same 2020 geography"): + _run_block_v1_main(monkeypatch, tmp_path, puma_ladder=_legacy_puma_ladder()) + assert not (tmp_path / "combined.h5").exists() + + +def test__legacy_tool__refuses_a_puma_ladder_of_another_district_vintage( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ladder: UsLocationLadder +) -> None: + builder = _load_legacy_builder() + stub = _puma_ladder_stub(builder, ladder) + layers = dict(stub.metadata["layers"]) + layers["congressional_district"] = {"vintage": "118th_congress"} + other = builder.UsPumaLadder( + **{ + **{name: getattr(stub, name) for name in stub.__dataclass_fields__}, + "metadata": {**stub.metadata, "layers": layers}, + } + ) + with pytest.raises(SystemExit, match="'118th_congress'"): + _run_block_v1_main(monkeypatch, tmp_path, puma_ladder=other) + + +@requires_pytables +def test__legacy_tool__staging_root_attributes_round_trip( + tmp_path: Path, ladder: UsLocationLadder +) -> None: + builder = _load_legacy_builder() + pooled = _pool(ladder, _donor_base(ladder, blocks="some"), _acs(ladder)) + attributes = builder.location_root_attributes( + location_rule="block_v1", + location_seed=4, + location_clones=1, + block_ladder_sha256=str(ladder.sha256), + block_ladder_vintages=ladder.layer_vintages, + puma_ladder_sha256="0" * 64, + puma_ladder_vintages={"congressional_district": "119th_congress"}, + ) + path = tmp_path / "staging.h5" + builder._write_dataset(pooled, path, period=2024, root_attributes=attributes) + with pd.HDFStore(path, mode="r") as store: + stored = store.get_node("/")._v_attrs + assert { + key: builder._attribute_text(stored[key]) for key in attributes + } == attributes + assert builder._base_location_rule(path) == { + "location_rule": "block_v1", + "recorded": True, + "attributes": { + "populace_location_rule": "block_v1", + "populace_location_seed": "4", + "populace_location_clones": "1", + }, + } + legacy = tmp_path / "legacy.h5" + builder._write_dataset(pooled, legacy, period=2024) + assert builder._base_location_rule(legacy) == { + "location_rule": "legacy", + "recorded": False, + "attributes": {}, + } + + +# --------------------------------------------------------------------------- +# tools/build_us_acs_local_release.py +# --------------------------------------------------------------------------- + + +def _block_v1_staging_summary(ladder_path: Path, sha256: str) -> dict: + return { + "orchestration": { + "max_households": None, + "seed": 0, + "geography_seed": 0, + "location_rule": "block_v1", + "location_seed": 7, + "location_clones": 1, + "block_ladder_sha256": sha256, + }, + "geography_ladder": {"sha256": "p" * 64}, + "household_location": { + "rule": US_BLOCK_LOCATION_RULE_ID, + "seed": 7, + "clones": 1, + "block_ladder": { + "sha256": sha256, + "layer_vintages": {"congressional_district": "119th_congress"}, + }, + "block_ladder_path": str(ladder_path), + "donor_blocks": {DONOR_BLOCK_PRESERVED: 3, DONOR_BLOCK_STATE_DRAWN: 1}, + }, + } + + +def test__release__legacy_staging_orchestration_projection_is_unchanged() -> None: + release = _load_release_tool() + projected = release._require_uncapped_staging( + {"orchestration": {"max_households": None, "seed": 1}} + ) + assert list(projected) == list(release._STAGING_ORCHESTRATION_KEYS) + assert "household_location" not in projected + assert release.staging_household_location({"orchestration": {}}) is None + assert release.staging_refresh_recipe({}) == release.LEGACY_STAGING_REFRESH_RECIPE + assert release.calibrated_location_root_attributes({}) is None + + +def test__release__block_v1_staging_orchestration_adds_location_keys( + tmp_path: Path, +) -> None: + release = _load_release_tool() + summary = _block_v1_staging_summary(tmp_path / "blocks.npz", "a" * 64) + projected = release._require_uncapped_staging(summary) + assert projected["location_rule"] == "block_v1" + assert projected["location_seed"] == 7 + assert projected["location_clones"] == 1 + assert projected["block_ladder_sha256"] == "a" * 64 + assert projected["household_location"] == summary["household_location"] + assert list(projected)[: len(release._STAGING_ORCHESTRATION_KEYS)] == list( + release._STAGING_ORCHESTRATION_KEYS + ) + + +def test__release__block_v1_summary_without_record_is_refused() -> None: + release = _load_release_tool() + with pytest.raises(SystemExit, match="no household_location"): + release.staging_household_location( + {"orchestration": {"location_rule": "block_v1"}} + ) + with pytest.raises(SystemExit, match="unknown location_rule"): + release.staging_household_location( + {"orchestration": {"location_rule": "block_v9"}} + ) + + +def test__release__block_v1_limitations_state_the_rule(tmp_path: Path) -> None: + release = _load_release_tool() + legacy = { + entry["id"]: entry + for entry in release.finalize_reviewed_limitations( + {"reviewed_limitations": []}, {}, {} + ) + } + block = { + entry["id"]: entry + for entry in release.finalize_reviewed_limitations( + _block_v1_staging_summary(tmp_path / "b.npz", "a" * 64), {}, {} + ) + } + assert list(legacy) == list(block) + assert "location_rule" not in legacy["mixed_sub_puma_column_coverage"] + assert "donor-spine-only" in legacy["mixed_sub_puma_column_coverage"]["reason"] + mixed = block["mixed_sub_puma_column_coverage"] + assert mixed["location_rule"] == "block_v1" + assert "Every household on both spines" in mixed["reason"] + assert "donor-spine-only" not in mixed["reason"] + assert mixed["donor_blocks"][DONOR_BLOCK_STATE_DRAWN] == 1 + cd = block["cd_population_marginal_vintage_2020"] + assert cd["household_district_plan"] == "119th_congress" + assert "one 2020 census block" in cd["reason"] + assert "checked that the two ladders" not in cd["reason"] + summary = _block_v1_staging_summary(tmp_path / "b.npz", "a" * 64) + summary["household_location"]["ladder_agreement"] = { + "congressional_districts": {"max_abs_population_difference": 0.0} + } + checked = { + entry["id"]: entry + for entry in release.finalize_reviewed_limitations(summary, {}, {}) + }["cd_population_marginal_vintage_2020"] + assert "same PUMAs, states and districts" in checked["reason"] + assert "difference between them was 0.0." in checked["reason"] + for key in legacy: + if key not in { + "mixed_sub_puma_column_coverage", + "cd_population_marginal_vintage_2020", + }: + assert legacy[key] == block[key] + + +def test__release__block_v1_refresh_recipe_parses_as_block_v1(tmp_path: Path) -> None: + release = _load_release_tool() + builder = _load_legacy_builder() + recipe = shlex.split( + release.staging_refresh_recipe( + _block_v1_staging_summary(tmp_path / "b.npz", "a" * 64) + ) + ) + assert recipe[:3] == ["uv", "run", "tools/build_us_acs_multispine_base.py"] + args = builder._parse_args(recipe[3:]) + assert args.location_rule == "block_v1" + assert args.location_seed == 7 + assert args.block_ladder is not None + builder._validate_location_arguments(args) + + +def test__release__block_ladder_is_sha_verified( + tmp_path: Path, ladder: UsLocationLadder +) -> None: + release = _load_release_tool() + path = write_location_ladder( + tmp_path / "blocks.npz", synthetic_blocks(3, states=STATES) + ) + good = _block_v1_staging_summary(path, release._sha256(path)) + loaded = release.load_staging_block_ladder(good) + assert loaded.sha256 == ladder.sha256 + bad = _block_v1_staging_summary(path, "b" * 64) + with pytest.raises(SystemExit, match="not the"): + release.load_staging_block_ladder(bad) + with pytest.raises(SystemExit, match="missing"): + release.load_staging_block_ladder( + _block_v1_staging_summary(tmp_path / "gone.npz", "a" * 64) + ) + with pytest.raises(SystemExit, match="applies only to a block_v1"): + release.load_staging_block_ladder({}, path) + assert release.load_staging_block_ladder({}) is None + + +def test__release__calibrated_root_attributes_match_the_staging_writer( + tmp_path: Path, +) -> None: + release = _load_release_tool() + attributes = release.calibrated_location_root_attributes( + _block_v1_staging_summary(tmp_path / "b.npz", "a" * 64) + ) + assert attributes == { + "populace_location_rule": "block_v1", + "populace_location_seed": "7", + "populace_location_clones": "1", + "populace_block_ladder_sha256": "a" * 64, + "populace_block_ladder_vintages": json.dumps( + {"congressional_district": "119th_congress"}, sort_keys=True + ), + "populace_puma_ladder_artifact_sha256": "p" * 64, + "populace_puma_ladder_vintages": json.dumps({}, sort_keys=True), + } + + +# --------------------------------------------------------------------------- +# Invariants, for every input (property-based), and the legacy differential +# --------------------------------------------------------------------------- + +try: + import hypothesis + from hypothesis import strategies as st +except ModuleNotFoundError: # pragma: no cover - the workspace env installs it + hypothesis = None + + +def _pool_scenario(draw): + """Donors in any ladder state, each with or without a block; ACS in any PUMA.""" + + n_donors = draw(st.integers(1, 10)) + donors = draw( + st.lists( + st.tuples( + st.sampled_from(STATES), + st.sampled_from(("block", "", None)), + st.integers(0, 10_000), + ), + min_size=n_donors, + max_size=n_donors, + ) + ) + acs_picks = draw(st.lists(st.integers(0, 10_000), min_size=1, max_size=12)) + seed = draw(st.integers(0, 2**32)) + return donors, acs_picks, seed + + +def _scenario_frames( + ladder: UsLocationLadder, donors, acs_picks, *, strip_blocks: bool = False +) -> tuple[Frame, Frame]: + states = [state for state, _, _ in donors] + blocks = [ + _ladder_block(ladder, state, which) + if kind == "block" and not strip_blocks + else (None if kind is None else "") + for state, kind, which in donors + ] + base = _frame( + pd.DataFrame( + { + "household_id": np.arange(1, len(donors) + 1, dtype=np.int64), + "state_fips": states, + "block_geoid": pd.Series(blocks, dtype=object), + "puma": pd.Series([None] * len(donors), dtype=object), + } + ), + weights=np.linspace(10.0, 60.0, len(donors)), + spine="asec_puf", + ) + assert ladder.puma is not None + pumas = np.unique(ladder.puma) + chosen = [int(pumas[pick % len(pumas)]) for pick in acs_picks] + acs = _frame( + pd.DataFrame( + { + "household_id": np.arange(1, len(chosen) + 1, dtype=np.int64), + "state_fips": [puma // 10**5 for puma in chosen], + "puma": [f"{puma:07d}" for puma in chosen], + } + ), + weights=np.full(len(chosen), 7.0), + spine=ACS_2024_1YR_SPINE, + ) + return base, acs + + +def _pool_invariants(ladder: UsLocationLadder, scenario) -> None: + donors, acs_picks, seed = scenario + base, acs = _scenario_frames(ladder, donors, acs_picks) + pooled = _pool(ladder, base, acs, location_seed=seed) + household = pooled.table("household") + is_acs = household[spine_column("household")].eq(ACS_2024_1YR_SPINE).to_numpy() + + # Consistency: every geography is its block's ladder lookup (the gate). + gate = us_block_location_gate(household, ladder) + assert gate.passed, gate.failures + # Conservation: rows and the base's household mass. + assert len(household) == len(donors) + len(acs_picks) + assert pooled.weights_for("household").total == pytest.approx( + base.weights_for("household").total, rel=1e-12 + ) + # state_fips is never rewritten. + assert household.loc[~is_acs, "state_fips"].tolist() == [s for s, _, _ in donors] + assert ( + household.loc[is_acs, "state_fips"].tolist() + == acs.table("household")["state_fips"].tolist() + ) + # ACS rows: the block lies in the observed PUMA. + assert ladder.puma is not None + observed = acs.table("household")["puma"].astype(np.int64).to_numpy() + index = _block_index(ladder, household.loc[is_acs, "block_geoid"]) + np.testing.assert_array_equal(ladder.puma[index], observed) + # Donor rows: a carried block is kept verbatim, never redrawn. + kept = base.table("household")["block_geoid"].tolist() + donor_blocks = household.loc[~is_acs, "block_geoid"].tolist() + for (_, kind, _), before, after in zip(donors, kept, donor_blocks, strict=True): + if kind == "block": + assert after == before + # Accounting: preserved + state-drawn = donors; drawn rows are sourced. + record = _record(pooled) + n_kept = sum(kind == "block" for _, kind, _ in donors) + assert record["donor_blocks"][DONOR_BLOCK_PRESERVED] == n_kept + assert record["donor_blocks"][DONOR_BLOCK_STATE_DRAWN] == len(donors) - n_kept + assert record["source_geography_households"][SOURCE_GEOGRAPHY_PUMA] == len( + acs_picks + ) + assert record["source_geography_households"][SOURCE_GEOGRAPHY_STATE] == ( + len(donors) - n_kept + ) + assert record["seed"] == seed + # Determinism. + again = _pool(ladder, base, acs, location_seed=seed).table("household") + pd.testing.assert_frame_equal(household, again) + # Keyed draws: an ACS household's block does not depend on which donors + # carry a block (the legacy rng.choice path couples them; this one must + # not). + stripped_base, _ = _scenario_frames(ladder, donors, acs_picks, strip_blocks=True) + stripped = _pool(ladder, stripped_base, acs, location_seed=seed) + assert ( + stripped.table("household").loc[is_acs, "block_geoid"].tolist() + == household.loc[is_acs, "block_geoid"].tolist() + ) + + +if hypothesis is not None: + + @hypothesis.settings( + max_examples=60, + deadline=None, + suppress_health_check=[hypothesis.HealthCheck.too_slow], + ) + @hypothesis.given(scenario=st.composite(_pool_scenario)()) + def test__block_v1__pool_invariants_hold_for_every_donor_and_acs_mix( + ladder: UsLocationLadder, scenario + ) -> None: + _pool_invariants(ladder, scenario) + +else: # pragma: no cover + + @pytest.mark.skip(reason="requires hypothesis") + def test__block_v1__pool_invariants_hold_for_every_donor_and_acs_mix() -> None: + pass + + +def _legacy_puma_ladder(): + from microcosm.build.us_runtime.puma_ladder import UsPumaLadder + + puma = np.asarray([100_101, 100_202], dtype=np.int64) + population = np.asarray([40.0, 60.0]) + return UsPumaLadder( + puma=puma, + puma_population=population, + cd_overlap_puma=puma.copy(), + cd_overlap_cd=np.asarray([101, 102], dtype=np.int64), + cd_overlap_population=population.copy(), + county_overlap_puma=puma.copy(), + county_overlap_county=np.asarray([1_001, 1_003], dtype=np.int32), + county_overlap_population=population.copy(), + tract_overlap_puma=puma.copy(), + tract_overlap_tract=np.asarray([1_001_000_100, 1_003_000_100], dtype=np.int64), + tract_overlap_population=population.copy(), + metadata={ + "schema_version": 1, + "kind": "us_puma_ladder", + "puma_vintage": "2020_puma", + "sampling_basis": "population", + "layers": { + "congressional_district": {"vintage": "119th_congress"}, + "county": {"vintage": "2020_census"}, + "tract": {"vintage": "2020_census"}, + }, + }, + ) + + +def test__legacy__explicit_default_location_options_reproduce_the_puma_ladder_pool() -> ( + None +): + """Differential: the new kwargs at their defaults are the old call, exactly.""" + + base = _frame( + pd.DataFrame( + {"household_id": np.arange(1, 6, dtype=np.int64), "state_fips": [1] * 5} + ), + weights=np.linspace(10.0, 50.0, 5), + spine="asec_puf", + ) + acs = _frame( + pd.DataFrame( + { + "household_id": np.arange(1, 5, dtype=np.int64), + "state_fips": [1] * 4, + "puma": ["0100101", "0100202", "0100101", "0100202"], + } + ), + weights=np.full(4, 3.0), + spine=ACS_2024_1YR_SPINE, + ) + options = { + "puma_ladder": _legacy_puma_ladder(), + "geography_seed": 11, + "expected_congressional_district_vintage": "119th_congress", + "max_peak_bytes": None, + } + implicit = with_optional_acs_spine(base, acs, **options) + explicit = with_optional_acs_spine( + base, + acs, + location_rule="legacy", + block_ladder=None, + location_seed=0, + location_clones=1, + **options, + ) + for entity in implicit.entities: + pd.testing.assert_frame_equal(implicit.table(entity), explicit.table(entity)) + np.testing.assert_array_equal( + implicit.weights_for("household").values, + explicit.weights_for("household").values, + ) + assert ACS_POOL_BLOCK_LOCATION_METADATA_KEY not in explicit.metadata + assert "block_geoid" not in explicit.table("household") + + +@requires_pytables +def test__legacy_tool__reads_a_base_rule_written_by_the_base_h5_line( + tmp_path: Path, ladder: UsLocationLadder +) -> None: + """The base-H5 line writes its rule with h5py; this line reads it with PyTables.""" + + h5py = pytest.importorskip("h5py") + builder = _load_legacy_builder() + path = tmp_path / "base.h5" + builder._write_dataset(_donor_base(ladder), path, period=2024) + with h5py.File(path, "a") as h5: + # As tools/build_us_puf_support_base.py:_write_block_v1_location_attrs. + h5.attrs["populace_location_rule"] = "block_v1" + h5.attrs["populace_location_seed"] = str(11) + h5.attrs["populace_location_clones"] = str(1) + assert builder._base_location_rule(path) == { + "location_rule": "block_v1", + "recorded": True, + "attributes": { + "populace_location_rule": "block_v1", + "populace_location_seed": "11", + "populace_location_clones": "1", + }, + } + + +@requires_pytables +def test__legacy_tool__block_v1_staging_bytes_reload_through_the_release_loader( + tmp_path: Path, ladder: UsLocationLadder +) -> None: + """Staging writes with the legacy writer; the release reads it back and gates it.""" + + builder = _load_legacy_builder() + release = _load_release_tool() + pooled = _pool(ladder, _donor_base(ladder, blocks="some"), _acs(ladder, n=30)) + written = pooled.table("household") + assert (written["place_fips"] == "").any() or (written["sldl"] == "").any() + path = tmp_path / "staging.h5" + builder._write_dataset(pooled, path, period=2024) + reloaded = release._load_staging_frame(path).table("household") + gate = us_block_location_gate(reloaded, ladder) + assert gate.passed, gate.failures + assert reloaded["block_geoid"].tolist() == written["block_geoid"].tolist() + + +@pytest.mark.parametrize( + ("donor_geography", "kept", "drawn"), + [ + ("preserved_donor_block", True, False), + ("state_drawn_missing_donor_block", False, True), + ("preserved_donor_block_with_state_draws", True, True), + ], +) +def test__block_v1__limitation_states_what_happened_to_donors( + donor_geography: str, kept: bool, drawn: bool +) -> None: + """The recorded reason matches the donor mode: never a false claim that + base blocks were kept when donors were drawn within their state.""" + + builder = _load_legacy_builder() + record = builder._block_v1_sub_puma_limitation( + {"donor_geography": donor_geography, "resolved_model_inputs": []} + ) + reason = record["reason"] + assert ("keep" in reason) is kept + assert ("within their state" in reason or "within that state" in reason) is drawn + observed = record["observed_geography"]["asec_puf"] + if donor_geography == "state_drawn_missing_donor_block": + assert observed == ["state_fips"] + elif donor_geography == "preserved_donor_block": + assert observed == ["state_fips", "block_geoid"] + else: + assert observed == { + "with_base_block": ["state_fips", "block_geoid"], + "without_base_block": ["state_fips"], + } diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_acs_local_release_tool.py b/packages/microcosm-build/tests/engine_free/us/test_us_acs_local_release_tool.py index 260c18f7a7..063460cf01 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_acs_local_release_tool.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_acs_local_release_tool.py @@ -1496,3 +1496,296 @@ def changed_while_writing_sums(path): assert root_copy.exists(), "the calibrated H5 itself is never removed" else: assert not root_copy.exists(), "a refused copy is not left at the root" + + +# --------------------------------------------------------------------------- +# --location-rule block_v1 staging (microcosm#696): finalize re-runs the +# block-location gate against the sha-verified block ladder the staging run +# recorded; a legacy staging run's finalize report is unchanged. +# --------------------------------------------------------------------------- + + +def _block_located_hours_frame(tmp_path: Path, *, tamper: bool = False): + from microcosm.build.us_runtime.block_location import ( + load_us_location_ladder, + with_household_us_block_location, + ) + from microcosm.frame import US_SCHEMA, Frame + from test_support.microcosm_build.us_block_location import ( + synthetic_blocks, + write_location_ladder, + ) + + ladder_path = write_location_ladder( + tmp_path / "blocks.npz", synthetic_blocks(3, states=(1, 2, 36)) + ) + ladder = load_us_location_ladder(ladder_path) + frame = _plausible_hours_frame() + tables = {entity: frame.table(entity).copy() for entity in frame.entities} + tables["household"]["state_fips"] = [1, 1, 2, 2, 36, 36, 1, 2] + frame = Frame(tables, US_SCHEMA, {"household": frame.weights_for("household")}) + located, _ = with_household_us_block_location(frame, ladder, seed=0) + if tamper: + tables = {entity: located.table(entity).copy() for entity in located.entities} + tables["household"].loc[0, "county_fips"] = "99999" + located = Frame( + tables, US_SCHEMA, {"household": located.weights_for("household")} + ) + return ladder_path, located + + +def _mark_staging_block_v1( + module, args, ladder_path: Path, *, targets_ladder: bool = False +) -> None: + path = module._staging_summary_path(args) + summary = json.loads(path.read_text()) + sha = module._sha256(ladder_path) + summary["orchestration"].update( + {"location_rule": "block_v1", "location_seed": 0, "location_clones": 1} + ) + summary["household_location"] = { + "rule": "us_block_location.population_draw.v1", + "block_ladder": {"sha256": sha, "layer_vintages": {}}, + "block_ladder_path": str(ladder_path), + } + if targets_ladder: + # Under block_v1 the targets' --ladder must be the PUMA ladder staging + # recorded (require_block_v1_targets_ladder). + summary.setdefault("geography_ladder", {})["sha256"] = module._sha256( + args.ladder + ) + path.write_text(json.dumps(summary)) + + +def test_finalize_runs_the_block_location_gate_for_a_block_v1_staging_run( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + args = _finalize_args(module, tmp_path) + ladder_path, frame = _block_located_hours_frame(tmp_path) + _mark_staging_block_v1(module, args, ladder_path, targets_ladder=True) + message, report = _run_finalize(module, monkeypatch, args, frame) + gate = report["gates"]["us_block_location_gate"] + assert gate["passed"] is True, gate["failures"] + assert "us_block_location_gate" not in message + assert report["gates"]["us_puma_ladder_gate"]["passed"] is True + + +def test_finalize_hard_fails_a_block_v1_artifact_whose_geography_left_its_block( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + args = _finalize_args(module, tmp_path) + ladder_path, frame = _block_located_hours_frame(tmp_path, tamper=True) + _mark_staging_block_v1(module, args, ladder_path, targets_ladder=True) + message, report = _run_finalize(module, monkeypatch, args, frame) + gate = report["gates"]["us_block_location_gate"] + assert gate["passed"] is False + assert any("county_fips" in failure for failure in gate["failures"]) + assert "us_block_location_gate" in message + + +def test_finalize_refuses_a_block_v1_run_whose_block_ladder_bytes_moved( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + args = _finalize_args(module, tmp_path) + ladder_path, frame = _block_located_hours_frame(tmp_path) + _mark_staging_block_v1(module, args, ladder_path, targets_ladder=True) + ladder_path.write_bytes(b"different ladder bytes") + message, report = _run_finalize(module, monkeypatch, args, frame) + assert "the block_v1 staging run located households with" in message + assert report is None + + +def test_finalize_report_of_a_legacy_staging_run_has_no_block_gate( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + args = _finalize_args(module, tmp_path) + _message, report = _run_finalize( + module, monkeypatch, args, _plausible_hours_frame() + ) + assert "us_block_location_gate" not in report["gates"] + + +def _write_calibrated( + module, monkeypatch, tmp_path: Path, frame, *, block_ladder: Path | None +): + """Run ``_write_calibrated_artifact`` on ``frame`` with its heavy steps stubbed. + + ``block_ladder`` marks the staging summary as a ``block_v1`` run located + with that ladder; ``None`` leaves it a legacy run. + """ + + args = _finalize_args(module, tmp_path) + args.out_h5.unlink() + if block_ladder is not None: + _mark_staging_block_v1(module, args, block_ladder) + path = module._staging_summary_path(args) + summary = json.loads(path.read_text()) + summary["geography_ladder"] = { + "sha256": "c" * 64, + "layer_vintages": {"puma": "2020_puma"}, + } + path.write_text(json.dumps(summary)) + with pd.HDFStore(args.checkpoint_dir / "target_frame_lean.h5", mode="w") as store: + store.put("household", frame.table("household")[["household_id"]]) + monkeypatch.setattr(module, "_load_staging_frame", lambda path: frame) + monkeypatch.setattr(module, "_require_local_hours", lambda *a, **k: None) + monkeypatch.setattr( + module, "project_input_only", lambda base, period=None: (base, {}) + ) + monkeypatch.setattr(module, "fill_reviewed_nulls", lambda *a, **k: ([], None)) + module._write_calibrated_artifact( + args, frame.weights_for("household").values.copy(), {} + ) + with pd.HDFStore(args.out_h5, mode="r") as store: + attributes = store.get_node("/")._v_attrs + return { + name: str(attributes[name]) + for name in attributes._v_attrnames + if name.startswith("populace_") + } + + +@requires_pytables +def test_calibrated_artifact_of_a_block_v1_staging_run_records_its_location_rule( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + ladder_path, frame = _block_located_hours_frame(tmp_path) + attributes = _write_calibrated( + module, monkeypatch, tmp_path, frame, block_ladder=ladder_path + ) + assert attributes["populace_location_rule"] == "block_v1" + assert attributes["populace_location_seed"] == "0" + assert attributes["populace_location_clones"] == "1" + assert attributes["populace_block_ladder_sha256"] == module._sha256(ladder_path) + assert attributes["populace_puma_ladder_artifact_sha256"] == "c" * 64 + assert json.loads(attributes["populace_puma_ladder_vintages"]) == { + "puma": "2020_puma" + } + + +@requires_pytables +def test_calibrated_artifact_of_a_legacy_staging_run_has_no_location_attributes( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + _ladder_path, frame = _block_located_hours_frame(tmp_path) + attributes = _write_calibrated( + module, monkeypatch, tmp_path, frame, block_ladder=None + ) + assert not any(name.startswith("populace_location") for name in attributes) + assert "populace_block_ladder_sha256" not in attributes + + +def test_block_v1_location_attributes_refuse_a_summary_without_the_puma_ladder_sha( + tmp_path, +) -> None: + module = _load_tool_module() + args = _finalize_args(module, tmp_path) + ladder_path, _ = _block_located_hours_frame(tmp_path) + _mark_staging_block_v1(module, args, ladder_path) + summary = json.loads(module._staging_summary_path(args).read_text()) + with pytest.raises(SystemExit, match="no PUMA-ladder sha256"): + module.calibrated_location_root_attributes(summary) + + +def test_package_of_a_block_v1_staging_run_records_its_location_rule( + tmp_path: Path, monkeypatch +) -> None: + module = _load_tool_module() + args = _package_evidence_args( + module, + tmp_path, + monkeypatch, + hours_report={"passed": True, "failures": [], "detail": {}}, + ) + ladder_path, _ = _block_located_hours_frame(tmp_path) + _mark_staging_block_v1(module, args, ladder_path) + result = module.do_package(args) + build_manifest = json.loads( + (Path(result["release_dir"]) / "build_manifest.json").read_text() + ) + orchestration = build_manifest["staging_orchestration"] + assert orchestration["location_rule"] == "block_v1" + assert orchestration["location_seed"] == 0 + assert orchestration["location_clones"] == 1 + location = orchestration["household_location"] + assert location["rule"] == "us_block_location.population_draw.v1" + assert location["block_ladder"]["sha256"] == module._sha256(ladder_path) + staging_recipe = build_manifest["refresh_recipe"]["staging"] + assert staging_recipe.startswith(module.LEGACY_STAGING_REFRESH_RECIPE) + assert "--location-rule block_v1 --location-seed 0" in staging_recipe + + +def test_package_of_a_legacy_staging_run_records_no_location_rule( + tmp_path: Path, monkeypatch +) -> None: + module = _load_tool_module() + args = _package_evidence_args( + module, + tmp_path, + monkeypatch, + hours_report={"passed": True, "failures": [], "detail": {}}, + ) + result = module.do_package(args) + build_manifest = json.loads( + (Path(result["release_dir"]) / "build_manifest.json").read_text() + ) + assert list(build_manifest["staging_orchestration"]) == list( + module._STAGING_ORCHESTRATION_KEYS + ) + assert ( + build_manifest["refresh_recipe"]["staging"] + == module.LEGACY_STAGING_REFRESH_RECIPE + ) + + +@requires_pytables +def test_block_location_gate_passes_on_the_written_calibrated_bytes( + tmp_path, monkeypatch +) -> None: + """Finalize gates the H5 it reloads; empty place/SLD/CBSA codes must survive.""" + + from microcosm.build.us_runtime.block_location import ( + load_us_location_ladder, + us_block_location_gate, + ) + + module = _load_tool_module() + load_written = module._load_staging_frame + ladder_path, frame = _block_located_hours_frame(tmp_path) + household = frame.table("household") + assert (household["place_fips"] == "").any() or (household["sldl"] == "").any() + _write_calibrated(module, monkeypatch, tmp_path, frame, block_ladder=ladder_path) + written = load_written(tmp_path / "out.h5").table("household") + gate = us_block_location_gate(written, load_us_location_ladder(ladder_path)) + assert gate.passed, gate.failures + + +def test_finalize_refuses_a_block_v1_run_whose_targets_ladder_is_not_the_staged_one( + tmp_path, monkeypatch +) -> None: + module = _load_tool_module() + args = _finalize_args(module, tmp_path) + ladder_path, frame = _block_located_hours_frame(tmp_path) + _mark_staging_block_v1(module, args, ladder_path, targets_ladder=True) + path = module._staging_summary_path(args) + summary = json.loads(path.read_text()) + summary["geography_ladder"]["sha256"] = "d" * 64 + path.write_text(json.dumps(summary)) + with pytest.raises(SystemExit, match="is not the PUMA ladder the block_v1"): + module.require_block_v1_targets_ladder(summary, module._sha256(args.ladder)) + with pytest.raises(SystemExit, match="is not the PUMA ladder the block_v1"): + module.calibrated_location_root_attributes( + summary, targets_ladder_sha256=module._sha256(args.ladder) + ) + # A legacy staging run is never refused on this account. + legacy = json.loads(json.dumps(summary)) + legacy.pop("household_location") + for key in ("location_rule", "location_seed", "location_clones"): + legacy["orchestration"].pop(key, None) + module.require_block_v1_targets_ladder(legacy, "e" * 64) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_block_ladder_sources.py b/packages/microcosm-build/tests/engine_free/us/test_us_block_ladder_sources.py index ea638558b7..a116fb0c4e 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_block_ladder_sources.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_block_ladder_sources.py @@ -279,3 +279,126 @@ def test_assemble_refuses_populated_block_without_a_district() -> None: cbsa_by_county={}, metadata={}, ) + + +def _metadata_with_puma() -> dict: + return { + "schema_version": 1, + "kind": "us_block_ladder", + "block_vintage": "2020_tabulation_blocks", + "sampling_basis": "population", + "layers": { + layer: {"vintage": "test", "source": "test source"} + for layer in ( + "congressional_district", + "sldu", + "sldl", + "place", + "cbsa", + "puma", + ) + }, + } + + +def test_assemble_adds_each_blocks_tract_puma(tmp_path) -> None: + from microcosm.build.us_runtime.block_location import load_us_location_ladder + from microcosm.build.us_runtime.geography_ladder import load_us_block_ladder + + payload = assemble_us_block_ladder( + block_population={100010401001000: 20, 100010401001001: 5, 360010001001000: 30}, + cd_by_block={ + 100010401001000: 1000, + 100010401001001: 1000, + 360010001001000: 3620, + }, + sldu_by_block={}, + sldl_by_block={}, + place_by_block={}, + cbsa_by_county={}, + metadata=_metadata_with_puma(), + tract_to_puma={10001040100: 1000100, 36001000100: 3600200}, + ) + assert payload["puma"].tolist() == [1000100, 1000100, 3600200] + path = tmp_path / "ladder.npz" + np.savez_compressed(path, **payload) + # The schema-1 loader ignores the additive array; the location loader reads it. + assert len(load_us_block_ladder(path)) == 3 + assert load_us_location_ladder(path).puma.tolist() == [1000100, 1000100, 3600200] + + +def test_assemble_refuses_a_block_whose_tract_has_no_puma() -> None: + with pytest.raises(ValueError, match="tract-to-PUMA"): + assemble_us_block_ladder( + block_population={100010401001000: 20}, + cd_by_block={100010401001000: 1000}, + sldu_by_block={}, + sldl_by_block={}, + place_by_block={}, + cbsa_by_county={}, + metadata={}, + tract_to_puma={}, + ) + + +def test_block_ladder_puma_matches_the_puma_ladder_built_from_the_same_sources() -> ( + None +): + """Differential: the block ladder's per-block PUMA and the PUMA ladder are + two implementations of the same tract -> PUMA geography; per-PUMA + population and the (PUMA, CD) and (PUMA, county) overlaps must agree.""" + + import pandas as pd + + from microcosm.build.us_runtime.puma_ladder_sources import ( + assemble_us_puma_ladder, + ) + + rng = np.random.default_rng(3) + blocks: dict[int, int] = {} + districts: dict[int, int] = {} + tract_to_puma: dict[int, int] = {} + for state in (1, 36): + for county in (1, 3, 5): + for tract in (100, 200, 300): + tract_geoid = int(f"{state:02d}{county:03d}{tract:06d}") + tract_to_puma[tract_geoid] = ( + state * 10**5 + 100 + int(rng.integers(0, 2)) + ) + for block in range(1000, 1000 + int(rng.integers(1, 5))): + geoid = tract_geoid * 10**4 + block + blocks[geoid] = int(rng.integers(1, 500)) + districts[geoid] = state * 100 + int(rng.integers(1, 3)) + block_payload = assemble_us_block_ladder( + block_population=blocks, + cd_by_block=districts, + sldu_by_block={}, + sldl_by_block={}, + place_by_block={}, + cbsa_by_county={}, + metadata=_metadata_with_puma(), + tract_to_puma=tract_to_puma, + ) + puma_payload = assemble_us_puma_ladder( + block_population=blocks, + cd_by_block=districts, + tract_to_puma=tract_to_puma, + metadata={}, + ) + population = pd.Series(block_payload["population"]) + puma = block_payload["puma"] + by_puma = population.groupby(puma).sum() + assert by_puma.index.tolist() == puma_payload["puma"].tolist() + assert by_puma.tolist() == puma_payload["puma_population"].tolist() + for layer, value in ( + ("cd", block_payload["congressional_district_geoid"]), + ("county", block_payload["block_geoid"] // 10**10), + ): + overlap = population.groupby([puma, value]).sum().sort_index() + assert overlap.index.get_level_values(0).tolist() == ( + puma_payload[f"{layer}_overlap_puma"].tolist() + ) + assert overlap.index.get_level_values(1).tolist() == ( + puma_payload[f"{layer}_overlap_{layer}"].tolist() + ) + assert overlap.tolist() == puma_payload[f"{layer}_overlap_population"].tolist() diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_block_location.py b/packages/microcosm-build/tests/engine_free/us/test_us_block_location.py new file mode 100644 index 0000000000..60ea11222a --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/us/test_us_block_location.py @@ -0,0 +1,1071 @@ +"""Invariants of the US block-location draw (location v1, microcosm#696). + +Each property holds for every input Hypothesis generates over small nested +synthetic ladders (states > counties > tracts > blocks; PUMAs are unions of +whole tracts; districts cut across tracts): + +1. Consistency: every derived geography (tract, county, state, place, SLDU, + SLDL, CBSA, PUMA, each attached CD plan) equals the ladder's lookup of the + drawn block, and the release gate passes. +2. Containment: a household's block lies inside its source geography (its + PUMA, its identified county, its state outside the excluded counties, or + its state). +3. Exactness: the inverse CDF gives each block exactly its population share + on a uniform grid; with a fixed seed the empirical block, county-within- + PUMA and CD-within-PUMA frequencies pass goodness-of-fit tests against the + population shares, and the keyed uniforms pass a uniformity test. +4. Determinism: same inputs and seed give identical output; permuting the + input rows changes nothing per household; clone k's block does not depend + on how many clones were drawn. +5. Conservation: with K clones every household appears K times with clone + indices 0..K-1 and clone ids key*K+k, and its clone weights sum to its + weight; frame cloning conserves every weighted entity's mass and keeps + the frame's linkage valid. + +Differential tests check the block ladder's PUMA against the PUMA ladder +built from the same sources (per-PUMA population and the (PUMA, CD) and +(PUMA, county) overlaps), and the attach path against the ladder's own +primary plan. + +Hypothesis is a workspace dev dependency; the clean-wheel lane installs only +pytest, where the property tests skip. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +import numpy as np +import pandas as pd +import pytest +from scipy import stats + +from microcosm.build.us_runtime.block_location import ( + LOCATION_CLONE_ID_COLUMN, + LOCATION_CLONE_INDEX_COLUMN, + LOCATION_SOURCE_GEOGRAPHY_COLUMN, + LOCATION_WEIGHT_COLUMN, + SOURCE_GEOGRAPHY_COUNTY, + SOURCE_GEOGRAPHY_PUMA, + SOURCE_GEOGRAPHY_STATE, + SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES, + SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA, + US_BLOCK_LOCATION_METADATA_KEY, + US_BLOCK_LOCATION_RULE_ID, + CpsIdentifiedCountyList, + _BlockStrata, + assign_us_block_location, + attach_congressional_district_plans, + cbsa_vintage_agreement, + clone_frame_for_location, + cps_county_coverage_ratios, + cps_excluded_county_sets, + cps_unidentified_county_shares, + default_cps_asec_identified_counties_path, + derive_us_block_geography, + draw_us_block_locations, + identified_counties_by_group, + load_cps_asec_identified_counties, + load_us_location_ladder, + location_geography_columns, + location_uniforms, + us_block_location_gate, + us_block_location_manifest, + with_household_us_block_location, +) +from microcosm.frame import US_SCHEMA, Frame, WeightKind, Weights +from test_support.microcosm_build.us_block_location import ( + ladder_metadata, + synthetic_blocks, + write_location_ladder, +) + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") +given = hypothesis.given +settings = hypothesis.settings +HealthCheck = hypothesis.HealthCheck + +_PROPERTY = settings( + max_examples=60, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def _load(arrays: dict[str, np.ndarray], *, with_puma: bool = True): + with TemporaryDirectory() as directory: + path = write_location_ladder( + Path(directory) / "ladder.npz", arrays, with_puma=with_puma + ) + return load_us_location_ladder(path) + + +def _with_117(ladder, seed: int): + """Attach a second plan: one district per block, drawn within the state.""" + + rng = np.random.default_rng(seed) + state = ladder.block_geoid // 10**13 + districts = state * 100 + rng.integers(1, 4, size=len(state)) + order = rng.permutation(len(state)) # the registry may come in any order + return attach_congressional_district_plans( + ladder, + block_geoid=ladder.block_geoid[order], + plans={"117th_congress": districts[order]}, + plan_sources={ + "117th_congress": { + "vintage": "117th_congress", + "source": "test BAF CD layer", + "known_deviations": {"37": "test note"}, + } + }, + ) + + +@st.composite +def _scenario(draw): + """A ladder plus households of every source-geography kind.""" + + ladder = _with_117( + _load(synthetic_blocks(draw(st.integers(0, 10_000)))), + draw(st.integers(0, 10_000)), + ) + block_state = ladder.block_geoid // 10**13 + block_county = ladder.block_geoid // 10**10 + states = np.unique(block_state) + counties = np.unique(block_county) + pumas = np.unique(ladder.puma) + # Group 0 identifies one county per state (always leaving the rest); + # group 1 identifies none. + identified = { + 0: frozenset( + int(np.unique(block_county[block_state == state])[0]) for state in states + ), + 1: frozenset(), + } + n = draw(st.integers(1, 25)) + kinds = draw( + st.lists( + st.sampled_from(["puma", "county", "residual0", "residual1", "state"]), + min_size=n, + max_size=n, + ) + ) + rows = [] + for kind in kinds: + if kind == "puma": + puma = int(draw(st.sampled_from(pumas.tolist()))) + rows.append((puma // 10**5, puma, 0, -1)) + elif kind == "county": + county = int(draw(st.sampled_from(counties.tolist()))) + rows.append((county // 1000, 0, county, -1)) + elif kind.startswith("residual"): + state = int(draw(st.sampled_from(states.tolist()))) + rows.append((state, 0, 0, int(kind[-1]))) + else: + rows.append((int(draw(st.sampled_from(states.tolist()))), 0, 0, -1)) + keys = draw(st.lists(st.integers(0, 10**9), min_size=n, max_size=n, unique=True)) + household = pd.DataFrame( + { + "household_id": keys, + "state_fips": [row[0] for row in rows], + "puma": [row[1] for row in rows], + "county": [row[2] for row in rows], + "group": [row[3] for row in rows], + "weight": draw( + st.lists(st.floats(0.01, 1e4, allow_nan=False), min_size=n, max_size=n) + ), + } + ) + return ladder, household, identified + + +def _assign(ladder, household, identified, *, seed=0, clones=1): + return assign_us_block_location( + household, + ladder, + seed=seed, + clones=clones, + weights=household["weight"].to_numpy(), + puma_column="puma", + county_column="county", + identification_group_column="group", + identified_counties=identified, + ) + + +def _block_index(ladder, table: pd.DataFrame) -> np.ndarray: + blocks = table["block_geoid"].astype(np.int64).to_numpy() + index = np.searchsorted(ladder.block_geoid, blocks) + assert (ladder.block_geoid[index] == blocks).all() + return index + + +# -------------------------------------------------------------------------- +# 1. Consistency +# -------------------------------------------------------------------------- + + +@_PROPERTY +@given(scenario=_scenario(), seed=st.integers(0, 2**32), clones=st.integers(1, 4)) +def test_every_derived_geography_is_the_blocks_lookup(scenario, seed, clones): + ladder, household, identified = scenario + table = _assign(ladder, household, identified, seed=seed, clones=clones) + index = _block_index(ladder, table) + block = table["block_geoid"].to_numpy().astype(str) + + assert us_block_location_gate(table, ladder).passed + assert (table["tract_geoid"].to_numpy() == np.array([b[:11] for b in block])).all() + assert (table["county_fips"].to_numpy() == np.array([b[:5] for b in block])).all() + assert ( + table["state_fips"].to_numpy() + == index * 0 + ladder.block_geoid[index] // 10**13 + ).all() + assert ( + table["puma"].to_numpy() == np.array([f"{p:07d}" for p in ladder.puma[index]]) + ).all() + assert ( + table["congressional_district_geoid"].to_numpy() + == ladder.blocks.congressional_district_geoid[index] + ).all() + assert ( + table["congressional_district_geoid__117th_congress"].to_numpy() + == ladder.congressional_district_plans["117th_congress"][index] + ).all() + assert (table["sldu"].to_numpy() == ladder.blocks.sldu[index]).all() + assert (table["sldl"].to_numpy() == ladder.blocks.sldl[index]).all() + for column, values in ( + ("place_fips", ladder.blocks.place_fips[index]), + ("cbsa_code", ladder.blocks.cbsa_code[index]), + ): + expected = np.array(["" if v == 0 else f"{v:05d}" for v in values]) + assert (table[column].to_numpy().astype(str) == expected).all() + # Household state never changes: every drawn block is in it. + assert ( + table["state_fips"].to_numpy() + == np.repeat(household["state_fips"].to_numpy(), clones) + ).all() + + +def test_gate_names_every_tampered_column(tmp_path) -> None: + ladder = _with_117( + load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")), 1 + ) + household = pd.DataFrame( + {"household_id": [1, 2, 3], "state_fips": [1, 2, 36], "weight": [1.0, 2.0, 3.0]} + ) + table = assign_us_block_location(household, ladder, seed=3) + assert us_block_location_gate(table, ladder).passed + + for column in location_geography_columns(ladder): + if column == "block_geoid": + continue + broken = table.copy() + value = broken.at[0, column] + broken.at[0, column] = ( + (value + 1) if isinstance(value, (int, np.integer)) else "99999999999" + ) + result = us_block_location_gate(broken, ladder) + assert not result.passed + assert any(failure.startswith(column) for failure in result.failures), column + + unknown = table.copy() + unknown.at[0, "block_geoid"] = "019999999999999" + assert "does not carry" in us_block_location_gate(unknown, ladder).failures[0] + moved = table.copy() + moved["state_fips"] = [2, 2, 36] + assert any( + "state_fips" in f for f in us_block_location_gate(moved, ladder).failures + ) + + +# -------------------------------------------------------------------------- +# 2. Containment +# -------------------------------------------------------------------------- + + +@_PROPERTY +@given(scenario=_scenario(), seed=st.integers(0, 2**32), clones=st.integers(1, 3)) +def test_block_lies_inside_the_source_geography(scenario, seed, clones): + ladder, household, identified = scenario + table = _assign(ladder, household, identified, seed=seed, clones=clones) + index = _block_index(ladder, table) + source = household.loc[household.index.repeat(clones)].reset_index(drop=True) + kind = table[LOCATION_SOURCE_GEOGRAPHY_COLUMN].to_numpy() + block_county = ladder.block_geoid[index] // 10**10 + + puma = kind == SOURCE_GEOGRAPHY_PUMA + assert (ladder.puma[index][puma] == source["puma"].to_numpy()[puma]).all() + county = kind == SOURCE_GEOGRAPHY_COUNTY + assert (block_county[county] == source["county"].to_numpy()[county]).all() + residual = kind == SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES + for row in np.flatnonzero(residual): + assert block_county[row] not in identified[int(source.at[row, "group"])] + # Kinds are exactly what each household's source provides. + expected = np.where( + source["puma"] > 0, + SOURCE_GEOGRAPHY_PUMA, + np.where( + source["county"] > 0, + SOURCE_GEOGRAPHY_COUNTY, + np.where( + source["group"] >= 0, + SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES, + SOURCE_GEOGRAPHY_STATE, + ), + ), + ) + assert (kind == expected).all() + + +def test_cbsa_narrowing_keeps_blocks_in_the_cbsa(tmp_path) -> None: + arrays = synthetic_blocks(4) + arrays["cbsa_code"] = np.where( + arrays["block_geoid"] // 10**10 % 2 == 1, 11111, 0 + ).astype(np.int32) + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz", arrays)) + state = int(ladder.block_geoid[0] // 10**13) + draw = draw_us_block_locations( + ladder, + household_key=np.arange(200), + state_fips=np.full(200, state), + seed=9, + identification_group=np.zeros(200), + identified_counties={0: frozenset()}, + cbsa_code=np.full(200, 11111), + ) + assert set(draw.source_geography) == { + SOURCE_GEOGRAPHY_STATE_UNIDENTIFIED_COUNTIES_CBSA + } + assert (ladder.cbsa_code[draw.block_index] == 11111).all() + + +def test_empty_candidate_sets_fail_closed(tmp_path) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + counties = np.unique( + ladder.block_geoid[ladder.block_geoid // 10**13 == 1] // 10**10 + ) + with pytest.raises(ValueError, match="no candidate block"): + draw_us_block_locations( + ladder, + household_key=[1], + state_fips=[1], + seed=0, + identification_group=[0], + identified_counties={0: frozenset(int(c) for c in counties)}, + ) + with pytest.raises(ValueError, match="no candidate block"): + draw_us_block_locations( + ladder, household_key=[1], state_fips=[1], seed=0, county_fips=[1999] + ) + with pytest.raises(ValueError, match="no candidate block"): + draw_us_block_locations( + ladder, household_key=[1], state_fips=[1], seed=0, puma=[100999] + ) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"household_key": [1, 1], "state_fips": [1, 1]}, "unique"), + ({"household_key": [-1], "state_fips": [1]}, "non-negative"), + ( + { + "household_key": [1], + "state_fips": [1], + "puma": [100100], + "county_fips": [1001], + }, + "at most one", + ), + ({"household_key": [1], "state_fips": [2], "county_fips": [1001]}, "disagrees"), + ( + {"household_key": [1], "state_fips": [1], "identification_group": [0]}, + "identified_counties", + ), + ({"household_key": [1], "state_fips": [1], "clones": 0}, "clones"), + ], +) +def test_inconsistent_inputs_are_refused(tmp_path, kwargs, message) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + with pytest.raises(ValueError, match=message): + draw_us_block_locations(ladder, seed=0, **kwargs) + + +def test_puma_draws_need_a_ladder_that_carries_puma(tmp_path) -> None: + ladder = load_us_location_ladder( + write_location_ladder(tmp_path / "l.npz", with_puma=False) + ) + assert ladder.puma is None + assert "puma" not in location_geography_columns(ladder) + with pytest.raises(ValueError, match="per-block 'puma'"): + draw_us_block_locations( + ladder, household_key=[1], state_fips=[1], seed=0, puma=[100100] + ) + # State draws still work on a ladder without PUMA (the Route A artifact). + table = assign_us_block_location( + pd.DataFrame({"household_id": [1], "state_fips": [1]}), ladder, seed=0 + ) + assert us_block_location_gate(table, ladder).passed + + +# -------------------------------------------------------------------------- +# 3. Exactness and convergence +# -------------------------------------------------------------------------- + + +@settings(max_examples=150, deadline=None) +@given( + populations=st.lists(st.integers(1, 5000), min_size=1, max_size=30), + grid=st.integers(1000, 20000), +) +def test_inverse_cdf_gives_each_block_its_population_share(populations, grid): + population = np.asarray(populations, dtype=np.float64) + geoid = np.arange(len(population), dtype=np.int64) + strata = _BlockStrata(np.zeros(len(population), dtype=np.int64), geoid, population) + uniforms = (np.arange(grid) + 0.5) / grid + drawn = strata.draw(np.zeros(grid, dtype=np.int64), uniforms) + counts = np.bincount(drawn, minlength=len(population)) + expected = population / population.sum() * grid + # A block's interval of width share*grid holds that many grid points +- 1. + assert np.abs(counts - expected).max() <= 1.0 + 1e-9 + # Uniform endpoints stay inside the stratum. + edge = strata.draw(np.zeros(2, dtype=np.int64), np.asarray([0.0, 1.0])) + assert edge.tolist() == [0, len(population) - 1] + + +def test_empirical_block_shares_converge_to_population_shares() -> None: + population = np.asarray([1, 3, 10, 30, 100, 300, 1000, 5, 50, 500], dtype=np.int64) + blocks = { + "block_geoid": 10010001001000 + np.arange(len(population), dtype=np.int64), + "population": population, + "congressional_district_geoid": np.full(len(population), 101, dtype=np.int64), + "puma": np.full(len(population), 100100, dtype=np.int64), + "sldu": np.full(len(population), "001", dtype="U3"), + "sldl": np.full(len(population), "", dtype="U3"), + "place_fips": np.zeros(len(population), dtype=np.int32), + "cbsa_code": np.zeros(len(population), dtype=np.int32), + } + ladder = _load(blocks) + n = 200_000 + draw = draw_us_block_locations( + ladder, household_key=np.arange(n), state_fips=np.ones(n), seed=20260927 + ) + counts = np.bincount(draw.block_index, minlength=len(population)) + expected = population / population.sum() * n + statistic, p_value = stats.chisquare(counts, expected) + assert p_value > 1e-3, (statistic, p_value) + assert np.abs(counts / n - population / population.sum()).max() < 0.005 + + +def test_county_and_district_within_puma_match_the_population_overlap() -> None: + """Differential vs. the PUMA ladder's semantics: the block draw's implied + county and CD distribution within a PUMA is the (PUMA, layer) population + overlap, which ``assign_us_puma_ladder`` draws from directly.""" + + ladder = _load(synthetic_blocks(11, states=(6,))) + puma = int(np.unique(ladder.puma)[0]) + n = 120_000 + draw = draw_us_block_locations( + ladder, + household_key=np.arange(n), + state_fips=np.full(n, puma // 10**5), + puma=np.full(n, puma), + seed=7, + ) + in_puma = ladder.puma == puma + for layer in ( + ladder.block_geoid // 10**10, + ladder.blocks.congressional_district_geoid, + ): + overlap = pd.Series(ladder.population[in_puma]).groupby(layer[in_puma]).sum() + drawn = ( + pd.Series(layer[draw.block_index]) + .value_counts() + .reindex(overlap.index, fill_value=0) + ) + if len(overlap) > 1: + _, p_value = stats.chisquare( + drawn.to_numpy(), overlap.to_numpy() / overlap.sum() * n + ) + assert p_value > 1e-3 + assert set(drawn[drawn > 0].index) <= set(overlap.index) + + +def test_keyed_uniforms_are_uniform_and_independent_across_clones() -> None: + keys = np.arange(100_000) + first = location_uniforms(0, keys, np.zeros_like(keys)) + second = location_uniforms(0, keys, np.ones_like(keys)) + assert stats.kstest(first, "uniform").pvalue > 1e-3 + assert abs(np.corrcoef(first, second)[0, 1]) < 0.01 + assert (first >= 0).all() and (first <= 1).all() + assert not np.array_equal(first, location_uniforms(1, keys, np.zeros_like(keys))) + + +# -------------------------------------------------------------------------- +# 4. Determinism +# -------------------------------------------------------------------------- + + +@_PROPERTY +@given(scenario=_scenario(), seed=st.integers(0, 2**32), data=st.data()) +def test_draw_is_deterministic_and_invariant_to_row_order(scenario, seed, data): + ladder, household, identified = scenario + clones = data.draw(st.integers(1, 3)) + first = _assign(ladder, household, identified, seed=seed, clones=clones) + again = _assign(ladder, household, identified, seed=seed, clones=clones) + pd.testing.assert_frame_equal(first, again) + + order = data.draw(st.permutations(range(len(household)))) + shuffled = _assign( + ladder, + household.iloc[list(order)].reset_index(drop=True), + identified, + seed=seed, + clones=clones, + ) + key = ["household_id", LOCATION_CLONE_INDEX_COLUMN] + pd.testing.assert_frame_equal( + first.sort_values(key).reset_index(drop=True), + shuffled.sort_values(key).reset_index(drop=True), + ) + + +@_PROPERTY +@given(scenario=_scenario(), seed=st.integers(0, 2**32), clones=st.integers(2, 5)) +def test_clone_k_block_does_not_depend_on_the_clone_count(scenario, seed, clones): + ladder, household, identified = scenario + single = _assign(ladder, household, identified, seed=seed, clones=1) + many = _assign(ladder, household, identified, seed=seed, clones=clones) + fewer = _assign(ladder, household, identified, seed=seed, clones=clones - 1) + first_clone = many[many[LOCATION_CLONE_INDEX_COLUMN] == 0].reset_index(drop=True) + assert (first_clone["block_geoid"] == single["block_geoid"]).all() + shared = many[many[LOCATION_CLONE_INDEX_COLUMN] < clones - 1].reset_index(drop=True) + assert (shared["block_geoid"] == fewer["block_geoid"]).all() + + +def test_a_different_seed_moves_draws(tmp_path) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + household = pd.DataFrame({"household_id": np.arange(500), "state_fips": 36}) + a = assign_us_block_location(household, ladder, seed=0) + b = assign_us_block_location(household, ladder, seed=1) + assert (a["block_geoid"] != b["block_geoid"]).any() + + +# -------------------------------------------------------------------------- +# 5. Conservation under cloning +# -------------------------------------------------------------------------- + + +@_PROPERTY +@given(scenario=_scenario(), seed=st.integers(0, 2**32), clones=st.integers(1, 6)) +def test_cloning_conserves_weight_and_household_mass(scenario, seed, clones): + ladder, household, identified = scenario + table = _assign(ladder, household, identified, seed=seed, clones=clones) + n = len(household) + assert len(table) == n * clones + grouped = table.groupby("household_id") + assert (grouped.size() == clones).all() + assert ( + grouped[LOCATION_CLONE_INDEX_COLUMN].apply(sorted).apply(list) + == [list(range(clones))] * n + ).all() + assert ( + table[LOCATION_CLONE_ID_COLUMN] + == table["household_id"] * clones + table[LOCATION_CLONE_INDEX_COLUMN] + ).all() + assert table[LOCATION_CLONE_ID_COLUMN].is_unique + per_household = ( + grouped[LOCATION_WEIGHT_COLUMN].sum().reindex(household["household_id"]) + ) + np.testing.assert_allclose( + per_household.to_numpy(), household["weight"].to_numpy(), rtol=1e-12 + ) + np.testing.assert_allclose( + table[LOCATION_WEIGHT_COLUMN].sum(), household["weight"].sum(), rtol=1e-12 + ) + + +def _us_frame(n_households: int, rng: np.random.Generator) -> Frame: + sizes = rng.integers(1, 4, size=n_households) + household_ids = np.arange(1, n_households + 1, dtype=np.int64) * 10 + person_household = np.repeat(household_ids, sizes) + n_people = len(person_household) + person_ids = np.arange(1, n_people + 1, dtype=np.int64) + parent = np.where( + rng.random(n_people) < 0.3, person_ids[rng.integers(0, n_people, n_people)], 0 + ) + tables = { + "person": pd.DataFrame( + { + "person_id": person_ids, + "person_household_id": person_household, + "person_tax_unit_id": person_household + 1, + "person_spm_unit_id": person_household + 2, + "person_family_id": person_household + 3, + "person_marital_unit_id": person_ids + 10**6, + "parent_1_id": parent, + } + ), + "household": pd.DataFrame( + { + "household_id": household_ids, + "state_fips": rng.choice([1, 2, 36], size=n_households), + } + ), + "tax_unit": pd.DataFrame({"tax_unit_id": household_ids + 1}), + "spm_unit": pd.DataFrame({"spm_unit_id": household_ids + 2}), + "family": pd.DataFrame({"family_id": household_ids + 3}), + "marital_unit": pd.DataFrame({"marital_unit_id": person_ids + 10**6}), + } + weights = { + "household": Weights( + values=rng.uniform(0.5, 500.0, size=n_households), kind=WeightKind.DESIGN + ) + } + strata = pd.Series(rng.choice(["asec_2023", "asec_2024"], size=n_people)) + return Frame(tables, US_SCHEMA, weights, strata, metadata={"stage": "test"}) + + +@_PROPERTY +@given( + n_households=st.integers(1, 12), + clones=st.integers(1, 5), + seed=st.integers(0, 2**32), +) +def test_frame_location_conserves_mass_and_linkage(n_households, clones, seed): + rng = np.random.default_rng(seed) + frame = _us_frame(n_households, rng) + ladder = _load(synthetic_blocks(seed % 97)) + located, table = with_household_us_block_location( + frame, + ladder, + seed=seed, + clones=clones, + person_reference_columns=("parent_1_id",), + ) + located.revalidate() + assert located.n("household") == frame.n("household") * clones + assert located.n("person") == frame.n("person") * clones + np.testing.assert_allclose( + located.weights_for("household").total, + frame.weights_for("household").total, + rtol=1e-12, + ) + assert located.weights_for("household").kind == frame.weights_for("household").kind + assert located.mass_log == frame.mass_log + household = located.table("household") + assert us_block_location_gate(household, ladder).passed + receipt = located.metadata[US_BLOCK_LOCATION_METADATA_KEY] + assert receipt["rule"] == US_BLOCK_LOCATION_RULE_ID + assert receipt["seed"] == seed and receipt["clones"] == clones + assert located.metadata["stage"] == "test" + if clones == 1: + assert ( + household["household_id"] == frame.table("household")["household_id"] + ).all() + assert "household_location_clone_index" not in household.columns + return + # Clone k of household h is household h*K+k, drawn for (h, k). + assert ( + household["household_id"].to_numpy() + == table[LOCATION_CLONE_ID_COLUMN].to_numpy() + ).all() + person = located.table("person") + original = frame.table("person") + clone = person["person_location_clone_index"].to_numpy() + assert ( + person["person_household_id"].to_numpy() + == np.repeat(original["person_household_id"].to_numpy(), clones) * clones + + clone + ).all() + raw_parent = np.repeat(original["parent_1_id"].to_numpy(), clones) + assert ( + person["parent_1_id"].to_numpy() + == np.where(raw_parent == 0, 0, raw_parent * clones + clone) + ).all() + # Each person's parent is in the same clone (and so the same household copy). + has_parent = person["parent_1_id"].to_numpy() > 0 + assert ( + person["parent_1_id"].to_numpy()[has_parent] % clones == clone[has_parent] + ).all() + assert ( + located.strata.to_numpy() == np.repeat(frame.strata.to_numpy(), clones) + ).all() + + +def test_clone_frame_refuses_a_second_clone_pass() -> None: + frame = clone_frame_for_location(_us_frame(3, np.random.default_rng(0)), 2) + with pytest.raises(ValueError, match="already location-cloned"): + clone_frame_for_location(frame, 2) + assert clone_frame_for_location(frame, 1) is frame + + +# -------------------------------------------------------------------------- +# CPS ASEC county identification +# -------------------------------------------------------------------------- + + +def _official(year: int, counties) -> dict[int, CpsIdentifiedCountyList]: + return { + year: CpsIdentifiedCountyList( + asec_year=year, + counties=frozenset(counties), + source="https://example.test/cpsmar.pdf", + source_sha256="0" * 64, + ) + } + + +@settings(max_examples=200, deadline=None) +@given( + coded=st.sets(st.sampled_from([1001, 1003, 1005, 2013, 2020, 36061]), max_size=6), + listed=st.sets(st.sampled_from([1001, 1003, 1005, 2013, 2020, 36061]), max_size=6), +) +def test_a_county_is_excluded_only_with_both_confirmations(coded, listed): + excluded, record = cps_excluded_county_sets( + {2024: coded}, _official(2025, listed), asec_year_of_group={2024: 2025} + ) + assert excluded[2024] == frozenset(coded) & frozenset(listed) + group = record["groups"]["2024"] + assert group["basis"] == "official_list_and_coded" + assert group["coded_not_listed"] == [ + f"{c:05d}" for c in sorted(set(coded) - set(listed)) + ] + assert group["listed_not_coded"] == [ + f"{c:05d}" for c in sorted(set(listed) - set(coded)) + ] + + +def test_without_an_official_list_the_coded_set_is_the_fallback() -> None: + excluded, record = cps_excluded_county_sets( + {2022: {1001}, 2023: {1001, 1003}}, None, asec_year_of_group={2022: 2023} + ) + assert excluded == {2022: frozenset({1001}), 2023: frozenset({1001, 1003})} + assert record["groups"]["2022"]["basis"] == "data_derived_fallback" + assert record["counties_whose_exclusion_varies_across_groups"] == ["01003"] + + +def test_identified_counties_are_kept_per_group() -> None: + sets = identified_counties_by_group( + state_fips=[1, 1, 1, 2], + county_code=[1, 0, 3, 13], + group=[2022, 2022, 2023, 2023], + ) + assert sets == {2022: frozenset({1001}), 2023: frozenset({1003, 2013})} + with pytest.raises(ValueError, match="3-digit"): + identified_counties_by_group([1], [1000], [2022]) + + +@_PROPERTY +@given(scenario=_scenario(), seed=st.integers(0, 2**32)) +def test_a_cps_record_lands_in_its_county_or_an_unexcluded_county(scenario, seed): + """The CPS form of containment: an identified county's record lands in it; + a GTCO=0 record lands in its state outside the excluded counties.""" + + ladder, household, identified = scenario + cps = household[household["puma"] == 0].reset_index(drop=True) + if cps.empty: + return + table = _assign(ladder, cps, identified, seed=seed) + county = table["county_fips"].astype(int).to_numpy() + for row, source in cps.iterrows(): + if source["county"] > 0: + assert county[row] == source["county"] + elif source["group"] >= 0: + assert county[row] // 1000 == source["state_fips"] + assert county[row] not in identified[int(source["group"])] + + +def test_partial_county_remainder_restores_expected_county_population() -> None: + """With the opt-in remainder weighting, a partially coded county's + expected mass (its coded share plus its expected share of the GTCO=0 + draws) equals its census population.""" + + population = np.asarray([1000, 1000, 2000, 4000], dtype=np.int64) + blocks = { + "block_geoid": np.asarray( + [c * 10**10 + 1001 for c in (1001, 1003, 1005, 1007)], dtype=np.int64 + ), + "population": population, + "congressional_district_geoid": np.full(4, 101, dtype=np.int64), + "puma": np.full(4, 100100, dtype=np.int64), + "sldu": np.full(4, "001", dtype="U3"), + "sldl": np.full(4, "", dtype="U3"), + "place_fips": np.zeros(4, dtype=np.int32), + "cbsa_code": np.zeros(4, dtype=np.int32), + } + ladder = _load(blocks) + # 1001 fully coded; 1003 coded for 25% of its people; 1005 and 1007 never. + coverage_raw = {1001: 1000.0, 1003: 250.0} + county = np.asarray([1001, 1003, 1005]) + weight = np.asarray([coverage_raw[1001], coverage_raw[1003], 0.0]) + ratios = cps_county_coverage_ratios( + ladder, + county_fips=np.where(weight > 0, county, 0), + person_weight=weight, + group=np.zeros(3, dtype=np.int64), + ) + # Median-normalized: raw ratios 1.0 and 0.25 have median 0.625. + assert ratios[0] == pytest.approx({1001: 1.6, 1003: 0.4}) + raw = {1001: 1.0, 1003: 0.25} + shares = cps_unidentified_county_shares({0: raw}, {0: {1001}}) + assert shares == {0: {1003: 0.75}} + uncoded_mass = population.sum() - 1000 - 250 + n = 200_000 + draw = draw_us_block_locations( + ladder, + household_key=np.arange(n), + state_fips=np.ones(n), + identification_group=np.zeros(n), + identified_counties={0: frozenset({1001})}, + unidentified_county_share=shares, + seed=5, + ) + drawn = np.bincount(draw.block_index, minlength=4) / n * uncoded_mass + expected = population - np.asarray([1000, 250, 0, 0]) + np.testing.assert_allclose(drawn, expected, rtol=0.02) + np.testing.assert_allclose( + drawn + np.asarray([1000, 250, 0, 0]), population, rtol=0.02 + ) + # Default (the ruled rule): plain population over the unexcluded counties. + plain = draw_us_block_locations( + ladder, + household_key=np.arange(n), + state_fips=np.ones(n), + identification_group=np.zeros(n), + identified_counties={0: frozenset({1001})}, + seed=5, + ) + shares_plain = np.bincount(plain.block_index, minlength=4)[1:] / n + np.testing.assert_allclose( + shares_plain, population[1:] / population[1:].sum(), atol=0.01 + ) + with pytest.raises(ValueError, match=r"\[0, 1\]"): + draw_us_block_locations( + ladder, + household_key=[1], + state_fips=[1], + seed=0, + identification_group=[0], + identified_counties={0: frozenset()}, + unidentified_county_share={0: {1003: 1.5}}, + ) + + +def test_cbsa_vintage_agreement_detects_a_different_delineation(tmp_path) -> None: + arrays = synthetic_blocks(2) + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz", arrays)) + county = np.unique(ladder.block_geoid // 10**10) + first = np.searchsorted(ladder.block_geoid // 10**10, county) + cbsa = ladder.cbsa_code[first].astype(np.int64) + agreed = cbsa_vintage_agreement(ladder, county, cbsa) + assert agreed["agrees"] and agreed["disagreeing_pairs"] == 0 + shifted = cbsa_vintage_agreement(ladder, county, cbsa + 1) + assert not shifted["agrees"] and shifted["disagreeing_pairs"] == len(county) + assert not cbsa_vintage_agreement(ladder, [0], [0])["agrees"] + + +def test_packaged_official_lists_load_and_match_their_provenance() -> None: + lists = load_cps_asec_identified_counties() + assert sorted(lists) == [2023, 2024, 2025, 2026] + assert {year: len(entry.counties) for year, entry in lists.items()} == { + 2023: 277, + 2024: 277, + 2025: 277, + 2026: 361, + } + path = default_cps_asec_identified_counties_path() + provenance = json.loads(path.with_name(path.name + ".provenance.json").read_text()) + import hashlib + + assert hashlib.sha256(path.read_bytes()).hexdigest() == provenance["csv_sha256"] + for year, entry in lists.items(): + assert entry.source_sha256 == provenance["years"][str(year)]["sha256"] + assert entry.source.endswith(f"cpsmar{year % 100:02d}.pdf") + # Spot checks against the documentation text. + assert 6037 in lists[2025].counties # Los Angeles + assert 48303 in lists[2025].counties # Lubbock, listed though never coded + assert 48439 not in lists[2025].counties # Tarrant, coded only in part + assert 48439 in lists[2026].counties # listed from the 2026 documentation on + + +# -------------------------------------------------------------------------- +# Ladder: PUMA, plans, manifest +# -------------------------------------------------------------------------- + + +def test_location_ladder_validates_the_puma_array(tmp_path) -> None: + arrays = synthetic_blocks(3) + wrong_state = dict(arrays, puma=arrays["puma"] + 10**5) + with pytest.raises(ValueError, match="lie in its block's state"): + load_us_location_ladder(write_location_ladder(tmp_path / "a.npz", wrong_state)) + split = dict(arrays) + split["puma"] = arrays["puma"].copy() + same_tract = np.flatnonzero( + (arrays["block_geoid"][1:] // 10**4) == (arrays["block_geoid"][:-1] // 10**4) + ) + split["puma"][same_tract[0] + 1] = split["puma"][same_tract[0]] + 1 + with pytest.raises(ValueError, match="constant within a 2020 tract"): + load_us_location_ladder(write_location_ladder(tmp_path / "b.npz", split)) + unrecorded = ladder_metadata(with_puma=False) + with pytest.raises(ValueError, match="'puma' layer"): + load_us_location_ladder( + write_location_ladder(tmp_path / "c.npz", arrays, metadata=unrecorded) + ) + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "d.npz", arrays)) + assert ladder.layer_vintages["puma"] == "2020_puma" + assert len(ladder.sha256) == 64 + + +def test_attach_aligns_by_block_and_refuses_disagreement(tmp_path) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + attached = _with_117(ladder, 5) + assert set(attached.congressional_district_plans) == { + "119th_congress", + "117th_congress", + } + assert attached.congressional_district_plan_sources["117th_congress"][ + "known_deviations" + ] == {"37": "test note"} + primary = ladder.blocks.congressional_district_geoid + same = attach_congressional_district_plans( + ladder, + block_geoid=ladder.block_geoid[::-1], + plans={"119th_congress": primary[::-1]}, + plan_sources={ + "119th_congress": {"vintage": "119th_congress", "source": "registry"} + }, + ) + assert "registry" in same.congressional_district_plan_sources["119th_congress"] + with pytest.raises(ValueError, match="disagrees"): + attach_congressional_district_plans( + ladder, + block_geoid=ladder.block_geoid, + plans={ + "119th_congress": primary // 100 * 100 + + np.where(primary % 100 == 1, 2, 1) + }, + plan_sources={ + "119th_congress": {"vintage": "119th_congress", "source": "x"} + }, + ) + with pytest.raises(ValueError, match="omits"): + attach_congressional_district_plans( + ladder, + block_geoid=ladder.block_geoid[1:], + plans={"p": primary[1:]}, + plan_sources={"p": {"vintage": "p", "source": "x"}}, + ) + with pytest.raises(ValueError, match="their block's state"): + attach_congressional_district_plans( + ladder, + block_geoid=ladder.block_geoid, + plans={"p": np.full(len(ladder), 5601)}, + plan_sources={"p": {"vintage": "p", "source": "x"}}, + ) + with pytest.raises(ValueError, match="vintage and source"): + attach_congressional_district_plans( + ladder, + block_geoid=ladder.block_geoid, + plans={"p": primary}, + plan_sources={}, + ) + + +def test_manifest_records_the_rule_seed_and_sources(tmp_path) -> None: + ladder = _with_117( + load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")), 2 + ) + household = pd.DataFrame( + { + "household_id": [1, 2, 3, 4], + "state_fips": [1, 1, 36, 2], + "puma": [0, int(ladder.puma[0]), 0, 0], + "county": [0, 0, 36001, 0], + "group": [0, -1, -1, -1], + "weight": [1.0, 2.0, 3.0, 4.0], + } + ) + table = _assign(ladder, household, {0: frozenset()}, seed=42, clones=3) + record = us_block_location_manifest( + table, ladder, seed=42, clones=3, candidate_set_rule={"rule": "test"} + ) + assert record["rule"] == US_BLOCK_LOCATION_RULE_ID + assert record["location_rule"] == "block_v1" + assert (record["seed"], record["clones"]) == (42, 3) + assert record["households"] == 4 and record["rows"] == 12 + assert record["source_geography_households"] == { + "puma": 1, + "county": 1, + "state_unidentified_counties_cbsa": 0, + "state_unidentified_counties": 1, + "state": 1, + } + assert record["block_ladder"]["sha256"] == ladder.sha256 + assert set(record["block_ladder"]["congressional_district_plans"]) == { + "117th_congress", + "119th_congress", + } + assert record["candidate_set_rule"] == {"rule": "test"} + assert record["location_weight_total"] == pytest.approx(10.0) + json.dumps(record) # serializable + + +def test_derived_columns_match_the_legacy_ladder_formats(tmp_path) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + columns = derive_us_block_geography(ladder, np.arange(len(ladder))) + assert all(len(value) == 15 for value in columns["block_geoid"]) + assert all(len(value) == 7 for value in columns["puma"]) + assert columns["congressional_district_geoid"].dtype == np.int64 + assert all(isinstance(value, str) for value in columns["county_fips"]) + assert set(columns["place_fips"]) <= {"", "12345", "67890"} + + +# -------------------------------------------------------------------------- +# Real artifacts (local only: set the paths to run) +# -------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_national_ladder_differential_against_the_puma_ladder() -> None: + """On the national artifacts: the PUMA-carrying block ladder's seven core + arrays equal the Route A ladder byte for byte, and its per-block PUMA + reproduces the PUMA ladder's per-PUMA population and its (PUMA, CD) and + (PUMA, county) overlaps exactly. Set MICROCOSM_US_BLOCK_LADDER_PUMA, + MICROCOSM_US_BLOCK_LADDER_ROUTE_A and MICROCOSM_US_PUMA_LADDER to run.""" + + import os + + paths = [ + os.environ.get(name) + for name in ( + "MICROCOSM_US_BLOCK_LADDER_PUMA", + "MICROCOSM_US_BLOCK_LADDER_ROUTE_A", + "MICROCOSM_US_PUMA_LADDER", + ) + ] + if not all(path and Path(path).exists() for path in paths): + pytest.skip("national ladder artifacts not configured") + new, route_a, puma_ladder = (np.load(path) for path in paths) + for key in route_a.files: + if key != "metadata_json": + assert np.array_equal(new[key], route_a[key]), key + population = pd.Series(new["population"].astype(np.int64)) + puma = new["puma"] + by_puma = population.groupby(puma).sum() + assert by_puma.index.tolist() == puma_ladder["puma"].tolist() + assert by_puma.tolist() == puma_ladder["puma_population"].astype(np.int64).tolist() + for layer, values in ( + ("cd", new["congressional_district_geoid"]), + ("county", new["block_geoid"] // 10**10), + ): + overlap = population.groupby([puma, values]).sum().sort_index() + assert overlap.tolist() == ( + puma_ladder[f"{layer}_overlap_population"].astype(np.int64).tolist() + ) + assert overlap.index.get_level_values(1).tolist() == ( + puma_ladder[f"{layer}_overlap_{layer}"].astype(np.int64).tolist() + ) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py b/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py new file mode 100644 index 0000000000..268ffc8ae2 --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py @@ -0,0 +1,430 @@ +"""CPS ASEC source geography for the block-location draw. + +Covers the List 4 parser (Census's entirely-identified county list), the join +of ASEC household geography onto frame households, and the draw inputs it +builds: identified-county households draw within their county, ``GTCO = 0`` +households within their state minus the doubly confirmed counties, and the +CBSA narrowing turns on only when the source's delineation matches the +ladder's. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from microcosm.build.us_runtime.block_location import ( + CpsIdentifiedCountyList, + draw_us_block_locations, + load_us_location_ladder, +) +from microcosm.build.us_runtime.cps_identified_county_sources import ( + parse_cps_identified_county_list, +) +from microcosm.build.us_runtime.cps_source_geography import ( + CPS_ASEC_SURVEY_YEAR_OFFSET, + cps_source_geography, + household_source_keys, + load_cps_household_geography, +) +from microcosm.frame import US_SCHEMA, Frame, WeightKind, Weights +from test_support.microcosm_build.us_block_location import ( + synthetic_blocks, + write_location_ladder, +) + +_RENDERED = """\ + List 4: FIPS County Code ........................................ F-18 + +SPECIFIC METROPOLITAN AREAS F-17 + List 4: FIPS County Codes +Please note that these county codes must be used in conjunction with state codes to create +unique county identifiers as county codes start with 001 in each state. Counties are only +included on this list if the entire county is identified. + +FIPS +County County +Code Name State + + Alabama + +003 Baldwin +081 Lee + +SPECIFIC METROPOLITAN AREAS F-25 + Connecticut + +005 Litchfield* + New York + +061 New York +* Counties marked with an asterisk (*) are also single county Micropolitan +12300 Augusta-Waterville, ME Kennebec 005 +""" + + +def test_parser_reads_list_4_and_stops_at_its_footnote() -> None: + counties = parse_cps_identified_county_list(_RENDERED.splitlines()) + assert [(c.state_fips, c.county_code) for c in counties] == [ + ("01", "003"), + ("01", "081"), + ("09", "005"), + ("36", "061"), + ] + assert [c.single_county_micropolitan for c in counties] == [ + False, + False, + True, + False, + ] + assert counties[3].county_name == "New York" + + +@pytest.mark.parametrize( + ("text", "message"), + [ + ("nothing here", "no 'List 4"), + ("List 4: FIPS County Codes\n003 Baldwin\n", "precedes every state"), + ( + "List 4: FIPS County Codes\nAlabama\n003 Baldwin\n003 Baldwin\n", + "listed twice", + ), + ("List 4: FIPS County Codes\nAlabama\n* Counties marked\n", "no counties"), + ], +) +def test_parser_refuses_malformed_lists(text, message) -> None: + with pytest.raises(ValueError, match=message): + parse_cps_identified_county_list(text.splitlines()) + + +def _cps_table() -> pd.DataFrame: + """Two income years of synthetic ASEC households over the synthetic ladder: + county 01001 coded in both years, 36001 coded only in 2024, 01003 never.""" + + rows = [] + for year in (2023, 2024): + for seq, (state, gtco, cbsa) in enumerate( + [ + (1, 1, 0), + (1, 0, 0), + (1, 0, 0), + (36, 1 if year == 2024 else 0, 0), + (36, 0, 0), + (2, 0, 0), + ], + start=1, + ): + rows.append((year, 100 * year + seq, state, gtco, cbsa, 1500.0)) + return pd.DataFrame( + rows, + columns=[ + "source_year", + "source_household_id", + "state_fips", + "gtco", + "gtcbsa", + "person_weight", + ], + ) + + +def _official(counties: set[int]) -> dict[int, CpsIdentifiedCountyList]: + return { + year + CPS_ASEC_SURVEY_YEAR_OFFSET: CpsIdentifiedCountyList( + asec_year=year + CPS_ASEC_SURVEY_YEAR_OFFSET, + counties=frozenset(counties), + source=f"https://example.test/cpsmar{(year + 1) % 100}.pdf", + source_sha256="f" * 64, + ) + for year in (2023, 2024) + } + + +def test_source_geography_joins_each_household_and_builds_the_rule(tmp_path) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + cps = _cps_table() + frame_rows = cps.sample(frac=1, random_state=0).reset_index(drop=True) + geography = cps_source_geography( + ladder, + cps, + source_year=frame_rows["source_year"], + source_household_id=frame_rows["source_household_id"], + state_fips=frame_rows["state_fips"], + official=_official({1001, 36001, 1005}), + ) + coded = frame_rows["gtco"].to_numpy() > 0 + assert ( + geography.county_fips[coded] + == frame_rows["state_fips"].to_numpy()[coded] * 1000 + 1 + ).all() + assert (geography.county_fips[~coded] == 0).all() + assert (geography.identification_group[coded] == -1).all() + assert ( + geography.identification_group[~coded] + == frame_rows["source_year"].to_numpy()[~coded] + ).all() + # Excluded only with both confirmations, per year: 36001 is coded in 2024 + # only; 1005 is listed but never coded. + assert geography.identified_counties == { + 2023: frozenset({1001}), + 2024: frozenset({1001, 36001}), + } + groups = geography.record["groups"] + assert groups["2023"]["listed_not_coded"] == ["01005", "36001"] + assert groups["2024"]["asec_year"] == 2025 + assert geography.record["counties_whose_exclusion_varies_across_groups"] == [ + "36001" + ] + assert geography.cbsa_code is None + assert geography.unidentified_county_share is None + # Draws honour the rule. + draw = draw_us_block_locations( + ladder, + household_key=np.arange(len(frame_rows)), + state_fips=frame_rows["state_fips"], + seed=3, + county_fips=geography.county_fips, + identification_group=geography.identification_group, + identified_counties=geography.identified_counties, + ) + county = ladder.block_geoid[draw.block_index] // 10**10 + assert (county[coded] == geography.county_fips[coded]).all() + for row in np.flatnonzero(~coded): + year = int(frame_rows.at[row, "source_year"]) + assert county[row] not in geography.identified_counties[year] + + +def test_source_geography_refuses_missing_rows_and_state_disagreement( + tmp_path, +) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + cps = _cps_table() + with pytest.raises(ValueError, match="no ASEC source row"): + cps_source_geography( + ladder, + cps, + source_year=[2023], + source_household_id=[999], + state_fips=[1], + official=_official(set()), + ) + with pytest.raises(ValueError, match="GESTFIPS"): + cps_source_geography( + ladder, + cps, + source_year=[2023], + source_household_id=[202301], + state_fips=[2], + official=_official(set()), + ) + + +def test_cbsa_narrowing_applies_only_when_the_delineations_agree(tmp_path) -> None: + arrays = synthetic_blocks(8) + arrays["cbsa_code"] = np.where( + arrays["block_geoid"] // 10**10 == 1001, 11111, 0 + ).astype(np.int32) + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz", arrays)) + cps = _cps_table() + cps.loc[cps["gtco"] > 0, "gtcbsa"] = np.where( + cps.loc[cps["gtco"] > 0, "state_fips"] == 1, 11111, 0 + ) + agreed = cps_source_geography( + ladder, + cps, + source_year=cps["source_year"], + source_household_id=cps["source_household_id"], + state_fips=cps["state_fips"], + official=_official({1001}), + ) + assert agreed.record["cbsa_narrowing"]["applied"] + assert agreed.cbsa_code is not None + off = cps_source_geography( + ladder, + cps, + source_year=cps["source_year"], + source_household_id=cps["source_household_id"], + state_fips=cps["state_fips"], + official=_official({1001}), + cbsa_narrowing=False, + ) + assert not off.record["cbsa_narrowing"]["applied"] + cps.loc[(cps["state_fips"] == 1) & (cps["gtco"] > 0), "gtcbsa"] = 22222 + disagreed = cps_source_geography( + ladder, + cps, + source_year=cps["source_year"], + source_household_id=cps["source_household_id"], + state_fips=cps["state_fips"], + official=_official({1001}), + ) + assert not disagreed.record["cbsa_narrowing"]["applied"] + assert ( + disagreed.record["cbsa_narrowing"]["vintage_agreement"]["disagreeing_pairs"] + == 1 + ) + + +def test_load_household_geography_reads_each_years_file(tmp_path) -> None: + sources = {} + for year in (2023, 2024): + path = tmp_path / f"census_cps_{year}.h5" + pd.DataFrame( + { + "H_SEQ": [1, 2], + "GESTFIPS": [1, 36], + "GTCO": [1, 0], + "GTCBSA": [0, 35620], + "HSUP_WGT": [150000.0, 90000.0], + "H_NUMPER": [2, 3], + } + ).to_hdf(path, key="household") + sources[year] = path + table = load_cps_household_geography(sources) + assert table["source_year"].tolist() == [2023, 2023, 2024, 2024] + assert table["gtco"].tolist() == [1, 0, 1, 0] + assert table["person_weight"].tolist() == [300000.0, 270000.0] * 2 + pd.DataFrame( + { + "H_SEQ": [1, 1], + "GESTFIPS": [1, 1], + "GTCO": [0, 0], + "GTCBSA": [0, 0], + "HSUP_WGT": [1.0, 1.0], + "H_NUMPER": [1, 1], + } + ).to_hdf(tmp_path / "dup.h5", key="household") + with pytest.raises(ValueError, match="repeats H_SEQ"): + load_cps_household_geography({2022: tmp_path / "dup.h5"}) + + +def _frame_with_source_keys(keys: list[tuple[int, int]]) -> Frame: + household_ids = np.arange(1, len(keys) + 1, dtype=np.int64) + person_household = np.repeat(household_ids, 2) + tables = { + "person": pd.DataFrame( + { + "person_id": np.arange(1, len(person_household) + 1), + "person_household_id": person_household, + "person_tax_unit_id": person_household, + "person_spm_unit_id": person_household, + "person_family_id": person_household, + "person_marital_unit_id": person_household, + "source_year": np.repeat([k[0] for k in keys], 2), + "source_household_id": np.repeat([k[1] for k in keys], 2), + } + ), + "household": pd.DataFrame( + {"household_id": household_ids, "state_fips": np.ones(len(keys), int)} + ), + "tax_unit": pd.DataFrame({"tax_unit_id": household_ids}), + "spm_unit": pd.DataFrame({"spm_unit_id": household_ids}), + "family": pd.DataFrame({"family_id": household_ids}), + "marital_unit": pd.DataFrame({"marital_unit_id": household_ids}), + } + weights = {"household": Weights(np.ones(len(keys)), WeightKind.DESIGN)} + return Frame(tables, US_SCHEMA, weights) + + +def test_household_source_keys_come_from_members() -> None: + frame = _frame_with_source_keys([(2023, 7), (2024, 7), (2023, 9)]) + year, household = household_source_keys(frame) + assert year.tolist() == [2023, 2024, 2023] + assert household.tolist() == [7, 7, 9] + person = frame.table("person").copy() + person.loc[1, "source_household_id"] = 8 + broken = Frame( + {**{e: frame.table(e) for e in frame.entities}, "person": person}, + US_SCHEMA, + {"household": frame.weights_for("household")}, + ) + with pytest.raises(ValueError, match="disagree"): + household_source_keys(broken) + + +def test_guarantee_is_read_from_the_list_preamble() -> None: + from microcosm.build.us_runtime.cps_identified_county_sources import ( + list_carries_entire_county_guarantee, + ) + + assert list_carries_entire_county_guarantee(_RENDERED.splitlines()) + without = _RENDERED.replace( + " Counties are only\nincluded on this list if the entire county is identified.", + "", + ) + assert "entire county" not in without + assert not list_carries_entire_county_guarantee(without.splitlines()) + # The sentence may wrap anywhere across the preamble's lines. + wrapped = _RENDERED.replace( + "included on this list if the entire county is identified.", + "included on this list if the entire\n county is identified.", + ) + assert list_carries_entire_county_guarantee(wrapped.splitlines()) + + +def test_packaged_lists_record_which_years_carry_the_guarantee() -> None: + from microcosm.build.us_runtime.block_location import ( + load_cps_asec_identified_counties, + ) + + lists = load_cps_asec_identified_counties() + assert {year: entry.entire_county_guarantee for year, entry in lists.items()} == { + 2023: True, + 2024: True, + 2025: True, + 2026: False, + } + + +def test_a_year_without_the_guarantee_excludes_nothing() -> None: + """ASEC 2026 drops the whole-county sentence and its file has GTCO=0 + households inside listed, fully coded counties (all of Delaware), so for + such a year a GTCO=0 record draws from its whole state.""" + + from microcosm.build.us_runtime.block_location import cps_excluded_county_sets + + listed = { + 2025: CpsIdentifiedCountyList( + asec_year=2025, + counties=frozenset({1001, 1003, 1005}), + source="https://example.test/cpsmar25.pdf", + source_sha256="a" * 64, + entire_county_guarantee=True, + ), + 2026: CpsIdentifiedCountyList( + asec_year=2026, + counties=frozenset({1001, 1003, 1005}), + source="https://example.test/cpsmar26.pdf", + source_sha256="b" * 64, + entire_county_guarantee=False, + ), + } + coded = {2024: {1001, 1003}, 2025: {1001, 1003}} + excluded, record = cps_excluded_county_sets( + coded, listed, asec_year_of_group={2024: 2025, 2025: 2026} + ) + assert excluded == {2024: frozenset({1001, 1003}), 2025: frozenset()} + assert record["groups"]["2025"]["basis"] == "no_whole_county_guarantee_state_draw" + assert record["groups"]["2025"]["listed_and_coded_counties"] == 2 + assert record["groups"]["2024"]["basis"] == "official_list_and_coded" + + +def test_every_state_keeps_candidate_blocks_for_its_gtco_zero_records( + tmp_path, +) -> None: + """A state whose every county is excluded would leave its GTCO=0 records + nowhere to go; the draw fails closed on that, never falls back.""" + + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + counties = {int(c) for c in np.unique(ladder.block_geoid // 10**10)} + state_one = {c for c in counties if c // 1000 == 1} + with pytest.raises(ValueError, match="no candidate block"): + draw_us_block_locations( + ladder, + household_key=[1], + state_fips=[1], + seed=0, + identification_group=[2024], + identified_counties={2024: frozenset(state_one)}, + ) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py b/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py new file mode 100644 index 0000000000..331fb66a2f --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py @@ -0,0 +1,963 @@ +"""The stacked multispine pool's ``--location-rule block_v1`` slice (microcosm#696). + +The legacy geography contract, operator order and parser surface are pinned by +the existing pool-tool and h5_io tests; these tests cover the opt-in rule: its +contract and operator order, the CLI refusals, the draw on a small synthetic +stacked frame, and the round trip through h5_io's consumer validators. +""" + +from __future__ import annotations + +# Test functions take the imported ``pool_tool`` fixture as a parameter. +# ruff: noqa: F811 +import copy +import json +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from microcosm.build.us_runtime import h5_io +from microcosm.build.us_runtime.block_location import ( + US_BLOCK_LOCATION_RULE_ID, + load_us_location_ladder, +) +from microcosm.build.us_runtime.congressional_district_vintage import ( + CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR, + CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR, + CURRENT_CONGRESSIONAL_DISTRICT_PREFIX, +) +from microcosm.build.us_runtime.cps_source_geography import cps_source_geography +from microcosm.build.us_runtime.support_provenance import ( + support_channel_column, + support_clone_index_column, + support_source_id_column, +) +from microcosm.frame import US_SCHEMA, Frame, WeightKind, Weights +from test_support.microcosm_build.us_block_location import ( + synthetic_blocks, + write_location_ladder, +) +from test_support.microcosm_build.us_multispine_pool_tool import ( # noqa: F401 + _stacked_main_argv, + pool_tool, +) + +_CROSSWALK_SHA256 = "c7cb040b1f57ca2ea2adcbfe60cc2b250ca23acbc4b640cd421e766fa54c1aec" +_CPS_SHA256 = "a" * 64 +_INCOME_YEAR = 2023 +# Seed 2 of the synthetic ladder carries the Bronx (36005), which the NYC +# checks of the PUMA-ladder gate need, plus Baldwin, AL (01003). +_LADDER_SEED = 2 + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def ladder_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + path = tmp_path_factory.mktemp("block-ladder") / "ladder.npz" + return write_location_ladder(path, synthetic_blocks(_LADDER_SEED)) + + +@pytest.fixture(scope="module") +def ladder(ladder_path: Path): + return load_us_location_ladder(ladder_path) + + +def _crosswalk(ladder) -> pd.DataFrame: + districts = sorted(set(ladder.blocks.congressional_district_geoid.tolist())) + return pd.DataFrame( + { + "target_geography_id": [ + f"{CURRENT_CONGRESSIONAL_DISTRICT_PREFIX}{district:04d}" + for district in districts + ] + } + ) + + +# (household channel, state, observed ACS PUMA or None, CPS GTCO or None, weight) +_HOUSEHOLDS = ( + ("acs", 1, "0100100", None, 15.0), + ("acs", 1, "0100101", None, 15.0), + ("acs", 2, "0200100", None, 15.0), + ("acs", 1, "0100100", None, 15.0), + ("acs", 1, "0100101", None, 15.0), + ("acs", 2, "0200100", None, 15.0), + ("asec", 36, None, 5, 4.0), # identified Bronx: the NYC anchor + ("asec", 36, None, 0, 6.0), + ("asec", 1, None, 3, 5.0), # identified Baldwin + ("asec", 1, None, 0, 5.0), + ("asec", 2, None, 0, 5.0), +) + + +def _stacked_frame(*, asec_source_year: int = _INCOME_YEAR) -> Frame: + n = len(_HOUSEHOLDS) + ids = np.arange(1, n + 1, dtype=np.int64) + channel = np.asarray([row[0] for row in _HOUSEHOLDS], dtype=object) + is_asec = channel == "asec" + person = pd.DataFrame( + { + "person_id": ids, + "person_household_id": ids, + "person_tax_unit_id": ids, + "person_spm_unit_id": ids, + "person_family_id": ids, + "person_marital_unit_id": ids, + "source_year": np.where(is_asec, float(asec_source_year), np.nan), + "source_household_id": np.where(is_asec, 900.0 + ids, np.nan), + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "state_fips": np.asarray([row[1] for row in _HOUSEHOLDS], dtype=np.int64), + "puma": pd.array([row[2] for row in _HOUSEHOLDS], dtype="string"), + support_channel_column("household"): channel, + } + ) + tables = { + "person": person, + "household": household, + **{ + entity: pd.DataFrame({f"{entity}_id": ids}) + for entity in US_SCHEMA.group_entities + if entity != "household" + }, + } + return Frame( + tables, + US_SCHEMA, + { + "household": Weights( + np.asarray([row[4] for row in _HOUSEHOLDS], dtype=np.float64), + WeightKind.IMPORTANCE, + ) + }, + pd.Series(["fixture"] * n, dtype=object), + ) + + +def _cps_household_geography() -> pd.DataFrame: + rows = [ + (900 + index + 1, state, gtco) + for index, (channel, state, _puma, gtco, _weight) in enumerate(_HOUSEHOLDS) + if channel == "asec" + ] + return pd.DataFrame( + { + "source_year": _INCOME_YEAR, + "source_household_id": np.asarray([row[0] for row in rows], np.int64), + "state_fips": np.asarray([row[1] for row in rows], np.int64), + "gtco": np.asarray([row[2] for row in rows], np.int64), + "gtcbsa": np.zeros(len(rows), dtype=np.int64), + "person_weight": np.full(len(rows), 1_000.0), + } + ) + + +def _contract(pool_tool: ModuleType, ladder, *, seed: int = 11) -> dict: + return pool_tool._stacked_block_location_contract( + seed, + 1, + ladder.sha256, + cps_asec_sha256={_INCOME_YEAR: _CPS_SHA256}, + ) + + +def _assign(pool_tool: ModuleType, ladder, *, seed: int = 11, frame=None): + return pool_tool._assign_stacked_household_block_location( + _stacked_frame() if frame is None else frame, + ladder=ladder, + crosswalk=_crosswalk(ladder), + cps_household_geography=_cps_household_geography(), + contract=_contract(pool_tool, ladder, seed=seed), + ) + + +def _manifest(receipt: dict, ladder) -> dict: + """A JSON round-tripped stacked manifest carrying a block_v1 receipt.""" + + stored = json.loads(json.dumps(receipt)) + authorities = stored["contract"]["authorities"] + return { + "pipeline": "us-stacked-pool", + "random_seed": 0, + "operator_order": list(h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER), + "geography_assignment": stored, + "stage_receipts": {"geography_assignment": copy.deepcopy(stored)}, + "provenance_pins": { + role: { + "expected_sha256": authority["sha256"], + "actual_sha256": authority["sha256"], + } + for role, authority in authorities.items() + }, + "household_location": copy.deepcopy(stored["household_location"]), + } + + +def _with_lineage(household: pd.DataFrame, *, clone_of: int | None = None): + """Add PUF-support lineage columns, optionally one clone of a household.""" + + table = household.copy() + table[support_source_id_column("household")] = table["household_id"] + table[support_clone_index_column("household")] = 0 + if clone_of is not None: + clone = table.loc[table["household_id"] == clone_of].copy() + clone["household_id"] = int(table["household_id"].max()) + 1 + clone[support_clone_index_column("household")] = 1 + table = pd.concat([table, clone], ignore_index=True) + return table + + +def _root_attributes(receipt: dict, ladder) -> dict[str, str]: + seed = receipt["contract"]["seed"]["value"] + return { + CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR: _CROSSWALK_SHA256, + CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR: "119th_congress", + h5_io.POPULACE_LOCATION_RULE_ATTR: "block_v1", + h5_io.POPULACE_LOCATION_SEED_ATTR: str(seed), + h5_io.POPULACE_LOCATION_CLONES_ATTR: "1", + h5_io.POPULACE_BLOCK_LADDER_SHA256_ATTR: ladder.sha256, + h5_io.POPULACE_BLOCK_LADDER_VINTAGES_ATTR: json.dumps( + receipt["household_location"]["block_ladder"]["layer_vintages"], + sort_keys=True, + ), + } + + +def _block_args(**overrides) -> SimpleNamespace: + values = { + "location_rule": "block_v1", + "location_seed": 3, + "location_clones": 1, + "block_ladder": Path("ladder.npz"), + "block_ladder_sha256": "b" * 64, + "cps_asec_h5": [f"{_INCOME_YEAR}=census_cps_{_INCOME_YEAR}.h5"], + "cps_asec_h5_sha256": [f"{_INCOME_YEAR}={_CPS_SHA256}"], + "puma_ladder": None, + "puma_ladder_sha256": None, + "congressional_district_vintage_crosswalk": Path("crosswalk.csv"), + "congressional_district_vintage_crosswalk_sha256": _CROSSWALK_SHA256, + "legacy_two_spine": False, + "config_authority": "constants", + } + values.update(overrides) + return SimpleNamespace(**values) + + +# -------------------------------------------------------------------------- +# Contract, operator order and the default path +# -------------------------------------------------------------------------- + + +def test_default_rule_keeps_the_legacy_contract_and_operator_order( + pool_tool: ModuleType, +) -> None: + legacy = pool_tool._stacked_geography_assignment_contract() + assert pool_tool._stacked_location_contract_for_args(SimpleNamespace()) == legacy + assert ( + pool_tool._stacked_operator_order_for_contract(legacy) + == h5_io.US_STACKED_POOL_OPERATOR_ORDER + ) + assert h5_io.us_stacked_pool_operator_order({}) == ( + h5_io.US_STACKED_POOL_OPERATOR_ORDER + ) + assert pool_tool._with_location_run_config( + {"config_authority": "constants"}, SimpleNamespace() + ) == {"config_authority": "constants"} + assert pool_tool._stacked_location_root_attributes(None, {}) == {} + assert pool_tool._stacked_location_root_attributes(legacy, {}) == {} + + +def test_block_operator_order_replaces_only_the_geography_slot() -> None: + legacy = h5_io.US_STACKED_POOL_OPERATOR_ORDER + block = h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + assert len(block) == len(legacy) + differing = [ + index for index, (a, b) in enumerate(zip(legacy, block, strict=True)) if a != b + ] + assert differing == [legacy.index("assign_us_puma_ladder")] + assert block[differing[0]] == "assign_us_block_location" + + +@pytest.mark.parametrize("seed", [0, 1, 578, 2**31]) +def test_tool_contract_equals_the_consumer_copy( + pool_tool: ModuleType, seed: int +) -> None: + cps = {2022: "1" * 64, 2024: "2" * 64} + tool = pool_tool._stacked_block_location_contract( + seed, 1, "f" * 64, cps_asec_sha256=cps + ) + consumer = h5_io.expected_us_stacked_block_location_contract( + seed=seed, + clones=1, + block_ladder_sha256="f" * 64, + crosswalk_sha256=_CROSSWALK_SHA256, + cps_asec_sha256=cps, + ) + assert json.loads(json.dumps(tool)) == consumer + assert tool["algorithm"]["id"] == US_BLOCK_LOCATION_RULE_ID + assert h5_io.US_STACKED_BLOCK_LOCATION_ALGORITHM_ID == US_BLOCK_LOCATION_RULE_ID + assert tool["seed"]["value"] == seed + # The block rule's seed site/stream stay outside the legacy-v1 protocol. + assert tool["seed"]["site"] != "legacy_puma_ladder" + assert tool["seed"]["stream"] != "geography_legacy" + assert ( + pool_tool._stacked_operator_order_for_contract(tool) + == h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + ) + + +def test_block_identity_binds_its_contract_and_order( + pool_tool: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + # The engine-free lane has no policyengine-us metadata index. + monkeypatch.setattr( + pool_tool, + "pool_remaining_stage_input_manifest_receipt", + lambda: {"fixture": "remaining-stage manifest"}, + ) + kwargs = { + "stack_receipt": {"sample_fraction": 1.0, "sample_seed": 578}, + "sample_fraction": 1.0, + "sample_seed": 578, + "clone_attachment_fraction": 1.0, + "clone_attachment_seed": 578, + "policyengine_us_version": "fixture", + } + legacy = pool_tool._stacked_checkpoint_base_identity({}, **kwargs) + contract = pool_tool._stacked_block_location_contract( + 4, 1, "f" * 64, cps_asec_sha256={_INCOME_YEAR: _CPS_SHA256} + ) + block = pool_tool._stacked_checkpoint_base_identity( + {}, geography_contract=contract, **kwargs + ) + assert legacy["geography_assignment"] == ( + pool_tool._stacked_geography_assignment_contract() + ) + assert legacy["pool_code"]["operator_order"] == list( + h5_io.US_STACKED_POOL_OPERATOR_ORDER + ) + assert block["geography_assignment"] == contract + assert block["pool_code"]["operator_order"] == list( + h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + ) + stripped = { + key: value + for key, value in block.items() + if key not in {"geography_assignment", "pool_code"} + } + assert stripped == { + key: value + for key, value in legacy.items() + if key not in {"geography_assignment", "pool_code"} + } + + +# -------------------------------------------------------------------------- +# CLI surface and refusals +# -------------------------------------------------------------------------- + + +def test_location_options_parse_beside_the_pinned_parser( + pool_tool: ModuleType, tmp_path: Path +) -> None: + legacy = pool_tool._parse_arguments(_stacked_main_argv(tmp_path)) + assert (legacy.location_rule, legacy.location_seed, legacy.location_clones) == ( + "legacy", + 0, + 1, + ) + pool_tool._require_location_rule_arguments(legacy) + argv = [ + argument + for argument in _stacked_main_argv(tmp_path) + if argument + not in { + "--puma-ladder", + str(tmp_path / "puma-ladder"), + "--puma-ladder-sha256", + "39a2ab2abeab07a88362af7ab2940e0e1d50a297c919e4bbc6fb65bab51147d8", + } + ] + block = pool_tool._parse_arguments( + [ + *argv, + "--location-rule", + "block_v1", + "--location-seed", + "7", + "--block-ladder", + str(tmp_path / "ladder.npz"), + "--block-ladder-sha256", + "b" * 64, + "--cps-asec-h5", + f"2023={tmp_path / 'census_cps_2023.h5'}", + "--cps-asec-h5-sha256", + f"2023={_CPS_SHA256}", + ] + ) + assert (block.location_rule, block.location_seed) == ("block_v1", 7) + pool_tool._require_location_rule_arguments(block) + contract = pool_tool._stacked_location_contract_for_args(block) + assert contract["seed"]["value"] == 7 + assert contract["authorities"]["block_ladder"]["sha256"] == "b" * 64 + assert set(contract["authorities"]) == { + "block_ladder", + "congressional_district_vintage_crosswalk", + "cps_asec_household_geography_2023", + } + assert pool_tool._with_location_run_config( + {"config_authority": "constants"}, block + ) == { + "config_authority": "constants", + "location_rule": "block_v1", + "location_seed": 7, + "location_clones": 1, + } + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ( + {"location_rule": "legacy", "location_seed": 5}, + "apply only to --location-rule block_v1", + ), + ( + {"location_rule": "legacy", "location_seed": 0, "location_clones": 3}, + "apply only to --location-rule block_v1", + ), + ( + { + "location_rule": "legacy", + "location_seed": 0, + "cps_asec_h5": None, + "cps_asec_h5_sha256": None, + "block_ladder_sha256": None, + }, + "--block-ladder apply only", + ), + ({"legacy_two_spine": True}, "--legacy-two-spine assigns no"), + ({"config_authority": "constants_adapter"}, "constants_adapter"), + ( + {"location_clones": 2}, + "validate_assembly_provenance.*validate_stacked_spine_frame", + ), + ({"location_seed": -1}, "non-negative"), + ({"block_ladder": None}, "missing --block-ladder"), + ({"block_ladder_sha256": None}, "missing --block-ladder-sha256"), + ({"puma_ladder": Path("puma.npz")}, "legacy-rule inputs"), + ({"cps_asec_h5": None}, "requires --cps-asec-h5"), + ( + {"cps_asec_h5_sha256": [f"2024={_CPS_SHA256}"]}, + "must name the same income years", + ), + ({"cps_asec_h5": ["2023"]}, "INCOME_YEAR=VALUE"), + ( + {"congressional_district_vintage_crosswalk_sha256": "0" * 64}, + "canonical US authority pin", + ), + ], +) +def test_location_arguments_are_refused( + pool_tool: ModuleType, overrides: dict, message: str +) -> None: + with pytest.raises(ValueError, match=message): + pool_tool._require_location_rule_arguments(_block_args(**overrides)) + + +def test_main_refuses_block_v1_with_the_legacy_two_spine_route( + pool_tool: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[str] = [] + monkeypatch.setattr(pool_tool, "_main_legacy", lambda _a: calls.append("l")) + monkeypatch.setattr(pool_tool, "_main_stacked", lambda _a: calls.append("s")) + with pytest.raises(ValueError, match="--legacy-two-spine"): + pool_tool.main( + [ + *_stacked_main_argv(tmp_path), + "--legacy-two-spine", + "--location-rule", + "block_v1", + ] + ) + with pytest.raises(ValueError, match="apply only to --location-rule block_v1"): + pool_tool.main([*_stacked_main_argv(tmp_path), "--location-seed", "9"]) + with pytest.raises(ValueError, match="constants_adapter"): + pool_tool.main( + [ + *_stacked_main_argv(tmp_path), + "--config-authority", + "constants_adapter", + "--location-rule", + "block_v1", + ] + ) + assert calls == [] + + +def test_block_root_attributes_record_rule_seed_and_ladder( + pool_tool: ModuleType, ladder +) -> None: + _assigned, receipt, _districts = _assign(pool_tool, ladder, seed=21) + verified = { + "block_ladder": SimpleNamespace(actual_sha256=ladder.sha256), + } + attributes = pool_tool._stacked_location_root_attributes( + receipt["contract"], + verified, + pool_tool._stacked_household_location_record({"geography_assignment": receipt}), + ) + assert attributes == { + key: value + for key, value in _root_attributes(receipt, ladder).items() + if key in h5_io.US_STACKED_BLOCK_LOCATION_ROOT_ATTRIBUTES + } + assert attributes[h5_io.POPULACE_LOCATION_SEED_ATTR] == "21" + assert set(attributes) == set(h5_io.US_STACKED_BLOCK_LOCATION_ROOT_ATTRIBUTES) + with pytest.raises(ValueError, match="layer vintages"): + pool_tool._stacked_location_root_attributes(receipt["contract"], verified) + + +# -------------------------------------------------------------------------- +# The draw on a synthetic stacked frame +# -------------------------------------------------------------------------- + + +def test_block_draw_respects_each_households_source_geography( + pool_tool: ModuleType, ladder +) -> None: + frame = _stacked_frame() + assigned, receipt, districts = _assign(pool_tool, ladder, frame=frame) + household = assigned.table("household") + source = frame.table("household") + assert list(household["household_id"]) == list(source["household_id"]) + + block = household["block_geoid"].astype(str) + assert block.str.fullmatch(r"[0-9]{15}").all() + block_index = np.searchsorted(ladder.block_geoid, block.astype(np.int64)) + assert (ladder.block_geoid[block_index] == block.astype(np.int64)).all() + # Every geography is the block's lookup; the state never moves. + assert (household["tract_geoid"] == block.str[:11]).all() + assert (household["county_fips"] == block.str[:5]).all() + assert (block.str[:2].astype(int) == source["state_fips"]).all() + assert ( + household["congressional_district_geoid"].to_numpy(np.int64) + == ladder.blocks.congressional_district_geoid[block_index] + ).all() + assert ( + household["puma"].astype(str).astype(np.int64).to_numpy() + == ladder.puma[block_index] + ).all() + + # ACS: the drawn block lies inside the observed PUMA. + is_acs = source[support_channel_column("household")].eq("acs").to_numpy() + assert ( + household.loc[is_acs, "puma"].astype(str).to_numpy() + == source.loc[is_acs, "puma"].astype(str).to_numpy() + ).all() + + # ASEC: identified counties are kept; GTCO = 0 avoids every county that is + # both coded in the file and on Census's official identified list. + cps = _cps_household_geography() + reference = cps_source_geography( + ladder, + cps, + source_year=cps["source_year"].to_numpy(), + source_household_id=cps["source_household_id"].to_numpy(), + state_fips=cps["state_fips"].to_numpy(), + ) + excluded = reference.identified_counties[_INCOME_YEAR] + asec_rows = np.flatnonzero(~is_acs) + for row, gtco in zip(asec_rows, cps["gtco"], strict=True): + state = int(source.loc[row, "state_fips"]) + county = int(household.loc[row, "county_fips"]) + if gtco: + assert county == state * 1000 + int(gtco) + else: + assert county not in excluded + + record = receipt["household_location"] + assert record["rule"] == US_BLOCK_LOCATION_RULE_ID + assert record["location_rule"] == "block_v1" + assert record["seed"] == 11 + assert record["clones"] == 1 + assert record["block_ladder"]["sha256"] == ladder.sha256 + assert record["source_geography_households"]["puma"] == int(is_acs.sum()) + assert record["candidate_set_rule"]["cps_asec"]["cbsa_narrowing"]["requested"] + assert receipt["gate"]["passed"] is True + assert set(receipt["gate"]["gates"]) == {"us_block_location", "us_puma_ladder"} + assert districts == tuple( + sorted(set(ladder.blocks.congressional_district_geoid.tolist())) + ) + + +def test_block_draw_is_deterministic_and_seeded(pool_tool: ModuleType, ladder) -> None: + _frame_a, first, _ = _assign(pool_tool, ladder, seed=11) + _frame_b, again, _ = _assign(pool_tool, ladder, seed=11) + assert json.dumps(first, sort_keys=True) == json.dumps(again, sort_keys=True) + moved = { + _assign(pool_tool, ladder, seed=seed)[1]["assigned_household_geography"][ + "sha256" + ] + for seed in range(12, 20) + } + assert len(moved | {first["assigned_household_geography"]["sha256"]}) > 1 + + +def test_asec_rows_without_a_pinned_source_year_fail_closed( + pool_tool: ModuleType, ladder +) -> None: + with pytest.raises(ValueError, match="pinned CPS ASEC household H5s"): + _assign(pool_tool, ladder, frame=_stacked_frame(asec_source_year=2022)) + + +def test_rows_without_an_observed_puma_or_cps_key_fail_closed( + pool_tool: ModuleType, ladder +) -> None: + frame = _stacked_frame() + tables = {entity: frame.table(entity).copy() for entity in frame.entities} + tables["household"].loc[0, "puma"] = pd.NA + broken = Frame( + tables, + frame.schema, + {"household": frame.weights_for("household")}, + frame.strata, + ) + # Spine-blind: without an observed PUMA a household must resolve through + # its CPS ASEC source key, which an ACS record does not have. + with pytest.raises(ValueError, match="source_household_id|ASEC source row"): + _assign(pool_tool, ladder, frame=broken) + + +def test_block_draw_refuses_a_ladder_with_other_bytes( + pool_tool: ModuleType, ladder +) -> None: + contract = pool_tool._stacked_block_location_contract( + 11, 1, "0" * 64, cps_asec_sha256={_INCOME_YEAR: _CPS_SHA256} + ) + with pytest.raises(ValueError, match="differ from the contract's pinned"): + pool_tool._assign_stacked_household_block_location( + _stacked_frame(), + ladder=ladder, + crosswalk=_crosswalk(ladder), + cps_household_geography=_cps_household_geography(), + contract=contract, + ) + + +def test_receipt_validator_selects_the_contract_by_algorithm_id( + pool_tool: ModuleType, ladder +) -> None: + assigned, receipt, districts = _assign(pool_tool, ladder) + contract = receipt["contract"] + validate = pool_tool._validate_stacked_geography_assignment_receipt + validate( + assigned, + receipt, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=True, + configured_contract=contract, + location_ladder=ladder, + ) + # A block receipt is never accepted by a legacy-configured run ... + for configured in (None, pool_tool._stacked_geography_assignment_contract()): + with pytest.raises(ValueError, match="configured location rule"): + validate( + assigned, + receipt, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=True, + configured_contract=configured, + ) + # ... nor under another seed, and a mismatched algorithm id is refused. + other_seed = _contract(pool_tool, ladder, seed=12) + with pytest.raises(ValueError, match="configured location rule"): + validate( + assigned, + receipt, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=True, + configured_contract=other_seed, + ) + renamed = copy.deepcopy(receipt) + renamed["contract"]["algorithm"]["id"] = "us_block_location.population_draw.v2" + with pytest.raises(ValueError, match="contract"): + validate( + assigned, + renamed, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=True, + configured_contract=contract, + ) + tampered = copy.deepcopy(receipt) + tampered["household_location"]["seed"] = 99 + with pytest.raises(ValueError, match="household_location record"): + validate( + assigned, + tampered, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=True, + configured_contract=contract, + ) + + +def _cloned_frame(assigned: Frame, household: pd.DataFrame) -> Frame: + """A frame whose extra household rows are PUF-support copies with members.""" + + ids = household["household_id"].to_numpy(np.int64) + person = pd.DataFrame( + { + "person_id": ids, + "person_household_id": ids, + "person_tax_unit_id": ids, + "person_spm_unit_id": ids, + "person_family_id": ids, + "person_marital_unit_id": ids, + } + ) + tables = { + "person": person, + "household": household.reset_index(drop=True), + **{ + entity: pd.DataFrame({f"{entity}_id": ids}) + for entity in US_SCHEMA.group_entities + if entity != "household" + }, + } + return Frame( + tables, + assigned.schema, + {"household": Weights(np.ones(len(ids)), WeightKind.IMPORTANCE)}, + pd.Series(["fixture"] * len(ids), dtype=object), + ) + + +def test_support_clones_inherit_the_located_block( + pool_tool: ModuleType, ladder +) -> None: + assigned, receipt, districts = _assign(pool_tool, ladder) + household = _with_lineage(assigned.table("household"), clone_of=7) + validate = pool_tool._validate_stacked_geography_assignment_receipt + validate( + _cloned_frame(assigned, household), + receipt, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=False, + configured_contract=receipt["contract"], + ) + moved = household.copy() + moved.loc[moved.index[-1], "sldu"] = "zzz" + with pytest.raises( + ValueError, match="disagree on assigned geography column 'sldu'" + ): + validate( + _cloned_frame(assigned, moved), + receipt, + target_districts=districts, + boundary="fixture", + require_exact_assembled_rows=False, + configured_contract=receipt["contract"], + ) + + +# -------------------------------------------------------------------------- +# h5_io consumer validators +# -------------------------------------------------------------------------- + + +def test_h5_io_accepts_a_block_manifest_round_trip( + pool_tool: ModuleType, ladder +) -> None: + assigned, receipt, _districts = _assign(pool_tool, ladder) + manifest = _manifest(receipt, ladder) + path = Path("fixture.manifest.json") + assert h5_io.us_stacked_pool_operator_order(manifest) == ( + h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + ) + h5_io._validate_stacked_geography_assignment_manifest_binding( + manifest, manifest_path=path + ) + household = _with_lineage(assigned.table("household"), clone_of=3) + h5_io._validate_stacked_geography_h5_binding( + manifest, + household, + _root_attributes(receipt, ladder), + manifest_path=path, + pool_path=Path("fixture.h5"), + ) + # The late-DAG binding accepts the block order and then asks for the DAG. + with pytest.raises(ValueError, match="late-producer DAG receipt"): + h5_io._validate_stacked_late_dag_manifest_binding(manifest, manifest_path=path) + legacy_order = dict( + manifest, operator_order=list(h5_io.US_STACKED_POOL_OPERATOR_ORDER) + ) + with pytest.raises(ValueError, match="canonical late-DAG operator order"): + h5_io._validate_stacked_late_dag_manifest_binding( + legacy_order, manifest_path=path + ) + + +def _mutated(manifest: dict, mutate) -> dict: + changed = copy.deepcopy(manifest) + mutate(changed) + changed["stage_receipts"]["geography_assignment"] = copy.deepcopy( + changed["geography_assignment"] + ) + return changed + + +def _set(path: tuple, value): + def mutate(manifest: dict) -> None: + target = manifest + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + + return mutate + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + _set(("geography_assignment", "contract", "algorithm", "id"), "other.v1"), + "geography declaration changed", + ), + ( + _set(("geography_assignment", "contract", "algorithm", "clones"), 2), + "location clones", + ), + ( + _set(("geography_assignment", "contract", "declaration", "anchor"), "puma"), + "block-location contract changed", + ), + ( + _set(("geography_assignment", "contract", "seed", "stream"), "x"), + "block-location contract changed", + ), + (_set(("household_location", "seed"), 12345), "household_location record"), + ( + _set(("household_location", "block_ladder", "layer_vintages"), {}), + "household_location record", + ), + (_set(("random_seed",), 1), "random_seed"), + ( + _set(("provenance_pins", "block_ladder", "actual_sha256"), "0" * 64), + "'block_ladder' differs from its authenticated input pin", + ), + ( + _set( + ( + "geography_assignment", + "gate", + "gates", + "us_block_location", + "passed", + ), + False, + ), + "passed block-location and PUMA-ladder gates", + ), + ( + _set( + ("geography_assignment", "assigned_household_geography", "codec"), "x" + ), + "block-location receipt", + ), + ], +) +def test_h5_io_refuses_a_changed_block_manifest( + pool_tool: ModuleType, ladder, mutate, message: str +) -> None: + _assigned, receipt, _districts = _assign(pool_tool, ladder) + manifest = _mutated(_manifest(receipt, ladder), mutate) + with pytest.raises(ValueError, match=message): + h5_io._validate_stacked_geography_assignment_manifest_binding( + manifest, manifest_path=Path("fixture.manifest.json") + ) + + +def test_h5_io_refuses_block_h5_drift(pool_tool: ModuleType, ladder) -> None: + assigned, receipt, _districts = _assign(pool_tool, ladder) + manifest = _manifest(receipt, ladder) + household = _with_lineage(assigned.table("household"), clone_of=3) + attributes = _root_attributes(receipt, ladder) + + def check(table: pd.DataFrame, attrs: dict) -> None: + h5_io._validate_stacked_geography_h5_binding( + manifest, + table, + attrs, + manifest_path=Path("fixture.manifest.json"), + pool_path=Path("fixture.h5"), + ) + + with pytest.raises(ValueError, match="location root attributes"): + check(household, {**attributes, h5_io.POPULACE_LOCATION_SEED_ATTR: "12"}) + with pytest.raises(ValueError, match="location root attributes"): + check( + household, + {**attributes, h5_io.POPULACE_BLOCK_LADDER_VINTAGES_ATTR: "{}"}, + ) + with pytest.raises(ValueError, match="location root attributes"): + check( + household, + { + key: value + for key, value in attributes.items() + if key != h5_io.POPULACE_LOCATION_RULE_ATTR + }, + ) + moved = household.copy() + first_block = str(moved.loc[0, "block_geoid"]) + other = next( + f"{value:015d}" + for value in ladder.block_geoid.tolist() + if f"{value:015d}"[:5] == first_block[:5] and f"{value:015d}" != first_block + ) + moved.loc[0, "block_geoid"] = other + with pytest.raises(ValueError, match="block location differs"): + check(moved, attributes) + incoherent = household.copy() + incoherent.loc[incoherent.index[-1], "place_fips"] = "99999" + with pytest.raises(ValueError, match="disagree on assigned geography column"): + check(incoherent, attributes) + prefix = household.copy() + prefix["tract_geoid"] = prefix["tract_geoid"].astype(str).str[:10] + "9" + with pytest.raises(ValueError, match="prefix of its household's located block"): + check(prefix, attributes) + + +def test_cps_pins_must_be_the_raw_stage_sources(pool_tool: ModuleType) -> None: + raw_stage = { + "source_receipt": { + "sources": [ + {"year": 2023, "sha256": "1" * 64}, + {"year": 2024, "sha256": "2" * 64}, + ] + } + } + bind = pool_tool._require_cps_asec_pins_match_raw_stage + bind(raw_stage, {2023: "1" * 64, 2024: "2" * 64}) + with pytest.raises(ValueError, match="differ from the ASEC raw-stage sources"): + bind(raw_stage, {2023: "1" * 64}) + with pytest.raises(ValueError, match="differ from the ASEC raw-stage sources"): + bind(raw_stage, {2023: "1" * 64, 2024: "3" * 64}) + with pytest.raises(ValueError, match="records no source_receipt.sources"): + bind({}, {2023: "1" * 64}) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_h5_io.py b/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_h5_io.py index 900ee24d41..fef6f7fd3c 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_h5_io.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_h5_io.py @@ -3377,3 +3377,226 @@ def test_ready_stacked_pool_loader_requires_both_terminal_gate_aliases( with pytest.raises(ValueError, match="terminal_gates must be an object"): load_simulation_ready_us_multispine_pool(manifest_path) + + +# -------------------------------------------------------------------------- +# --location-rule block_v1 (microcosm#696): the full ready-pool loader path +# -------------------------------------------------------------------------- + +_BLOCK_FIXTURE_LADDER_SHA256 = "e" * 64 +_BLOCK_FIXTURE_LAYER_VINTAGES = { + "block": "2020_census_block", + "congressional_district": "119th_congress", +} +_BLOCK_FIXTURE_CPS_SHA256 = {2023: "a" * 64} +# Native households 10 and 20, plus the PUF-support clone of 20; every +# derived column is a prefix or ladder lookup of the one located block. +_BLOCK_FIXTURE_BLOCKS = ("060014001001000", "060014002002001", "060014002002001") + + +def _block_location_root_attributes(seed: int) -> dict[str, str]: + return { + h5_io.POPULACE_LOCATION_RULE_ATTR: "block_v1", + h5_io.POPULACE_LOCATION_SEED_ATTR: str(seed), + h5_io.POPULACE_LOCATION_CLONES_ATTR: "1", + h5_io.POPULACE_BLOCK_LADDER_SHA256_ATTR: _BLOCK_FIXTURE_LADDER_SHA256, + h5_io.POPULACE_BLOCK_LADDER_VINTAGES_ATTR: json.dumps( + _BLOCK_FIXTURE_LAYER_VINTAGES, sort_keys=True + ), + } + + +def _rewrite_as_block_location_pool( + manifest_path: Path, + *, + seed: int = 5, +) -> dict[str, object]: + """Turn the ready stacked fixture into a block_v1 pool and manifest.""" + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + pool_path = Path(manifest["pool_h5"]["path"]) + with pd.HDFStore(pool_path, mode="a") as store: + household = h5_io.read_frame_table(store, "household") + blocks = pd.Series(_BLOCK_FIXTURE_BLOCKS, index=household.index, dtype=object) + household["block_geoid"] = blocks + household["tract_geoid"] = blocks.str[:11] + household["county_fips"] = blocks.str[:5] + household["place_fips"] = pd.Series(["", "53000", "53000"], dtype=object) + household["sldu"] = pd.Series(["06009", "06010", "06010"], dtype=object) + household["sldl"] = pd.Series(["06018", "06020", "06020"], dtype=object) + household["cbsa_code"] = pd.Series(["41860"] * 3, dtype=object) + store.put("household", household, format="fixed") + attributes = store.get_node("/")._v_attrs + for key, value in _block_location_root_attributes(seed).items(): + attributes[key] = value + + clone_column = support_clone_index_column("household") + native = household.loc[household[clone_column].eq(0)] + legacy = manifest["geography_assignment"] + contract = h5_io.expected_us_stacked_block_location_contract( + seed=seed, + clones=1, + block_ladder_sha256=_BLOCK_FIXTURE_LADDER_SHA256, + crosswalk_sha256=( + legacy["contract"]["authorities"][ + "congressional_district_vintage_crosswalk" + ]["sha256"] + ), + cps_asec_sha256=_BLOCK_FIXTURE_CPS_SHA256, + ) + location = { + "rule": h5_io.US_STACKED_BLOCK_LOCATION_ALGORITHM_ID, + "location_rule": "block_v1", + "seed": seed, + "clones": 1, + "block_ladder": { + "sha256": _BLOCK_FIXTURE_LADDER_SHA256, + "layer_vintages": dict(_BLOCK_FIXTURE_LAYER_VINTAGES), + }, + "households": len(native), + "rows": len(native), + } + assignment = { + **legacy, + "contract": contract, + "assigned_household_geography": ( + h5_io.ordered_household_block_location_receipt( + native, boundary="block_v1 fixture" + ) + ), + "household_location": location, + "gate": { + "passed": True, + "gates": { + "us_block_location": {"passed": True}, + "us_puma_ladder": {"passed": True}, + }, + }, + } + manifest["geography_assignment"] = assignment + manifest["stage_receipts"]["geography_assignment"] = deepcopy(assignment) + manifest["household_location"] = deepcopy(location) + manifest["operator_order"] = list( + h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + ) + manifest["provenance_pins"] = { + role: { + "path": f"/fixture/{role}", + "expected_sha256": authority["sha256"], + "actual_sha256": authority["sha256"], + "size_bytes": 1, + } + for role, authority in contract["authorities"].items() + } + manifest["pool_h5"]["sha256"] = _sha256(pool_path) + manifest["pool_h5"]["size_bytes"] = pool_path.stat().st_size + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + return manifest + + +def test_ready_stacked_pool_loader_accepts_a_block_location_pool( + tmp_path: Path, +) -> None: + pytest.importorskip("tables") + manifest_path = _write_ready_pool(tmp_path, stacked=True) + _rewrite_as_block_location_pool(manifest_path, seed=5) + + frame, manifest, _ = load_simulation_ready_us_multispine_pool(manifest_path) + + household = frame.table("household") + assert household["block_geoid"].astype(str).tolist() == list(_BLOCK_FIXTURE_BLOCKS) + assert manifest["operator_order"] == list( + h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + ) + assert manifest["household_location"]["seed"] == 5 + assert h5_io.us_stacked_pool_operator_order(manifest) == ( + h5_io.US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + ) + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda manifest: manifest.update( + {"operator_order": list(US_STACKED_POOL_OPERATOR_ORDER)} + ), + "canonical late-DAG operator order", + ), + ( + lambda manifest: manifest["household_location"].update({"seed": 6}), + "household_location record does not match", + ), + ( + lambda manifest: manifest["provenance_pins"].pop("block_ladder"), + "geography authority 'block_ladder' differs", + ), + ], +) +def test_ready_stacked_pool_loader_refuses_a_changed_block_manifest( + tmp_path: Path, + mutate: Callable[[dict[str, object]], object], + message: str, +) -> None: + pytest.importorskip("tables") + manifest_path = _write_ready_pool(tmp_path, stacked=True) + manifest = _rewrite_as_block_location_pool(manifest_path) + mutate(manifest) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + load_simulation_ready_us_multispine_pool(manifest_path) + + +def test_ready_stacked_pool_loader_binds_block_location_root_attrs( + tmp_path: Path, +) -> None: + pytest.importorskip("tables") + manifest_path = _write_ready_pool(tmp_path, stacked=True) + manifest = _rewrite_as_block_location_pool(manifest_path, seed=5) + pool_path = Path(manifest["pool_h5"]["path"]) + with pd.HDFStore(pool_path, mode="a") as store: + store.get_node("/")._v_attrs[h5_io.POPULACE_LOCATION_SEED_ATTR] = "6" + manifest["pool_h5"]["sha256"] = _sha256(pool_path) + manifest["pool_h5"]["size_bytes"] = pool_path.stat().st_size + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match="location root attributes"): + load_simulation_ready_us_multispine_pool(manifest_path) + + +def test_ready_stacked_pool_loader_refuses_block_drift_across_clones( + tmp_path: Path, +) -> None: + pytest.importorskip("tables") + manifest_path = _write_ready_pool(tmp_path, stacked=True) + manifest = _rewrite_as_block_location_pool(manifest_path) + pool_path = Path(manifest["pool_h5"]["path"]) + clone_column = support_clone_index_column("household") + with pd.HDFStore(pool_path, mode="a") as store: + household = h5_io.read_frame_table(store, "household") + household.loc[household[clone_column].eq(1), "sldl"] = "06099" + store.put("household", household, format="fixed") + manifest["pool_h5"]["sha256"] = _sha256(pool_path) + manifest["pool_h5"]["size_bytes"] = pool_path.stat().st_size + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match="cloned household rows disagree.*sldl"): + load_simulation_ready_us_multispine_pool(manifest_path) + + +def test_legacy_stacked_manifest_refuses_h5_location_rule_attrs( + tmp_path: Path, +) -> None: + pytest.importorskip("tables") + manifest_path = _write_ready_pool(tmp_path, stacked=True) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + pool_path = Path(manifest["pool_h5"]["path"]) + with pd.HDFStore(pool_path, mode="a") as store: + store.get_node("/")._v_attrs[h5_io.POPULACE_LOCATION_RULE_ATTR] = "block_v1" + manifest["pool_h5"]["sha256"] = _sha256(pool_path) + manifest["pool_h5"]["size_bytes"] = pool_path.stat().st_size + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match="legacy-rule manifest"): + load_simulation_ready_us_multispine_pool(manifest_path) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_puf_support_base_block_location.py b/packages/microcosm-build/tests/engine_free/us/test_us_puf_support_base_block_location.py new file mode 100644 index 0000000000..a25c425075 --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/us/test_us_puf_support_base_block_location.py @@ -0,0 +1,618 @@ +"""``--location-rule block_v1`` on the PUF-support base builder (microcosm #696). + +The legacy rule must stay byte-identical: same stage list, descriptions and +pipeline sha, same run config and child argv. ``block_v1`` swaps the two +legacy location stages for one ``household_location_assignment`` stage that +draws each household's 2020 block from its CPS ASEC source geography and +records the rule and seed in stage metadata, the summary and H5 root attrs. +Everything here is engine-free: a synthetic block ladder, synthetic ASEC +household tables and a small US frame. +""" + +from __future__ import annotations + +import importlib.util +import json +from contextlib import nullcontext +from pathlib import Path +from types import SimpleNamespace + +import h5py +import numpy as np +import pandas as pd +import pytest + +from microcosm.build.gates import GateResult +from microcosm.build.outer_stage_runtime import frame_identity +from microcosm.build.us_runtime.block_location import ( + US_BLOCK_LOCATION_METADATA_KEY, + US_BLOCK_LOCATION_RULE_ID, + load_us_location_ladder, + location_geography_columns, + us_block_location_gate, +) +from microcosm.build.us_runtime.congressional_district_vintage import ( + CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR, + CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR, + default_congressional_district_vintage_crosswalk_path, +) +from microcosm.build.us_runtime.cps_source_geography import ( + cps_source_geography, + household_source_keys, + load_cps_household_geography, +) +from microcosm.build.us_runtime.geography_ladder import ( + GEOGRAPHY_LADDER_ARTIFACT_SHA256_ATTR, + GEOGRAPHY_LADDER_VINTAGES_ATTR, +) +from microcosm.frame import US_SCHEMA, Frame, WeightKind, Weights +from test_support.microcosm_build.us_block_location import ( + synthetic_blocks, + write_location_ladder, +) +from test_support.paths import paths_for + +_TEST_PATHS = paths_for("microcosm-build") +LEGACY_PIPELINE_SHA256 = ( + "906c9c022de6d154771d8250897a77430b0f457371b3d76a6bd046bceaccd266" +) +LEGACY_LOCATION_STAGES = ( + "congressional_district_assignment", + "block_ladder_assignment", +) + +# (source_year, H_SEQ, GESTFIPS, GTCO, GTCBSA). The synthetic ladder's +# counties are 001, 003 and sometimes 005 in states 01, 02 and 36. +_ASEC_HOUSEHOLDS = ( + (2023, 11, 1, 1, 0), + (2023, 12, 1, 0, 0), + (2023, 13, 2, 0, 0), + (2023, 14, 36, 3, 0), + (2023, 15, 36, 0, 0), + (2023, 16, 2, 1, 0), + (2024, 11, 1, 0, 0), + (2024, 21, 36, 0, 0), + (2024, 22, 2, 3, 0), +) +# Frame households: every ASEC row once (the native copy), plus PUF-support +# style copies of three rows that share their native row's source key. +_COPIED_SOURCE_ROWS = (0, 3, 6) + + +def _load_builder(): + path = _TEST_PATHS.repository / "tools" / "build_us_puf_support_base.py" + spec = importlib.util.spec_from_file_location("build_us_puf_support_base", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def builder(): + return _load_builder() + + +def _write_asec_h5s(tmp_path: Path) -> dict[int, Path]: + paths: dict[int, Path] = {} + for year in sorted({row[0] for row in _ASEC_HOUSEHOLDS}): + rows = [row for row in _ASEC_HOUSEHOLDS if row[0] == year] + household = pd.DataFrame( + { + "H_SEQ": [row[1] for row in rows], + "GESTFIPS": [row[2] for row in rows], + "GTCO": [row[3] for row in rows], + "GTCBSA": [row[4] for row in rows], + "HSUP_WGT": np.full(len(rows), 1500.0), + "H_NUMPER": np.full(len(rows), 2.0), + } + ) + path = tmp_path / f"census_cps_{year}.h5" + household.to_hdf(path, key="household", format="table") + paths[year] = path + return paths + + +def _located_source_frame() -> Frame: + rows = [*_ASEC_HOUSEHOLDS, *(_ASEC_HOUSEHOLDS[i] for i in _COPIED_SOURCE_ROWS)] + n = len(rows) + household_ids = np.arange(1, n + 1, dtype=np.int64) * 10 + sizes = np.array([1 + (i % 2) for i in range(n)]) + person_household = np.repeat(household_ids, sizes) + person_row = np.repeat(np.arange(n), sizes) + person_ids = np.arange(1, len(person_household) + 1, dtype=np.int64) + tables = { + "person": pd.DataFrame( + { + "person_id": person_ids, + "person_household_id": person_household, + "person_tax_unit_id": person_household + 1, + "person_spm_unit_id": person_household + 2, + "person_family_id": person_household + 3, + "person_marital_unit_id": person_ids + 10**6, + "source_year": np.array([rows[i][0] for i in person_row]), + "source_household_id": np.array([rows[i][1] for i in person_row]), + } + ), + "household": pd.DataFrame( + { + "household_id": household_ids, + "state_fips": np.array([row[2] for row in rows], dtype=np.int64), + } + ), + "tax_unit": pd.DataFrame({"tax_unit_id": household_ids + 1}), + "spm_unit": pd.DataFrame({"spm_unit_id": household_ids + 2}), + "family": pd.DataFrame({"family_id": household_ids + 3}), + "marital_unit": pd.DataFrame({"marital_unit_id": person_ids + 10**6}), + } + weights = { + "household": Weights( + values=np.linspace(50.0, 900.0, n), kind=WeightKind.CALIBRATED + ) + } + strata = pd.Series(np.where(tables["person"]["source_year"] == 2023, "a", "b")) + return Frame(tables, US_SCHEMA, weights, strata, metadata={"stage": "test"}) + + +def _location_args(tmp_path: Path, **overrides) -> SimpleNamespace: + ladder = write_location_ladder(tmp_path / "ladder.npz", synthetic_blocks(3)) + asec = _write_asec_h5s(tmp_path) + values = { + "location_rule": "block_v1", + "location_seed": 11, + "location_clones": 1, + "block_ladder_artifact": ladder, + "congressional_district_vintage_crosswalk": ( + default_congressional_district_vintage_crosswalk_path() + ), + "asec_h5": [f"{year}={path}" for year, path in sorted(asec.items())], + "allow_geography_ladder_gate_failures": True, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _argv(tmp_path: Path, *extra: str) -> list[str]: + return [ + "--asec-h5", + f"2023={tmp_path / 'census_cps_2023.h5'}", + "--asec-h5", + f"2024={tmp_path / 'census_cps_2024.h5'}", + "--puf-h5", + str(tmp_path / "puf.h5"), + "--out", + str(tmp_path / "out"), + *extra, + ] + + +def _block_v1_argv(tmp_path: Path, *extra: str) -> list[str]: + return _argv( + tmp_path, + "--location-rule", + "block_v1", + "--block-ladder-artifact", + str(tmp_path / "ladder.npz"), + "--congressional-district-vintage-crosswalk", + str(default_congressional_district_vintage_crosswalk_path()), + *extra, + ) + + +# -------------------------------------------------------------------------- +# Pipelines +# -------------------------------------------------------------------------- + + +def test_default_args_keep_the_legacy_pipeline(builder, tmp_path: Path) -> None: + args = builder._parse_args( + _argv(tmp_path, "--without-block-ladder", "--checkpoint-dir", "ck") + ) + + assert args.location_rule == "legacy" + assert builder._pipeline_for(args) is builder.OUTER_STAGE_PIPELINE + assert builder._pipeline_steps_for(args) == builder.PIPELINE_STEPS + assert builder.OUTER_STAGE_PIPELINE.sha256 == LEGACY_PIPELINE_SHA256 + assert builder.OUTER_STAGE_PIPELINE.names == builder.PIPELINE_STEPS + assert set(LEGACY_LOCATION_STAGES) <= set(builder.PIPELINE_STEPS) + assert builder.HOUSEHOLD_LOCATION_STAGE_NAME not in builder.STAGE_NAMES + # An args object without the new attribute (older callers) is legacy too. + assert builder._pipeline_for(SimpleNamespace()) is builder.OUTER_STAGE_PIPELINE + + config = builder._stage_run_config(args) + assert not {key for key in config if key.startswith("location_")} + assert "block_ladder_artifact_sha256" not in config + child = builder._stage_cli_args(args, "source_construction") + assert not [token for token in child if token.startswith("--location")] + + +def test_block_v1_pipeline_replaces_only_the_location_stages(builder) -> None: + legacy = builder.PIPELINE_STEPS + block_v1 = builder.BLOCK_V1_PIPELINE_STEPS + index = legacy.index(LEGACY_LOCATION_STAGES[0]) + + assert block_v1 == ( + *legacy[:index], + "household_location_assignment", + *legacy[index + 2 :], + ) + assert block_v1[-1] == "final_export" + assert dict(builder.BLOCK_V1_STAGE_BOUNDARIES)["household_location_assignment"] == ( + "with_household_us_block_location[block_v1]", + ) + assert builder.BLOCK_V1_OUTER_STAGE_PIPELINE.names == block_v1 + assert builder.BLOCK_V1_OUTER_STAGE_PIPELINE.sha256 != LEGACY_PIPELINE_SHA256 + shared = dict(builder.STAGE_BOUNDARIES) + for name, boundaries in builder.BLOCK_V1_STAGE_BOUNDARIES: + if name != "household_location_assignment": + assert shared[name] == boundaries + args = SimpleNamespace(location_rule="block_v1") + assert builder._pipeline_for(args) is builder.BLOCK_V1_OUTER_STAGE_PIPELINE + assert "household_location_assignment" in builder.ALL_STAGE_NAMES + + +# -------------------------------------------------------------------------- +# Parse rules +# -------------------------------------------------------------------------- + + +def _parse_error(builder, argv: list[str], capsys) -> str: + with pytest.raises(SystemExit): + builder._parse_args(argv) + return capsys.readouterr().err + + +def test_legacy_rule_refuses_location_seed_and_clones( + builder, tmp_path: Path, capsys +) -> None: + for extra in (("--location-seed", "3"), ("--location-clones", "2")): + err = _parse_error( + builder, _argv(tmp_path, "--without-block-ladder", *extra), capsys + ) + assert "apply only to --location-rule block_v1" in err + + +@pytest.mark.parametrize( + ("extra", "message"), + ( + (("--without-block-ladder",), "contradictory"), + (("--assign-congressional-districts",), "replaces the SOI return-count"), + (("--ledger-facts", "facts.jsonl"), "replaces the SOI return-count"), + (("--congressional-district-seed", "4"), "--location-seed"), + (("--geography-ladder-seed", "4"), "--location-seed"), + (("--location-seed", "-1"), "non-negative"), + ), +) +def test_block_v1_refuses_legacy_location_inputs( + builder, tmp_path: Path, capsys, extra: tuple[str, ...], message: str +) -> None: + assert message in _parse_error(builder, _block_v1_argv(tmp_path, *extra), capsys) + + +def test_block_v1_requires_ladder_crosswalk_and_asec_sources( + builder, tmp_path: Path, capsys +) -> None: + crosswalk = str(default_congressional_district_vintage_crosswalk_path()) + ladder = str(tmp_path / "ladder.npz") + no_ladder = _argv( + tmp_path, + "--location-rule", + "block_v1", + "--congressional-district-vintage-crosswalk", + crosswalk, + ) + assert "requires --block-ladder-artifact" in _parse_error( + builder, no_ladder, capsys + ) + no_crosswalk = _argv( + tmp_path, "--location-rule", "block_v1", "--block-ladder-artifact", ladder + ) + assert "requires --congressional-district-vintage-crosswalk" in _parse_error( + builder, no_crosswalk, capsys + ) + base_h5 = [ + "--base-h5", + str(tmp_path / "base.h5"), + "--puf-h5", + str(tmp_path / "puf.h5"), + "--out", + str(tmp_path / "out"), + "--location-rule", + "block_v1", + "--block-ladder-artifact", + ladder, + "--congressional-district-vintage-crosswalk", + crosswalk, + ] + assert "requires --asec-h5 sources" in _parse_error(builder, base_h5, capsys) + + +def test_block_v1_refuses_location_clones_naming_the_route_a_checks( + builder, tmp_path: Path, capsys +) -> None: + err = _parse_error( + builder, _block_v1_argv(tmp_path, "--location-clones", "3"), capsys + ) + + assert "--location-clones must be 1 on the base-h5 line" in err + for check in ( + "support_provenance.py:support_copy_rank_series", + "sipp_head_start.py:_recipient_predictors", + "voluntary_filing.py:_source_receiver_rows", + "puf_capital_gains_tail.py:assert_puf_capital_gains_tail_survives_selection", + ): + assert check in " ".join(err.split()) + + +def test_block_v1_run_config_round_trips_and_locks_the_ladder( + builder, tmp_path: Path +) -> None: + write_location_ladder(tmp_path / "ladder.npz", synthetic_blocks(3)) + parent = builder._parse_args( + _block_v1_argv(tmp_path, "--location-seed", "9", "--checkpoint-dir", "ck") + ) + child_argv = builder._stage_cli_args(parent, "household_location_assignment") + child = builder._parse_args(child_argv) + + parent_config = builder._stage_run_config(parent) + assert builder._stage_run_config(child) == parent_config + assert parent_config["location_rule"] == "block_v1" + assert parent_config["location_seed"] == 9 + assert parent_config["location_clones"] == 1 + assert parent_config["block_ladder_artifact_sha256"] == ( + load_us_location_ladder(tmp_path / "ladder.npz").sha256 + ) + assert builder._pipeline_for(child) is builder.BLOCK_V1_OUTER_STAGE_PIPELINE + + +# -------------------------------------------------------------------------- +# The location stage +# -------------------------------------------------------------------------- + + +def test_block_v1_location_writes_consistent_geography_from_the_asec_row( + builder, tmp_path: Path +) -> None: + args = _location_args(tmp_path) + frame = _located_source_frame() + + located, record = builder._block_v1_household_location(args, frame) + + ladder = load_us_location_ladder(args.block_ladder_artifact) + household = located.table("household") + assert us_block_location_gate(household, ladder).passed + assert set(location_geography_columns(ladder)) <= set(household.columns) + # Adds columns only: same rows, ids, source keys and weights. + assert frame_identity(located) == frame_identity(frame) + np.testing.assert_array_equal( + located.weights_for("household").values, frame.weights_for("household").values + ) + # A household with an identified ASEC county lands in that county; one + # without lands in its state outside the year's excluded counties. + source_year, source_household_id = household_source_keys(frame) + geography = cps_source_geography( + ladder, + load_cps_household_geography( + { + int(year): Path(path) + for year, path in (value.split("=", 1) for value in args.asec_h5) + } + ), + source_year=source_year, + source_household_id=source_household_id, + state_fips=household["state_fips"].to_numpy(np.int64), + ) + county = household["county_fips"].astype(str).to_numpy() + for row, identified in enumerate(geography.county_fips): + if identified: + assert county[row] == f"{identified:05d}" + else: + excluded = geography.identified_counties.get(int(source_year[row]), ()) + assert int(county[row]) not in excluded + assert county[row][:2] == f"{household['state_fips'].iloc[row]:02d}" + + assert record["rule"] == US_BLOCK_LOCATION_RULE_ID + assert record["location_rule"] == "block_v1" + assert record["seed"] == 11 + assert record["clones"] == 1 + assert record["households"] == len(household) + assert record["gate"]["passed"] is True + assert record["candidate_set_rule"] == geography.record + assert record["source_geography_households"]["county"] == int( + (geography.county_fips > 0).sum() + ) + assert record["artifact_sha256"] == ladder.sha256 + assert record["congressional_district_vintage_target"] == "119th_congress" + assert "geography_ladder_gate" in record + json.dumps(record) # the record is the summary's JSON + receipt = located.metadata[US_BLOCK_LOCATION_METADATA_KEY] + assert receipt["seed"] == 11 + assert receipt["rule"] == US_BLOCK_LOCATION_RULE_ID + + again, _ = builder._block_v1_household_location(args, frame) + pd.testing.assert_frame_equal(again.table("household"), household) + + +def test_block_v1_location_gate_failure_aborts_unless_allowed( + builder, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + failing = GateResult( + name="us_block_location", + passed=False, + failures=("tract_geoid: 1 row",), + details={}, + ) + monkeypatch.setattr(builder, "us_block_location_gate", lambda *_a, **_k: failing) + frame = _located_source_frame() + + with pytest.raises(SystemExit, match="us_block_location: tract_geoid"): + builder._block_v1_household_location( + _location_args(tmp_path, allow_geography_ladder_gate_failures=False), frame + ) + _, record = builder._block_v1_household_location(_location_args(tmp_path), frame) + assert record["gate"] == { + "passed": False, + "failures": ["tract_geoid: 1 row"], + "details": {}, + } + + +def test_block_v1_refuses_a_ladder_whose_primary_plan_is_not_current( + builder, tmp_path: Path +) -> None: + from test_support.microcosm_build.us_block_location import ladder_metadata + + metadata = ladder_metadata() + metadata["layers"]["congressional_district"]["vintage"] = "118th_congress" + ladder = write_location_ladder( + tmp_path / "old.npz", synthetic_blocks(3), metadata=metadata + ) + args = _location_args(tmp_path) + args.block_ladder_artifact = ladder + + with pytest.raises(SystemExit, match="118th_congress"): + builder._block_v1_household_location(args, _located_source_frame()) + + +def test_staged_household_location_stage_checkpoints_the_record( + builder, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + frame = _located_source_frame() + checkpoint_dir = tmp_path / "ck" + runtime = builder.StageRuntime( + checkpoint_dir, builder.BLOCK_V1_OUTER_STAGE_PIPELINE, run_config={} + ) + for stage in builder.BLOCK_V1_PIPELINE_STEPS: + if stage == "household_location_assignment": + break + if stage == "primary_qrf_chain": + runtime.complete_without_frame(stage) + else: + runtime.complete(stage, frame) + monkeypatch.setattr(builder, "_stage_run_config", lambda _args: {}) + monkeypatch.setattr( + builder, "profile_stage", lambda *_args, **_kwargs: nullcontext() + ) + args = _location_args( + tmp_path, stage="household_location_assignment", checkpoint_dir=checkpoint_dir + ) + + builder._run_outer_stage(args) + + completed = runtime.load("household_location_assignment") + record = runtime.metadata["household_location_assignment"]["household_location"] + assert record["location_rule"] == "block_v1" + assert record["seed"] == 11 + ladder = load_us_location_ladder(args.block_ladder_artifact) + assert us_block_location_gate(completed.frame.table("household"), ladder).passed + assert frame_identity(completed.frame) == frame_identity(frame) + + +def test_stage_names_are_refused_outside_their_rule(builder, tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="not a stage of the --location-rule legacy"): + builder._run_outer_stage( + SimpleNamespace( + stage="household_location_assignment", checkpoint_dir=tmp_path + ) + ) + for stage in LEGACY_LOCATION_STAGES: + with pytest.raises(SystemExit, match="block_v1 pipeline"): + builder._run_outer_stage( + SimpleNamespace( + stage=stage, checkpoint_dir=tmp_path, location_rule="block_v1" + ) + ) + + +def test_staged_export_records_rule_and_seed_in_summary_and_attrs( + builder, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + location_args = _location_args(tmp_path) + frame, record = builder._block_v1_household_location( + location_args, _located_source_frame() + ) + + class AllSignals(dict): + def __contains__(self, _key: object) -> bool: + return True + + def __getitem__(self, key: str) -> object: + return {"name": key, "passed": True} + + def fake_write(_args, _frame, path: Path) -> None: + with h5py.File(path, "w") as h5: + h5.attrs["fixture"] = "h5" + + monkeypatch.setattr(builder, "_ensure_capital_gains_tail_manifest", lambda _m: None) + monkeypatch.setattr(builder, "_write_policyengine_dataset", fake_write) + monkeypatch.setattr( + builder, "finalize_puf_e01000_reconciliation", lambda *_a, **_k: {} + ) + monkeypatch.setattr(builder, "_merged_stage_signals", lambda _m: AllSignals()) + monkeypatch.setattr(builder, "_channel_weight_totals", lambda _frame: {}) + monkeypatch.setattr(builder, "_channel_output_totals", lambda _frame: {}) + monkeypatch.setattr(builder, "us_immigration_composition_summary", lambda _f: {}) + args = SimpleNamespace( + out=tmp_path / "out", + target_year=2024, + seed=7, + n_estimators=4, + **vars(location_args), + ) + stage_metadata = { + "source_construction": { + "base_source": {"kind": "pooled_asec"}, + "base_rows": {"household": 12}, + "base_household_weight_total": 1.0, + }, + "pre_clone_enrichment": { + "acs_h5": None, + "acs_sha256": None, + "acs_rent_donor_rows": None, + "weeks_unemployed_source": {}, + }, + "clone_feature_extraction": { + "puf_h5": "puf.h5", + "puf_sha256": "puf-sha", + "puf_source_year_csv": None, + "puf_source_year_csv_sha256": None, + "puf_donor_rows": 0, + "puf_donor_columns": [], + "puf_donor_build_summary": {}, + "puf_e01000_reconciliation_basis": {}, + }, + "qrf_finalization": { + "weights_audit": {"passed": True}, + "puf_tax_detail_tail_bounds": [], + }, + builder.PUF_CAPITAL_GAINS_TAIL_STAGE_NAME: {}, + "household_location_assignment": { + "household_location": json.loads(json.dumps(record)) + }, + } + + result = builder._export_staged_result(args, frame, stage_metadata) + + summary = json.loads(Path(result["summary_path"]).read_text()) + assert summary["household_location"]["seed"] == 11 + assert summary["household_location"]["rule"] == US_BLOCK_LOCATION_RULE_ID + assert summary["congressional_district_assignment"]["applied"] is False + assert summary["geography_ladder_assignment"]["applied"] is False + ladder = load_us_location_ladder(location_args.block_ladder_artifact) + with h5py.File(result["output_h5"], "r") as h5: + attrs = dict(h5.attrs) + assert attrs["populace_location_rule"] == "block_v1" + assert attrs["populace_location_seed"] == "11" + assert attrs["populace_location_clones"] == "1" + assert attrs[GEOGRAPHY_LADDER_ARTIFACT_SHA256_ATTR] == ladder.sha256 + assert json.loads(attrs[GEOGRAPHY_LADDER_VINTAGES_ATTR]) == ladder.layer_vintages + # The block-ladder attrs every block_v1 line writes, under their own names. + assert attrs["populace_block_ladder_sha256"] == ladder.sha256 + assert ( + json.loads(attrs["populace_block_ladder_vintages"]) == ladder.layer_vintages + ) + assert attrs[CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR] == "119th_congress" + assert ( + attrs[CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR] + == (record["congressional_district_vintage_crosswalk_sha256"]) + ) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py b/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py index 2c5a35548c..6ef8b728fa 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py @@ -257,7 +257,19 @@ "congressional_district_geography.py", "congressional_district_vintage.py", "congressional_district_vintage_crosswalk.py", + # Household location v1 (#696): one block per household within its + # finest source geography; every geography derives from the block. + # Reached by the stacked pool under --location-rule block_v1 and + # scanned there like every other reached module. + "block_location.py", "cps_carried.py", + # Parser for Census's CPS ASEC identified-county list (List 4); no + # population treatment. + "cps_identified_county_sources.py", + # Joins each CPS household's GTCO/GESTFIPS by its (source_year, + # source_household_id) key for the block_v1 draw; no source-spine + # read. Remains subject to the all-runtime source-identity scan. + "cps_source_geography.py", "demographics.py", "education_assistance_source.py", "eligibility_inputs.py", @@ -3475,10 +3487,12 @@ def test_pool_build_tool_import_graph_is_source_spine_blind() -> None: # 73 = main's 70 plus spm_independence_role.py, spm_role_source.py and # spm_composition.py, reached because the pool's engine-input # projection names the SPM role as a required source input (#893). - # All three are classified in _OTHER_US_RUNTIME_MODULES and scanned - # below like every other reached module. - assert len(runtime_graph) == 73, ( - f"{tool.name} must reach the pinned 73-module runtime graph; " + # 75 adds block_location.py and cps_source_geography.py, which the + # --location-rule block_v1 household location reaches (#696). All are + # classified in _OTHER_US_RUNTIME_MODULES and scanned below like every + # other reached module. + assert len(runtime_graph) == 75, ( + f"{tool.name} must reach the pinned 75-module runtime graph; " f"reached {len(runtime_graph)}" ) assert not missing_modules, ( diff --git a/test_support/microcosm_build/us_block_location.py b/test_support/microcosm_build/us_block_location.py new file mode 100644 index 0000000000..6dc3f0f779 --- /dev/null +++ b/test_support/microcosm_build/us_block_location.py @@ -0,0 +1,110 @@ +"""Synthetic block ladders for US block-location tests. + +``write_location_ladder`` writes a schema-1 block-ladder NPZ (the loader's +required arrays and metadata) plus the additive per-block ``puma`` array, so +tests exercise the real loaders. ``synthetic_blocks`` builds a small nested +geography: states > counties > tracts > blocks, with PUMAs as unions of whole +tracts and congressional districts that cut across tracts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +LADDER_LAYERS = { + "congressional_district": {"vintage": "119th_congress", "source": "test CD119"}, + "sldu": {"vintage": "2020_baf", "source": "test SLDU"}, + "sldl": {"vintage": "2020_baf", "source": "test SLDL"}, + "place": {"vintage": "2020_census", "source": "test place"}, + "cbsa": {"vintage": "omb_2023_delineations", "source": "test CBSA"}, + "puma": {"vintage": "2020_puma", "source": "test tract-to-PUMA"}, +} + + +def ladder_metadata(*, with_puma: bool = True) -> dict: + layers = { + name: dict(spec) + for name, spec in LADDER_LAYERS.items() + if with_puma or name != "puma" + } + return { + "schema_version": 1, + "kind": "us_block_ladder", + "block_vintage": "2020_tabulation_blocks", + "sampling_basis": "population", + "layers": layers, + } + + +def synthetic_blocks(seed: int = 0, *, states=(1, 2, 36)) -> dict[str, np.ndarray]: + """A small nested ladder: 2-3 counties per state, 1-3 tracts per county, + 1-4 blocks per tract, 1-2 PUMAs per state (unions of whole tracts), 1-3 + districts per state assigned per block (districts cut across tracts).""" + + rng = np.random.default_rng(seed) + rows: list[tuple[int, int, int, int, str, str, int, int]] = [] + for state in states: + n_puma = int(rng.integers(1, 3)) + n_cd = int(rng.integers(1, 4)) + for county_index in range(int(rng.integers(2, 4))): + county = 1 + 2 * county_index + cbsa = int(rng.choice([0, 10000 + state * 10 + county_index])) + for tract_index in range(int(rng.integers(1, 4))): + tract = 100 * (tract_index + 1) + puma = state * 10**5 + 100 + int(rng.integers(0, n_puma)) + for block_index in range(int(rng.integers(1, 5))): + block = int( + f"{state:02d}{county:03d}{tract:06d}{1000 + block_index:04d}" + ) + population = int(rng.choice([1, 7, 40, 250, 3000])) + district = int(rng.integers(0, n_cd)) + (1 if n_cd > 1 else 0) + rows.append( + ( + block, + population, + state * 100 + district, + puma, + str(rng.choice(["001", "002", "00A"])), + str(rng.choice(["", "010", "011"])), + int(rng.choice([0, 12345, 67890])), + cbsa, + ) + ) + rows.sort() + return { + "block_geoid": np.asarray([row[0] for row in rows], dtype=np.int64), + "population": np.asarray([row[1] for row in rows], dtype=np.int64), + "congressional_district_geoid": np.asarray( + [row[2] for row in rows], dtype=np.int64 + ), + "puma": np.asarray([row[3] for row in rows], dtype=np.int64), + "sldu": np.asarray([row[4] for row in rows], dtype="U3"), + "sldl": np.asarray([row[5] for row in rows], dtype="U3"), + "place_fips": np.asarray([row[6] for row in rows], dtype=np.int32), + "cbsa_code": np.asarray([row[7] for row in rows], dtype=np.int32), + } + + +def write_location_ladder( + path: Path, + arrays: dict[str, np.ndarray] | None = None, + *, + with_puma: bool = True, + metadata: dict | None = None, +) -> Path: + """Write a block-ladder NPZ; ``arrays`` defaults to ``synthetic_blocks()``.""" + + payload = dict(arrays if arrays is not None else synthetic_blocks()) + if not with_puma: + payload.pop("puma", None) + payload["metadata_json"] = np.asarray( + json.dumps(metadata or ladder_metadata(with_puma=with_puma), sort_keys=True) + ) + np.savez_compressed(path, **payload) + return path + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/tools/_legacy/build_us_acs_multispine_base.py b/tools/_legacy/build_us_acs_multispine_base.py index 80dba82181..51b551f029 100644 --- a/tools/_legacy/build_us_acs_multispine_base.py +++ b/tools/_legacy/build_us_acs_multispine_base.py @@ -6,6 +6,15 @@ donor, assigns the PUMA-anchored state/CD/county geography ladder, audits every fit's resolved typed weight kind, and writes the combined base. Calibration is deliberately downstream. + +``--location-rule block_v1`` (microcosm#696; default ``legacy``) replaces the +PUMA-ladder assignment with one 2020 census block per household from +``--block-ladder``: ACS rows draw within their observed PUMA, donor rows keep a +block their base already carries (else draw within their state), and every +geography derives from the block. The block-location gate is then a hard +failure, and the rule, seed and both ladders' sha256 are recorded in the +summary (``household_location``, ``orchestration``, ``geography_ladder``) and +the staging H5's root attributes. The legacy default is unchanged. """ from __future__ import annotations @@ -15,6 +24,7 @@ import hashlib import json import os +from collections.abc import Mapping from dataclasses import replace from pathlib import Path from typing import Any @@ -24,6 +34,7 @@ from microcosm.build import ( FitWeightRecord, + GateResult, default_valued_columns_gate, weights_audit_gate, ) @@ -53,13 +64,34 @@ default_acs_transfer_target_families, resolve_acs_donor_channel, ) -from microcosm.build.us_runtime.base_pool import spine_column +from microcosm.build.us_runtime.base_pool import ( + ACS_POOL_LOCATION_CLONES_REFUSAL, + DONOR_BLOCK_PRESERVED, + DONOR_BLOCK_STATE_DRAWN, + spine_column, +) +from microcosm.build.us_runtime.block_location import ( + POPULACE_BLOCK_LADDER_SHA256_ATTR, + POPULACE_BLOCK_LADDER_VINTAGES_ATTR, + POPULACE_LOCATION_CLONES_ATTR, + POPULACE_LOCATION_RULE_ATTR, + POPULACE_LOCATION_SEED_ATTR, + US_LOCATION_RULE_BLOCK_V1, + US_LOCATION_RULE_CHOICES, + US_LOCATION_RULE_LEGACY, + UsLocationLadder, + load_us_location_ladder, + location_geography_columns, + us_block_location_gate, +) from microcosm.build.us_runtime.h5_io import ( assert_h5_unchanged, refuse_denied_frame, refuse_denied_pool_h5, ) from microcosm.build.us_runtime.puma_ladder import ( + PUMA_LADDER_ARTIFACT_SHA256_ATTR, + PUMA_LADDER_VINTAGES_ATTR, UsPumaLadder, load_us_puma_ladder, ) @@ -85,6 +117,16 @@ _PACKAGED_MANIFEST_REFERENCE = ( "package:microcosm.build.us_runtime/acs_2024_1yr_sources.json" ) +#: H5 root attributes that record a build's household location rule. The +#: base-H5 line writes the first three under ``block_v1``, and the stacked pool +#: writes all five; this line writes them (plus the PUMA ladder's +#: ``PUMA_LADDER_ARTIFACT_SHA256_ATTR`` / ``PUMA_LADDER_VINTAGES_ATTR``) on its +#: staging H5 only under ``block_v1`` (a legacy staging H5 is unchanged). +LOCATION_RULE_ATTR = POPULACE_LOCATION_RULE_ATTR +LOCATION_SEED_ATTR = POPULACE_LOCATION_SEED_ATTR +LOCATION_CLONES_ATTR = POPULACE_LOCATION_CLONES_ATTR +LOCATION_BLOCK_LADDER_SHA256_ATTR = POPULACE_BLOCK_LADDER_SHA256_ATTR +LOCATION_BLOCK_LADDER_VINTAGES_ATTR = POPULACE_BLOCK_LADDER_VINTAGES_ATTR def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -146,6 +188,45 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: type=_nonnegative_int, help="Deterministic PUMA/CD/county assignment seed (default: 0).", ) + parser.add_argument( + "--location-rule", + choices=US_LOCATION_RULE_CHOICES, + default=US_LOCATION_RULE_LEGACY, + help=( + "Household location rule (default: legacy, the PUMA-ladder draw " + "seeded by --geography-seed). block_v1 (microcosm#696) gives every " + "household one 2020 census block drawn by population within its " + "finest source geography (ACS: observed PUMA; an ASEC-by-PUF donor " + "keeps a block its base already carries, else draws within its " + "state) and derives every other geography from the block. " + "Requires --block-ladder." + ), + ) + parser.add_argument( + "--location-seed", + default=0, + type=_nonnegative_int, + help="block_v1 location seed (default: 0; legacy refuses non-zero).", + ) + parser.add_argument( + "--location-clones", + default=1, + type=_positive_int, + help=( + "block_v1 location clones per household (default: 1; values above " + "1 are refused on this line for now, see the error for why)." + ), + ) + parser.add_argument( + "--block-ladder", + type=Path, + help=( + "Block-ladder NPZ carrying the per-block 2020 PUMA (required by, " + "and only accepted with, --location-rule block_v1). The PUMA " + "ladder is still loaded for its coverage summary and downstream " + "population targets." + ), + ) parser.add_argument( "--n-estimators", default=DEFAULT_N_ESTIMATORS, @@ -228,10 +309,81 @@ def _donor_release_identity( } +def _validate_location_arguments(args: argparse.Namespace) -> None: + """Refuse location flags the selected rule would silently ignore.""" + + if args.location_rule == US_LOCATION_RULE_LEGACY: + if args.location_seed != 0 or args.location_clones != 1: + raise SystemExit( + "--location-seed and --location-clones apply only to " + "--location-rule block_v1; the legacy rule is seeded by " + f"--geography-seed (got --location-seed {args.location_seed}, " + f"--location-clones {args.location_clones})." + ) + if args.block_ladder is not None: + raise SystemExit( + "--block-ladder is only used by --location-rule block_v1; the " + "legacy rule assigns geography from --puma-ladder." + ) + return + if args.block_ladder is None: + raise SystemExit("--location-rule block_v1 requires --block-ladder.") + if args.location_clones > 1: + raise SystemExit(ACS_POOL_LOCATION_CLONES_REFUSAL) + if args.geography_seed != 0: + raise SystemExit( + "--geography-seed seeds only the legacy PUMA-ladder draw; under " + "--location-rule block_v1 use --location-seed (refusing " + f"--geography-seed {args.geography_seed} rather than ignoring it)." + ) + + +def _base_location_rule(path: Path) -> dict[str, object]: + """The donor base's recorded location rule, from its H5 root attributes. + + A base H5 without :data:`LOCATION_RULE_ATTR` predates the attribute, so + its rule is recorded as ``legacy``. + """ + + with pd.HDFStore(path, mode="r") as store: + attributes = store.get_node("/")._v_attrs + names = set(attributes._v_attrnames) + recorded = { + key: _attribute_text(attributes[key]) + for key in (LOCATION_RULE_ATTR, LOCATION_SEED_ATTR, LOCATION_CLONES_ATTR) + if key in names + } + return { + "location_rule": recorded.get(LOCATION_RULE_ATTR, US_LOCATION_RULE_LEGACY), + "recorded": LOCATION_RULE_ATTR in recorded, + "attributes": recorded, + } + + +def _attribute_text(value: object) -> str: + if isinstance(value, bytes): + return value.decode("utf-8") + if isinstance(value, np.ndarray) and value.shape == (): + return _attribute_text(value.item()) + return str(value) + + def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) summary_path = args.summary or args.out_h5.with_suffix(".summary.json") _validate_artifact_paths(args, summary_path=summary_path) + _validate_location_arguments(args) + block_location = args.location_rule != US_LOCATION_RULE_LEGACY + block_ladder: UsLocationLadder | None = None + base_location: dict[str, object] | None = None + if block_location: + block_ladder = load_us_location_ladder(args.block_ladder) + if block_ladder.puma is None: + raise SystemExit( + f"--block-ladder {args.block_ladder} carries no per-block 'puma' " + "array; block_v1 ACS rows draw within their observed PUMA." + ) + base_location = _base_location_rule(args.base_h5) manifest = acs_sources.load_acs_source_manifest(args.source_manifest) manifest_file = _manifest_file(args.source_manifest) @@ -250,6 +402,10 @@ def main(argv: list[str] | None = None) -> int: base_mass = float(base.weights_for("household").total) puma_ladder_sha256 = _sha256(args.puma_ladder) puma_ladder = load_us_puma_ladder(args.puma_ladder) + ladder_agreement: dict[str, object] | None = None + if block_location: + assert block_ladder is not None + ladder_agreement = _block_puma_ladder_agreement(block_ladder, puma_ladder) source = acs_sources.fetch_acs_pums_sources( args.inputs_dir, @@ -258,6 +414,15 @@ def main(argv: list[str] | None = None) -> int: if args.max_households is not None: source = replace(source, max_households=args.max_households) + # The legacy call is unchanged; block_v1 adds its options only when set. + location_options: dict[str, object] = {} + if block_location: + location_options = { + "location_rule": args.location_rule, + "block_ladder": block_ladder, + "location_seed": args.location_seed, + "location_clones": args.location_clones, + } result = build_optional_acs_multispine( base, source, @@ -274,8 +439,13 @@ def main(argv: list[str] | None = None) -> int: max_targets_per_fit=args.max_targets_per_fit, puma_ladder=puma_ladder, geography_seed=args.geography_seed, + **location_options, ) _require_puma_ladder_assignment(result) + location_gate = None + if block_location: + assert block_ladder is not None + location_gate = _require_block_location(result, block_ladder) _require_benefit_participation_transfer(result) transfer_coverage = _require_default_transfer_coverage( result, @@ -301,7 +471,24 @@ def main(argv: list[str] | None = None) -> int: args.out_h5.parent.mkdir(parents=True, exist_ok=True) staging_export_peak_bytes = _preflight_staging_export(result.frame) - _write_dataset(result.frame, args.out_h5, period=args.period) + if block_location: + assert block_ladder is not None + _write_dataset( + result.frame, + args.out_h5, + period=args.period, + root_attributes=location_root_attributes( + location_rule=args.location_rule, + location_seed=args.location_seed, + location_clones=args.location_clones, + block_ladder_sha256=str(block_ladder.sha256), + block_ladder_vintages=block_ladder.layer_vintages, + puma_ladder_sha256=puma_ladder_sha256, + puma_ladder_vintages=puma_ladder.layer_vintages, + ), + ) + else: + _write_dataset(result.frame, args.out_h5, period=args.period) summary = _build_summary( args=args, @@ -321,6 +508,17 @@ def main(argv: list[str] | None = None) -> int: puma_ladder=puma_ladder, puma_ladder_sha256=puma_ladder_sha256, ) + if block_location: + assert block_ladder is not None and location_gate is not None + summary["household_location"] = _household_location_summary( + args, + result, + block_ladder=block_ladder, + puma_ladder_sha256=puma_ladder_sha256, + base_location=base_location, + ladder_agreement=ladder_agreement, + gate=location_gate, + ) summary["local_hours_source"] = result.provenance.get("local_hours_source") summary["local_hours_gate"] = { "name": hours_gate.name, @@ -521,6 +719,182 @@ def _require_puma_ladder_assignment(result: AcsMultispineResult) -> None: ) +def _require_block_location( + result: AcsMultispineResult, block_ladder: UsLocationLadder +) -> GateResult: + """Hard-gate a ``block_v1`` pool: every geography is its block's lookup.""" + + household = result.frame.table("household") + columns = location_geography_columns(block_ladder) + missing = [column for column in columns if column not in household] + if missing: + raise SystemExit(f"Block location omitted household column(s): {missing}.") + nulls = { + column: int(household[column].isna().sum()) + for column in columns + if household[column].isna().any() + } + if nulls: + raise SystemExit(f"Block location left null household values: {nulls}.") + if not isinstance(result.provenance.get("household_location"), dict): + raise SystemExit( + "ACS multispine did not record the block_v1 household location." + ) + gate = us_block_location_gate(household, block_ladder) + if not gate.passed: + raise SystemExit("Block location gate failed: " + "; ".join(gate.failures)) + return gate + + +def _block_puma_ladder_agreement( + block_ladder: UsLocationLadder, puma_ladder: UsPumaLadder +) -> dict[str, object]: + """Refuse a block/PUMA ladder pair that is not the same 2020 geography. + + Under ``block_v1`` each household's PUMA and congressional district come + from the block ladder, while the local release still takes its state and + CD population targets from the PUMA ladder + (``tools/build_us_acs_local_release.py:ladder_population``). The pair + must therefore carry the same PUMAs, the same districts and the same CD + vintage; that is checked here, before the transfer runs. The largest + per-state and per-district population differences are recorded, not + refused. + """ + + assert block_ladder.puma is not None # checked when the ladder is loaded + plan = block_ladder.primary_congressional_district_plan + puma_cd_vintage = puma_ladder.layer_vintages["congressional_district"] + if plan != puma_cd_vintage: + raise SystemExit( + f"--block-ladder assigns {plan!r} congressional districts but " + f"--puma-ladder's population targets use {puma_cd_vintage!r}." + ) + population = np.asarray(block_ladder.population, dtype=np.float64) + block_state = block_ladder.block_geoid // 10**13 + block_cd = np.asarray( + block_ladder.congressional_district_plans[plan], dtype=np.int64 + ) + puma_state = np.asarray(puma_ladder.puma, dtype=np.int64) // 100_000 + comparisons = { + "pumas": ( + pd.Series(population).groupby(block_ladder.puma).sum(), + pd.Series(np.asarray(puma_ladder.puma_population, dtype=np.float64)) + .groupby(np.asarray(puma_ladder.puma, dtype=np.int64)) + .sum(), + ), + "states": ( + pd.Series(population).groupby(block_state).sum(), + pd.Series(np.asarray(puma_ladder.puma_population, dtype=np.float64)) + .groupby(puma_state) + .sum(), + ), + "congressional_districts": ( + pd.Series(population).groupby(block_cd).sum(), + pd.Series(np.asarray(puma_ladder.cd_overlap_population, dtype=np.float64)) + .groupby(np.asarray(puma_ladder.cd_overlap_cd, dtype=np.int64)) + .sum(), + ), + } + record: dict[str, object] = {"congressional_district_plan": plan} + mismatched: list[str] = [] + for name, (block_totals, puma_totals) in comparisons.items(): + only_block = sorted(set(block_totals.index) - set(puma_totals.index)) + only_puma = sorted(set(puma_totals.index) - set(block_totals.index)) + shared = block_totals.index.intersection(puma_totals.index) + difference = (block_totals[shared] - puma_totals[shared]).abs() + record[name] = { + "block_ladder": int(len(block_totals)), + "puma_ladder": int(len(puma_totals)), + "only_in_block_ladder": [int(value) for value in only_block[:10]], + "only_in_puma_ladder": [int(value) for value in only_puma[:10]], + "max_abs_population_difference": ( + float(difference.max()) if len(difference) else 0.0 + ), + } + if only_block or only_puma: + mismatched.append( + f"{name}: {len(only_block)} only in --block-ladder " + f"(e.g. {only_block[:3]}), {len(only_puma)} only in " + f"--puma-ladder (e.g. {only_puma[:3]})" + ) + if mismatched: + raise SystemExit( + "--block-ladder and --puma-ladder are not the same 2020 geography: " + + "; ".join(mismatched) + + "." + ) + return record + + +def location_root_attributes( + *, + location_rule: str, + location_seed: int, + location_clones: int, + block_ladder_sha256: str, + block_ladder_vintages: Mapping[str, str], + puma_ladder_sha256: str, + puma_ladder_vintages: Mapping[str, str], +) -> dict[str, str]: + """H5 root attributes recording a ``block_v1`` location step. + + The block ladder located every household; the PUMA ladder is still pinned + because the local release draws its state and CD population targets from + it. ``tools/build_us_acs_local_release.py`` writes the same attributes on + the calibrated artifact from the staging summary. + """ + + return { + LOCATION_RULE_ATTR: location_rule, + LOCATION_SEED_ATTR: str(int(location_seed)), + LOCATION_CLONES_ATTR: str(int(location_clones)), + LOCATION_BLOCK_LADDER_SHA256_ATTR: str(block_ladder_sha256), + LOCATION_BLOCK_LADDER_VINTAGES_ATTR: json.dumps( + dict(block_ladder_vintages), sort_keys=True + ), + PUMA_LADDER_ARTIFACT_SHA256_ATTR: str(puma_ladder_sha256), + PUMA_LADDER_VINTAGES_ATTR: json.dumps( + dict(puma_ladder_vintages), sort_keys=True + ), + } + + +def _household_location_summary( + args: argparse.Namespace, + result: AcsMultispineResult, + *, + block_ladder: UsLocationLadder, + puma_ladder_sha256: str, + base_location: dict[str, object] | None, + ladder_agreement: dict[str, object] | None, + gate: GateResult, +) -> dict[str, object]: + """The staging summary's ``household_location`` block (``block_v1`` only).""" + + record = result.provenance["household_location"] + assert isinstance(record, dict) + return { + **record, + "block_ladder_path": str(args.block_ladder.resolve()), + "puma_ladder": { + "path": str(args.puma_ladder.resolve()), + "sha256": puma_ladder_sha256, + "role": ( + "coverage summary and downstream state/CD population targets; " + "not used to assign geography under block_v1" + ), + }, + "donor_base_location": base_location, + "ladder_agreement": ladder_agreement, + "gate": { + "name": gate.name, + "passed": gate.passed, + "failures": list(gate.failures), + "details": dict(gate.details), + }, + } + + def _require_default_transfer_coverage( result: AcsMultispineResult, donor: Frame, @@ -771,68 +1145,141 @@ def is_exact_gq_null(entry: dict[str, object]) -> bool: "engine_input_nulls_excluding_group_quarters_housing": (other_native_nulls), "calibration_blocker": False, }, - { - "id": "sub_puma_geographic_precision", - "status": "reviewed_probabilistic_assignment", - "observed_geography": { - "acs_2024_1yr": ["state_fips", "puma"], - "asec_puf": ( - [ - "state_fips", - "tract_geoid", - "congressional_district_geoid", - "county_fips", - ] + ( + _block_v1_sub_puma_limitation(geography) + if geography.get("location_rule") == US_LOCATION_RULE_BLOCK_V1 + else { + "id": "sub_puma_geographic_precision", + "status": "reviewed_probabilistic_assignment", + "observed_geography": { + "acs_2024_1yr": ["state_fips", "puma"], + "asec_puf": ( + [ + "state_fips", + "tract_geoid", + "congressional_district_geoid", + "county_fips", + ] + if geography.get("donor_geography") == "preserved_assigned" + else ["state_fips"] + ), + }, + "assigned_geography": [ + "puma", + "congressional_district_geoid", + "county_fips", + ], + "donor_geography": geography.get("donor_geography"), + "unavailable_exact_geography": list( + geography.get( + "unresolved_sub_puma_inputs", + ["block_geoid", "tract_geoid"], + ) + ), + "unavailable_exact_geography_scope": ( + "acs_2024_1yr" if geography.get("donor_geography") == "preserved_assigned" - else ["state_fips"] + else "acs_2024_1yr,asec_puf" ), - }, - "assigned_geography": [ - "puma", - "congressional_district_geoid", - "county_fips", - ], - "donor_geography": geography.get("donor_geography"), - "unavailable_exact_geography": list( - geography.get( - "unresolved_sub_puma_inputs", - ["block_geoid", "tract_geoid"], - ) - ), - "unavailable_exact_geography_scope": ( - "acs_2024_1yr" - if geography.get("donor_geography") == "preserved_assigned" - else "acs_2024_1yr,asec_puf" - ), - "reason": ( - "ACS PUMS identifies residence only through state and 2020 " - "PUMA; exact block, tract, county, and congressional district " - "cannot be recovered for a source microrecord." - ), - "treatment": ( - ( - "Retain each ACS record's observed PUMA and assign its " - "119th-CD/county from official population-weighted PUMA " - "overlaps using the recorded seed. Donor records keep " - "their certified block-ladder district/county, with PUMA " - "derived exactly from the assigned 2020 tract. Do not " - "synthesize ACS block or tract." - ) - if geography.get("donor_geography") == "preserved_assigned" - else ( - "Retain each ACS record's observed PUMA, draw ASEC PUMA " - "within native state, and assign 119th-CD/county from " - "official population-weighted PUMA overlaps using the " - "recorded seed. Do not synthesize block or tract." - ) - ), - "assignment_seed": geography.get("seed"), - "layer_vintages": geography.get("layer_vintages", {}), - "calibration_blocker": False, - }, + "reason": ( + "ACS PUMS identifies residence only through state and 2020 " + "PUMA; exact block, tract, county, and congressional district " + "cannot be recovered for a source microrecord." + ), + "treatment": ( + ( + "Retain each ACS record's observed PUMA and assign its " + "119th-CD/county from official population-weighted PUMA " + "overlaps using the recorded seed. Donor records keep " + "their certified block-ladder district/county, with PUMA " + "derived exactly from the assigned 2020 tract. Do not " + "synthesize ACS block or tract." + ) + if geography.get("donor_geography") == "preserved_assigned" + else ( + "Retain each ACS record's observed PUMA, draw ASEC PUMA " + "within native state, and assign 119th-CD/county from " + "official population-weighted PUMA overlaps using the " + "recorded seed. Do not synthesize block or tract." + ) + ), + "assignment_seed": geography.get("seed"), + "layer_vintages": geography.get("layer_vintages", {}), + "calibration_blocker": False, + } + ), ] +def _block_v1_sub_puma_limitation(geography: dict[str, object]) -> dict[str, object]: + """The ``sub_puma_geographic_precision`` limitation under ``block_v1``. + + Every household carries one 2020 block and every geography derived from + it; what stays unknowable is which block inside the source geography a + record's respondent actually lives in. + """ + + donor_geography = geography.get("donor_geography") + columns = list(geography.get("resolved_model_inputs", [])) + if donor_geography == DONOR_BLOCK_PRESERVED: + donor_observed: object = ["state_fips", "block_geoid"] + donor_reason = ( + "An ASEC-by-PUF donor's block is the one its base-H5 build assigned " + "(donor_base_location in the staging summary's household_location " + "records that build's rule); this stage keeps it rather than " + "redrawing it." + ) + elif donor_geography == DONOR_BLOCK_STATE_DRAWN: + donor_observed = ["state_fips"] + donor_reason = ( + "The donor base carries no block_geoid, so each ASEC-by-PUF " + "donor's finest known geography here is its state; this stage " + "draws its block within that state." + ) + else: + donor_observed = { + "with_base_block": ["state_fips", "block_geoid"], + "without_base_block": ["state_fips"], + } + donor_reason = ( + "ASEC-by-PUF donors that carry a base-H5 block keep it (never " + "redrawn); donors without one draw a block within their state " + "(counts in household_location.donor_blocks)." + ) + return { + "id": "sub_puma_geographic_precision", + "status": "reviewed_probabilistic_assignment", + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "assignment_rule": geography.get("assignment_rule"), + "observed_geography": { + "acs_2024_1yr": ["state_fips", "puma"], + "asec_puf": donor_observed, + }, + "assigned_geography": columns, + "donor_geography": donor_geography, + "unavailable_exact_geography": [], + "unavailable_exact_geography_scope": None, + "reason": ( + "ACS PUMS identifies residence only through state and 2020 PUMA, " + "so the block an ACS household occupies cannot be recovered for " + "a source microrecord; it is drawn. " + donor_reason + ), + "treatment": ( + "Give each ACS household one 2020 census block drawn with " + "probability proportional to 2020 block population within its " + "observed PUMA, keep each donor's already-assigned block (a donor " + "without one draws within its state), and derive block, tract, " + "county, place, SLDU/SLDL, CBSA, PUMA and congressional district " + "from the block through the block ladder. ACS rows therefore now " + "carry block/tract/place/SLD/CBSA, and no household's geographies " + "contradict each other." + ), + "assignment_seed": geography.get("seed"), + "layer_vintages": geography.get("block_layer_vintages", {}), + "calibration_blocker": False, + } + + def _acs_group_quarters_counts(frame: Frame) -> dict[str, int]: household = frame.table("household") person = frame.table("person") @@ -886,6 +1333,49 @@ def _build_summary( ) -> dict[str, object]: output_rows = _row_counts(result.frame) output_mass = float(result.frame.weights_for("household").total) + block_location = args.location_rule != US_LOCATION_RULE_LEGACY + geography_ladder: dict[str, object] = { + "path": str(args.puma_ladder.resolve()), + "sha256": puma_ladder_sha256, + "pumas": len(puma_ladder), + "layer_vintages": puma_ladder.layer_vintages, + "seed": args.geography_seed, + "assignment": result.provenance["geography_ladder"], + } + orchestration: dict[str, object] = { + "chunksize": args.chunksize, + "acs_share": args.acs_share, + "max_households": args.max_households, + "seed": args.seed, + "geography_seed": args.geography_seed, + "n_estimators": args.n_estimators, + "max_targets_per_fit": args.max_targets_per_fit, + "donor_channel": args.donor_channel, + "provenance": result.provenance, + } + if block_location: + # The PUMA ladder no longer assigns geography; its sha stays pinned + # (population targets) and the block ladder's is recorded beside it. + household_location = result.provenance["household_location"] + assert isinstance(household_location, dict) + geography_ladder.update( + { + "seed": args.location_seed, + "location_rule": args.location_rule, + "block_ladder": { + "path": str(args.block_ladder.resolve()), + **dict(household_location["block_ladder"]), + }, + } + ) + orchestration.update( + { + "location_rule": args.location_rule, + "location_seed": args.location_seed, + "location_clones": args.location_clones, + "block_ladder_sha256": household_location["block_ladder"]["sha256"], + } + ) return { "version": 1, "stage": "acs_2024_1yr_multispine_base", @@ -913,25 +1403,8 @@ def _build_summary( manifest_path=args.source_manifest, manifest_sha256=manifest_sha256, ), - "geography_ladder": { - "path": str(args.puma_ladder.resolve()), - "sha256": puma_ladder_sha256, - "pumas": len(puma_ladder), - "layer_vintages": puma_ladder.layer_vintages, - "seed": args.geography_seed, - "assignment": result.provenance["geography_ladder"], - }, - "orchestration": { - "chunksize": args.chunksize, - "acs_share": args.acs_share, - "max_households": args.max_households, - "seed": args.seed, - "geography_seed": args.geography_seed, - "n_estimators": args.n_estimators, - "max_targets_per_fit": args.max_targets_per_fit, - "donor_channel": args.donor_channel, - "provenance": result.provenance, - }, + "geography_ladder": geography_ladder, + "orchestration": orchestration, "weights_audit": weights_audit, "transfer_coverage": transfer_coverage, "reviewed_engine_input_nulls": input_null_audit, @@ -1016,6 +1489,9 @@ def _validate_artifact_paths( ladder = args.puma_ladder.resolve() if ladder == output: raise SystemExit("--puma-ladder must differ from --out-h5.") + block_ladder = getattr(args, "block_ladder", None) + if block_ladder is not None and block_ladder.resolve() in {output, summary}: + raise SystemExit("--block-ladder must differ from --out-h5 and --summary.") def _spine_totals(frame: Frame) -> dict[str, dict[str, Any]]: @@ -1085,8 +1561,13 @@ def _write_dataset( *, period: int, artifact_kind: str = "nullable_precalibration_staging_h5", + root_attributes: dict[str, str] | None = None, ) -> None: - """Write a microcosm US H5 with one-table-at-a-time verification.""" + """Write a microcosm US H5 with one-table-at-a-time verification. + + ``root_attributes`` (text values) are set on the HDF5 root node and read + back; the default writes none, so a legacy file is unchanged. + """ output = Path(path) output.unlink(missing_ok=True) @@ -1127,8 +1608,19 @@ def _write_dataset( ), format="table", ) + if root_attributes: + node_attributes = store.get_node("/")._v_attrs + for key, value in root_attributes.items(): + node_attributes[key] = str(value) with pd.HDFStore(output, mode="r") as store: + if root_attributes: + stored_attributes = store.get_node("/")._v_attrs + for key, value in root_attributes.items(): + if _attribute_text(stored_attributes[key]) != str(value): + raise RuntimeError( + f"Staging H5 root attribute {key!r} did not round-trip." + ) for entity in frame.entities: expected = frame.table(entity) if not len(expected): diff --git a/tools/build_us_acs_local_release.py b/tools/build_us_acs_local_release.py index 7d0efaad99..497316070e 100644 --- a/tools/build_us_acs_local_release.py +++ b/tools/build_us_acs_local_release.py @@ -146,6 +146,166 @@ def release_refresh_recipe(soi_mode: str) -> str: ) +#: The staging summary's ``orchestration.location_rule`` value that marks a +#: ``block_v1`` staging run (``tools/_legacy/build_us_acs_multispine_base.py +#: --location-rule block_v1``). A summary without the key is a legacy run. +BLOCK_V1_LOCATION_RULE = "block_v1" + + +def staging_location_rule(staging_summary: dict) -> str: + """The household location rule the staging run recorded (default legacy).""" + + orchestration = staging_summary.get("orchestration") + if not isinstance(orchestration, dict): + return "legacy" + return str(orchestration.get("location_rule", "legacy")) + + +def staging_household_location(staging_summary: dict) -> dict | None: + """The ``block_v1`` location record, or ``None`` for a legacy staging run. + + A summary that says ``block_v1`` but lacks the record (or its block-ladder + path and sha) is refused: finalize could not re-run the block gate. + """ + + rule = staging_location_rule(staging_summary) + if rule == "legacy": + return None + if rule != BLOCK_V1_LOCATION_RULE: + raise SystemExit( + f"The staging summary records an unknown location_rule {rule!r}." + ) + record = staging_summary.get("household_location") + ladder = record.get("block_ladder") if isinstance(record, dict) else None + if ( + not isinstance(record, dict) + or not isinstance(ladder, dict) + or not ladder.get("sha256") + or not record.get("block_ladder_path") + ): + raise SystemExit( + "The staging summary records location_rule block_v1 but no " + "household_location block-ladder path and sha256; re-run staging " + "with the current builder." + ) + return record + + +def staging_refresh_recipe(staging_summary: dict) -> str: + """The staging command that reproduces the recorded location rule. + + A legacy staging run's recipe is :data:`LEGACY_STAGING_REFRESH_RECIPE`, + unchanged; a ``block_v1`` run's names its rule, seed and block ladder. + """ + + if staging_household_location(staging_summary) is None: + return LEGACY_STAGING_REFRESH_RECIPE + orchestration = staging_summary["orchestration"] + return ( + f"{LEGACY_STAGING_REFRESH_RECIPE} " + f"--location-rule {BLOCK_V1_LOCATION_RULE} " + f"--location-seed {int(orchestration.get('location_seed', 0))} " + "--block-ladder " + ) + + +def calibrated_location_root_attributes( + staging_summary: dict, *, targets_ladder_sha256: str | None = None +) -> dict | None: + """H5 root attributes recording a ``block_v1`` staging run's location rule. + + ``None`` for a legacy staging run, whose calibrated artifact is written + exactly as before. ``targets_ladder_sha256`` is the PUMA ladder the + population targets were materialized from (run identity + ``ladder_sha256``); under ``block_v1`` it must be the ladder staging + recorded, because the attribute names that ladder as the targets' source. + """ + + record = staging_household_location(staging_summary) + if record is None: + return None + from build_us_acs_multispine_base import _legacy + + orchestration = staging_summary["orchestration"] + geography_ladder = staging_summary.get("geography_ladder") or {} + if not geography_ladder.get("sha256"): + raise SystemExit( + "The block_v1 staging summary records no PUMA-ladder sha256 " + "(geography_ladder.sha256); refusing to write location attributes " + "that cannot name it." + ) + require_block_v1_targets_ladder(staging_summary, targets_ladder_sha256) + return _legacy.location_root_attributes( + location_rule=BLOCK_V1_LOCATION_RULE, + location_seed=int(orchestration.get("location_seed", record.get("seed", 0))), + location_clones=int( + orchestration.get("location_clones", record.get("clones", 1)) + ), + block_ladder_sha256=str(record["block_ladder"]["sha256"]), + block_ladder_vintages=dict(record["block_ladder"].get("layer_vintages") or {}), + puma_ladder_sha256=str(geography_ladder["sha256"]), + puma_ladder_vintages=dict(geography_ladder.get("layer_vintages") or {}), + ) + + +def require_block_v1_targets_ladder( + staging_summary: dict, targets_ladder_sha256: str | None +) -> None: + """Refuse a block_v1 release whose targets ladder is not the staged one. + + Staging checked the block ladder against its ``--puma-ladder``; the + release's state and CD population targets come from ``--ladder``. Under + ``block_v1`` they must be the same file, or the recorded ladder agreement + and ``populace_puma_ladder_artifact_sha256`` would name a ladder the + calibration never used. + """ + + if staging_household_location(staging_summary) is None: + return + staged = (staging_summary.get("geography_ladder") or {}).get("sha256") + if targets_ladder_sha256 is not None and targets_ladder_sha256 != staged: + raise SystemExit( + f"--ladder (sha {str(targets_ladder_sha256)[:12]}…) is not the PUMA " + f"ladder the block_v1 staging run recorded ({str(staged)[:12]}…); " + "the calibrated artifact and the ladder-agreement check name that " + "ladder." + ) + + +def load_staging_block_ladder(staging_summary: dict, override: Path | None = None): + """Load the block ladder a ``block_v1`` staging run recorded, sha-verified. + + ``override`` relocates the file (another machine or volume); its sha256 + must still equal the one the staging summary recorded. + """ + + from microcosm.build.us_runtime.block_location import load_us_location_ladder + + record = staging_household_location(staging_summary) + if record is None: + if override is not None: + raise SystemExit( + "--block-ladder applies only to a block_v1 staging run; this " + "staging summary records the legacy location rule." + ) + return None + path = Path(override or record["block_ladder_path"]) + expected = record["block_ladder"]["sha256"] + if not path.exists(): + raise SystemExit( + f"Block ladder {path} (recorded by the block_v1 staging run) is " + "missing; pass --block-ladder with the same bytes." + ) + actual = _sha256(path) + if actual != expected: + raise SystemExit( + f"Block ladder {path} has sha256 {actual[:12]}…, not the " + f"{str(expected)[:12]}… the block_v1 staging run located households " + "with." + ) + return load_us_location_ladder(path) + + # --------------------------------------------------------------------------- # Shared telemetry # --------------------------------------------------------------------------- @@ -1234,12 +1394,28 @@ def _write_calibrated_artifact(args, weights: np.ndarray, identity: dict) -> Non projected.strata, mass_log=projected.mass_log, ) - _write_dataset( - calibrated, - Path(args.out_h5), - period=PERIOD, - artifact_kind="calibrated_local_area_artifact", + location_attributes = calibrated_location_root_attributes( + _load_json(_staging_summary_path(args)), + targets_ladder_sha256=identity.get("ladder_sha256"), ) + if location_attributes is None: + _write_dataset( + calibrated, + Path(args.out_h5), + period=PERIOD, + artifact_kind="calibrated_local_area_artifact", + ) + else: + # block_v1: the shim's writer plus the location root attributes. + from microcosm.build.us_runtime.h5_io import write_nullable_us_h5 + + write_nullable_us_h5( + calibrated, + Path(args.out_h5), + period=PERIOD, + artifact_kind="calibrated_local_area_artifact", + root_attributes=location_attributes, + ) (args.checkpoint_dir / "consumer_export.json").write_text( json.dumps( { @@ -1424,6 +1600,7 @@ def finalize_reviewed_limitations( ess_fraction = diagnostics.get("ess_fraction", 0.0) households = diagnostics.get("households") or 0 donor_release = (staging_summary.get("base") or {}).get("donor_release") or {} + household_location = staging_household_location(staging_summary) limitations = list(staging_summary.get("reviewed_limitations", [])) aged_ssi = (spine_qa or {}).get("per_spine", {}) limitations += [ @@ -1476,17 +1653,21 @@ def finalize_reviewed_limitations( ), "calibration_blocker": False, }, - { - "id": "cd_population_marginal_vintage_2020", - "status": "reviewed_vintage", - "reason": ( - "Congressional-district population marginals use " - "119th-boundary / 2020-apportionment Census populations from " - "the PUMA ladder; the build period is 2024. Boundaries are " - "2020-census-drawn (honest v1)." - ), - "calibration_blocker": False, - }, + ( + _block_v1_cd_population_limitation(household_location) + if household_location is not None + else { + "id": "cd_population_marginal_vintage_2020", + "status": "reviewed_vintage", + "reason": ( + "Congressional-district population marginals use " + "119th-boundary / 2020-apportionment Census populations from " + "the PUMA ladder; the build period is 2024. Boundaries are " + "2020-census-drawn (honest v1)." + ), + "calibration_blocker": False, + } + ), { "id": "low_effective_sample_size_lambda_zero", "status": "reviewed_concentration", @@ -1513,25 +1694,29 @@ def finalize_reviewed_limitations( ), "calibration_blocker": False, }, - { - "id": "mixed_sub_puma_column_coverage", - "status": "reviewed_construction", - "columns": [ - "block_geoid", - "tract_geoid", - "cbsa_code", - "place_fips", - "sldl", - "sldu", - ], - "reason": ( - "Donor rows keep their certified block-ladder geography " - "columns; ACS rows carry no sub-PUMA geography, so these " - "columns are donor-spine-only. Consumers filtering on them " - "must scope to the asec_puf spine." - ), - "calibration_blocker": False, - }, + ( + _block_v1_sub_puma_column_limitation(household_location) + if household_location is not None + else { + "id": "mixed_sub_puma_column_coverage", + "status": "reviewed_construction", + "columns": [ + "block_geoid", + "tract_geoid", + "cbsa_code", + "place_fips", + "sldl", + "sldu", + ], + "reason": ( + "Donor rows keep their certified block-ladder geography " + "columns; ACS rows carry no sub-PUMA geography, so these " + "columns are donor-spine-only. Consumers filtering on them " + "must scope to the asec_puf spine." + ), + "calibration_blocker": False, + } + ), ] deduped: dict[str, dict] = {} for limitation in limitations: @@ -1539,6 +1724,79 @@ def finalize_reviewed_limitations( return list(deduped.values()) +def _block_v1_cd_population_limitation(household_location: dict) -> dict: + """``cd_population_marginal_vintage_2020`` for a ``block_v1`` staging run.""" + + vintages = (household_location.get("block_ladder") or {}).get( + "layer_vintages" + ) or {} + plan = vintages.get("congressional_district", "unrecorded") + agreement = household_location.get("ladder_agreement") + districts = ( + agreement.get("congressional_districts") + if isinstance(agreement, dict) + else None + ) + checked = ( + ( + " The staging run checked that the two ladders carry the same " + "PUMAs, states and districts; the largest per-district population " + "difference between them was " + f"{districts.get('max_abs_population_difference')}." + ) + if isinstance(districts, dict) + else "" + ) + return { + "id": "cd_population_marginal_vintage_2020", + "status": "reviewed_vintage", + "reason": ( + "Congressional-district population marginals use " + "119th-boundary / 2020-apportionment Census populations from " + "the PUMA ladder; the build period is 2024. Each household's " + "congressional district is the block ladder's " + f"{plan!r} district of its one 2020 census block " + f"({household_location.get('rule')}).{checked} Boundaries are " + "2020-census-drawn (honest v1)." + ), + "location_rule": BLOCK_V1_LOCATION_RULE, + "household_district_plan": plan, + "ladder_agreement": agreement, + "calibration_blocker": False, + } + + +def _block_v1_sub_puma_column_limitation(household_location: dict) -> dict: + """``mixed_sub_puma_column_coverage`` for a ``block_v1`` staging run.""" + + donor_blocks = household_location.get("donor_blocks") or {} + return { + "id": "mixed_sub_puma_column_coverage", + "status": "reviewed_construction", + "location_rule": BLOCK_V1_LOCATION_RULE, + "columns": [ + "block_geoid", + "tract_geoid", + "cbsa_code", + "place_fips", + "sldl", + "sldu", + ], + "reason": ( + "Every household on both spines carries these columns, derived " + "from its one 2020 census block through the block ladder: ACS " + "rows from a block drawn with probability proportional to 2020 " + "block population within their observed PUMA; donor rows from " + "the block their base-H5 build assigned, or, lacking one, a " + "block drawn within their state. On ACS rows they are draws, " + "not observations; the PUMS record identifies only state and " + "PUMA." + ), + "donor_blocks": dict(donor_blocks), + "calibration_blocker": False, + } + + def _local_hours_gate(frame, staging_summary: dict): audit = staging_summary.get("reviewed_engine_input_nulls") if not isinstance(audit, list) or not all(isinstance(item, dict) for item in audit): @@ -1594,6 +1852,15 @@ def do_finalize(args) -> None: weights = np.asarray(frame.weights_for("household").values, dtype=np.float64) load_us_puma_ladder(args.ladder) ladder_gate = us_puma_ladder_gate(households, weights) + block_location_gate = None + require_block_v1_targets_ladder(staging_summary, ladder_sha) + block_ladder = load_staging_block_ladder( + staging_summary, getattr(args, "block_ladder", None) + ) + if block_ladder is not None: + from microcosm.build.us_runtime.block_location import us_block_location_gate + + block_location_gate = us_block_location_gate(households, block_ladder) composition = spine_composition(households, frame.table("person"), weights) # microcosm#765: refuse to package an artifact whose usual-weekly-hours # surface is the engine's constant-40 default (or otherwise out of band), @@ -1700,6 +1967,12 @@ def do_finalize(args) -> None: "detail": composition, }, } + if block_location_gate is not None: + gates["us_block_location_gate"] = { + "passed": bool(block_location_gate.passed), + "failures": list(block_location_gate.failures), + "detail": dict(block_location_gate.details), + } if spine_qa: gates["spine_ssi_qa"] = { "passed": True, @@ -1741,6 +2014,8 @@ def do_finalize(args) -> None: ) if not gates[name]["passed"] ] + if block_location_gate is not None and not block_location_gate.passed: + hard_failures.append("us_block_location_gate") updated_summary = dict(staging_summary) updated_summary.update( { @@ -1781,6 +2056,16 @@ def do_finalize(args) -> None: "geography_seed", "donor_channel", ) +#: Staging location settings a ``block_v1`` staging run records. A legacy +#: summary has none of them, and they are projected only when present (with +#: the run's ``household_location`` record), so a legacy build manifest's +#: ``staging_orchestration`` is unchanged. +_STAGING_LOCATION_ORCHESTRATION_KEYS = ( + "location_rule", + "location_seed", + "location_clones", + "block_ladder_sha256", +) def _require_uncapped_staging(staging_summary: dict) -> dict[str, object]: @@ -1809,7 +2094,21 @@ def _require_uncapped_staging(staging_summary: dict) -> dict[str, object]: f"The staging run was capped at {cap} ACS household(s) " "(--max-households); a smoke's output must not be packaged." ) - return {key: orchestration.get(key) for key in _STAGING_ORCHESTRATION_KEYS} + projected = {key: orchestration.get(key) for key in _STAGING_ORCHESTRATION_KEYS} + projected.update( + { + key: orchestration[key] + for key in _STAGING_LOCATION_ORCHESTRATION_KEYS + if key in orchestration + } + ) + # A block_v1 staging run's full location record (rule, seed, clones, + # ladders, donor-block accounting, gate) rides into the build manifest + # with the settings above; a legacy run records none. + household_location = staging_household_location(staging_summary) + if household_location is not None: + projected["household_location"] = household_location + return projected def _require_recorded_soi_mode(materialize_rss: dict) -> str: @@ -2007,7 +2306,7 @@ def _version(package: str) -> str: "against the new certified release H5; everything else is " "unchanged." ), - "staging": LEGACY_STAGING_REFRESH_RECIPE, + "staging": staging_refresh_recipe(staging_summary), "release": release_refresh_recipe(soi_mode), "publish": ( "tools/publish_release.sh --no-latest " @@ -2298,6 +2597,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: type=Path, help="Release root for --stage package (releases// lands here).", ) + parser.add_argument( + "--block-ladder", + type=Path, + default=None, + help=( + "Relocated copy of the block ladder a --location-rule block_v1 " + "staging run recorded (default: the path in its summary). Its " + "sha256 must equal the recorded one; refused for a legacy run." + ), + ) parser.add_argument("--allow-dirty", action="store_true") parser.add_argument( "--allow-partial-geography", diff --git a/tools/build_us_block_ladder_artifact.py b/tools/build_us_block_ladder_artifact.py index 1627059a46..17b545faf4 100644 --- a/tools/build_us_block_ladder_artifact.py +++ b/tools/build_us_block_ladder_artifact.py @@ -2,8 +2,11 @@ Downloads (with a local cache) the 2020 P.L. 94-171 geographic headers (block populations), the 119th Congressional District block equivalency -file, the 2020 Block Assignment Files (SLDU/SLDL/INCPLACE_CDP), and the OMB -CBSA delineation workbook; joins them at 2020-tabulation-block grain; and +file, the 2020 Block Assignment Files (SLDU/SLDL/INCPLACE_CDP), the OMB +CBSA delineation workbook, and the 2020 tract-to-PUMA relationship file; +joins them at 2020-tabulation-block grain (every block also carries its 2020 +PUMA, an additive array, so an ACS record's PUMA can constrain its block +draw); and writes one national NPZ artifact whose embedded metadata records a vintage and source per derived layer (``vintage_policy: error`` — the loader refuses an artifact missing any of them). No per-area files, the standing rule. @@ -45,6 +48,10 @@ parse_national_cd_bef, parse_pl_geo_blocks, ) +from microcosm.build.us_runtime.block_location import load_us_location_ladder +from microcosm.build.us_runtime.puma_ladder_sources import ( + parse_tract_to_puma_relationship, +) CD119_BEF_URL = ( "https://www2.census.gov/programs-surveys/decennial/rdo/mapping-files/" @@ -63,6 +70,10 @@ "https://www2.census.gov/programs-surveys/metro-micro/geographies/" "reference-files/2023/delineation-files/list1_2023.xlsx" ) +TRACT_TO_PUMA_URL = ( + "https://www2.census.gov/geo/docs/maps-data/data/rel2020/" + "2020_Census_Tract_to_2020_PUMA.txt" +) LAYER_VINTAGES = { "congressional_district": { @@ -99,6 +110,14 @@ ), "url": CBSA_DELINEATIONS_URL, }, + "puma": { + "vintage": "2020_puma", + "source": ( + "Census 2020 Census Tract to 2020 PUMA relationship file (each " + "block's PUMA is its tract's PUMA)" + ), + "url": TRACT_TO_PUMA_URL, + }, } @@ -216,6 +235,17 @@ def main(argv: list[str] | None = None) -> None: cbsa_by_county = parse_cbsa_delineations(_cbsa_rows(cbsa_xlsx)) _log(f" OMB delineations: {len(cbsa_by_county):,} counties in a CBSA") + tract_puma_txt = _download(TRACT_TO_PUMA_URL, args.cache_dir) + source_files["tract_to_puma"] = { + "path": str(tract_puma_txt), + "sha256": _sha256(tract_puma_txt), + } + tract_to_puma = parse_tract_to_puma_relationship( + tract_puma_txt.read_text(encoding="utf-8-sig").splitlines(), + allowed_state_fips=frozenset(fips for fips, _, _ in states), + ) + _log(f" tract-to-PUMA: {len(tract_to_puma):,} tracts") + block_population: dict[int, int] = {} sldu_by_block: dict[int, str] = {} sldl_by_block: dict[int, str] = {} @@ -272,6 +302,10 @@ def main(argv: list[str] | None = None) -> None: "missing_baf_layers": { layer: sorted(values) for layer, values in missing_layers.items() }, + # Additive to schema 1: the per-block 2020 PUMA. The schema-1 loader + # ignores arrays it does not require, so legacy builds load this file + # unchanged; microcosm.build.us_runtime.block_location reads it. + "optional_arrays": {"puma": "puma"}, "source_files": source_files, } _log(f" assembling {len(block_population):,} populated blocks") @@ -283,11 +317,13 @@ def main(argv: list[str] | None = None) -> None: place_by_block=place_by_block, cbsa_by_county=cbsa_by_county, metadata=metadata, + tract_to_puma=tract_to_puma, ) args.out.parent.mkdir(parents=True, exist_ok=True) np.savez_compressed(args.out, **payload) ladder = load_us_block_ladder(args.out) # self-check: must load cleanly + location = load_us_location_ladder(args.out) # and so must the PUMA array summary = { "output": str(args.out.resolve()), "output_sha256": _sha256(args.out), @@ -297,6 +333,7 @@ def main(argv: list[str] | None = None) -> None: np.unique(ladder.congressional_district_geoid).size ), "counties": int(np.unique(ladder.block_geoid // 10**10).size), + "pumas": int(np.unique(location.puma).size), "layer_vintages": ladder.layer_vintages, "states": [fips for fips, _, _ in states], "missing_baf_layers": metadata["missing_baf_layers"], diff --git a/tools/build_us_cps_identified_counties.py b/tools/build_us_cps_identified_counties.py new file mode 100644 index 0000000000..f73a36f7b3 --- /dev/null +++ b/tools/build_us_cps_identified_counties.py @@ -0,0 +1,186 @@ +"""Build the packaged CPS ASEC identified-county list from Census documentation. + +For each ASEC year, downloads (with a local cache) the technical documentation +``cpsmar{yy}.pdf``, renders it with ``pdftotext -layout`` (poppler), parses +"List 4: FIPS County Codes" with +``microcosm.build.us_runtime.cps_identified_county_sources``, and writes one +CSV row per (ASEC year, county) into +``microcosm/build/us_runtime/data/cps_asec_identified_counties.csv``, plus a +sibling ``.provenance.json`` recording each PDF's URL and SHA-256, the +``pdftotext`` version, and per-year counts. + +The location draw (``us_runtime.block_location``) rules a county out for a +``GTCO = 0`` record only when this list names it *and* the year's file codes +it; see ``cps_excluded_county_sets``. + +Example: + uv run python tools/build_us_cps_identified_counties.py \ + --asec-year 2023 --asec-year 2024 --asec-year 2025 +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import io +import json +import subprocess +import sys +import urllib.request +from pathlib import Path + +from microcosm.build.us_runtime.block_location import ( + default_cps_asec_identified_counties_path, + load_cps_asec_identified_counties, +) +from microcosm.build.us_runtime.cps_identified_county_sources import ( + LIST_HEADING, + list_carries_entire_county_guarantee, + parse_cps_identified_county_list, +) + +TECHDOC_URL_TEMPLATE = ( + "https://www2.census.gov/programs-surveys/cps/techdocs/cpsmar{yy}.pdf" +) +CSV_COLUMNS = ( + "asec_year", + "state_fips", + "county_code", + "county_name", + "single_county_micropolitan", + "entire_county_guarantee", + "source_url", + "source_sha256", +) + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--asec-year", + type=int, + action="append", + required=True, + help="ASEC survey year (repeatable), e.g. 2025 for cpsmar25.pdf.", + ) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path.home() / ".cache" / "microcosm-cps-techdocs", + ) + parser.add_argument( + "--out", + type=Path, + default=default_cps_asec_identified_counties_path(), + ) + return parser.parse_args(argv) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _download(url: str, cache_dir: Path) -> Path: + cache_dir.mkdir(parents=True, exist_ok=True) + destination = cache_dir / url.rsplit("/", 1)[-1] + if destination.exists() and destination.stat().st_size > 0: + return destination + print(f" downloading {url}", file=sys.stderr, flush=True) + request = urllib.request.Request( + url, headers={"User-Agent": "microcosm-build (CPS identified counties)"} + ) + with urllib.request.urlopen(request) as response: + payload = response.read() + if not payload.startswith(b"%PDF"): + raise RuntimeError(f"{url} did not return a PDF.") + partial = destination.with_suffix(destination.suffix + ".partial") + partial.write_bytes(payload) + partial.replace(destination) + return destination + + +def _pdftotext_version() -> str: + result = subprocess.run( + ["pdftotext", "-v"], capture_output=True, text=True, check=False + ) + return (result.stderr or result.stdout).splitlines()[0].strip() + + +def _render(pdf: Path) -> list[str]: + result = subprocess.run( + ["pdftotext", "-layout", str(pdf), "-"], + capture_output=True, + check=True, + ) + return io.StringIO(result.stdout.decode("utf-8")).read().splitlines() + + +def main(argv: list[str] | None = None) -> None: + args = _parse_args(argv) + rows: list[dict[str, str]] = [] + provenance: dict[str, object] = { + "kind": "cps_asec_identified_counties", + "list": LIST_HEADING, + "note": ( + "Census (ASEC 2023-2025): 'Counties are only included on this list " + "if the entire county is identified.' The 2026 documentation drops " + "that sentence; entire_county_guarantee records, per year, whether " + "the preamble carries it. An asterisk marks a county that is also a " + "single-county micropolitan area." + ), + "pdftotext": _pdftotext_version(), + "years": {}, + } + for year in sorted(set(args.asec_year)): + url = TECHDOC_URL_TEMPLATE.format(yy=f"{year % 100:02d}") + pdf = _download(url, args.cache_dir) + sha = _sha256(pdf) + rendered = _render(pdf) + counties = parse_cps_identified_county_list(rendered) + guarantee = list_carries_entire_county_guarantee(rendered) + provenance["years"][str(year)] = { # type: ignore[index] + "url": url, + "sha256": sha, + "counties": len(counties), + "states": len({county.state_fips for county in counties}), + "entire_county_guarantee": guarantee, + } + for county in counties: + rows.append( + { + "asec_year": str(year), + "state_fips": county.state_fips, + "county_code": county.county_code, + "county_name": county.county_name, + "single_county_micropolitan": str( + county.single_county_micropolitan + ).lower(), + "entire_county_guarantee": str(guarantee).lower(), + "source_url": url, + "source_sha256": sha, + } + ) + args.out.parent.mkdir(parents=True, exist_ok=True) + with args.out.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=CSV_COLUMNS, lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + provenance["csv_sha256"] = _sha256(args.out) + provenance_path = args.out.with_name(args.out.name + ".provenance.json") + provenance_path.write_text(json.dumps(provenance, indent=2, sort_keys=True) + "\n") + loaded = load_cps_asec_identified_counties(args.out) # self-check + print( + json.dumps( + {str(year): len(entry.counties) for year, entry in loaded.items()}, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tools/build_us_multispine_pool.py b/tools/build_us_multispine_pool.py index 828cceffdc..c9545a71ff 100644 --- a/tools/build_us_multispine_pool.py +++ b/tools/build_us_multispine_pool.py @@ -51,7 +51,7 @@ import subprocess import time import uuid -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from dataclasses import asdict, dataclass, fields, is_dataclass, replace from datetime import UTC, datetime from enum import Enum @@ -106,6 +106,17 @@ from microcosm.build.us_runtime.asec_checkpoint import ( load_asec_raw_stage_checkpoint, ) +from microcosm.build.us_runtime.block_location import ( + US_BLOCK_LOCATION_RULE_ID, + US_LOCATION_RULE_BLOCK_V1, + US_LOCATION_RULE_CHOICES, + US_LOCATION_RULE_LEGACY, + UsLocationLadder, + load_us_location_ladder, + us_block_location_gate, + us_block_location_manifest, + with_household_us_block_location, +) from microcosm.build.us_runtime.congressional_district_geography import ( CONGRESSIONAL_DISTRICT_GEOID_COLUMN, ) @@ -116,18 +127,29 @@ CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE, load_congressional_district_vintage_crosswalk, ) +from microcosm.build.us_runtime.cps_source_geography import ( + cps_source_geography, + load_cps_household_geography, +) from microcosm.build.us_runtime.eligibility_inputs import ( US_ELIGIBILITY_INPUTS_OUTPUT_COLUMNS, US_ELIGIBILITY_INPUTS_PARENT_ID_COLUMNS, ) from microcosm.build.us_runtime.h5_io import ( + POPULACE_BLOCK_LADDER_SHA256_ATTR, + POPULACE_BLOCK_LADDER_VINTAGES_ATTR, + POPULACE_LOCATION_CLONES_ATTR, + POPULACE_LOCATION_RULE_ATTR, + POPULACE_LOCATION_SEED_ATTR, US_MULTISPINE_AGREEMENT_DIAGNOSTICS_ARTIFACT_KIND, US_MULTISPINE_POOL_H5_ARTIFACT_KIND, US_MULTISPINE_POOL_H5_MATERIALIZER_VERSION, US_MULTISPINE_POOL_MANIFEST_ARTIFACT_KIND, US_MULTISPINE_POOL_MANIFEST_SCHEMA_VERSION, + US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER, US_STACKED_POOL_OPERATOR_ORDER, load_simulation_ready_us_multispine_pool_manifest, + ordered_household_block_location_receipt, write_nullable_us_h5, ) from microcosm.build.us_runtime.housing_inputs import ( @@ -370,6 +392,39 @@ _STACKED_CD_CROSSWALK_SOURCE_VINTAGE_REF = "vintage:cd_117" _STACKED_CD_CROSSWALK_SOURCE_VINTAGE = "117th_congress" _STACKED_CD_CROSSWALK_TARGET_VINTAGE_REF = "vintage:cd_119" +# --location-rule block_v1 (microcosm#696). Its seed site and stream are +# deliberately outside the spec engine's legacy-v1 seed protocol, which pins +# only the default (legacy) geography operator. +_STACKED_BLOCK_LOCATION_OPERATOR = "assign_us_block_location" +_STACKED_BLOCK_LOCATION_SEED_SITE = "us_block_location" +_STACKED_BLOCK_LOCATION_SEED_STREAM = "household_location_block_v1" +_STACKED_BLOCK_LADDER_INPUT_ROLE = "block_ladder" +_STACKED_BLOCK_LADDER_SOURCE_REF = "source:us_block_ladder_2020_puma" +_STACKED_CPS_ASEC_INPUT_ROLE_PREFIX = "cps_asec_household_geography_" +_STACKED_BLOCK_LOCATION_DERIVED_COLUMNS = ( + "block_geoid", + "tract_geoid", + "county_fips", + "place_fips", + "sldu", + "sldl", + "cbsa_code", + "puma", + CONGRESSIONAL_DISTRICT_GEOID_COLUMN, +) +# The downstream checks that fix one household row per source record and so +# cannot yet admit K > 1 location clones on the stacked line. +_STACKED_LOCATION_CLONE_BLOCKERS = ( + "support_provenance.validate_assembly_provenance (exact native row counts " + "per survey channel)", + "stacked_spine.validate_stacked_spine_frame (realized stack counts and " + "lineage digests, re-checked against the stack receipt in " + "build_us_multispine_pool.build_stacked_pool)", + "stacked_spine._validate_stacked_clone_role_lifecycle (household clone " + "roles {0}, {0,1} or {0,1,2} only)", + "build_us_multispine_pool._validate_stacked_geography_assignment_receipt " + "(ordered native household-id digest and household_rows)", +) type PoolOperator = Callable[[Frame], PoolStageOutput] @@ -665,6 +720,91 @@ def _parser() -> argparse.ArgumentParser: return parser +def _location_parser() -> argparse.ArgumentParser: + """Household-location options (microcosm#696), parsed beside ``_parser``. + + They live in their own parser so the pinned stacked parser surface and + its tests stay byte-identical; ``main`` merges both namespaces. + """ + + parser = argparse.ArgumentParser( + add_help=False, + allow_abbrev=False, + description="Household location options (default: --location-rule legacy).", + ) + parser.add_argument( + "--location-rule", + choices=US_LOCATION_RULE_CHOICES, + default=US_LOCATION_RULE_LEGACY, + help=( + "Household location rule. legacy (default) keeps the pinned " + "PUMA-ladder draw byte-for-byte; block_v1 draws one 2020 census " + "block per household within its finest source geography (ACS " + "PUMA; CPS ASEC county, or state minus identified counties) and " + "derives every other geography from the block (microcosm#696)." + ), + ) + parser.add_argument( + "--location-seed", + type=int, + default=0, + help="Non-negative block_v1 location seed (default: 0; legacy refuses others).", + ) + parser.add_argument( + "--location-clones", + type=int, + default=1, + help=( + "Locations per household (default: 1). The stacked pool refuses " + "more than one; see the error for the checks that still fix one " + "row per source household." + ), + ) + parser.add_argument( + "--block-ladder", + type=Path, + help=( + "2020 block ladder carrying the additive per-block PUMA array. " + "Required by --location-rule block_v1; refused otherwise." + ), + ) + parser.add_argument( + "--block-ladder-sha256", + type=_sha256_argument, + help="Expected SHA-256 of --block-ladder (required by block_v1).", + ) + parser.add_argument( + "--cps-asec-h5", + action="append", + metavar="INCOME_YEAR=PATH", + help=( + "Processed CPS ASEC H5 (census_cps_{INCOME_YEAR}.h5) whose " + "household GESTFIPS/GTCO/GTCBSA define ASEC candidate blocks. " + "Repeat once per ASEC source year; required by block_v1." + ), + ) + parser.add_argument( + "--cps-asec-h5-sha256", + action="append", + metavar="INCOME_YEAR=SHA256", + help="Expected SHA-256 of each --cps-asec-h5, keyed by the same year.", + ) + return parser + + +def _parse_arguments(argv: list[str] | None) -> argparse.Namespace: + """Parse the location options, then the pinned stacked/legacy options.""" + + location_parser = _location_parser() + location_args, remaining = location_parser.parse_known_args(argv) + if any(token in {"-h", "--help"} for token in remaining): + location_parser.print_help() + args = _parser().parse_args(remaining) + for key, value in vars(location_args).items(): + setattr(args, key, value) + return args + + def _output_paths( path: Path, *, @@ -902,9 +1042,306 @@ def _stacked_geography_assignment_contract() -> dict[str, object]: } +def _stacked_block_location_contract( + seed: int, + clones: int, + ladder_sha256: str, + *, + cps_asec_sha256: Mapping[int, str], + crosswalk_sha256: str = _STACKED_CD_CROSSWALK_SHA256, +) -> dict[str, object]: + """Return the closed ``block_v1`` household-location authority. + + The legacy contract (:func:`_stacked_geography_assignment_contract`) is + untouched; this one replaces it only under ``--location-rule block_v1``. + Its algorithm id is the shared block-location rule id, its seed site and + stream are outside the legacy-v1 seed protocol, and its authorities are + the block ladder, the congressional-district vintage crosswalk (target + universe and release attrs) and every CPS ASEC household-geography H5 + the ASEC candidate sets read. ``h5_io.expected_us_stacked_block_location_contract`` + is the consumer's independent copy. + """ + + authorities: dict[str, object] = { + _STACKED_BLOCK_LADDER_INPUT_ROLE: { + "input_role": _STACKED_BLOCK_LADDER_INPUT_ROLE, + "source_ref": _STACKED_BLOCK_LADDER_SOURCE_REF, + "sha256": ladder_sha256, + }, + _STACKED_CD_CROSSWALK_INPUT_ROLE: { + "input_role": _STACKED_CD_CROSSWALK_INPUT_ROLE, + "source_ref": _STACKED_CD_CROSSWALK_SOURCE_REF, + "sha256": crosswalk_sha256, + "source_vintage_ref": _STACKED_CD_CROSSWALK_SOURCE_VINTAGE_REF, + "source_vintage": _STACKED_CD_CROSSWALK_SOURCE_VINTAGE, + "target_vintage_ref": _STACKED_CD_CROSSWALK_TARGET_VINTAGE_REF, + "target_vintage": CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE, + }, + } + for income_year, sha256 in sorted(cps_asec_sha256.items()): + role = _stacked_cps_asec_input_role(income_year) + authorities[role] = { + "input_role": role, + "source_ref": f"source:census_cps_{int(income_year)}", + "income_year": int(income_year), + "sha256": sha256, + } + return { + "declaration": { + "anchor": "block", + "order": _STACKED_GEOGRAPHY_ASSIGNMENT_ORDER, + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "kernels": { + "assign": f"kernel:{_STACKED_BLOCK_LOCATION_OPERATOR}", + "validate": "kernel:us_block_location_gate", + }, + "draw": { + "unit": "household", + "universe": "census_block_2020", + "weight": "block_population_2020", + "candidate_sets": { + "acs": "blocks_within_observed_puma", + "asec": "cps_asec_source_geography", + }, + "uniforms": "stable_identity_uniforms", + }, + "derive": list(_STACKED_BLOCK_LOCATION_DERIVED_COLUMNS), + "assertions": [ + "observed_acs_puma_preserved", + "block_state_matches_state_fips", + "geography_derived_from_block", + ], + "ladder_source": _STACKED_BLOCK_LADDER_SOURCE_REF, + "congressional_district_vintage_crosswalk": { + "source_ref": _STACKED_CD_CROSSWALK_SOURCE_REF, + "source_vintage": _STACKED_CD_CROSSWALK_SOURCE_VINTAGE_REF, + "target_vintage": _STACKED_CD_CROSSWALK_TARGET_VINTAGE_REF, + }, + "seed": f"stream:{_STACKED_BLOCK_LOCATION_SEED_STREAM}", + "clones": int(clones), + "validation": [ + "us_block_location_gate", + "puma_ladder_gate", + "vintage_refusal", + ], + }, + "algorithm": { + "id": US_BLOCK_LOCATION_RULE_ID, + "kernel": "with_household_us_block_location", + "operator": _STACKED_BLOCK_LOCATION_OPERATOR, + "order": _STACKED_GEOGRAPHY_ASSIGNMENT_ORDER, + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "clones": int(clones), + }, + "authorities": authorities, + "seed": { + "site": _STACKED_BLOCK_LOCATION_SEED_SITE, + "stream": _STACKED_BLOCK_LOCATION_SEED_STREAM, + "value_source": "cli.location_seed", + "value": int(seed), + }, + } + + +def _stacked_cps_asec_input_role(income_year: int) -> str: + return f"{_STACKED_CPS_ASEC_INPUT_ROLE_PREFIX}{int(income_year)}" + + +def _location_rule(args: argparse.Namespace) -> str: + """The requested location rule; namespaces without the flag are legacy.""" + + return getattr(args, "location_rule", US_LOCATION_RULE_LEGACY) + + +def _is_block_location(args: argparse.Namespace) -> bool: + return _location_rule(args) == US_LOCATION_RULE_BLOCK_V1 + + +def _year_keyed_arguments( + values: Sequence[str] | None, + *, + option: str, +) -> dict[int, str]: + """Parse repeated ``INCOME_YEAR=VALUE`` options into a year-keyed map.""" + + parsed: dict[int, str] = {} + for raw in values or (): + year_text, separator, value = str(raw).partition("=") + if not separator or not year_text.strip().isdigit() or not value: + raise ValueError(f"{option} expects INCOME_YEAR=VALUE, got {raw!r}.") + year = int(year_text) + if year in parsed: + raise ValueError(f"{option} repeats income year {year}.") + parsed[year] = value + return parsed + + +def _cps_asec_h5_arguments( + args: argparse.Namespace, +) -> tuple[dict[int, Path], dict[int, str]]: + """The block_v1 CPS ASEC household-geography H5s and their pins.""" + + paths = { + year: Path(value) + for year, value in _year_keyed_arguments( + getattr(args, "cps_asec_h5", None), option="--cps-asec-h5" + ).items() + } + pins = _year_keyed_arguments( + getattr(args, "cps_asec_h5_sha256", None), option="--cps-asec-h5-sha256" + ) + for year, value in pins.items(): + if not _LOWERCASE_SHA256.fullmatch(value): + raise ValueError( + f"--cps-asec-h5-sha256 {year}= must be exactly 64 lowercase " + "hexadecimal characters." + ) + return paths, pins + + +def _stacked_location_contract_for_args( + args: argparse.Namespace, +) -> dict[str, object]: + """The geography contract a configured run binds (legacy by default).""" + + if not _is_block_location(args): + return _stacked_geography_assignment_contract() + _require_stacked_block_location_arguments(args) + _paths, cps_pins = _cps_asec_h5_arguments(args) + return _stacked_block_location_contract( + args.location_seed, + args.location_clones, + args.block_ladder_sha256, + cps_asec_sha256=cps_pins, + crosswalk_sha256=args.congressional_district_vintage_crosswalk_sha256, + ) + + +def _stacked_operator_order_for_contract( + contract: Mapping[str, object], +) -> tuple[str, ...]: + algorithm = contract.get("algorithm") + if isinstance(algorithm, Mapping) and algorithm.get("id") == ( + US_BLOCK_LOCATION_RULE_ID + ): + return US_STACKED_POOL_BLOCK_LOCATION_OPERATOR_ORDER + return US_STACKED_POOL_OPERATOR_ORDER + + +def _require_location_rule_arguments(args: argparse.Namespace) -> None: + """Refuse location options that the requested rule does not use.""" + + rule = _location_rule(args) + seed = getattr(args, "location_seed", 0) + clones = getattr(args, "location_clones", 1) + if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0: + raise ValueError("--location-seed must be a non-negative integer.") + if isinstance(clones, bool) or not isinstance(clones, int) or clones < 1: + raise ValueError("--location-clones must be a positive integer.") + block_options = { + "--block-ladder": getattr(args, "block_ladder", None), + "--block-ladder-sha256": getattr(args, "block_ladder_sha256", None), + "--cps-asec-h5": getattr(args, "cps_asec_h5", None) or None, + "--cps-asec-h5-sha256": getattr(args, "cps_asec_h5_sha256", None) or None, + } + if rule == US_LOCATION_RULE_LEGACY: + if seed != 0 or clones != 1: + raise ValueError( + "--location-seed and --location-clones apply only to " + "--location-rule block_v1; the legacy rule draws with the " + "pinned model seed and one location per household." + ) + given = [option for option, value in block_options.items() if value] + if given: + raise ValueError( + f"{', '.join(given)} apply only to --location-rule block_v1." + ) + return + if rule != US_LOCATION_RULE_BLOCK_V1: + raise ValueError(f"Unknown --location-rule {rule!r}.") + if getattr(args, "legacy_two_spine", False): + raise ValueError( + "--location-rule block_v1 is a stacked-pipeline rule; " + "--legacy-two-spine assigns no household geography." + ) + if getattr(args, "config_authority", "constants") != "constants": + raise ValueError( + "--location-rule block_v1 cannot run under --config-authority " + "constants_adapter: that authority equality-attests the packaged " + "spec bundle, which certifies only the legacy geography contract." + ) + if clones != 1: + raise ValueError( + "--location-rule block_v1 on the stacked pool admits exactly one " + f"location per household (got --location-clones {clones}); these " + "downstream checks fix one household row per source record: " + + "; ".join(_STACKED_LOCATION_CLONE_BLOCKERS) + + "." + ) + _require_stacked_block_location_arguments(args) + + +def _require_stacked_block_location_arguments(args: argparse.Namespace) -> None: + """Require the block_v1 authorities and refuse the unused PUMA ladder.""" + + unused = [ + option + for option, value in { + "--puma-ladder": getattr(args, "puma_ladder", None), + "--puma-ladder-sha256": getattr(args, "puma_ladder_sha256", None), + }.items() + if value is not None + ] + if unused: + raise ValueError( + f"{', '.join(unused)} are legacy-rule inputs; --location-rule " + "block_v1 draws every household from --block-ladder." + ) + required = { + "--block-ladder": getattr(args, "block_ladder", None), + "--block-ladder-sha256": getattr(args, "block_ladder_sha256", None), + "--congressional-district-vintage-crosswalk": getattr( + args, "congressional_district_vintage_crosswalk", None + ), + "--congressional-district-vintage-crosswalk-sha256": getattr( + args, "congressional_district_vintage_crosswalk_sha256", None + ), + } + missing = [option for option, value in required.items() if value is None] + if missing: + raise ValueError( + "--location-rule block_v1 requires pinned household-location " + f"authorities; missing {', '.join(missing)}." + ) + if required["--congressional-district-vintage-crosswalk-sha256"] != ( + _STACKED_CD_CROSSWALK_SHA256 + ): + raise ValueError( + "--congressional-district-vintage-crosswalk-sha256 differs from the " + "canonical US authority pin: got " + f"{required['--congressional-district-vintage-crosswalk-sha256']}, " + f"expected {_STACKED_CD_CROSSWALK_SHA256}." + ) + paths, pins = _cps_asec_h5_arguments(args) + if not paths: + raise ValueError( + "--location-rule block_v1 requires --cps-asec-h5 INCOME_YEAR=PATH " + "for every ASEC source year (census_cps_{year}.h5): ASEC households " + "draw within their CPS source county or state remainder." + ) + if set(paths) != set(pins): + raise ValueError( + "--cps-asec-h5 and --cps-asec-h5-sha256 must name the same income " + f"years; got {sorted(paths)} and {sorted(pins)}." + ) + + def _require_stacked_geography_arguments(args: argparse.Namespace) -> None: """Require both authenticated geography authorities on the stacked route.""" + if _is_block_location(args): + _require_location_rule_arguments(args) + return required = { "--puma-ladder": getattr(args, "puma_ladder", None), "--puma-ladder-sha256": getattr(args, "puma_ladder_sha256", None), @@ -995,7 +1432,9 @@ def _verify_inputs( args.puf_source_year_csv_sha256, ), } - if not args.legacy_two_spine: + if not args.legacy_two_spine and _is_block_location(args): + verified.update(_verify_block_location_inputs(args)) + elif not args.legacy_two_spine: verified.update( { _STACKED_PUMA_LADDER_INPUT_ROLE: _verify_file( @@ -1013,6 +1452,42 @@ def _verify_inputs( return verified, acs_source_manifest +def _verify_block_location_inputs( + args: argparse.Namespace, +) -> dict[str, _VerifiedInput]: + """Authenticate the block_v1 location authorities against their pins.""" + + cps_paths, cps_pins = _cps_asec_h5_arguments(args) + verified = { + _STACKED_BLOCK_LADDER_INPUT_ROLE: _verify_file( + "US block location ladder", + args.block_ladder, + args.block_ladder_sha256, + ), + _STACKED_CD_CROSSWALK_INPUT_ROLE: _verify_file( + "congressional-district vintage crosswalk", + args.congressional_district_vintage_crosswalk, + args.congressional_district_vintage_crosswalk_sha256, + ), + } + for year in sorted(cps_paths): + verified[_stacked_cps_asec_input_role(year)] = _verify_file( + f"CPS ASEC {year} household geography H5", + cps_paths[year], + cps_pins[year], + ) + return verified + + +def _block_location_source_paths(args: argparse.Namespace) -> set[Path]: + cps_paths, _cps_pins = _cps_asec_h5_arguments(args) + return { + Path(args.block_ladder).resolve(), + Path(args.congressional_district_vintage_crosswalk).resolve(), + *(Path(path).resolve() for path in cps_paths.values()), + } + + def _configured_source_paths(args: argparse.Namespace) -> set[Path]: """Resolve immutable input locations without opening them.""" @@ -1024,7 +1499,10 @@ def _configured_source_paths(args: argparse.Namespace) -> set[Path]: Path(args.puf_h5).resolve(), Path(args.puf_source_year_csv).resolve(), } - if not args.legacy_two_spine: + if not args.legacy_two_spine and _is_block_location(args): + _require_stacked_geography_arguments(args) + paths.update(_block_location_source_paths(args)) + elif not args.legacy_two_spine: _require_stacked_geography_arguments(args) paths.update( { @@ -1314,7 +1792,22 @@ def _configured_input_pins_digest(args: argparse.Namespace) -> str: "processed_puf": args.puf_h5_sha256, "puf_source_year": args.puf_source_year_csv_sha256, } - if not args.legacy_two_spine: + if not args.legacy_two_spine and _is_block_location(args): + _require_stacked_geography_arguments(args) + _cps_paths, cps_pins = _cps_asec_h5_arguments(args) + payload.update( + { + _STACKED_BLOCK_LADDER_INPUT_ROLE: args.block_ladder_sha256, + _STACKED_CD_CROSSWALK_INPUT_ROLE: ( + args.congressional_district_vintage_crosswalk_sha256 + ), + **{ + _stacked_cps_asec_input_role(year): sha256 + for year, sha256 in sorted(cps_pins.items()) + }, + } + ) + elif not args.legacy_two_spine: _require_stacked_geography_arguments(args) payload.update( { @@ -1329,7 +1822,14 @@ def _configured_input_pins_digest(args: argparse.Namespace) -> str: def _assert_stacked_geography_verified_inputs( verified_inputs: Mapping[str, _VerifiedInput], + *, + geography_contract: Mapping[str, object] | None = None, ) -> None: + if geography_contract is not None and _is_block_location_contract( + geography_contract + ): + _assert_block_location_verified_inputs(verified_inputs, geography_contract) + return expected = { _STACKED_PUMA_LADDER_INPUT_ROLE: _STACKED_PUMA_LADDER_SHA256, _STACKED_CD_CROSSWALK_INPUT_ROLE: _STACKED_CD_CROSSWALK_SHA256, @@ -1350,6 +1850,46 @@ def _assert_stacked_geography_verified_inputs( ) +def _is_block_location_contract(contract: Mapping[str, object]) -> bool: + algorithm = contract.get("algorithm") + return isinstance(algorithm, Mapping) and ( + algorithm.get("id") == US_BLOCK_LOCATION_RULE_ID + ) + + +def _assert_block_location_verified_inputs( + verified_inputs: Mapping[str, _VerifiedInput], + contract: Mapping[str, object], +) -> None: + """Every block_v1 authority must be a verified input with its pinned bytes.""" + + authorities = contract.get("authorities") + if not isinstance(authorities, Mapping) or not authorities: + raise ValueError("Block-location contract has no authorities.") + if verified_inputs.get(_STACKED_PUMA_LADDER_INPUT_ROLE) is not None: + raise ValueError( + "A block-location identity must not also bind the legacy PUMA ladder." + ) + for role, authority in sorted(authorities.items()): + pin = verified_inputs.get(role) + if pin is None: + raise ValueError( + f"Stacked checkpoint identity requires verified input role {role!r}." + ) + sha256 = authority.get("sha256") if isinstance(authority, Mapping) else None + if pin.expected_sha256 != sha256 or pin.actual_sha256 != sha256: + raise ValueError( + f"Stacked verified input {role!r} differs from its " + f"block-location authority SHA-256 {sha256}." + ) + crosswalk = verified_inputs[_STACKED_CD_CROSSWALK_INPUT_ROLE] + if crosswalk.actual_sha256 != _STACKED_CD_CROSSWALK_SHA256: + raise ValueError( + f"Stacked verified input {_STACKED_CD_CROSSWALK_INPUT_ROLE!r} differs " + f"from its canonical SHA-256 {_STACKED_CD_CROSSWALK_SHA256}." + ) + + def _stacked_checkpoint_base_identity( verified_inputs: Mapping[str, _VerifiedInput], *, @@ -1359,13 +1899,27 @@ def _stacked_checkpoint_base_identity( clone_attachment_fraction: float, clone_attachment_seed: int, policyengine_us_version: str | None = None, + geography_contract: Mapping[str, object] | None = None, ) -> dict[str, object]: - """Bind #599/#608 caches to the live stack and both scale controls.""" + """Bind #599/#608 caches to the live stack and both scale controls. + + ``geography_contract`` defaults to the legacy geography contract; a + ``block_v1`` run passes its block-location contract, which also selects + the block-location operator order. + """ + geography = ( + _stacked_geography_assignment_contract() + if geography_contract is None + else _json_ready(dict(geography_contract)) + ) # The empty mapping is reserved for the pure static-component oracle. # Every configured/runtime identity carries verified inputs. if verified_inputs: - _assert_stacked_geography_verified_inputs(verified_inputs) + _assert_stacked_geography_verified_inputs( + verified_inputs, + geography_contract=geography_contract, + ) fraction_token = _stacked_rung(sample_fraction) if isinstance(sample_seed, bool) or sample_seed < 0: raise ValueError("sample_seed must be a non-negative integer.") @@ -1401,10 +1955,10 @@ def _stacked_checkpoint_base_identity( "fraction": float(clone_attachment_fraction), "seed": clone_attachment_seed, }, - "geography_assignment": _stacked_geography_assignment_contract(), + "geography_assignment": geography, "stacked_authority": stacked_spine_authority_receipt(), "pool_code": { - "operator_order": list(US_STACKED_POOL_OPERATOR_ORDER), + "operator_order": list(_stacked_operator_order_for_contract(geography)), "pre_clone_source_operator_order": list( POOL_PRE_CLONE_SOURCE_OPERATOR_ORDER ), @@ -1474,7 +2028,7 @@ def _configured_stacked_identity(args: argparse.Namespace) -> dict[str, object]: "sample_seed": args.sample_seed, "clone_attachment_fraction": float(args.clone_attachment_fraction), "clone_attachment_seed": args.clone_attachment_seed, - "geography_assignment": _stacked_geography_assignment_contract(), + "geography_assignment": _stacked_location_contract_for_args(args), "stacked_authority": stacked_spine_authority_receipt(), } @@ -1499,6 +2053,7 @@ def _discover_stacked_checkpoint_identity( sample_seed: int, clone_attachment_fraction: float, clone_attachment_seed: int, + geography_contract: Mapping[str, object] | None = None, ) -> dict[str, object] | None: """Recover a current live-stack identity before loading survey sources. @@ -1551,6 +2106,7 @@ def _discover_stacked_checkpoint_identity( sample_seed=sample_seed, clone_attachment_fraction=clone_attachment_fraction, clone_attachment_seed=clone_attachment_seed, + geography_contract=geography_contract, ) if base_identity != expected: raise ValueError("checkpoint base identity is stale") @@ -1721,8 +2277,16 @@ def _validate_stacked_geography_assignment_receipt( target_districts: tuple[int, ...], boundary: str, require_exact_assembled_rows: bool, + configured_contract: Mapping[str, object] | None = None, + location_ladder: UsLocationLadder | None = None, ) -> None: - """Bind a live assembled-or-later Frame to its assignment receipt.""" + """Bind a live assembled-or-later Frame to its assignment receipt. + + The expected contract is selected by the receipt's algorithm id (legacy + by default). A ``block_v1`` receipt is accepted only when the run's + ``configured_contract`` is that same block-location contract, and a + block-location configured run refuses a legacy receipt. + """ if ( receipt.get("artifact_kind") @@ -1730,6 +2294,32 @@ def _validate_stacked_geography_assignment_receipt( or receipt.get("schema_version") != 1 ): raise ValueError(f"{boundary}: geography assignment receipt is unsupported.") + receipt_contract = receipt.get("contract") + block_receipt = isinstance( + receipt_contract, Mapping + ) and _is_block_location_contract(receipt_contract) + block_configured = isinstance( + configured_contract, Mapping + ) and _is_block_location_contract(configured_contract) + if block_receipt or block_configured: + if not ( + block_receipt + and block_configured + and _json_ready(receipt_contract) == _json_ready(configured_contract) + ): + raise ValueError( + f"{boundary}: geography assignment contract differs from the " + "configured location rule." + ) + _validate_stacked_block_location_receipt( + frame, + receipt, + target_districts=target_districts, + boundary=boundary, + require_exact_assembled_rows=require_exact_assembled_rows, + location_ladder=location_ladder, + ) + return expected_contract = _stacked_geography_assignment_contract() if _json_ready(receipt.get("contract")) != _json_ready(expected_contract): raise ValueError(f"{boundary}: geography assignment contract changed.") @@ -1935,6 +2525,575 @@ def _assign_stacked_household_geography( return assigned, receipt, target_districts +def _expected_block_location_contract_from( + contract: Mapping[str, object], +) -> dict[str, object]: + """Rebuild the canonical block_v1 contract from a receipt's variable fields.""" + + seed = contract.get("seed") + algorithm = contract.get("algorithm") + authorities = contract.get("authorities") + if ( + not isinstance(seed, Mapping) + or not isinstance(algorithm, Mapping) + or not isinstance(authorities, Mapping) + ): + raise ValueError("block-location contract is malformed.") + block = authorities.get(_STACKED_BLOCK_LADDER_INPUT_ROLE) + crosswalk = authorities.get(_STACKED_CD_CROSSWALK_INPUT_ROLE) + if not isinstance(block, Mapping) or not isinstance(crosswalk, Mapping): + raise ValueError("block-location contract authorities are incomplete.") + cps_asec_sha256: dict[int, str] = {} + for role, authority in authorities.items(): + if not str(role).startswith(_STACKED_CPS_ASEC_INPUT_ROLE_PREFIX): + continue + year = authority.get("income_year") if isinstance(authority, Mapping) else None + if ( + isinstance(year, bool) + or not isinstance(year, int) + or role != _stacked_cps_asec_input_role(year) + ): + raise ValueError( + f"block-location CPS ASEC authority {role!r} is malformed." + ) + cps_asec_sha256[year] = str(authority.get("sha256")) + value = seed.get("value") + clones = algorithm.get("clones") + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError("block-location seed must be a non-negative integer.") + if isinstance(clones, bool) or clones != 1: + raise ValueError( + f"block-location contract binds {clones!r} location clones; the " + "stacked pool admits exactly one." + ) + return _stacked_block_location_contract( + value, + clones, + str(block.get("sha256")), + cps_asec_sha256=cps_asec_sha256, + crosswalk_sha256=str(crosswalk.get("sha256")), + ) + + +def _block_location_summary( + household: pd.DataFrame, + record: Mapping[str, object], +) -> dict[str, object]: + return { + "applied": True, + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "household_rows": len(household), + "source_geography_households": _json_ready( + record.get("source_geography_households") + ), + "unique_values": { + column: int(household[column].astype(str).nunique(dropna=False)) + for column in ( + "block_geoid", + "tract_geoid", + "county_fips", + "puma", + CONGRESSIONAL_DISTRICT_GEOID_COLUMN, + ) + }, + } + + +def _block_location_gate_report( + household: pd.DataFrame, + weights: np.ndarray, + ladder: UsLocationLadder, +) -> GateReport: + return GateReport( + ( + us_block_location_gate(household, ladder), + us_puma_ladder_gate(household, weights, assign_tract=False), + ) + ) + + +def _validate_stacked_block_location_receipt( + frame: Frame, + receipt: Mapping[str, object], + *, + target_districts: tuple[int, ...], + boundary: str, + require_exact_assembled_rows: bool, + location_ladder: UsLocationLadder | None, +) -> None: + """Bind a live Frame to its ``block_v1`` household-location receipt.""" + + contract = receipt.get("contract") + if not isinstance(contract, Mapping): + raise ValueError(f"{boundary}: geography assignment contract changed.") + try: + expected_contract = _expected_block_location_contract_from(contract) + except ValueError as error: + raise ValueError(f"{boundary}: {error}") from error + if _json_ready(contract) != _json_ready(expected_contract): + raise ValueError(f"{boundary}: geography assignment contract changed.") + if receipt.get("target_universe") != ( + _target_congressional_district_universe_receipt(target_districts) + ): + raise ValueError(f"{boundary}: congressional-district target universe changed.") + output = receipt.get("output") + if not isinstance(output, Mapping): + raise ValueError(f"{boundary}: geography assignment output is missing.") + assigned_rows = output.get("household_rows") + if ( + isinstance(assigned_rows, bool) + or not isinstance(assigned_rows, int) + or assigned_rows < 1 + ): + raise ValueError( + f"{boundary}: geography assignment household row count is invalid." + ) + household = frame.table("household") + if len(household) < assigned_rows or ( + require_exact_assembled_rows and len(household) != assigned_rows + ): + raise ValueError( + f"{boundary}: live household rows do not match the assembled " + "geography assignment." + ) + native_household = household + clone_column = support_clone_index_column("household") + if clone_column in household: + clone_index = pd.to_numeric(household[clone_column], errors="coerce") + native_household = household.loc[clone_index.eq(0)] + if len(native_household) != assigned_rows or receipt.get( + "pre_assignment_household_order" + ) != _ordered_household_id_receipt(native_household): + raise ValueError( + f"{boundary}: ordered native household IDs differ from the seeded " + "geography assignment receipt." + ) + if receipt.get("assigned_household_geography") != ( + ordered_household_block_location_receipt(native_household, boundary=boundary) + ): + raise ValueError( + f"{boundary}: ordered native household block location differs from " + "the assignment output receipt." + ) + values = _assigned_congressional_district_values(household, boundary=boundary) + outside = ~np.isin(values, np.asarray(target_districts, dtype=np.int64)) + if outside.any(): + raise ValueError( + f"{boundary}: household congressional-district values fall outside " + f"the authenticated 119th-Congress crosswalk target universe: " + f"{sorted(set(values[outside].tolist()))[:5]}." + ) + if require_exact_assembled_rows: + expected_output = { + "household_rows": len(household), + "positive_congressional_district_rows": len(values), + "unique_congressional_district_values": int(len(np.unique(values))), + } + if output != expected_output: + raise ValueError( + f"{boundary}: assembled geography assignment counts changed." + ) + summary = receipt.get("summary") + if ( + not isinstance(summary, Mapping) + or summary.get("applied") is not True + or summary.get("household_rows") != assigned_rows + ): + raise ValueError(f"{boundary}: geography assignment summary is invalid.") + location = receipt.get("household_location") + authorities = contract.get("authorities") + block_sha256 = ( + authorities[_STACKED_BLOCK_LADDER_INPUT_ROLE].get("sha256") # type: ignore[index,union-attr] + if isinstance(authorities, Mapping) + else None + ) + ladder_record = ( + location.get("block_ladder") if isinstance(location, Mapping) else None + ) + if ( + not isinstance(location, Mapping) + or location.get("rule") != US_BLOCK_LOCATION_RULE_ID + or location.get("location_rule") != US_LOCATION_RULE_BLOCK_V1 + or location.get("seed") != contract["seed"]["value"] # type: ignore[index] + or location.get("clones") != 1 + or location.get("households") != assigned_rows + or location.get("rows") != assigned_rows + or not isinstance(ladder_record, Mapping) + or ladder_record.get("sha256") != block_sha256 + ): + raise ValueError( + f"{boundary}: household_location record differs from its " + "block-location contract." + ) + gate_receipt = receipt.get("gate") + gates = gate_receipt.get("gates") if isinstance(gate_receipt, Mapping) else None + if ( + not isinstance(gate_receipt, Mapping) + or gate_receipt.get("passed") is not True + or not isinstance(gates, Mapping) + or set(gates) != {"us_block_location", "us_puma_ladder"} + or any( + not isinstance(gates[name], Mapping) + or gates[name].get("passed") is not True + for name in gates + ) + ): + raise ValueError( + f"{boundary}: block-location and PUMA-ladder gate receipts did not pass." + ) + if require_exact_assembled_rows: + live_puma = GateReport( + ( + us_puma_ladder_gate( + household, + frame.weights_for("household").values, + assign_tract=False, + ), + ) + ).to_manifest()["gates"]["us_puma_ladder"] + if gates["us_puma_ladder"] != live_puma: + raise ValueError( + f"{boundary}: PUMA-ladder gate receipt changed from live output." + ) + if location_ladder is not None: + live_block = GateReport( + (us_block_location_gate(household, location_ladder),) + ).to_manifest()["gates"]["us_block_location"] + if gates["us_block_location"] != live_block: + raise ValueError( + f"{boundary}: block-location gate receipt changed from live output." + ) + + source_column = support_source_id_column("household") + if source_column in household and clone_column in household: + grouped = household.groupby(source_column, sort=False, dropna=False) + for column in _STACKED_BLOCK_LOCATION_DERIVED_COLUMNS: + if column not in household: + raise ValueError( + f"{boundary}: household table lost block-location column " + f"{column!r}." + ) + incoherent = grouped[column].nunique(dropna=False) > 1 + if bool(incoherent.any()): + raise ValueError( + f"{boundary}: cloned household support rows disagree on " + f"assigned geography column {column!r}." + ) + + +def _require_cps_asec_pins_match_raw_stage( + raw_stage_metadata: Mapping[str, object], + cps_asec_sha256: Mapping[int, str], +) -> None: + """The CPS H5s the ASEC draw reads must be the ones the ASEC rows came from. + + The ASEC raw-stage checkpoint's ``source_receipt`` records each pooled + source year's ``census_cps_{year}.h5`` digest; the block_v1 pins must name + exactly those years with exactly those digests. + """ + + receipt = raw_stage_metadata.get("source_receipt") + sources = receipt.get("sources") if isinstance(receipt, Mapping) else None + if not isinstance(sources, list) or not sources: + raise ValueError( + "block_v1 cannot bind its CPS ASEC pins: the ASEC raw-stage " + "checkpoint records no source_receipt.sources." + ) + recorded: dict[int, str] = {} + for source in sources: + year = source.get("year") if isinstance(source, Mapping) else None + sha256 = source.get("sha256") if isinstance(source, Mapping) else None + if ( + isinstance(year, bool) + or not isinstance(year, int) + or not isinstance(sha256, str) + or year in recorded + ): + raise ValueError( + "block_v1 cannot bind its CPS ASEC pins: a raw-stage source " + f"entry is malformed: {source!r}." + ) + recorded[year] = sha256 + pinned = {int(year): sha256 for year, sha256 in cps_asec_sha256.items()} + if pinned != recorded: + raise ValueError( + "block_v1 CPS ASEC pins differ from the ASEC raw-stage sources: " + f"pinned {dict(sorted(pinned.items()))}, raw stage " + f"{dict(sorted(recorded.items()))}." + ) + + +def _stacked_asec_source_keys( + frame: Frame, + asec_household_ids: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """``(source_year, source_household_id)`` of each ASEC household, in order. + + Same contract as ``cps_source_geography.household_source_keys`` restricted + to ASEC-channel households (ACS persons carry no CPS source key): every + member of a household must carry one complete integral pair. + """ + + person = frame.table("person") + missing = [ + column + for column in ("person_household_id", "source_year", "source_household_id") + if column not in person + ] + if missing: + raise ValueError( + "block_v1 ASEC households need a CPS source key; the stacked person " + f"table lacks {missing}." + ) + ids = np.asarray(asec_household_ids, dtype=np.int64) + members = person.loc[ + person["person_household_id"].isin(ids), + ["person_household_id", "source_year", "source_household_id"], + ] + numeric = members[["source_year", "source_household_id"]].apply( + pd.to_numeric, errors="coerce" + ) + complete = numeric.notna().all(axis=1) & (numeric == np.floor(numeric)).all(axis=1) + if not bool(complete.all()): + raise ValueError( + f"block_v1: {int((~complete).sum())} ASEC person row(s) carry no " + "complete integral (source_year, source_household_id) key." + ) + keyed = pd.DataFrame( + { + "person_household_id": members["person_household_id"].to_numpy(np.int64), + "source_year": numeric["source_year"].to_numpy(np.int64), + "source_household_id": numeric["source_household_id"].to_numpy(np.int64), + } + ).drop_duplicates() + if keyed["person_household_id"].duplicated().any(): + raise ValueError( + "block_v1: an ASEC household's members disagree on their CPS source key." + ) + aligned = keyed.set_index("person_household_id").reindex(ids) + if aligned.isna().any().any(): + raise ValueError( + "block_v1: some ASEC households have no member carrying a CPS source key." + ) + return ( + aligned["source_year"].to_numpy(np.int64), + aligned["source_household_id"].to_numpy(np.int64), + ) + + +def _assign_stacked_household_block_location( + frame: Frame, + *, + ladder: UsLocationLadder, + crosswalk: pd.DataFrame, + cps_household_geography: pd.DataFrame, + contract: Mapping[str, object], +) -> tuple[Frame, dict[str, object], tuple[int, ...]]: + """Apply the ``block_v1`` household-location rule after source assembly. + + Every household draws one 2020 block in proportion to block population + within its finest source geography: an ACS household within its observed + PUMA; an ASEC household within its CPS county (``GTCO != 0``) or its state + minus the counties its ASEC vintage identifies (``GTCO = 0``), per + ``cps_source_geography``. Every other geography is the block's ladder + lookup. PUF support clones are created later and inherit it. + """ + + if not _is_block_location_contract(contract): + raise ValueError("block-location assignment requires a block_v1 contract.") + seed_block = contract["seed"] + algorithm = contract["algorithm"] + seed = int(seed_block["value"]) # type: ignore[index] + clones = int(algorithm["clones"]) # type: ignore[index] + if clones != 1: + raise ValueError( + "The stacked pool admits exactly one location per household; " + + "; ".join(_STACKED_LOCATION_CLONE_BLOCKERS) + + "." + ) + authorities = contract["authorities"] + expected_ladder_sha256 = authorities[_STACKED_BLOCK_LADDER_INPUT_ROLE]["sha256"] # type: ignore[index] + if ladder.sha256 != expected_ladder_sha256: + raise ValueError( + "US block location ladder bytes differ from the contract's pinned " + f"SHA-256: got {ladder.sha256}, expected {expected_ladder_sha256}." + ) + if ladder.puma is None: + raise ValueError( + "block_v1 needs a block ladder carrying the per-block 'puma' array: " + "ACS households draw within their observed PUMA." + ) + if ladder.primary_congressional_district_plan != ( + CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE + ): + raise ValueError( + "US block location ladder primary congressional-district plan is " + f"{ladder.primary_congressional_district_plan!r}, expected " + f"{CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE!r}." + ) + target_districts = _crosswalk_target_congressional_districts(crosswalk) + ladder_districts = tuple( + sorted( + int(value) + for value in np.unique(ladder.blocks.congressional_district_geoid).tolist() + ) + ) + if ladder_districts != target_districts: + raise ValueError( + "US block location ladder congressional-district universe differs " + "from the authenticated vintage-crosswalk target universe." + ) + + # Source-spine blind, like the legacy PUMA ladder: a household that + # carries an observed 7-digit PUMA (an ACS record) draws within it, and + # every other household resolves its finest geography from its CPS ASEC + # source row. The support channel is never read (test_us_spine_blindness). + household = frame.table("household") + for column in ("state_fips", "puma"): + if column not in household: + raise ValueError(f"block_v1 assignment needs household column {column!r}.") + n = len(household) + observed = household["puma"].astype("string") + has_puma = observed.str.fullmatch(r"[0-9]{7}").fillna(False).to_numpy(bool) + malformed = (observed.fillna("").str.len() > 0).to_numpy(bool) & ~has_puma + if malformed.any(): + raise ValueError( + f"block_v1: {int(malformed.sum())} household(s) carry a puma that is " + "not a 7-digit 2020 PUMA geoid; the rule never widens such a draw." + ) + puma = np.zeros(n, dtype=np.int64) + if has_puma.any(): + puma[has_puma] = observed[has_puma].astype(np.int64).to_numpy() + needs_source = ~has_puma + state_fips = pd.to_numeric(household["state_fips"], errors="coerce").to_numpy( + dtype=np.float64 + ) + if not np.isfinite(state_fips).all(): + raise ValueError("block_v1 needs a state_fips on every household.") + county_fips = np.zeros(n, dtype=np.int64) + identification_group = np.full(n, -1, dtype=np.int64) + cbsa_code = np.zeros(n, dtype=np.int64) + identified_counties: dict[int, frozenset[int]] = {} + unidentified_county_share = None + candidate_set_rule: dict[str, object] = { + "observed_puma": "blocks_within_observed_puma", + "no_observed_puma": "cps_asec_source_geography", + } + cbsa_applied = False + if needs_source.any(): + asec_ids = household.loc[needs_source, "household_id"].to_numpy(np.int64) + source_year, source_household_id = _stacked_asec_source_keys(frame, asec_ids) + pinned_years = { + int(authority["income_year"]) # type: ignore[index] + for role, authority in authorities.items() # type: ignore[union-attr] + if str(role).startswith(_STACKED_CPS_ASEC_INPUT_ROLE_PREFIX) + } + loaded_years = { + int(year) + for year in np.unique( + cps_household_geography["source_year"].to_numpy(np.int64) + ).tolist() + } + missing_years = sorted(set(np.unique(source_year).tolist()) - pinned_years) + if missing_years or loaded_years != pinned_years: + raise ValueError( + "block_v1 ASEC source years need exactly their pinned CPS ASEC " + f"household H5s: frame years missing a pin {missing_years}; " + f"pinned {sorted(pinned_years)}; loaded {sorted(loaded_years)}." + ) + cps = cps_source_geography( + ladder, + cps_household_geography, + source_year=source_year, + source_household_id=source_household_id, + state_fips=state_fips[needs_source].astype(np.int64), + ) + county_fips[needs_source] = cps.county_fips + identification_group[needs_source] = cps.identification_group + if cps.cbsa_code is not None: + cbsa_applied = True + cbsa_code[needs_source] = cps.cbsa_code + identified_counties = dict(cps.identified_counties) + unidentified_county_share = cps.unidentified_county_share + candidate_set_rule["cps_asec"] = _json_ready(cps.record) + + pre_assignment_order = _ordered_household_id_receipt(household) + assigned, table = with_household_us_block_location( + frame, + ladder, + seed=seed, + clones=clones, + puma=puma, + county_fips=county_fips, + identification_group=identification_group, + identified_counties=identified_counties, + cbsa_code=cbsa_code if cbsa_applied else None, + unidentified_county_share=unidentified_county_share, + ) + located = assigned.table("household") + acs_located = located.loc[has_puma, "puma"].astype(str).to_numpy() + acs_observed = household.loc[has_puma, "puma"].astype(str).to_numpy() + if not np.array_equal(acs_located, acs_observed): + raise AssertionError("block_v1 moved a household out of its observed PUMA.") + record = _json_ready( + us_block_location_manifest( + table, + ladder, + seed=seed, + clones=clones, + candidate_set_rule=candidate_set_rule, + ) + ) + values = _assigned_congressional_district_values( + located, + boundary="stacked post-assembly block location", + ) + gate_report = _block_location_gate_report( + located, assigned.weights_for("household").values, ladder + ) + if not gate_report.passed: + failures = [ + f"{gate.name}: {failure}" + for gate in gate_report.results + for failure in gate.failures + ] + raise ValueError( + "Stacked post-assembly block-location gates failed:\n " + + "\n ".join(failures) + ) + receipt = { + "artifact_kind": "populace_us_stacked_household_geography_assignment", + "schema_version": 1, + "contract": _json_ready(dict(contract)), + "pre_assignment_household_order": pre_assignment_order, + "assigned_household_geography": ordered_household_block_location_receipt( + located, + boundary="stacked post-assembly block location", + ), + "target_universe": _target_congressional_district_universe_receipt( + target_districts + ), + "output": { + "household_rows": len(located), + "positive_congressional_district_rows": len(values), + "unique_congressional_district_values": int(len(np.unique(values))), + }, + "summary": _block_location_summary(located, record), + "household_location": record, + "gate": gate_report.to_manifest(), + } + _validate_stacked_geography_assignment_receipt( + assigned, + receipt, + target_districts=target_districts, + boundary="stacked post-assembly block location", + require_exact_assembled_rows=True, + configured_contract=contract, + location_ladder=ladder, + ) + return assigned, receipt, target_districts + + def _stacked_realized_counts( stack_receipt: Mapping[str, object], ) -> tuple[int, int]: @@ -3621,6 +4780,13 @@ def mark_phase(name: str) -> None: ) if _json_ready(current_stack_receipt) != _json_ready(expected_stack_receipt): raise ValueError("Fresh stacked assembly receipt changed before execution.") + # The checkpoint identity binds the run's geography contract; a block_v1 + # identity admits only its own block-location receipt (legacy identities, + # and the empty identity some callers pass, keep the legacy checks). + identity_geography = checkpoint_identity.get("geography_assignment") + configured_geography_contract = ( + identity_geography if isinstance(identity_geography, Mapping) else None + ) if resume is None: if not isinstance(geography_assignment_receipt, Mapping): @@ -3633,6 +4799,7 @@ def mark_phase(name: str) -> None: target_districts=geography_target_districts, boundary="stacked pool fresh assembly", require_exact_assembled_rows=True, + configured_contract=configured_geography_contract, ) current = canonicalize_frame_string_dtypes( assembled, @@ -3693,6 +4860,7 @@ def mark_phase(name: str) -> None: target_districts=geography_target_districts, boundary=f"stacked pool {resume.stage} resume", require_exact_assembled_rows=resume.stage == "assembled", + configured_contract=configured_geography_contract, ) qbi_transition_authority_sha256 = resume.qbi_transition_authority_sha256 late_producer_transition_authority_sha256 = ( @@ -4072,6 +5240,7 @@ def primary_puf_producer(primary_input: Frame): target_districts=geography_target_districts, boundary="stacked pool terminal input-only output", require_exact_assembled_rows=False, + configured_contract=configured_geography_contract, ) return StackedPoolBuildResult( frame=current, @@ -4248,8 +5417,15 @@ def _stacked_manifest_payload( clone_attachment_fraction: float, clone_attachment_seed: int, run_config: Mapping[str, object] | None = None, + geography_contract: Mapping[str, object] | None = None, ) -> dict[str, object]: - """Build the stacked-only manifest without changing the legacy envelope.""" + """Build the stacked-only manifest without changing the legacy envelope. + + ``geography_contract`` defaults to the legacy geography contract. A + ``block_v1`` run passes its block-location contract; only then does the + manifest bind the block-location operator order and gain a top-level + ``household_location`` record. + """ _validate_stacked_post_puf_stage_receipt( result.frame, @@ -4274,17 +5450,25 @@ def _stacked_manifest_payload( raise ValueError( "Stacked pool stage receipts have no geography assignment object." ) - if _json_ready(geography_assignment.get("contract")) != _json_ready( + expected_geography_contract = ( _stacked_geography_assignment_contract() + if geography_contract is None + else geography_contract + ) + if _json_ready(geography_assignment.get("contract")) != _json_ready( + expected_geography_contract ): raise ValueError("Stacked pool geography assignment contract changed.") - _assert_stacked_geography_verified_inputs(verified_inputs) + _assert_stacked_geography_verified_inputs( + verified_inputs, + geography_contract=geography_contract, + ) gates = _stacked_gate_payload(result) stack_manifest = _json_ready(result.stack_receipt) worker_execution_authentication = _stacked_worker_execution_authentication( result.stage_receipts ) - return { + payload: dict[str, object] = { "artifact_kind": US_MULTISPINE_POOL_MANIFEST_ARTIFACT_KIND, "schema_version": POOL_MANIFEST_SCHEMA_VERSION, "pipeline": _STACKED_PIPELINE, @@ -4293,7 +5477,9 @@ def _stacked_manifest_payload( "simulation_ready": result.simulation_ready, "publication_run_id": publication_run_id, "calibration_applied": False, - "operator_order": list(US_STACKED_POOL_OPERATOR_ORDER), + "operator_order": list( + _stacked_operator_order_for_contract(expected_geography_contract) + ), "period": POOL_TIME_PERIOD, "random_seed": POOL_RANDOM_SEED, "run_config": _stacked_run_config_receipt(run_config), @@ -4378,6 +5564,11 @@ def _stacked_manifest_payload( "requires_manifest_simulation_ready": True, }, } + if _is_block_location_contract(expected_geography_contract): + payload["household_location"] = _json_ready( + geography_assignment.get("household_location") + ) + return payload def _stacked_publication_tombstone( @@ -4528,6 +5719,65 @@ def _write_outputs( temporary_diagnostics.unlink(missing_ok=True) +def _stacked_household_location_record( + stage_receipts: Mapping[str, object], +) -> Mapping[str, object] | None: + """The geography receipt's ``household_location`` record (block_v1 only).""" + + assignment = stage_receipts.get("geography_assignment") + record = ( + assignment.get("household_location") + if isinstance(assignment, Mapping) + else None + ) + return record if isinstance(record, Mapping) else None + + +def _stacked_location_root_attributes( + geography_contract: Mapping[str, object] | None, + verified_inputs: Mapping[str, _VerifiedInput], + household_location: Mapping[str, object] | None = None, +) -> dict[str, str]: + """H5 root attrs recording a block_v1 rule and seed (none under legacy). + + Seed and clones are text, as the base-H5 line writes them; the block + ladder digest and layer vintages use the ACS line's attribute names. + """ + + if geography_contract is None or not _is_block_location_contract( + geography_contract + ): + return {} + seed = geography_contract["seed"]["value"] # type: ignore[index] + clones = geography_contract["algorithm"]["clones"] # type: ignore[index] + ladder_record = ( + household_location.get("block_ladder") + if isinstance(household_location, Mapping) + else None + ) + vintages = ( + ladder_record.get("layer_vintages") + if isinstance(ladder_record, Mapping) + else None + ) + if not isinstance(vintages, Mapping) or not vintages: + raise ValueError( + "A block_v1 pool H5 needs the household_location record's block " + "ladder layer vintages." + ) + return { + POPULACE_LOCATION_RULE_ATTR: US_LOCATION_RULE_BLOCK_V1, + POPULACE_LOCATION_SEED_ATTR: str(int(seed)), + POPULACE_LOCATION_CLONES_ATTR: str(int(clones)), + POPULACE_BLOCK_LADDER_SHA256_ATTR: ( + verified_inputs[_STACKED_BLOCK_LADDER_INPUT_ROLE].actual_sha256 + ), + POPULACE_BLOCK_LADDER_VINTAGES_ATTR: json.dumps( + _json_ready(dict(vintages)), sort_keys=True + ), + } + + def _write_stacked_outputs( result: StackedPoolBuildResult, *, @@ -4541,6 +5791,7 @@ def _write_stacked_outputs( clone_attachment_fraction: float, clone_attachment_seed: int, run_config: Mapping[str, object] | None = None, + geography_contract: Mapping[str, object] | None = None, ) -> dict[str, object]: """Atomically publish the stacked input-only pool and terminal receipts.""" @@ -4606,6 +5857,11 @@ def _write_stacked_outputs( CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR: ( CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE ), + **_stacked_location_root_attributes( + geography_contract, + verified_inputs, + _stacked_household_location_record(result.stage_receipts), + ), }, ) _atomic_write_json(temporary_diagnostics, diagnostics) @@ -4624,6 +5880,7 @@ def _write_stacked_outputs( clone_attachment_fraction=clone_attachment_fraction, clone_attachment_seed=clone_attachment_seed, run_config=run_config, + geography_contract=geography_contract, ) _atomic_write_json(outputs.manifest, manifest) return manifest @@ -4902,6 +6159,22 @@ def _stacked_run_config(args: argparse.Namespace) -> dict[str, object]: } +def _with_location_run_config( + run_config: dict[str, object], + args: argparse.Namespace, +) -> dict[str, object]: + """Add the location rule and seed to run_config only under block_v1.""" + + if not _is_block_location(args): + return run_config + return { + **run_config, + "location_rule": US_LOCATION_RULE_BLOCK_V1, + "location_seed": getattr(args, "location_seed", 0), + "location_clones": getattr(args, "location_clones", 1), + } + + def _requested_stacked_run_config(args: argparse.Namespace) -> dict[str, object]: """Return receipt state before any fallible adapter resolution.""" @@ -5257,7 +6530,7 @@ def _main_stacked(args: argparse.Namespace) -> int: predecessor = args.logbook_prev_row_digest rung = _stacked_rung(args.sample_fraction) logbook_seed: int | None = None - run_config = _requested_stacked_run_config(args) + run_config = _with_location_run_config(_requested_stacked_run_config(args), args) preflight_digest = hashlib.sha256( _canonical_json_bytes( { @@ -5306,7 +6579,7 @@ def _main_stacked(args: argparse.Namespace) -> int: # configured/checkpoint identity, then deliberately leaves those # generation-0 identities untouched. The binding is a run receipt. try: - run_config = _stacked_run_config(args) + run_config = _with_location_run_config(_stacked_run_config(args), args) except Exception: if run_config.get("spec_binding_status") == "resolution_pending": run_config = { @@ -5314,6 +6587,12 @@ def _main_stacked(args: argparse.Namespace) -> int: "spec_binding_status": "resolution_failed", } raise + _require_location_rule_arguments(args) + geography_contract = ( + _stacked_location_contract_for_args(args) + if _is_block_location(args) + else None + ) configured_identity = _configured_stacked_identity(args) state.identity_digest = hashlib.sha256( _canonical_json_bytes(configured_identity) @@ -5351,6 +6630,7 @@ def _main_stacked(args: argparse.Namespace) -> int: sample_seed=args.sample_seed, clone_attachment_fraction=args.clone_attachment_fraction, clone_attachment_seed=args.clone_attachment_seed, + geography_contract=geography_contract, ) checkpoint_store: _PoolStageCheckpointStore | None = None resume: MultispinePoolCheckpoint | None = None @@ -5415,16 +6695,36 @@ def _main_stacked(args: argparse.Namespace) -> int: sample_fraction=args.sample_fraction, sample_seed=args.sample_seed, ) - puma_ladder = load_us_puma_ladder(args.puma_ladder) - ( - stack_frame, - geography_assignment_receipt, - assigned_target_districts, - ) = _assign_stacked_household_geography( - stack.frame, - ladder=puma_ladder, - crosswalk=congressional_district_crosswalk, - ) + if geography_contract is None: + puma_ladder = load_us_puma_ladder(args.puma_ladder) + ( + stack_frame, + geography_assignment_receipt, + assigned_target_districts, + ) = _assign_stacked_household_geography( + stack.frame, + ladder=puma_ladder, + crosswalk=congressional_district_crosswalk, + ) + else: + cps_asec_paths, cps_asec_pins = _cps_asec_h5_arguments(args) + _require_cps_asec_pins_match_raw_stage( + loaded.asec_raw_stage_checkpoint, + cps_asec_pins, + ) + ( + stack_frame, + geography_assignment_receipt, + assigned_target_districts, + ) = _assign_stacked_household_block_location( + stack.frame, + ladder=load_us_location_ladder(args.block_ladder), + crosswalk=congressional_district_crosswalk, + cps_household_geography=load_cps_household_geography( + cps_asec_paths + ), + contract=geography_contract, + ) if assigned_target_districts != geography_target_districts: raise ValueError( "Post-assembly geography assignment changed the authenticated " @@ -5441,6 +6741,7 @@ def _main_stacked(args: argparse.Namespace) -> int: sample_seed=args.sample_seed, clone_attachment_fraction=args.clone_attachment_fraction, clone_attachment_seed=args.clone_attachment_seed, + geography_contract=geography_contract, ) _promote_stacked_attempt_identity( state, @@ -5518,6 +6819,7 @@ def _main_stacked(args: argparse.Namespace) -> int: clone_attachment_fraction=args.clone_attachment_fraction, clone_attachment_seed=args.clone_attachment_seed, run_config=run_config, + geography_contract=geography_contract, ) _append_phase(state, "publication_completed") state.artifact_location = _local_artifact_reference(outputs.pool_h5) @@ -5590,7 +6892,8 @@ def _main_stacked(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: """Dispatch to the stacked default or explicit legacy two-spine path.""" - args = _parser().parse_args(argv) + args = _parse_arguments(argv) + _require_location_rule_arguments(args) if args.legacy_two_spine: if getattr(args, "config_authority", "constants") != "constants": raise ValueError( diff --git a/tools/build_us_puf_support_base.py b/tools/build_us_puf_support_base.py index b5f8663ea1..4842247f22 100644 --- a/tools/build_us_puf_support_base.py +++ b/tools/build_us_puf_support_base.py @@ -15,7 +15,7 @@ import shutil import subprocess import sys -from collections.abc import Callable, Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence from contextlib import contextmanager from dataclasses import replace from pathlib import Path @@ -152,6 +152,26 @@ write_puf_capital_gains_tail_manifest, ) from microcosm.build.us_runtime.asec_sources import ASEC_SOURCE_ARTIFACTS +from microcosm.build.us_runtime.block_location import ( + POPULACE_BLOCK_LADDER_SHA256_ATTR, + POPULACE_BLOCK_LADDER_VINTAGES_ATTR, + POPULACE_LOCATION_CLONES_ATTR, + POPULACE_LOCATION_RULE_ATTR, + POPULACE_LOCATION_SEED_ATTR, + US_LOCATION_RULE_BLOCK_V1, + US_LOCATION_RULE_CHOICES, + US_LOCATION_RULE_LEGACY, + UsLocationLadder, + load_us_location_ladder, + us_block_location_gate, + us_block_location_manifest, + with_household_us_block_location, +) +from microcosm.build.us_runtime.cps_source_geography import ( + cps_source_geography, + household_source_keys, + load_cps_household_geography, +) from microcosm.build.us_runtime.h5_io import ( assert_h5_unchanged, refuse_denied_frame, @@ -162,7 +182,10 @@ initialize_primary_puf_qrf_chain, run_primary_puf_qrf_chain, ) -from microcosm.build.us_runtime.puf_support import PUF_TAX_DETAIL_DEFAULT_PREDICTORS +from microcosm.build.us_runtime.puf_support import ( + PUF_TAX_DETAIL_DEFAULT_PREDICTORS, + US_PERSON_REFERENCE_ID_COLUMNS, +) from microcosm.frame import Frame, WeightKind, Weights from microcosm.frame.adapters.policyengine_us import PolicyEngineUSEngine from microcosm.frame.units import US_SCHEMA @@ -281,6 +304,60 @@ OUTER_STAGE_PIPELINE = StagePipeline( tuple(Stage(name, " -> ".join(boundaries)) for name, boundaries in STAGE_BOUNDARIES) ) +# --location-rule block_v1 (microcosm #696) replaces the legacy two-step +# location (SOI return-count congressional-district draw, then a block within +# that district) with one household block draw from which every geography, +# the congressional district included, derives. It is a separate pipeline so +# the legacy stage list, its descriptions and its sha stay byte-identical. +HOUSEHOLD_LOCATION_STAGE_NAME = "household_location_assignment" +_LEGACY_LOCATION_STAGE_NAMES = ( + "congressional_district_assignment", + "block_ladder_assignment", +) + + +def _block_v1_stage_boundaries() -> tuple[tuple[str, tuple[str, ...]], ...]: + boundaries: list[tuple[str, tuple[str, ...]]] = [] + for stage_name, steps in STAGE_BOUNDARIES: + if stage_name == _LEGACY_LOCATION_STAGE_NAMES[0]: + boundaries.append( + ( + HOUSEHOLD_LOCATION_STAGE_NAME, + ("with_household_us_block_location[block_v1]",), + ) + ) + elif stage_name != _LEGACY_LOCATION_STAGE_NAMES[1]: + boundaries.append((stage_name, steps)) + return tuple(boundaries) + + +BLOCK_V1_STAGE_BOUNDARIES = _block_v1_stage_boundaries() +BLOCK_V1_PIPELINE_STEPS = tuple(name for name, _ in BLOCK_V1_STAGE_BOUNDARIES) +BLOCK_V1_OUTER_STAGE_PIPELINE = StagePipeline( + tuple( + Stage(name, " -> ".join(boundaries)) + for name, boundaries in BLOCK_V1_STAGE_BOUNDARIES + ) +) +# Every stage name --stage accepts under either rule; a rule's own pipeline +# refuses the other rule's location stages at run time. +ALL_STAGE_NAMES = (*STAGE_NAMES, HOUSEHOLD_LOCATION_STAGE_NAME) + + +def _location_rule(args: object) -> str: + return str(getattr(args, "location_rule", US_LOCATION_RULE_LEGACY)) + + +def _is_block_v1(args: object) -> bool: + return _location_rule(args) == US_LOCATION_RULE_BLOCK_V1 + + +def _pipeline_steps_for(args: object) -> tuple[str, ...]: + return BLOCK_V1_PIPELINE_STEPS if _is_block_v1(args) else PIPELINE_STEPS + + +def _pipeline_for(args: object) -> StagePipeline: + return BLOCK_V1_OUTER_STAGE_PIPELINE if _is_block_v1(args) else OUTER_STAGE_PIPELINE def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -363,7 +440,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--out", required=True, type=Path) parser.add_argument( "--stage", - choices=(*STAGE_NAMES, "all"), + choices=(*ALL_STAGE_NAMES, "all"), default="all", help=( "Run one descriptive checkpointed stage, or run all stages as fresh " @@ -446,7 +523,46 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: "build." ), ) + parser.add_argument( + "--location-rule", + choices=US_LOCATION_RULE_CHOICES, + default=US_LOCATION_RULE_LEGACY, + help=( + "Household location rule. 'legacy' (default) draws a " + "congressional district from SOI return-count Ledger facts and " + "then a block within it. 'block_v1' (microcosm #696) draws one " + "2020 census block per household proportional to block " + "population within its CPS ASEC source geography (identified " + "county, else its state outside the ASEC year's identified " + "counties) and derives every other geography, the congressional " + "district included, from the block." + ), + ) + parser.add_argument( + "--location-seed", + default=0, + type=int, + help="Seed of the block_v1 household block draw (block_v1 only).", + ) + parser.add_argument( + "--location-clones", + default=1, + type=int, + help=( + "Location clones per household (block_v1 only). Only 1 is " + "admitted on this line today; see the parse-time refusal." + ), + ) args = parser.parse_args(argv) + if args.location_rule == US_LOCATION_RULE_BLOCK_V1: + _validate_block_v1_args(parser, args) + return _validate_common_args(parser, args) + if args.location_seed != 0 or args.location_clones != 1: + parser.error( + "--location-seed and --location-clones apply only to " + "--location-rule block_v1; the legacy rule draws with " + "--congressional-district-seed and --geography-ladder-seed" + ) if ( args.congressional_district_vintage_crosswalk is not None and not args.assign_congressional_districts @@ -476,6 +592,75 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: "(tools/build_us_block_ladder_artifact.py) or opt out " "explicitly with --without-block-ladder" ) + return _validate_common_args(parser, args) + + +# Why --location-clones > 1 is refused on this line (read this session): the +# release that consumes this base refuses duplicate copies of one source unit. +_LOCATION_CLONES_BLOCKERS = ( + "support_provenance.py:support_copy_rank_series admits only support clone " + "indices 0, 1 and 2 on a historical frame", + "sipp_head_start.py:_recipient_predictors (via with_us_sipp_head_start_input) " + "refuses repeated (person_source_id, copy rank) pairs", + "voluntary_filing.py:_source_receiver_rows refuses repeated " + "(tax_unit_source_id, copy rank) pairs", + "puf_capital_gains_tail.py:assert_puf_capital_gains_tail_survives_selection " + "requires each capital-gains tail donor once among applied tax units", +) + + +def _validate_block_v1_args( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> None: + if args.location_clones != 1: + parser.error( + "--location-clones must be 1 on the base-h5 line for now: the " + "Route A release (tools/build_us_fiscal_refresh_release.py " + "--base-h5) cannot yet admit location clones of one source " + "household: " + "; ".join(_LOCATION_CLONES_BLOCKERS) + ) + if args.location_seed < 0: + parser.error("--location-seed must be non-negative") + if args.block_ladder_artifact is None: + parser.error( + "--location-rule block_v1 requires --block-ladder-artifact: the " + "household block draw samples the ladder's 2020 blocks" + ) + if args.without_block_ladder: + parser.error( + "--location-rule block_v1 and --without-block-ladder are " + "contradictory: every geography derives from the drawn block" + ) + if args.assign_congressional_districts or args.ledger_facts is not None: + parser.error( + "--location-rule block_v1 replaces the SOI return-count " + "congressional-district draw: drop --assign-congressional-" + "districts and --ledger-facts (the district derives from the block)" + ) + if args.congressional_district_seed != 0 or args.geography_ladder_seed != 0: + parser.error( + "--congressional-district-seed and --geography-ladder-seed seed " + "the legacy draws; under --location-rule block_v1 pass " + "--location-seed" + ) + if args.congressional_district_vintage_crosswalk is None: + parser.error( + "--location-rule block_v1 requires " + "--congressional-district-vintage-crosswalk: the Route A release " + "checks the base's crosswalk sha and 119th-Congress target " + "attributes before it runs" + ) + if args.base_h5 is not None: + parser.error( + "--location-rule block_v1 requires --asec-h5 sources: a CPS " + "household's candidate blocks come from its ASEC GTCO/GESTFIPS " + "row, which a --base-h5 input does not carry" + ) + + +def _validate_common_args( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> argparse.Namespace: if args.support_spine_spec is not None and args.asec_h5 is None: parser.error("--support-spine-spec requires --asec-h5") if args.stage != "all" and args.checkpoint_dir is None: @@ -672,7 +857,11 @@ def main(argv: list[str] | None = None) -> None: # pipeline and remains the byte-for-byte compatibility path. boundary_dir = getattr(args, "equivalence_boundary_dir", None) observer = ( - None if boundary_dir is None else _EquivalenceBoundaryObserver(boundary_dir) + None + if boundary_dir is None + else _EquivalenceBoundaryObserver( + boundary_dir, pipeline_steps=_pipeline_steps_for(args) + ) ) if observer is None: _run_all(args) @@ -686,14 +875,15 @@ def main(argv: list[str] | None = None) -> None: def _run_staged_all(args: argparse.Namespace) -> None: """Run every outer boundary in a fresh interpreter, resuming by prefix.""" + pipeline_steps = _pipeline_steps_for(args) runtime = StageRuntime( args.checkpoint_dir, - OUTER_STAGE_PIPELINE, + _pipeline_for(args), run_config=_stage_run_config(args), ) completed_prefix = runtime.context.completed - remaining = PIPELINE_STEPS[len(completed_prefix) :] - if not remaining and completed_prefix == PIPELINE_STEPS: + remaining = pipeline_steps[len(completed_prefix) :] + if not remaining and completed_prefix == pipeline_steps: # Re-enter only the fresh final child so it can validate/repair the # export and stage_all alias after a crash in the post-context window. remaining = ("final_export",) @@ -767,6 +957,12 @@ def _stage_cli_args(args: argparse.Namespace, stage: str) -> list[str]: command.extend(("--geography-ladder-seed", str(args.geography_ladder_seed))) if args.allow_geography_ladder_gate_failures: command.append("--allow-geography-ladder-gate-failures") + if _is_block_v1(args): + # Emitted only off the legacy rule, so legacy child command lines + # stay byte-identical. + command.extend(("--location-rule", _location_rule(args))) + command.extend(("--location-seed", str(args.location_seed))) + command.extend(("--location-clones", str(args.location_clones))) if getattr(args, "equivalence_deterministic_h5_metadata", False): command.append("--equivalence-deterministic-h5-metadata") return command @@ -846,7 +1042,7 @@ def path(value: Path | None) -> str | None: "PYTHONHASHSEED", ) } - return { + config: dict[str, object] = { "allow_geography_ladder_gate_failures": bool( args.allow_geography_ladder_gate_failures ), @@ -904,6 +1100,24 @@ def path(value: Path | None) -> str | None: "thread_environment": thread_environment, "without_block_ladder": bool(args.without_block_ladder), } + if _is_block_v1(args): + # Off the legacy rule only, so a legacy run config (and every legacy + # checkpoint directory that locked one) is unchanged. The ladder's + # digest is locked too: a ladder rewritten in place at the same path + # would silently move every drawn block. + config.update( + { + "location_rule": _location_rule(args), + "location_seed": int(args.location_seed), + "location_clones": int(args.location_clones), + "block_ladder_artifact_sha256": ( + _sha256(args.block_ladder_artifact) + if Path(args.block_ladder_artifact).is_file() + else None + ), + } + ) + return config def _builder_code_identity() -> dict[str, object]: @@ -928,13 +1142,19 @@ def _builder_code_identity() -> dict[str, object]: class _EquivalenceBoundaryObserver: """Write test-only monolith snapshots without changing production stages.""" - def __init__(self, root: Path) -> None: + def __init__( + self, + root: Path, + *, + pipeline_steps: tuple[str, ...] = PIPELINE_STEPS, + ) -> None: self.root = root.resolve() self.root.mkdir(parents=True, exist_ok=True) self._next_stage_index = 0 + self.pipeline_steps = pipeline_steps def observe_frame(self, stage: str, frame: Frame) -> None: - expected = PIPELINE_STEPS[self._next_stage_index] + expected = self.pipeline_steps[self._next_stage_index] if stage != expected: raise AssertionError( f"Monolith boundary order changed: expected {expected!r}, got " @@ -945,7 +1165,7 @@ def observe_frame(self, stage: str, frame: Frame) -> None: frame, metadata={ "artifact_kind": "populace_monolith_equivalence_boundary", - "pipeline_steps": list(PIPELINE_STEPS), + "pipeline_steps": list(self.pipeline_steps), "stage": stage, "stage_index": self._next_stage_index, }, @@ -987,10 +1207,10 @@ def observe_primary_qrf( temporary.unlink(missing_ok=True) def assert_complete(self) -> None: - if self._next_stage_index != len(PIPELINE_STEPS): + if self._next_stage_index != len(self.pipeline_steps): raise AssertionError( "Monolith boundary observer stopped after " - f"{self._next_stage_index}/{len(PIPELINE_STEPS)} stages." + f"{self._next_stage_index}/{len(self.pipeline_steps)} stages." ) @@ -1048,6 +1268,165 @@ def _write_policyengine_dataset( ) +def _load_block_v1_location_ladder(args: argparse.Namespace) -> UsLocationLadder: + """Load the block_v1 ladder; its primary CD plan must be the current one. + + Route A reads the base's ``congressional_district_geoid`` as a + current-vintage (119th Congress) district and checks the vintage attrs + this builder writes, so a ladder whose primary plan is another vintage + is refused before any draw. + """ + + ladder = load_us_location_ladder(args.block_ladder_artifact) + if ladder.primary_congressional_district_plan != ( + CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE + ): + raise SystemExit( + "--location-rule block_v1: the block ladder's primary " + "congressional-district plan is " + f"{ladder.primary_congressional_district_plan!r}, but this base " + f"records districts as {CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE!r}." + ) + # Validates the crosswalk artifact (current-vintage targets) even though + # block_v1 translates no facts: its digest is the attr Route A checks. + load_congressional_district_vintage_crosswalk( + args.congressional_district_vintage_crosswalk + ) + return ladder + + +def _block_v1_household_location( + args: argparse.Namespace, + frame: Frame, +) -> tuple[Frame, dict[str, object]]: + """Draw every household's 2020 block (microcosm #696) and record it. + + Every household on this line is CPS ASEC lineage: PUF-detail and + capital-gains tail copies share their native row's + ``(source_year, source_household_id)``, so each copy's candidate blocks + come from the same ASEC row (identified county when ``GTCO`` is nonzero, + else its state outside that ASEC year's identified counties), while each + copy gets its own keyed draw. Returns the located frame and the + ``household_location`` manifest record. + """ + + ladder = _load_block_v1_location_ladder(args) + asec_paths = _asec_source_paths(args) + source_year, source_household_id = household_source_keys(frame) + geography = cps_source_geography( + ladder, + load_cps_household_geography(asec_paths), + source_year=source_year, + source_household_id=source_household_id, + state_fips=frame.table("household")["state_fips"].to_numpy(np.int64), + ) + located, table = with_household_us_block_location( + frame, + ladder, + seed=args.location_seed, + clones=args.location_clones, + county_fips=geography.county_fips, + identification_group=geography.identification_group, + identified_counties=geography.identified_counties, + cbsa_code=geography.cbsa_code, + unidentified_county_share=geography.unidentified_county_share, + person_reference_columns=US_PERSON_REFERENCE_ID_COLUMNS, + ) + household = located.table("household") + household_weights = located.weights_for("household").values + location_gate = us_block_location_gate(household, ladder) + # The L0/refit release export re-runs the legacy geography-ladder gate + # (l0_refit_export.py:export_us_l0_refit_h5) on frames built from this + # base, so the block draw must pass it here too. + ladder_gate = us_geography_ladder_gate(household, household_weights) + failures = [ + *(f"us_block_location: {failure}" for failure in location_gate.failures), + *(f"geography_ladder: {failure}" for failure in ladder_gate.failures), + ] + if failures and not args.allow_geography_ladder_gate_failures: + raise SystemExit( + "Household location (block_v1) gate failed:\n " + "\n ".join(failures) + ) + record = us_block_location_manifest( + table, + ladder, + seed=args.location_seed, + clones=args.location_clones, + candidate_set_rule=geography.record, + ) + record.update( + { + "applied": True, + "artifact": str(args.block_ladder_artifact.resolve()), + "artifact_sha256": ladder.sha256, + "asec_h5": { + str(year): str(path.resolve()) + for year, path in sorted(asec_paths.items()) + }, + "congressional_district_vintage_crosswalk": str( + args.congressional_district_vintage_crosswalk.resolve() + ), + "congressional_district_vintage_crosswalk_sha256": _sha256( + args.congressional_district_vintage_crosswalk + ), + "congressional_district_vintage_target": ( + CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE + ), + "coverage": us_geography_ladder_assignment_summary( + household, + ladder.blocks, + weight_values=household_weights, + ), + "gate": { + "passed": location_gate.passed, + "failures": list(location_gate.failures), + "details": dict(location_gate.details), + }, + "geography_ladder_gate": { + "passed": ladder_gate.passed, + "failures": list(ladder_gate.failures), + "details": dict(ladder_gate.details), + }, + } + ) + return located, record + + +def _write_block_v1_location_attrs( + attrs: MutableMapping[str, object], + household_location: Mapping[str, object], +) -> None: + """Root attrs of a block_v1 base; legacy bases never carry the location ones. + + The crosswalk digest and ``119th_congress`` target are the attrs Route A + checks before it runs. The ladder digest and vintages go under both the + legacy ladder names and the ``populace_block_ladder_*`` names every + block_v1 line writes. Seed and clones are written as text, as the stacked + pool writes them. + """ + + ladder = household_location["block_ladder"] + if not isinstance(ladder, Mapping): + raise TypeError("household_location['block_ladder'] must be a mapping.") + attrs[CONGRESSIONAL_DISTRICT_VINTAGE_CROSSWALK_SHA256_ATTR] = str( + household_location["congressional_district_vintage_crosswalk_sha256"] + ) + attrs[CONGRESSIONAL_DISTRICT_VINTAGE_TARGET_ATTR] = str( + household_location["congressional_district_vintage_target"] + ) + attrs[GEOGRAPHY_LADDER_ARTIFACT_SHA256_ATTR] = str(ladder["sha256"]) + attrs[GEOGRAPHY_LADDER_VINTAGES_ATTR] = json.dumps( + ladder["layer_vintages"], sort_keys=True + ) + attrs[POPULACE_BLOCK_LADDER_SHA256_ATTR] = str(ladder["sha256"]) + attrs[POPULACE_BLOCK_LADDER_VINTAGES_ATTR] = json.dumps( + ladder["layer_vintages"], sort_keys=True + ) + attrs[POPULACE_LOCATION_RULE_ATTR] = str(household_location["location_rule"]) + attrs[POPULACE_LOCATION_SEED_ATTR] = str(household_location["seed"]) + attrs[POPULACE_LOCATION_CLONES_ATTR] = str(household_location["clones"]) + + def _run_all( args: argparse.Namespace, *, @@ -1516,89 +1895,116 @@ def _run_all( + "\n ".join(education_inputs_gate.failures) ) _observe_frame_boundary(boundary_observer, "education_inputs_post_clone", imputed) - congressional_district_assignment = {"applied": False} - if args.assign_congressional_districts: - ledger_facts = load_ledger_consumer_artifact(args.ledger_facts).facts - if args.congressional_district_vintage_crosswalk is not None: - ledger_facts = translate_congressional_district_facts_to_current_vintage( - ledger_facts, - load_congressional_district_vintage_crosswalk( - args.congressional_district_vintage_crosswalk - ), - ) - distribution = congressional_district_distribution_from_ledger_facts( - ledger_facts - ) - imputed = with_household_congressional_districts( - imputed, - distribution, - seed=args.congressional_district_seed, + household_location: dict[str, object] | None = None + if _is_block_v1(args): + # block_v1 replaces both legacy location stages with one block draw; + # their summary keys stay present and unapplied. + imputed, household_location = _block_v1_household_location(args, imputed) + congressional_district_assignment = { + "applied": False, + "replaced_by": "household_location", + } + geography_ladder_assignment = { + "applied": False, + "opted_out": False, + "replaced_by": "household_location", + } + _observe_frame_boundary( + boundary_observer, HOUSEHOLD_LOCATION_STAGE_NAME, imputed ) - congressional_district_assignment = congressional_district_assignment_summary( - imputed.table("household"), - distribution, + else: + congressional_district_assignment = {"applied": False} + if args.assign_congressional_districts: + ledger_facts = load_ledger_consumer_artifact(args.ledger_facts).facts + if args.congressional_district_vintage_crosswalk is not None: + ledger_facts = ( + translate_congressional_district_facts_to_current_vintage( + ledger_facts, + load_congressional_district_vintage_crosswalk( + args.congressional_district_vintage_crosswalk + ), + ) + ) + distribution = congressional_district_distribution_from_ledger_facts( + ledger_facts + ) + imputed = with_household_congressional_districts( + imputed, + distribution, + seed=args.congressional_district_seed, + ) + congressional_district_assignment = ( + congressional_district_assignment_summary( + imputed.table("household"), + distribution, + ) + ) + congressional_district_assignment.update( + { + "ledger_facts": str(args.ledger_facts.resolve()), + "ledger_facts_sha256": _sha256(args.ledger_facts), + "congressional_district_vintage_crosswalk": ( + str(args.congressional_district_vintage_crosswalk.resolve()) + if args.congressional_district_vintage_crosswalk is not None + else None + ), + "congressional_district_vintage_crosswalk_sha256": ( + _sha256(args.congressional_district_vintage_crosswalk) + if args.congressional_district_vintage_crosswalk is not None + else None + ), + "seed": args.congressional_district_seed, + } + ) + _observe_frame_boundary( + boundary_observer, "congressional_district_assignment", imputed ) - congressional_district_assignment.update( - { - "ledger_facts": str(args.ledger_facts.resolve()), - "ledger_facts_sha256": _sha256(args.ledger_facts), - "congressional_district_vintage_crosswalk": ( - str(args.congressional_district_vintage_crosswalk.resolve()) - if args.congressional_district_vintage_crosswalk is not None - else None - ), - "congressional_district_vintage_crosswalk_sha256": ( - _sha256(args.congressional_district_vintage_crosswalk) - if args.congressional_district_vintage_crosswalk is not None - else None + geography_ladder_assignment = { + "applied": False, + "opted_out": bool(args.without_block_ladder), + } + if args.block_ladder_artifact is not None: + ladder = load_us_block_ladder(args.block_ladder_artifact) + imputed = with_household_us_geography_ladder( + imputed, + ladder, + seed=args.geography_ladder_seed, + expected_congressional_district_vintage=( + CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE ), - "seed": args.congressional_district_seed, - } - ) - _observe_frame_boundary( - boundary_observer, "congressional_district_assignment", imputed - ) - geography_ladder_assignment = { - "applied": False, - "opted_out": bool(args.without_block_ladder), - } - if args.block_ladder_artifact is not None: - ladder = load_us_block_ladder(args.block_ladder_artifact) - imputed = with_household_us_geography_ladder( - imputed, - ladder, - seed=args.geography_ladder_seed, - expected_congressional_district_vintage=( - CURRENT_CONGRESSIONAL_DISTRICT_VINTAGE - ), - ) - household = imputed.table("household") - household_weights = imputed.weights_for("household").values - gate = us_geography_ladder_gate(household, household_weights) - if not gate.passed and not args.allow_geography_ladder_gate_failures: - raise SystemExit( - "Geography-ladder gate failed:\n " + "\n ".join(gate.failures) ) - geography_ladder_assignment = us_geography_ladder_assignment_summary( - household, - ladder, - weight_values=household_weights, - ) - geography_ladder_assignment.update( - { - "artifact": str(args.block_ladder_artifact.resolve()), - "artifact_sha256": _sha256(args.block_ladder_artifact), - "seed": args.geography_ladder_seed, - "gate": { - "passed": gate.passed, - "failures": list(gate.failures), - "details": dict(gate.details), - }, - } - ) - _observe_frame_boundary(boundary_observer, "block_ladder_assignment", imputed) + household = imputed.table("household") + household_weights = imputed.weights_for("household").values + gate = us_geography_ladder_gate(household, household_weights) + if not gate.passed and not args.allow_geography_ladder_gate_failures: + raise SystemExit( + "Geography-ladder gate failed:\n " + "\n ".join(gate.failures) + ) + geography_ladder_assignment = us_geography_ladder_assignment_summary( + household, + ladder, + weight_values=household_weights, + ) + geography_ladder_assignment.update( + { + "artifact": str(args.block_ladder_artifact.resolve()), + "artifact_sha256": _sha256(args.block_ladder_artifact), + "seed": args.geography_ladder_seed, + "gate": { + "passed": gate.passed, + "failures": list(gate.failures), + "details": dict(gate.details), + }, + } + ) + _observe_frame_boundary(boundary_observer, "block_ladder_assignment", imputed) _write_policyengine_dataset(args, imputed, output_h5) - if ( + if household_location is not None: + import h5py + + with h5py.File(output_h5, "a") as h5: + _write_block_v1_location_attrs(h5.attrs, household_location) + elif ( args.congressional_district_vintage_crosswalk is not None or args.block_ladder_artifact is not None ): @@ -1830,6 +2236,8 @@ def _run_all( "channel_output_totals": _channel_output_totals(imputed), "immigration_composition": us_immigration_composition_summary(imputed), } + if household_location is not None: + summary["household_location"] = household_location summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True)) print(json.dumps(summary, indent=2, sort_keys=True)) _observe_frame_boundary(boundary_observer, "final_export", imputed) @@ -1842,7 +2250,7 @@ def _run_configured_stage(args: argparse.Namespace) -> None: if args.stage in LEGACY_STAGE_ALIASES: raise SystemExit( f"Stage alias {args.stage!r} was Phase-1-only; use one of the " - f"descriptive stages {list(PIPELINE_STEPS)}." + f"descriptive stages {list(_pipeline_steps_for(args))}." ) _run_outer_stage(args) @@ -1850,9 +2258,16 @@ def _run_configured_stage(args: argparse.Namespace) -> None: def _run_outer_stage(args: argparse.Namespace) -> None: """Run one exact pipeline step through the lossless outer-stage runtime.""" + pipeline_steps = _pipeline_steps_for(args) + if args.stage not in pipeline_steps: + raise SystemExit( + f"--stage {args.stage!r} is not a stage of the " + f"--location-rule {_location_rule(args)} pipeline " + f"{list(pipeline_steps)}." + ) runtime = StageRuntime( args.checkpoint_dir, - OUTER_STAGE_PIPELINE, + _pipeline_for(args), run_config=_stage_run_config(args), ) if args.stage in runtime.context.completed: @@ -1934,6 +2349,12 @@ def _run_outer_stage(args: argparse.Namespace) -> None: after = before metadata = _export_staged_result(args, after, stage_metadata) assert_unchanged_identity(before, after, stage=args.stage) + elif args.stage == HOUSEHOLD_LOCATION_STAGE_NAME: + after, household_location = _block_v1_household_location(args, before) + metadata = {"household_location": household_location} + # --location-clones is 1 on this line (refused at parse time), so + # the draw adds geography columns and changes no row or id. + assert_unchanged_identity(before, after, stage=args.stage) else: after, metadata = _post_qrf_frame_stage( args.stage, @@ -2029,7 +2450,7 @@ def _ensure_asec_raw_stage_checkpoint( "artifact_kind": ASEC_RAW_STAGE_ARTIFACT_KIND, "identity": frame_identity(raw_frame).to_payload(), "operator_status": ASEC_RAW_STAGE_OPERATOR_STATUS, - "pipeline_sha256": OUTER_STAGE_PIPELINE.sha256, + "pipeline_sha256": _pipeline_for(args).sha256, "raw_source_mappings": raw_source_mappings, "schema_version": ASEC_RAW_STAGE_SCHEMA_VERSION, "source_construction_identity": frame_identity( @@ -2974,11 +3395,33 @@ def _export_staged_result( output_h5 = out_dir / _dataset_filename(args.target_year) summary_path = out_dir / _summary_filename(args.target_year) _write_policyengine_dataset(args, frame, output_h5) - congressional = stage_metadata["congressional_district_assignment"][ - "congressional_district_assignment" - ] - geography = stage_metadata["block_ladder_assignment"]["geography_ladder_assignment"] - if ( + household_location: dict[str, object] | None = None + if _is_block_v1(args): + household_location = stage_metadata[HOUSEHOLD_LOCATION_STAGE_NAME][ + "household_location" + ] + congressional: dict[str, object] = { + "applied": False, + "replaced_by": "household_location", + } + geography: dict[str, object] = { + "applied": False, + "opted_out": False, + "replaced_by": "household_location", + } + else: + congressional = stage_metadata["congressional_district_assignment"][ + "congressional_district_assignment" + ] + geography = stage_metadata["block_ladder_assignment"][ + "geography_ladder_assignment" + ] + if household_location is not None: + import h5py + + with h5py.File(output_h5, "a") as h5: + _write_block_v1_location_attrs(h5.attrs, household_location) + elif ( args.congressional_district_vintage_crosswalk is not None or args.block_ladder_artifact is not None ): @@ -3084,13 +3527,13 @@ def _export_staged_result( "puf_tax_detail_tail_bounds": qrf["puf_tax_detail_tail_bounds"], "puf_capital_gains_tail_transfer": capital_gains_tail, **{name: signals[name] for name in required_signals}, - "congressional_district_assignment": stage_metadata[ - "congressional_district_assignment" - ]["congressional_district_assignment"], + "congressional_district_assignment": congressional, "geography_ladder_assignment": geography, "channel_output_totals": _channel_output_totals(frame), "immigration_composition": us_immigration_composition_summary(frame), } + if household_location is not None: + summary["household_location"] = household_location summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True)) print(json.dumps(summary, indent=2, sort_keys=True)) return { From 4324432e5f50b320c132851e13395910d657db39 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 10:41:43 -0400 Subject: [PATCH 2/2] Location v1: address review round 2 - 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 --- .../696-us-location-v1-block-draw.added.md | 4 +- docs/us-location-v1.md | 26 ++++- .../build/us_runtime/cps_source_geography.py | 18 +++- .../us/test_us_cps_source_geography.py | 96 +++++++++++++++++++ .../test_us_multispine_pool_block_location.py | 35 +++++++ tools/build_us_cps_identified_counties.py | 2 +- tools/build_us_multispine_pool.py | 25 +++++ 7 files changed, 201 insertions(+), 5 deletions(-) diff --git a/changelog.d/696-us-location-v1-block-draw.added.md b/changelog.d/696-us-location-v1-block-draw.added.md index 7d8426190c..6ca4504534 100644 --- a/changelog.d/696-us-location-v1-block-draw.added.md +++ b/changelog.d/696-us-location-v1-block-draw.added.md @@ -1,9 +1,9 @@ Add US household location v1 (`us_runtime.block_location`), the rule Max set on 27 September 2026. -Each household draws one 2020 census block, in proportion to block population, within the finest geography its source provides. That is the PUMA for ACS records and the identified county for CPS records. A CPS record with no county code draws within its state, outside the counties that Census's List 4 marks as entirely identified and that the year's file actually codes. +Each household draws one 2020 census block, in proportion to block population, within the finest geography its source provides. That is the PUMA for ACS records and the identified county for CPS records. A CPS record with no county code draws within its state, outside the counties that Census's List 4 for that ASEC year names and that the year's file actually codes. For a year whose List 4 drops the whole-county guarantee (ASEC 2026), it draws from the whole state. Every other geography is then looked up from the block: tract, county, place, SLDU/SLDL, CBSA, PUMA, and the district of every attached congressional plan. Draws are keyed by household id, so they do not depend on row order. The draw also supports K clones at weight/K. Each US line gains `--location-rule block_v1`. The default, `legacy`, stays byte-identical. -The block ladder builder now adds a per-block `puma` array; the schema-1 loader ignores it. Census's identified-county lists for ASEC 2023-2025 are packaged with their provenance. +The block ladder builder now adds a per-block `puma` array; the schema-1 loader ignores it. Census's identified-county lists for ASEC 2023-2026 are packaged with their PDF digests and a per-year whole-county-guarantee flag. diff --git a/docs/us-location-v1.md b/docs/us-location-v1.md index 6f87bea138..6b38927e6e 100644 --- a/docs/us-location-v1.md +++ b/docs/us-location-v1.md @@ -75,6 +75,30 @@ With `clones = K`, each household is drawn K times. Clone k of household h becom Every line currently refuses K > 1, because Route A's and the stacked pool's duplicate-copy checks do not yet recognize a location clone. That follow-up is tracked separately. +## On each line + +Every US line takes `--location-rule {legacy,block_v1}` (default `legacy`, byte-identical to before), `--location-seed` and `--location-clones`. + +Under `block_v1`, each line records: + +- the `us_block_location_manifest` record under `household_location`; +- the H5 root attributes `populace_location_rule`, `populace_location_seed`, `populace_location_clones`, `populace_block_ladder_sha256` and `populace_block_ladder_vintages`. + +What each line does: + +- **Base-h5** (`tools/build_us_puf_support_base.py`, the Route A base). One stage, `household_location_assignment`, replaces the district and block stages in a separate pipeline, so the legacy pipeline sha is unchanged. + - Every household, including its PUF support copies, resolves its CPS ASEC row by `(source_year, source_household_id)` from `--asec-h5`. + - The crosswalk and `119th_congress` attributes Route A checks are still written. +- **Stacked pool** (`tools/build_us_multispine_pool.py`, validated by `us_runtime/h5_io.py`). The line stays source-spine blind: + - a household with an observed 7-digit `puma` draws within it; + - every other household resolves through its pinned CPS ASEC source key; + - a household that carries an ACS `puma_geoid` or `PUMA` without a valid `puma` is refused, because ACS source keys share the CPS 2024 key space. + + Location runs before PUF cloning, so support clones inherit their native row's block. +- **ACS local release** (`tools/_legacy/build_us_acs_multispine_base.py` → `tools/build_us_acs_local_release.py`). + - ACS households draw within their observed PUMA. Donors that already carry a block from their base keep it, and donors without one draw within their state. + - Finalize runs `us_block_location_gate` on the staged ladder. The targets' `--ladder` must be the PUMA ladder staging recorded. + ## Invariants Each of these is a property test in `tests/engine_free/us/test_us_block_location.py` (Hypothesis over nested synthetic ladders) or `test_us_cps_source_geography.py`: @@ -97,7 +121,7 @@ Each of these is a property test in `tests/engine_free/us/test_us_block_location - Each household appears K times, with clone indices 0 to K−1 and clone ids key·K + k. - Its clone weights sum to its weight (relative tolerance 1e-12). - Frame cloning conserves every weighted entity's mass, keeps linkage valid, and places every parent in the same clone. -6. **Both confirmations.** The excluded set is exactly the intersection of listed and coded counties, per year. +6. **Both confirmations.** The excluded set is exactly the intersection of listed and coded counties, per year, when that year's list carries the whole-county guarantee, and empty when it does not. The opt-in remainder weighting never applies to a year without the guarantee. The differential tests are: diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py b/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py index 3d9f82d86a..b4eda58e94 100644 --- a/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/cps_source_geography.py @@ -209,8 +209,23 @@ def cps_source_geography( if use_cbsa else None ) + # The remainder weighting presumes countywide censoring, which a year + # without the whole-county guarantee does not honour (ASEC 2026 codes all + # of Delaware yet has Delaware GTCO=0 households), so such a year keeps + # its whole-state draw even when the option is on. + no_guarantee = sorted( + int(group) + for group, entry in record["groups"].items() + if entry.get("basis") == "no_whole_county_guarantee_state_draw" + ) shares = ( - cps_unidentified_county_shares(coverage, excluded) + { + group: share + for group, share in cps_unidentified_county_shares( + coverage, excluded + ).items() + if group not in no_guarantee + } if partial_county_remainder else None ) @@ -237,6 +252,7 @@ def cps_source_geography( }, "partial_county_remainder": { "applied": bool(partial_county_remainder), + "skipped_groups_without_guarantee": no_guarantee, "coverage_ratio_definition": ( "coded weighted persons / 2020 census population, " "median-normalized per source year" diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py b/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py index 268ffc8ae2..a4e6c92e86 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_cps_source_geography.py @@ -428,3 +428,99 @@ def test_every_state_keeps_candidate_blocks_for_its_gtco_zero_records( identification_group=[2024], identified_counties={2024: frozenset(state_one)}, ) + + +def test_parser_stops_at_the_next_section_without_a_footnote() -> None: + """The 2026 documentation has no footnote after List 4; the list ends at + the first line that is not a state, county or page header.""" + + rendered = _RENDERED.split("* Counties marked")[0] + ( + " Source of the Data and Accuracy of the Estimates\n" + "001 Not a county\n" + ) + counties = parse_cps_identified_county_list(rendered.splitlines()) + assert [(c.state_fips, c.county_code) for c in counties][-1] == ("36", "061") + assert len(counties) == 4 + + +def test_the_official_list_must_match_its_provenance_digest( + tmp_path, monkeypatch +) -> None: + import shutil + + from microcosm.build.us_runtime import block_location + from microcosm.build.us_runtime.block_location import ( + default_cps_asec_identified_counties_path, + load_cps_asec_identified_counties, + ) + + packaged = default_cps_asec_identified_counties_path() + copy = tmp_path / packaged.name + shutil.copy(packaged, copy) + shutil.copy( + packaged.with_name(packaged.name + ".provenance.json"), + copy.with_name(copy.name + ".provenance.json"), + ) + assert load_cps_asec_identified_counties(copy) + copy.write_text(copy.read_text().replace(",003,Baldwin,", ",005,Baldwin,", 1)) + with pytest.raises(ValueError, match="provenance records"): + load_cps_asec_identified_counties(copy) + # The packaged list must ship with its provenance. + bare = tmp_path / "bare" / packaged.name + bare.parent.mkdir() + shutil.copy(packaged, bare) + monkeypatch.setattr( + block_location, "default_cps_asec_identified_counties_path", lambda: bare + ) + with pytest.raises(FileNotFoundError, match="lacks"): + load_cps_asec_identified_counties() + + +def test_record_names_the_packaged_list_digest(tmp_path) -> None: + from microcosm.build.us_runtime.block_location import ( + default_cps_asec_identified_counties_path, + file_sha256, + ) + + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + cps = _cps_table() + geography = cps_source_geography( + ladder, + cps, + source_year=cps["source_year"], + source_household_id=cps["source_household_id"], + state_fips=cps["state_fips"], + ) + assert geography.record["official_list_csv_sha256"] == file_sha256( + default_cps_asec_identified_counties_path() + ) + + +def test_remainder_weighting_never_applies_to_a_year_without_the_guarantee( + tmp_path, +) -> None: + ladder = load_us_location_ladder(write_location_ladder(tmp_path / "l.npz")) + cps = _cps_table() + lists = _official({1001}) + lists[2025] = CpsIdentifiedCountyList( + asec_year=2025, + counties=frozenset({1001}), + source="https://example.test/cpsmar25.pdf", + source_sha256="c" * 64, + entire_county_guarantee=False, + ) + geography = cps_source_geography( + ladder, + cps, + source_year=cps["source_year"], + source_household_id=cps["source_household_id"], + state_fips=cps["state_fips"], + official=lists, + partial_county_remainder=True, + ) + assert geography.identified_counties[2024] == frozenset() + assert 2024 not in geography.unidentified_county_share + assert 2023 in geography.unidentified_county_share + assert geography.record["partial_county_remainder"][ + "skipped_groups_without_guarantee" + ] == [2024] diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py b/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py index 331fb66a2f..140de6a265 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_multispine_pool_block_location.py @@ -961,3 +961,38 @@ def test_cps_pins_must_be_the_raw_stage_sources(pool_tool: ModuleType) -> None: bind(raw_stage, {2023: "1" * 64, 2024: "3" * 64}) with pytest.raises(ValueError, match="records no source_receipt.sources"): bind({}, {2023: "1" * 64}) + + +def test_an_acs_household_that_lost_its_puma_is_refused_not_joined_to_cps( + pool_tool: ModuleType, ladder +) -> None: + """Production ACS rows carry the ``puma_geoid`` alias and raw ``PUMA`` and + share the CPS 2024 source-key space, so one without a valid ``puma`` must + be refused rather than routed through a CPS source-key join.""" + + frame = _stacked_frame() + tables = {entity: frame.table(entity).copy() for entity in frame.entities} + household = tables["household"] + has_puma = household["puma"].astype("string").str.len().fillna(0) > 0 + household["puma_geoid"] = household["puma"].where(has_puma, pd.NA) + first_acs = int(np.flatnonzero(has_puma.to_numpy())[0]) + household.loc[first_acs, "puma"] = pd.NA + broken = Frame( + tables, + frame.schema, + {"household": frame.weights_for("household")}, + frame.strata, + ) + with pytest.raises(ValueError, match="carry an ACS 'puma_geoid' but no valid"): + _assign(pool_tool, ladder, frame=broken) + + household.loc[first_acs, "puma"] = household.loc[first_acs, "puma_geoid"] + household.loc[first_acs, "puma_geoid"] = "0100999" + mismatched = Frame( + tables, + frame.schema, + {"household": frame.weights_for("household")}, + frame.strata, + ) + with pytest.raises(ValueError, match="disagrees with their puma_geoid"): + _assign(pool_tool, ladder, frame=mismatched) diff --git a/tools/build_us_cps_identified_counties.py b/tools/build_us_cps_identified_counties.py index f73a36f7b3..60ece5481e 100644 --- a/tools/build_us_cps_identified_counties.py +++ b/tools/build_us_cps_identified_counties.py @@ -15,7 +15,7 @@ Example: uv run python tools/build_us_cps_identified_counties.py \ - --asec-year 2023 --asec-year 2024 --asec-year 2025 + --asec-year 2023 --asec-year 2024 --asec-year 2025 --asec-year 2026 """ from __future__ import annotations diff --git a/tools/build_us_multispine_pool.py b/tools/build_us_multispine_pool.py index c9545a71ff..ec48a0b92c 100644 --- a/tools/build_us_multispine_pool.py +++ b/tools/build_us_multispine_pool.py @@ -2961,6 +2961,31 @@ def _assign_stacked_household_block_location( f"block_v1: {int(malformed.sum())} household(s) carry a puma that is " "not a 7-digit 2020 PUMA geoid; the rule never widens such a draw." ) + # An ACS PUMS record also carries its PUMA as the ``puma_geoid`` alias and + # the raw five-digit ``PUMA``; an ASEC record carries neither. A household + # with either field but no valid matching ``puma`` is malformed, so it + # can never fall through to a CPS source-key join (ACS source keys share + # the CPS 2024 key space). + for alias in ("puma_geoid", "PUMA"): + if alias not in household: + continue + carried = household[alias].astype("string").fillna("").str.len() > 0 + carried = carried.to_numpy(bool) + orphaned = carried & ~has_puma + if orphaned.any(): + raise ValueError( + f"block_v1: {int(orphaned.sum())} household(s) carry an ACS " + f"{alias!r} but no valid 7-digit puma; refusing to resolve them " + "through CPS source keys." + ) + if "puma_geoid" in household: + alias_values = household["puma_geoid"].astype("string").fillna("") + disagree = has_puma & (alias_values != observed.fillna("")).to_numpy(bool) + if disagree.any(): + raise ValueError( + f"block_v1: {int(disagree.sum())} household(s) carry a puma that " + "disagrees with their puma_geoid alias." + ) puma = np.zeros(n, dtype=np.int64) if has_puma.any(): puma[has_puma] = observed[has_puma].astype(np.int64).to_numpy()