Skip to content

Restart simulations mid-life on pylcm #604 and offer uncompensated-care targets - #24

Draft
hmgaudecker wants to merge 118 commits into
mainfrom
feat/did-2014-pylcm604
Draft

hmgaudecker wants to merge 118 commits into
mainfrom
feat/did-2014-pylcm604

Conversation

@hmgaudecker

@hmgaudecker hmgaudecker commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Simulations of the unanticipated ACA introduction can restart mid-life from the living regimes active at the requested ages. The baseline, ACA-policy and benchmark model factories accept initial_nodes, and build_restart_initial_nodes(ages=...) constructs the admissible starts.

The combined change also makes baseline and policy construction compatible with pylcm #604: register MAGI only with Medicaid-expansion eligibility, keep insurer risk prediction in the regime pool so community-rated premium overrides can remove it, and register transfers for the brute-force asset laws that consume them. Existing income, eligibility, premium and budget functions supply the calculations.

A new test checks the benchmark reporting inputs for uncompensated-care calculations: transfers, consumption_dollars_floor, is_married and good_health. CI installs the compatible pylcm revision 7e99a19badafe20e29a517f69d9ec155bba8e0a0.

Registration compatibility was checked against the candidate pylcm implementation using baseline, ACA-policy and NBEGM-policy construction and templates, premium and SSI/MAGI anchors, and strict normalization of the frozen baseline benchmark parameters. Added tests cover admissible restart regimes, a Medicare-age restart and availability of the benchmark reporting targets. Scientific solves and complete external workflow parameter preparation remain outside those local checks.

CI passes on d0d2ea620e1e766775b25e9d9dd12081919f3550 (workflow run).

The reporting test checks health-cost covariates rather than advertising total_health_costs as a deferred target. That function depends on realized next-period shocks, which the current pylcm reporting-target contract excludes. This change does not implement direct reporting of realized total medical bills.

Supersedes #23 and incorporates the mid-life restart work from #22. Based on compat/pylcm-array-grids so the registration changes are included in this PR's review and merge.

hmgaudecker and others added 30 commits August 14, 2026 21:16
`build_actions` and the nongroup builder narrowed the M1 regime for NBEGM,
dropping `buy_private` and `labor_supply` as actions and fixing their former
outputs to constants. That made the solved model a different model from the one
brute force solves: the household lost its coverage and hours choices.

The regime now declares whichever choices its structure affords under every
solver. pylcm's ride-along discrete envelope is written over a single action's
grid and refuses a regime declaring several, so model build under NBEGM raises
until that arity widens. The refusal is the honest outcome — a solver that
cannot carry a choice refuses the regime rather than being handed a narrower
one.

Tests split accordingly: the regime-level and solver-config assertions stay
green, model-build assertions become strict xfails naming the arity, and one
green test pins the refusal itself so the xfails flip when the arity widens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm
The M1 regime declares both buy_private and labor_supply, and pylcm's envelope
now branches over the Cartesian product of a regime's discrete action grids, so
the eight strict xfails pinned to the old single-action refusal all XPASS.
Removes them and converts the refusal test into the capability it replaced: the
model builds with both actions live.

25 passed where 15 had failed. Every one of the 15 was a stale expectation --
including the five policy-variant builds, which are reported as FAILED rather
than XPASS under strict=True and so read like real build failures until the
XPASS(strict) marker is inspected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm
`solver="nbegm"` attached the solver to one regime and left the other 17 on
brute force, so the model's stated solver was not the one that produced most
of its result. Every living regime now gets the NBEGM config, and the retiree
and tied builders carry the savings-form budget (`resources`, `savings`) that
the solver's contract reads -- without it those regimes cannot be built at all.

