Repository navigation
Design: persisted take-up propensities vs simulation-time choice (the health coverage utility layer) #360
Description
Context
The take-up stages landed and in flight (#315 seeded rates, #334 Medicaid count-calibration, SNAP #294, CHIP after #321) all assign static baseline flags: correct for the current world, inert under reform. A reform that expands Medicaid eligibility enrolls newly eligible people at whatever the data package happened to persist — #334 deliberately ships a draw-based off-domain propensity instead of a hard-coded zero, but that is an placeholder, not a behavioral model.
The long-term goal (HPSIM-style coverage modeling, #331/#332 design record) needs reform-responsive take-up: households re-deciding when prices and eligibility change. Urban's HIPSM does this with an expected-utility model whose parameters are calibrated so baseline choices reproduce administrative enrollment — exactly the baseline our count-calibrated stages already pin.
The boundary question this issue exists to settle
Two ownership models are currently drifting toward collision:
- Data-package-owned (populace convention, per the deterministic-country-package rule: randomness lives in the data): populace persists per-unit latent components; the engine reads them.
- Engine-owned (the policyengine-us#8693 direction noted in Assign data-construction inputs added by recent policyengine-us merges (SNAP ABAWD discretionary exemptions, CHIP take-up) #323, and the aca_take_up_seed migration the take-up contract's engine_state_note tracks for Migrate ACA health-input gates after PE-US take-up contract changes #76): the engine simulates take-up itself and writes the flag.
If 2 proceeds without the frame's role being designed in, the latent heterogeneity that makes reform response stable and reproducible (same households switch under the same reform, run after run) has nowhere to live: an engine-side draw either re-randomizes per run or reinvents seeded-identity machinery the frame already owns.
Proposed architecture (for discussion)
Split the behavioral response along the existing RulesEngine boundary:
- populace persists the latent parameters as ordinary frame columns with stable-identity seeding: today the draw column; next a per-unit taste/reservation parameter θ backed out at baseline so that choice(θ, baseline prices) reproduces the count-calibrated flag exactly (consistency constraint: the utility layer must be a refinement of the calibrated baseline, never a second opinion on it).
- the engine evaluates choice at simulation time: formulas compute the menu and prices (eligibility, premiums, subsidies — all already engine-owned) and take-up becomes a deterministic function of persisted θ and reform-dependent prices. No randomness in the engine; no behavioral parameters invisible to the data package.
This keeps both packages' contracts intact: populace stays the home of stochastic assignment and identity stability; the engine stays deterministic given a dataset; reform response emerges from their composition.
What this issue asks for
- A short design doc (or DESIGN.md section) agreed across populace and policyengine-us maintainers settling the boundary — before the #8693-family migrations harden an engine-owned pattern.
- An interim rule for new take-up work: flags stay data-seeded inputs; stages persist their draw columns (all current stages already do); no engine formula bakes in a take-up response until the design lands.
- A v1 scope sketch for one program (ACA marketplace is the natural first: prices/menus richest, Investigate ACA take-up and plan-choice inputs driving high PTC estimates #70/Implement ACA marketplace take-up input materialization #74/Migrate ACA health-input gates after PE-US take-up contract changes #76 family already active): θ backed out from the count-calibrated baseline, choice = enroll iff net-premium utility clears θ, literature elasticity pinning the slope, calibration pinning the intercepts (the HIPSM parameterization pattern).
References
- Materialize Medicaid and CHIP take-up via anchored count-calibration (ACA stage pattern) #331 / Declare point-in-time (average-month) semantics for enrollment take-up flags and snapshot-month provenance #332 (count-calibrated stages, point-in-time doctrine), Materialize Medicaid take-up via anchored count-calibration to CMS state snapshots #334 (off-domain propensity as the v0 primitive), Assign data-construction inputs added by recent policyengine-us merges (SNAP ABAWD discretionary exemptions, CHIP take-up) #323 item 3 (pe-us take-up input class), policyengine-us#8693 / #8679
- Take-up contract engine_state_note on the unlanded aca_take_up_seed migration (the drift this issue wants to get ahead of)
- DESIGN.md: RulesEngine protocol, longitudinal one-weight-per-trajectory rule (the same identity-stability doctrine this extends to behavior)
- Urban HIPSM methodology 2020: expected-utility take-up calibrated to reproduce administrative enrollment
Activity
MaxGhenis commentedon Jul 8, 2026
Strong framing, and I think the proposed split is the right one. Persist the latent parameters in the frame; let the engine evaluate a deterministic choice over them. Below is the case for that direction grounded in machinery already in the repo, a concrete consistency invariant, a baseline-preserving migration, and the open questions I would not want the design doc to gloss.
Why persisted θ (not engine-side choice) is the correct default
Three doctrines already in this codebase point the same way:
1. The persistence-scope lesson (#340). An input absent from the persisted H5 silently falls back to its engine default, invisible to every weights-side gate and catchable only by a column-manifest check. Put the behavioral parameter engine-side and you inherit exactly that failure on the behavioral margin: turning the utility layer on/off, or bumping the engine, shifts results with no column-level diff and no gate to trip. Persisting θ makes the behavioral parameter a first-class input — diffable, gate-able, and coverable by the same required-column manifest that PR #350 just used to make the SNAP eligibility inputs a hard release precondition. The static take-up flags already live under this rule; #353's
is_snap_abawd_discretionary_exemptparameter states it outright (economy: false, "like takeup, assigned during microdata construction rather than at simulation time"). θ is the natural extension of a convention that is already load-bearing, not a new principle.2. The take-up contract is the machinery to extend — and it already carries the tripwire for the drift this issue fears.
take_up_contract()derives each flag's engine facts (engine_class:model_simulatedvsdata_seeded) from pinned-engine metadata, andtest_us_take_up_contract.pyfails when a checked-in fact drifts from the pinned engine — the #301 formula-owned-guard doctrine applied to take-up. So when PE-US #76'saca_take_up_seedlands, that test fires rather than silently flipping ACA fromdata_seededtomodel_simulated; theengine_state_noteon the contract confirms the pinned engine carries no such seed today, so the flag isdata_seededright now. That gives the boundary a home and an enforcement point: add a treatment class for the latent-parameter case (populace persists θ; the engine reads θ in a randomness-free choice formula) distinct from bothseed(static flag) andmodel_simulated(engine owns the draw), and let the contract test guard the transition. The contract'sprovenance_ruleextends cleanly too — the elasticity/slope that turns θ into a reform response must itself be traceable to an administrative or published source (HIPSM's calibrated elasticities), or it is therate_unsourced/ ledger#77 fabricated-parameter class again, just one derivative up.3. The release-as-reproducible-artifact principle. Persisted θ makes reform response reproducible in the sense the issue itself requires — the same households switch under the same reform, run over run — and reform-visible, because the response is a deterministic function of a persisted column that a promotion/validation suite can pin as an artifact property. An engine-time draw couples the result to engine RNG state and re-randomizes; that response can't be certified as a property of the artifact. θ-as-data keeps the behavioral layer inside the reproducibility boundary the release is promoted against.
Make the consistency constraint a tested invariant, not an aspiration
The sharpest line in the proposal is "the utility layer must be a refinement of the calibrated baseline, never a second opinion on it." I'd harden it into a fixed-point test: θ is backed out at baseline so that
choice(θ, baseline prices)reproduces the count-calibrated flag exactly, and CI asserts that composing the engine choice formula over the persisted θ on the baseline dataset returns the persisted baseline flag. Without that test the utility layer can quietly move the baseline enrollment that #331/#332/#334 calibrated — the #334 off-domain-propensity worry, inverted. The invariant is what lets the layer be "a refinement," and only a test makes it true release over release.Migration path (baseline-preserving, per-program)
in_effecttoggle defaulted off, so baseline artifacts are unchanged until the layer is validated; enable per-program once the fixed-point test and an elasticity check pass.aca_take_up_seed— otherwise v2's engine choice formula and Migrate ACA health-input gates after PE-US take-up contract changes #76 collide on who writes the flag, which is precisely the drift this issue wants to pre-empt.Open questions I would not want the doc to overclaim
On the interim rule (ask #2)
Agree, and it is already the de facto state — the pinned engine carries no
aca_take_up_seedand no formula on the ACA flag, so "flags stay data-seeded; no engine formula bakes in take-up until the design lands" is holding the current line, not adding a constraint. The one addition I'd make explicit: nametest_us_take_up_contract.pyas the enforcement point, so an accidental engine-owned migration is caught in CI rather than in a release diff.Net: I'd green-light the persisted-θ / deterministic-engine-choice architecture as the target, gate it behind the fixed-point invariant and an
in_effecttoggle, extend the take-up contract's vocabulary and drift test to cover the latent-parameter class, and settle the ACA-vs-#76 ownership before any engine formula writes a take-up flag.