Skip to content

Materialize Medicaid take-up via anchored count-calibration to CMS state snapshots#334

Merged
MaxGhenis merged 7 commits into
mainfrom
medicaid-chip-take-up
Jul 9, 2026
Merged

Materialize Medicaid take-up via anchored count-calibration to CMS state snapshots#334
MaxGhenis merged 7 commits into
mainfrom
medicaid-chip-take-up

Conversation

@daphnehanse11

Copy link
Copy Markdown
Collaborator

Summary

Implements #331 (and the doctrine half of #332): takes_up_medicaid_if_eligible is now materialized by a medicaid_take_up source stage instead of shipping at the engine's all-True default, healing the #170 enrollment == eligibility degeneracy (72.30M == 72.30M in the published 2024 release) and stopping weight calibration from absorbing take-up error into the eligible population.

No administrative participation rate clears the #315 provenance bar for Medicaid (us-data's state rates were MACPAC enrollment over modeled eligibility — a model-relative ratio), so this stage cites no rate. It follows the native ACA stage pattern instead, recorded as a new contract treatment:

  • count_calibrated contract treatment: anchored assignment calibrated to administrative enrollment counts (Ledger facts). Loader validation requires a calibration block naming the anchor and target fact families, and refuses a sourced rate (that would be seed misclassified).
  • Anchor: eligible reporters of has_medicaid_health_coverage_at_interview (CPS ASEC, point-in-time) always take up. CPS undercounts Medicaid, so the anchor is a floor.
  • Fill: everyone else draws at an in-build state fill rate (CMS count over weighted modeled eligibles — computed transparently, never cited as provenance), then calibrate_binary_assignment greedily hits the CMS December 2024 state snapshot among eligible non-anchored persons.
  • Point-in-time semantics (Declare point-in-time (average-month) semantics for enrollment take-up flags and snapshot-month provenance #332): anchor, targets, and concept all sit at a representative month — the Urban HIPSM convention ("a point-in-time snapshot … rather than an annual average"), now contract doctrine. Ever-enrolled counts would overstate spending against per-FYE cost denominators.

Mechanics

  • Builder mirrors the ACA stage: batched engine simulation materializes person-level is_medicaid_eligible, the medicaid_enrollment target role compiles the state target table, and the stage output writes back to the person table.
  • Gate: fails on the all-True landmine (unsaturated state with enrollment == eligibility), a missed CMS count beyond 2% relative, a dropped anchor, or a state missing its target row. Saturated states (CMS count ≥ modeled eligible weight — Add Medicaid and CHIP eligibility-to-enrollment diagnostics #170's −2.4% combined shortfall) are classified and reported, not failed: full enrollment is the calibrated answer there, and the shortfall is an eligibility-undercount question out of scope here.
  • Off-domain propensity: the assign op deliberately carries no eligibility mask, so persons outside today's modeled eligibility keep a draw-based flag at the state fill rate. Invisible at baseline (the engine gates on is_medicaid_eligible), but eligibility-expanding reforms enroll newly eligible people at a plausible propensity instead of a hard-coded zero. Not a behavioral model — that is future utility-layer work.
  • Removes takes_up_medicaid_if_eligible from the reviewed degenerate-input exclusions (that registry fails when a listed column becomes non-degenerate).
  • Diagnostics artifact us_medicaid_take_up.json written beside the take-up participation diagnostics; the participation diagnostics now report count_calibrated programs' live surface instead of "ships at engine default".

Deliberately out of scope

Tests

  • 23 new tests in test_us_medicaid_take_up.py: contract validation (calibration block required, sourced rate refused), anchor preservation (including model-ineligible reporters), unsaturated states hitting the CMS count to unit-weight granularity, saturation classification, bit-reproducible reruns, source-identity draw keying, off-domain propensity, gate failure modes (landmine, missing target, dropped anchor, anchor-above-count floor), and builder helpers (target-role filtering, person state mapping).
  • Updated: contract treatment enum test, builder main() end-to-end stubs, US_DONORS/stage-order plan alignment.
  • Full workspace suite green (uv run pytest), ruff check clean.

Closes #331. Refs #332, #170, #321, #323, #315.

🤖 Generated with Claude Code

@daphnehanse11

Copy link
Copy Markdown
Collaborator Author

Ran an adversarial multi-angle review of this branch (8 finder angles, per-finding verification) before marking ready. Outcome, for reviewer context:

Confirmed and fixed in 2cb7f97:

  • Checkpoint staleness: the target-frame checkpoint identity hashes the on-disk base dataset, not the staged frame, so a pre-branch checkpoint would have silently calibrated weights against all-True medicaid_enrolled. TARGET_FRAME_CHECKPOINT_MATERIALIZER_VERSION bumped to 2.
  • Gate landmine false-positives: full enrollment explained by anchor mass (all eligibles reported coverage) or by greedy overshoot within one person weight of the count is a legitimate outcome; the check now exempts both instead of aborting the build.
  • Anchor preservation is now a person-level invariant (anchored_not_taking_up_count) checked in every state including saturated ones — aggregates can't see a dropped anchor once calibration back-fills to the count.
  • Stale eCPS parity exemption for takes_up_medicaid_if_eligible deleted (its "Populace does not yet produce it" reason is false as of this PR; parity_gate has no staleness check so it would have exempted the layer forever).
  • Duplicate per-state target rows now refuse to compile (calibrate op is last-row-wins; rate/diagnostics/gate sum them).
  • Export-frame re-check: a count-calibrated column shipping at the engine default on the export frame now fails the release rather than being recorded observationally.
  • Participation diagnostics keep the scope_owner debt pointer on count_calibrated rows and label the share's universe (the flag deliberately carries off-domain propensity Trues).
  • Reuse: draws now delegate to take_up._stable_unit_draws, person state codes use Frame.broadcast; empty targets refuse early; dead knobs removed.

Refuted in verification (no change): NaN CMS targets can't reach the stage — ledger compilation enforces math.isfinite at both entry points.

Known accepted interplay: if literally every state saturated (CMS count ≥ modeled eligibles everywhere), the flag would be constant-True — this stage's gate passes by saturation doctrine while the degenerate-input gate fails the build. That's treated as the intended loud backstop for a pathological feed, not a contradiction to engineer away; mechanics verified, trigger judged improbable (mixed saturation map expected).

Deferred (chips filed): sharing the ACA batch sims instead of a second full-population eligibility pass (~15–45 min/build); generalizing the ACA-branded error labels in the shared binary ops; parity-gate staleness enforcement.

daphnehanse11 added a commit that referenced this pull request Jul 6, 2026
The module helper shared a name with the builder's _state_fips_text but
not its semantics (regex strip vs int coercion; silent 'nan' strings vs
raising) — the same-name trap the #334 review flagged. Rename to
_normalize_state_fips and refuse NaN input rather than letting a missing
code surface later as a confusing states-without-targets gate failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
daphnehanse11 added a commit that referenced this pull request Jul 7, 2026
Max's review of #294 (SNAP take-up) spotted that the static guard
covered immigration outputs plus seeded_take_up_programs() only, so a
program whose stage produces its flag under a different contract
treatment sat outside it — SNAP (out_of_scope) was hand-fixed on #294,
and count_calibrated programs (Medicaid in #334, CHIP next per the
contract followup) had the same exposure: the staleness failure would
only surface on the first release build, not in CI.

The guard now derives the produced set from contract treatments
({seed, count_calibrated}) instead of the seeded helper, making it the
CI enforcement of the rule that each producing PR removes its own
register line. Keyed on the treatment string rather than #334's
count_calibrated_take_up_programs() helper so the test imports nothing
that does not exist on main yet; it picks up the Medicaid flip (and any
future CHIP flip) automatically at merge, in either merge order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@daphnehanse11 daphnehanse11 marked this pull request as ready for review July 7, 2026 14:42
@daphnehanse11 daphnehanse11 requested a review from MaxGhenis July 7, 2026 14:42
@daphnehanse11

Copy link
Copy Markdown
Collaborator Author

Marking ready and requesting review. One decision here is genuinely doctrine-level and yours to bless: the count_calibrated contract treatment (amends the #315 enum) — used when no administrative participation rate exists but administrative counts do, per the native ACA stage pattern your contract note describes. Your #294 approval covered the same mechanical family (reported anchor, identity-keyed draws, constant-True healing, gate); the deltas here are count-calibration in place of a cited rate, the unmasked anchor, and the off-domain propensity, all flagged in the PR body with rationale. #331/#332 carry the full design record, including the HIPSM point-in-time precedent. The register line for this flag is removed in-PR per your #294 guidance, and #339 (stacked on this branch) holds the batch-sim sharing follow-up.

daphnehanse11 and others added 4 commits July 7, 2026 15:07
…ate snapshots

Add a count_calibrated take-up contract treatment (no administrative
participation rate exists for Medicaid, but administrative enrollment
counts do) and a medicaid_take_up source stage following the native ACA
pattern: eligible reporters of has_medicaid_health_coverage_at_interview
always take up, everyone else draws at an in-build state fill rate, and
the assignment is greedily calibrated to CMS December 2024 state
enrollment snapshots among eligible non-anchored persons. Point-in-time
(average-month) semantics per #332, HIPSM precedent recorded in the
contract doctrine.

Builder wiring mirrors the ACA stage: batched engine simulation
materializes person-level is_medicaid_eligible, the medicaid_enrollment
target role compiles the state target table, and a release gate fails on
the all-True landmine while classifying saturated states (CMS count at
or above modeled eligible weight) as the eligibility-undercount symptom
they are. Removes takes_up_medicaid_if_eligible from the reviewed
degenerate-input exclusions; heals the #170 enrollment==eligibility
degeneracy and stops weight calibration from absorbing take-up error
into the eligible population.

CHIP is deliberately deferred behind the #321 M-CHIP/separate-CHIP
ledger concept split; its contract entry records that dependency.

Closes #331; doctrine half of #332.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review

Findings from an 8-angle review with per-finding verification; the
confirmed defects and their fixes:

- Bump TARGET_FRAME_CHECKPOINT_MATERIALIZER_VERSION to 2: the checkpoint
  identity hashes the on-disk base dataset, not the staged frame, so a
  pre-stage checkpoint would silently calibrate weights against all-True
  medicaid_enrolled columns while the export ships the new flag.
- Rework the gate landmine check: full enrollment explained by anchor
  mass or by a floor within granularity (2% or one person weight) of
  eligibility is a legitimate calibrated outcome, not a spurious build
  abort; the count-miss check gains the same granularity allowance.
- Make anchor preservation a person-level invariant checked in every
  state, saturated or not (anchored_not_taking_up_count in diagnostics);
  aggregate comparisons cannot see a dropped anchor once calibration
  back-fills to the count.
- Delete the now-false eCPS parity exemption for
  takes_up_medicaid_if_eligible (parity_gate has no staleness check, so
  the stale entry would exempt the layer forever); the anchored column
  clears the presence-based gate on its own.
- Refuse duplicate per-state target rows in the builder table: the
  calibrate op is last-row-wins while rate/diagnostics/gate sum them.
- Re-check count-calibrated columns on the export frame: a downstream
  transform dropping the column can no longer ship the engine-default
  landmine with only an observational JSON field recording it.
- Keep the debt-ledger pointer (scope_owner/followup) on
  count_calibrated participation rows and label the share's universe
  (off-domain propensity included) so it cannot be read as enrollment.
- Refuse empty state targets up front; record weights_basis and
  max_person_weight in diagnostics; reuse take_up's stable-draw keying
  and Frame.broadcast instead of private near-copies; drop dead knobs.

Refuted in verification (no change): NaN targets cannot reach the stage
(ledger compilation enforces math.isfinite at both entry points).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module helper shared a name with the builder's _state_fips_text but
not its semantics (regex strip vs int coercion; silent 'nan' strings vs
raising) — the same-name trap the #334 review flagged. Rename to
_normalize_state_fips and refuse NaN input rather than letting a missing
code surface later as a confusing states-without-targets gate failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Max's #294 review pattern (he independently audited the FNS rate's
vintage against newer publications) says the sharpest domain question
on this stage is the one thing left implicit: the ASEC anchor is
interview-point (~Feb-Apr) while the CMS target is the December
snapshot. Record in the module docstring and the contract calibration
block that both are point-in-time stocks with deliberately unaligned
months, and that the gate's anchor-floor rule absorbs the
declining-2024 direction (spring reporters exceeding a December count)
rather than reading it as a calibration miss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
daphnehanse11 added a commit that referenced this pull request Jul 8, 2026
…ries

parity_gate skipped known-gap names unconditionally, unlike its sibling
gates (default_valued_columns_gate at gates.py:707 and
source_stage_input_coverage_gate at gates.py:1499), which both fail on
stale reviewed exclusions. That let register entries rot silently once
the candidate started producing the exempted layer (the
takes_up_medicaid_if_eligible case surfaced in PR #334).

The gate now fails on any exemption whose candidate nonzero share is
positive, reports exemptions the reference never populates as dormant,
and records both in the gate details. Auditing the register against the
current release pipeline found four entries stale on arrival — the
pipeline deterministically materializes them before the gate runs:
takes_up_tanf_if_eligible and takes_up_eitc (contract-seeded take-up,
PR #315), ssn_card_type and immigration_status_str (immigration stage,
#266). A new register test pins that statically-knowable subset so a
stale entry is caught at test time, not first at release-build time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
daphnehanse11 added a commit that referenced this pull request Jul 8, 2026
Max's review of #294 (SNAP take-up) spotted that the static guard
covered immigration outputs plus seeded_take_up_programs() only, so a
program whose stage produces its flag under a different contract
treatment sat outside it — SNAP (out_of_scope) was hand-fixed on #294,
and count_calibrated programs (Medicaid in #334, CHIP next per the
contract followup) had the same exposure: the staleness failure would
only surface on the first release build, not in CI.

The guard now derives the produced set from contract treatments
({seed, count_calibrated}) instead of the seeded helper, making it the
CI enforcement of the rule that each producing PR removes its own
register line. Keyed on the treatment string rather than #334's
count_calibrated_take_up_programs() helper so the test imports nothing
that does not exist on main yet; it picks up the Medicaid flip (and any
future CHIP flip) automatically at merge, in either merge order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
daphnehanse11 and others added 2 commits July 8, 2026 17:18
# Conflicts:
#	tools/build_us_fiscal_refresh_release.py
…weight

A state whose eligibility feed collapsed to zero classifies as saturated
(any positive count exceeds zero eligible weight), which skipped the
count checks and would ship anchored-only enrollment silently. Treat
that saturation as a division artifact and fail the gate instead.

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

@MaxGhenis MaxGhenis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve

Verified the anchor sources, the new treatment's provenance discipline, and the count-calibration pattern against the ACA/SNAP precedents.

Anchor sources are real and correctly characterized. source_stages.json declares the target with locator "CMS Medicaid and CHIP monthly enrollment, December 2024 state snapshot", vintage month2024_12, target table cms_medicaid_enrollment_by_state, fact family cms_medicaid.month2024_12.state_enrollment.*.total_medicaid_enrollment. Populace declares the reference only; the values arrive as hash-pinned Ledger facts at build time (the established --ledger-facts provenance model), so the CMS citation lives in the locator and the numbers are pinned upstream. The point-in-time (average-month) vs ever-enrolled-in-year distinction is correctly load-bearing — the doctrine notes ever-enrolled runs "~10M+ higher for Medicaid" and would overstate spending, and cites the Urban HIPSM precedent ("targets the CMS point-in-time snapshot 'rather than an annual average'", #332). The deliberate ASEC-interview (~Feb–Apr) vs December-snapshot month mismatch is handled by the anchor-floor rule, with the unwinding-decline rationale spelled out.

The new count_calibrated treatment is the doctrine-compliant choice, and it's structurally enforced. _validate_count_calibration rejects any count_calibrated entry that lacks a calibration block naming the anchor and count targets ("a sourced rate is what seed is for"). The contract explicitly rejects the us-data value ("state-specific 0.53-0.99") as a model-relative ratio (admin count over modeled eligibility, 0.99 = ratio artifact) — exactly the class the provenance_rule bars and that ledger#77 removed. No rate is fabricated.

Count-calibration correctly reuses the native ACA stage, distinct from #294. The stage emits calibrate_binary_assignment — the same operation the ACA stage uses for takes_up_aca_if_eligible — anchoring on survey-reported coverage as a floor, filling everyone else at an in-build state prior (CMS count over weighted modeled eligibles, "never cited as a sourced rate"), then greedily calibrating to the CMS state count among eligible non-anchored persons. This differs from the #294 SNAP pattern deliberately and correctly: SNAP has a published FNS participation rate (a seed), Medicaid has none (only counts), so count-calibration is the only provenance-clean path. #353 (statutory-cap seed) is a different mechanism again; nothing here contradicts it.

Design details that are right, not just present:

  • The assign runs with no eligibility mask — off-domain persons keep a draw-based propensity so eligibility-expanding reforms don't inherit a hard-coded zero response; is_medicaid_eligible hides the flag at baseline. This is the correct reform-safe behavior.
  • States where the CMS count ≥ modeled eligible weight saturate and are recorded as such in diagnostics, not failed (#170's eligibility-undercount is explicitly out of scope).
  • An empty-targets guard prevents a missing feed from silently shipping anchored-only enrollment.
  • CHIP is deliberately excluded pending the #321 M-CHIP / separate-CHIP concept split (CMS total_chip_enrollment mixes concepts the model can't yet reach) — good scoping restraint rather than over-reach.

Cross-PR coupling with #337 (both touch ecps_parity_known_gaps.json): this PR removes the takes_up_medicaid_if_eligible register entry in the same pass it flips the treatment to count_calibrated — so it resolves the exact forcing function #337 installs, in either merge order. Because both PRs edit that JSON (and take_up_contract.json), whichever merges second will need a trivial rebase; the second one is currently CLEAN only against today's main.

One dependency to confirm before a release build: the pinned Ledger feed must actually carry the cms_medicaid.month2024_12.state_enrollment.*.total_medicaid_enrollment fact family. The empty-feed guard makes a missing feed fail loudly rather than silently, so this is safe, but the release that turns this stage on depends on those CMS facts being present in the consumer artifact.

CI green (test + wheels, 3.13/3.14), MERGEABLE/CLEAN. Not merging (review-only per request).

MaxGhenis pushed a commit that referenced this pull request Jul 9, 2026
…ies (#337)

* Fail the eCPS parity gate on stale exemptions; remove four rotted entries

parity_gate skipped known-gap names unconditionally, unlike its sibling
gates (default_valued_columns_gate at gates.py:707 and
source_stage_input_coverage_gate at gates.py:1499), which both fail on
stale reviewed exclusions. That let register entries rot silently once
the candidate started producing the exempted layer (the
takes_up_medicaid_if_eligible case surfaced in PR #334).

The gate now fails on any exemption whose candidate nonzero share is
positive, reports exemptions the reference never populates as dormant,
and records both in the gate details. Auditing the register against the
current release pipeline found four entries stale on arrival — the
pipeline deterministically materializes them before the gate runs:
takes_up_tanf_if_eligible and takes_up_eitc (contract-seeded take-up,
PR #315), ssn_card_type and immigration_status_str (immigration stage,
#266). A new register test pins that statically-knowable subset so a
stale entry is caught at test time, not first at release-build time.

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

* Extend the register staleness guard to count-calibrated take-up programs

Max's review of #294 (SNAP take-up) spotted that the static guard
covered immigration outputs plus seeded_take_up_programs() only, so a
program whose stage produces its flag under a different contract
treatment sat outside it — SNAP (out_of_scope) was hand-fixed on #294,
and count_calibrated programs (Medicaid in #334, CHIP next per the
contract followup) had the same exposure: the staleness failure would
only surface on the first release build, not in CI.

The guard now derives the produced set from contract treatments
({seed, count_calibrated}) instead of the seeded helper, making it the
CI enforcement of the rule that each producing PR removes its own
register line. Keyed on the treatment string rather than #334's
count_calibrated_take_up_programs() helper so the test imports nothing
that does not exist on main yet; it picks up the Medicaid flip (and any
future CHIP flip) automatically at merge, in either merge order.

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

* Remove the two hours-stage register entries #293 left behind; widen the guard

The #293 hours stage merged without removing its parity register
exemptions, so hours_worked_last_week and weekly_hours_worked_before_lsr
carried false "not yet on the candidate frame" reasons — the stage runs
before the parity gate and its signal gate requires both non-constant,
so the first release build after the staleness enforcement lands would
have failed on them. Both confirmed as engine input variables (its third
output, weeks_worked, has no register entry). hourly_wage stays: it is
an org_wages output and that stage is not executed anywhere yet.

The static register guard now also pins the deterministic stage output
constants (hours, SNAP take-up, eligibility inputs) alongside the
immigration outputs and contract-produced take-up flags, so the next
producing stage that forgets its register line fails in CI rather than
on a release build — #293 slipped through exactly because the guard
did not know stage outputs, only take-up programs.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis merged commit a740b28 into main Jul 9, 2026
4 checks passed
MaxGhenis pushed a commit that referenced this pull request Jul 9, 2026
…#338)

The generic assign_binary_from_rate / calibrate_binary_assignment /
calibrate_binary_assignment_joint_targets handlers hard-coded "ACA binary
assignment" / "ACA binary calibration" in every error message, so a failure
in any non-ACA stage driving them (e.g. PR #334's person-grain
medicaid_take_up stage) reported itself as an ACA failure. Labels are now
keyed to the operation kind ("US binary assignment" / "US binary
calibration" / "US joint binary calibration", matching the file's existing
"US ratio computation" style) and threaded through the shared helpers so
the joint handler reports its own name.

The "aca:{output}" draw salt is deliberately unchanged (now commented as
frozen): existing ACA releases are bit-reproducible against it, and
label-only changes leave every seeded outcome byte-identical.

The calibrate handlers' silent 'stable_tax_unit_draw' draw-column default
is removed: 'draw' is now a required parameter (the assign handler already
required it), and the ACA manifest states the previous default explicitly,
so behavior is unchanged while a future person-grain stage that omits the
param fails loudly instead of minting positionally-keyed draws under a
tax-unit column name. _stable_draws documents its tax_unit_id-vs-positional
keying fallback.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Materialize Medicaid and CHIP take-up via anchored count-calibration (ACA stage pattern)

2 participants