The build-time affinity and interval-constancy probes cannot run on the added
regimes: they differentiate the budget on scalar inputs and
`assets_and_income.capital_income` declares `rate_of_return: ScalarFloat`,
which the probe's one-element array violates. The probes fall back to
`assume_declared` and warn, so NB-EGM's exactness precondition is asserted
rather than checked there, and the solve needs validating against an
independent reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm
The probes run on the first solve against the model's complete parameter
vector, so the tax tables and threshold schedules the budget reads are
the model's own rather than synthesized stand-ins. Affinity and
constancy are checked rather than asserted.
pylcm takes the liquid roles from the regime: a `ConsumptionSavingsRegime`
names the liquid state, the action paid from resources, the resources node
and the post-decision state, and the solver carries numerical configuration
only. `ACA_LIQUID_MARGIN` states that once for the three living-regime
builders, which now route through `build_alive_regime`; a brute-force regime
has no `resources` or `savings` node to name and stays a plain `Regime`.

The role assertions move with the declaration: they read `regime.liquid`,
which is public API, rather than the bound solver's attributes.
Both callers of `build_model_constraints` still said DC-EGM gets no
broadcast constraint. It is declared under every solver: the EGM solve
enforces the limit through the savings grid's lower bound, but forward
simulation re-decides consumption by an argmax over the consumption grid
and needs the explicit feasibility mask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm
The xfail reason described the assets-law chain, but the build stops
earlier: pylcm's DC-EGM refuses the broadcast borrowing constraint for
reading continuous variables. Both blockers are now listed in the order
they fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm
pylcm's `Model.solve` returns a `SolutionResult` and `Model.simulate`
takes `solution=` in place of `period_to_regime_to_V_arr=`. The value
mapping these tests read is `SolutionResult.values`; passing `None` for the
old keyword meant "solve inside simulate", which is the new default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7QCx9tkGD3TqwNw681QwD
Forward ExecutionConfig through baseline, ACA and benchmark factories. Derive omitted accelerator budgets from selected JAX allocator limits and reject retired GridConfig execution fields. Preserve economic grids and numerical solver settings.

