PolicyEngine / PolicyEngine/microcosm
Pooled ASEC 2022/2023 vintages lack the NOW_* coverage recodes: reported Medicaid at interview thins to 24.6M vs ~58M survey under 65 on the certified artifact
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 0
- Forks
- 4
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 94
Description
Summary
On the certified default artifact (hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-buildp-sparse-rmloss100-cae8640-20260728T011454Z), the reported-coverage person input has_medicaid_health_coverage_at_interview sums to 24.6M weighted under-65 persons versus ~57.5M survey-reported (CPS ASEC 2023 NOW_MCAID, MARSUPWT). Root cause: the pooled support spine stacks three ASEC vintages keyed by income year (source_year 2022/2023/2024 = the ASEC 2023/2024/2025 survey files), and the income-year-2022 and -2023 raw H5 inputs carry only 2 of the 18 NOW_* at-interview coverage recodes (NOW_GRP and NOW_MRK). The CPS-carried derivation maps missing/NaN source values to False, so roughly two-thirds of the weighted population — every 2022- and 2023-vintage person, 173.2M weighted under 65 — is structurally unable to report Medicaid (or TRICARE, VA, CHAMPVA, IHS, other-means-tested, or non-marketplace direct-purchase) coverage.
has_esi and has_marketplace_health_coverage_at_interview escape only because NOW_GRP and NOW_MRK happen to be the two NOW_* columns the archived raw-lane converter carried for all vintages.
Downstream consequence (policyengine-scorecard PR #69, annotation kff-medicaid-reported-denominator-sanity): the "no reported coverage" construct identifies 80.9M under-65 persons versus 25.9M survey uninsured-at-interview (NOW_COV==2, ASEC 2023) and ~27M ACS nonelderly uninsured, so uninsured-population constructs on the certified artifact are not survey-faithful. Modeled enrollment is separately CMS-calibrated and does not backfill the reported flag.
No artifacts were published or promoted as part of this investigation.
Reproduction
Weighted person counts on the certified artifact (person-table columns × household weight; the columns are physically present in the h5 and the engine surface reads them as-is), against full-file CPS ASEC 2023 (MARSUPWT/100):
| input (under 65, millions) | artifact | ASEC 2023 survey | survey column |
|---|---|---|---|
has_esi |
146.9 | 164.7 | NOW_GRP |
has_marketplace_health_coverage_at_interview |
21.4 | 11.4 | NOW_MRK |
has_non_marketplace_direct_purchase_…_at_interview |
4.0 | 8.8 | NOW_NONM |
has_medicaid_health_coverage_at_interview |
24.6 | 57.5 | NOW_MCAID |
has_champva_health_coverage_at_interview |
0.3 | 0.4 | NOW_CHAMPVA |
has_tricare_health_coverage_at_interview |
3.5 | 6.0 | NOW_MIL |
has_va_health_coverage_at_interview |
0.7 | 1.6 | NOW_VACARE |
has_other_means_tested_…_at_interview |
0.5 | 0.7 | NOW_OTHMT |
has_indian_health_service_coverage_at_interview |
0.3 | 0.8 | NOW_IHSFLG |
| none of the nine ("no reported coverage") | 80.9 | 25.9 | NOW_COV==2 |
The marketplace flag tracks NOW_MRK one-to-one on all vintages, so its level above the ASEC 2023 survey reflects vintage composition and calibration-era weighting, not the missing-column mechanism (out of scope here).
Artifact totals: 340.1M persons, 278.9M under 65 (57,240 households, 166,321 person rows; pe-us pin 1.764.6 per the build manifest).
Compact repro (certified snapshot cached locally)
import os, h5py, numpy as np
p = "~/.cache/huggingface/hub/datasets--policyengine--populace-us/snapshots/26dcad66867687f15735dc4926523e3741920836/populace_us_2024.h5"
f = h5py.File(os.path.expanduser(p), "r")
per, hh = f["person/table"][:], f["household/table"][:]
hw = dict(zip(hh["household_id"], hh["household_weight"]))
w = np.array([hw[h] for h in per["person_household_id"]])
u65 = per["A_AGE"] < 65
flag = per["has_medicaid_health_coverage_at_interview"].astype(bool)
print(w[flag & u65].sum() / 1e6) # 24.6
for yr in (2022, 2023, 2024): # vintage split
m = per["source_year"] == yr
print(yr, w[m & flag & u65].sum() / 1e6, np.isnan(per["NOW_MCAID"][m]).mean())
# 2022 0.0 1.00 / 2023 0.0 1.00 / 2024 24.6 0.00
Root cause
-
The pooled spine stacks three ASEC vintages keyed by income year.
source_yearon the certified person table: 2022 = ASEC 2023 survey (54,464 rows, 106.8M weighted), 2023 = ASEC 2024 (54,654 rows, 105.7M), 2024 = ASEC 2025 (57,203 rows, 127.6M). Pooling unions per-year columns viapd.concat(asec_pool.py#L141), leaving NaN where a vintage lacks a column. -
The 2022/2023 raw H5 inputs carry only 2 of the 18
NOW_*fields. On the certified frame,NOW_MCAID,NOW_CAID,NOW_COV,NOW_MRKS,NOW_MRKUN,NOW_PCHIP,NOW_CHAMPVA,NOW_VACARE,NOW_MIL,NOW_IHSFLG,NOW_NONM,NOW_OTHMT,NOW_PUB,NOW_PRIV,NOW_DIRare populated on exactly the 57,203 2024-vintage rows and NaN on all 109,118 rows from 2022/2023. OnlyNOW_GRPandNOW_MRKare populated for all vintages. This is a property of the SHA-locked inputs themselves:policyengine_us_data/storage/census_cps_2022.h5(146,133 persons =pppub23, 100% PERIDNUM match; 137 person columns;NOW_*= {NOW_GRP,NOW_MRK}),census_cps_2023.h5(144,265 persons = ASEC 2024; 138 columns; the same two),census_cps_2024.h5(142,125 persons = ASEC 2025; 162 columns; all 18NOW_*fields). The 2022/2023 inputs match the archivedpolicyengine-us-dataextractor's column list before410f63a7("Add CPS current health coverage anchors", 2026-03-20, which added the broader block); the 2024 input was extracted with it. The input-coverage manifest already documents this vintage asymmetry for other fields ("The 2022 and 2023 inputs also omit PECOHAB, A_EXPRRP, and A_FAMREL; only 2024 carries those alternatives" —is_unmarried_partner_of_household_headexclusion; same pattern in theemployer_sponsored_insurance_premiumsexclusion, #32). -
The CPS-carried derivation silently maps missing to False.
_fill_health_coverage_inputsmapshas_medicaid_health_coverage_at_interview ← NOW_MCAID(L272) through_yes_code, where_sourcereturns zeros for an absent column andfillna(0.0)for NaN. The certified flag equalsNOW_MCAID == 1exactly (weighted crosstab: 24.6M both-true, 0.0M in either disagreement cell) — no later stage rewrites it, and the export carries it (this is not the #361 export-drop class; the column is present). -
The 2024-vintage slice is survey-plausible; selection and reweighting push the other way. Within the 2024 vintage the flag carries 24.6M of 105.7M under-65 = 23.3%, versus 21.1% in full ASEC 2023 (57.5M of 272.7M). Joining the 2022-vintage rows back to
pppub23.csvby PERIDNUM (100% match, zero age mismatches), the under-65 Medicaid-at-interview rate moves from 21.1% in the full survey to 23.8% among the selected source persons, 24.5% with support clones, and 25.8% under final artifact weights — selection and calibration mildly enrich reporters, the opposite direction from the thinning. Counterfactual scale: restoring the raw codes on the artifact's existing rows and weights gives ≈ 70M under 65 (22.7M on the 2022 vintage, verified directly againstpppub23; the 2023 slice extrapolated at the same enrichment; 24.6M already on 2024); at survey rates ≈ 0.211 × 278.9M ≈ 59M. A rebuild reselects and reweights, so neither is a guaranteed post-fix value — the point is the scale, ~2.5–3× today's 24.6M. -
The Medicaid take-up stage consumes the already-thinned anchor.
medicaid_take_up.pyanchors on the flag (eligible reporters always take up) and hard-fails only if the column is absent (L173-L177) — there is no guard on per-vintage coverage. The CMS state-count fill compensates in enrollment terms, so enrollment-side calibration passes while the reported-coverage denominator stays thinned; the thinned anchor also shifts enrollment composition from reported toward filled and overstates saturation pressure (#170's diagnostic direction).
Why certification passed
release_input_coveragerequires the column to be present with non-default signal ("Column mass parity is not column coverage" — the gate closes the absence class, #368); 24.6M true satisfies it.degenerate_input_signal(175 columns) passes any column that is neither all-default nor constant.health_input_signalchecks 2 columns (selected_marketplace_plan_benchmark_ratio,takes_up_aca_if_eligible) — no reported-coverage flags.ecps_parityis a populated-layer presence check (158 layers, 0 gaps).
Nothing measures per-vintage signal or survey-mass fidelity for the reported-coverage family, so a flag populated for one of three vintages certifies.
Relationship to existing issues
- #591 (pool default-value holes): same family — engine inputs the pool under-populates. Its table notes the sibling
receives_medicaidis never emitted whilehas_medicaid_health_coverage_at_interview"is emitted"; this issue sharpens that: it is emitted for the 2024 vintage only. Partial-vintage holes are invisible to presence-style checks. - #361 / #278 / #356 (export-side column drops): distinct mechanism — here the column survives export; the values were born thinned at source pooling.
- #296 (ingest ASEC 2024/2025 into the support pool): the natural vehicle for the data-side fix — any re-extraction should carry the full
NOW_*coverage block for every vintage. - #170 / #331 / #605 (Medicaid take-up and enrollment diagnostics): enrollment-side consumers of the thinned anchor.
- #230 (coverage-universe targets for uninsured and private coverage): targeting the uninsured universe would have surfaced this earlier and remains the calibration-side complement.
Proposed fix
- Data side (the actual fix): re-extract the income-year 2022 and 2023 inputs (ASEC 2023/2024) with the full Census person column set — not a
NOW_*-only patch, so the next missing field (#32'sNOW_OWNGRP/NOW_HIPAID/NOW_GRPFTYP, #38'sPERRP/A_EXPRRP/PECOHAB) does not repeat this cycle, while any new derivations stay in separate PRs — and rebuild the pooled source base. Do it as one schema migration with #296 (whose body predates the ASEC 2025 ingest: Build P already consumes income years 2022–2024) after reconciling survey-year vs income-year naming. The raw CSVs carry the data — verified for ASEC 2023 (pppub23.csvhasNOW_MCAID, column 547). This runs in the gated data lane outside PR CI; no artifact publication is part of this issue. - Regression guard (PR #744): a per-vintage reported-coverage signal gate — for each pooled
source_year, every reported-coverage input must carry non-default signal (the crisp invariant violated today: 2022/2023 vintages have exactly zero true mass for seven of the nine flags). Ships red against current bases by design, like the #368 asset gates, and turns green with the re-extraction. - Scorecard follow-up: once a fixed artifact certifies, the
kff-medicaid-reported-denominator-sanityannotation and the reported-uninsured rows in policyengine-scorecard PR #69 can be revisited.
Numbers computed 2026-08-18 on the locally cached certified snapshot (26dcad668676…, sha256 48b9d479fb4f…) and asecpub23csv with policyengine-us 1.764.6 (the build pin).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/populace-build/src/populace/build/us_runtime/asec_pool.py and cps_carried.py, then inspect the release_input_coverage gates and the reported-coverage inputs. Verify the pooled source-year slices and run the per-vintage signal check described in PR #744. Done means the 2022, 2023, and 2024 vintages carry non-default reported-coverage signals and the regression gate passes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- data-engineering, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 38/100