Materialize Medicaid take-up via anchored count-calibration to CMS state snapshots#334
Conversation
|
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:
Refuted in verification (no change): NaN CMS targets can't reach the stage — ledger compilation enforces 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. |
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 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>
|
Marking ready and requesting review. One decision here is genuinely doctrine-level and yours to bless: the |
…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>
eefdd92 to
6b0ea78
Compare
…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>
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>
# 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
left a comment
There was a problem hiding this comment.
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_eligiblehides 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_enrollmentmixes 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).
…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>
…#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>
Summary
Implements #331 (and the doctrine half of #332):
takes_up_medicaid_if_eligibleis now materialized by amedicaid_take_upsource stage instead of shipping at the engine's all-True default, healing the #170enrollment == eligibilitydegeneracy (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_calibratedcontract treatment: anchored assignment calibrated to administrative enrollment counts (Ledger facts). Loader validation requires acalibrationblock naming the anchor and target fact families, and refuses a sourced rate (that would beseedmisclassified).has_medicaid_health_coverage_at_interview(CPS ASEC, point-in-time) always take up. CPS undercounts Medicaid, so the anchor is a floor.calibrate_binary_assignmentgreedily hits the CMS December 2024 state snapshot among eligible non-anchored persons.Mechanics
is_medicaid_eligible, themedicaid_enrollmenttarget role compiles the state target table, and the stage output writes back to the person table.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.takes_up_medicaid_if_eligiblefrom the reviewed degenerate-input exclusions (that registry fails when a listed column becomes non-degenerate).us_medicaid_take_up.jsonwritten beside the take-up participation diagnostics; the participation diagnostics now reportcount_calibratedprograms' live surface instead of "ships at engine default".Deliberately out of scope
total_chip_enrollmentmixes M-CHIP into a concept the model does not materialize (20 states zero-support). The contract entry records the dependency; CHIP follows this pattern once the ledger splits the concepts.Tests
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).main()end-to-end stubs,US_DONORS/stage-order plan alignment.uv run pytest),ruff checkclean.Closes #331. Refs #332, #170, #321, #323, #315.
🤖 Generated with Claude Code