Validation against pylcm f6d41d54: exact ASV CPU preflight, focused compatibility tests, Ruff, ty and repository hooks pass. DC-EGM construction still exposes an upstream continuation-template placement failure on three CPU devices.
Leisure is s*logaddexp(F/s, available/s) with F = 1e-5 * time_endowment, so
float32 felicity and its consumption derivative stay finite when work costs
exceed the endowment, while cells away from the floor are unchanged to ~1e-8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
…te (#18)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stage transitions use `ByAge` schedules over `AgeRange` constants from the
config, the dead regime is terminal with `regime_transitions=None`, and a
coverage test checks every (age, regime) node. CI installs the matching pylcm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Both factories declare INITIAL_REGIMES (ages 51-61 x the five
*_inelig_canwork regimes) instead of forwarding an optional root mapping.
Declarations use the keyword-only AgeRange/ByAge/MarkovTransition
signatures. simulate_with_dense_index drops initial-condition rows older
than the last admissible start age and raises on any kept row outside
model.initial_nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
INITIAL_REGIMES spans ages start_age..last_start_age (51-60, the baseline
estimation sample) in the five *_inelig_canwork regimes, 50 pairs. Starts
at age 61 or later are dropped by select_admissible_starts and rejected at
admission. The solved domain stays 181 nodes: age-61 entry regimes are
reached from age 60.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
The expansion track tests MAGI only, for every household under 65,
regardless of health or assets, as the draft's post-ACA eligibility rule
states. The aged keep the categorical track with its asset test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
…xpansion

Retiree and tied HIS transitions, and the pension imputation's target HIS,
read categorical (SSI) Medicaid eligibility rather than the two-track
eligibility. The draft assumes the ACA does not affect employer-provided
insurance; an expansion-eligible retiree or tied household keeps its
employer coverage, with Medicaid paying on top of its OOP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Tests for the PIA carried across a claim-age adjustment, the earnings-test
credit, age x health spousal income, disabled AIME accrual, frozen AIME at
forced-claim ages, the taxable-maximum cap after accrual, the end of pension
accrual at 71, the household's own consumption floor and the ACA surtaxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
hmgaudecker and others added 26 commits October 7, 2026 00:55
Each living regime's per-target probability cells move from
`Regime(regime_transitions=...)` into `Transition(targets=..., law=...)`
under `Model(edges=...)`; `dead` has no outgoing edges and is terminal.
Terminal checks read `model.graph.laws[name].terminal`. Edges, targets
and probability cells are unchanged. Standalone CI installs the pylcm
revision that carries the graph-only transition API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
The regime-transition law reads `survival_probs` and `prob_disabled_next`;
pylcm resolves an edge-declared callable's parameters under
`params["edges"][source]`, never under the source regime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
`get_benchmark_params` moves each source regime's `survival_probs` and
`prob_disabled_next` from `fixed_params[source]` to
`fixed_params["edges"][source]`, where pylcm resolves the parameters of
the regime-transition law declared in `Model(edges=...)`. The law reads
the same per-source Series as before; the snapshot file is unchanged.

The NB-EGM template walk skips the `edges` root, whose entries are
source regimes rather than regime functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
The snapshot stores `survival_probs` and `prob_disabled_next` under
`fixed_params["edges"][source]`, as `aca_slurm.assemble_fixed_params` writes
them, so `get_benchmark_params` returns the fixed params as stored.

Regenerated with aca-dev `scripts/regen_benchmark_params.py` against aca-data
652f5c6b, aca-slurm 93472b9 and pylcm db04e088; every other leaf equals the
previous snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
pylcm derives a per-target law's targets from its cases and refuses
supplied targets that name fewer ages than the law. Each living regime's
law is a `ByAge` schedule whose cases name exactly the destinations its
source ages reach, so the edges declare no `targets` of their own. The
parameter paths are unchanged: a schedule adds no level below the source.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
…nity-rated

The ACA overlay replaces the non-group premium with the community-rated plan
premium, but left the risk-rated premium's zero-profit intercept in the
regime. Nothing reads it any more, yet its inputs (markup, minimum,
predicted-cost loading and the insurer-cost sample) stayed required
parameters of the ACA model, which assembles none of them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
…s from the graph

pylcm now publishes `Model.initial_nodes` as an `InitialNodes` declaration and
the expanded `(age, regime)` pairs on `Model.graph.initial_nodes`. The entry
ages are declared with `InitialNodes(by_age=...)`, and admissible-start
filtering reads the pairs from the graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
`GridConfig.fold_hcc_transitory` declares the transitory shock `fold=True`.
Nothing a period computes reads last year's transitory shock, so pylcm then
draws it on the transition only: living regimes keep the lagged persistent
shock, which prices the private premium, and lose the transitory axis.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
`GridConfig.subtract_hcc_bill` declares the asset law toward every target as
`lcm.SubtractedBill`: assets before the out-of-pocket bill minus `oop_costs`,
conditioned on marital status, health, Medicaid eligibility, the private-cover
choice and the ACA cost-sharing bracket. With `fold_hcc_transitory`, pylcm then
averages each target's value over the transitory medical-cost shock once per
period instead of at every source point.

The ACA cost-sharing scale is split at its income bracket, which takes finitely
many values, so the bill can be conditioned on it; the scale is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
The shock is `negative_oop_costs`, the out-of-pocket bill negated, added to the
assets before the bill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
pylcm's IrregSpacedGrid now accepts only one-dimensional NumPy or JAX
arrays for points. The assets grid, the runtime consumption grid and
the DC-EGM/NB-EGM savings grids passed a list or tuple; they now pass
arrays with the same values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
`build_restart_initial_nodes(ages=...)` admits every living regime active at
each given age, and both model constructors (and the benchmark) take
`initial_nodes`. The default stays the estimation sample's entry starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
@hmgaudecker
hmgaudecker changed the base branch from codex/pylcm-600-function-usage to compat/pylcm-array-grids October 23, 2026 06:05
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW
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.

1 participant