From 5e5d5298f9d01dd16db3db6b9fc62661bce49ab7 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 14 Aug 2026 21:16:10 +0200 Subject: [PATCH 001/101] Let the NBEGM regime declare every choice its structure affords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm --- src/aca_model/baseline/regimes/_common.py | 23 ++- src/aca_model/baseline/regimes/_nongroup.py | 55 +------ src/aca_model/config.py | 17 +-- tests/test_nbegm_labor_live_validation.py | 68 +++------ tests/test_nbegm_model_creation.py | 156 ++++++++++---------- tests/test_nbegm_solve_validation.py | 48 ++---- 6 files changed, 122 insertions(+), 245 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 750bce4..eddfa52 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -454,27 +454,22 @@ def build_states(spec: RegimeSpec, grids: Grids) -> dict: return states -def build_actions( - spec: RegimeSpec, - grids: Grids, - *, - drop_buy_private: bool = False, - drop_labor_supply: bool = False, -) -> dict: +def build_actions(spec: RegimeSpec, grids: Grids) -> dict: """Build the action dict for a non-dead regime. - The `drop_*` flags fix a discrete action to a single level for the NBEGM - M1 vertical slice (its case-piece envelope handles at most one discrete - action). The dropped action's former consumers are rebound to the fixed - level at the regime builder, so removing it here is the action side of the - dags remove-and-fix. + Every choice the regime's structure affords is a live action: whether to + claim Social Security where claiming is neither impossible nor already + forced, how many hours to work where work is still available, and whether + to buy non-group coverage before Medicare. Which solver runs the regime + does not enter — a solver that cannot carry a choice refuses the regime + rather than being handed a narrower one. """ actions: dict = {} if spec["ss"] == "choose": actions["claim_ss"] = DiscreteGrid(ClaimedSS) - if spec["canwork"] == "canwork" and not drop_labor_supply: + if spec["canwork"] == "canwork": actions["labor_supply"] = DiscreteGrid(LaborSupply) - if spec["his"] == "nongroup" and spec["mc"] == "nomc" and not drop_buy_private: + if spec["his"] == "nongroup" and spec["mc"] == "nomc": actions["buy_private"] = DiscreteGrid(BuyPrivate) actions["consumption_dollars"] = grids.consumption_dollars return actions diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index 8d329ea..5189cf9 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -4,7 +4,6 @@ Already nongroup, so no SSI/Medicaid override needed for HIS transitions. """ -import functools from collections.abc import Callable from lcm import Regime @@ -13,7 +12,6 @@ from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance -from aca_model.baseline.health_insurance import BuyPrivate from aca_model.baseline.regimes._common import ( REGIME_SPECS, Grids, @@ -78,32 +76,11 @@ def transition( return transition -def _fixed_full_time_labor_supply() -> DiscreteAction: - """Labor supply fixed to full-time work for the NBEGM M1 slice.""" - return LaborSupply.h2000 - - -def _build_functions( - spec: RegimeSpec, *, fix_buy_private: bool = False, fix_labor_supply: bool = False -) -> dict: - """Build functions dict for a nongroup regime. - - The NBEGM M1 slice fixes both discrete actions to a single level so the - only choice is continuous consumption: - - - `fix_buy_private` binds `buy_private` to `BuyPrivate.yes` in its consumers - (premium, OOP) — the `buy_private == BuyPrivate.yes` arm — leaving the - remaining budget structure untouched. - - `fix_labor_supply` supplies `labor_supply` as a fixed full-time node read - by labor income, AIME accrual, and the lagged-supply transition (which - stays a state, so the cross-regime continuation space is unchanged). - """ +def _build_functions(spec: RegimeSpec) -> dict: + """Build functions dict for a nongroup regime.""" can_work = spec["canwork"] == "canwork" functions = build_common_functions(spec) - if can_work and fix_labor_supply: - functions["labor_supply"] = _fixed_full_time_labor_supply - functions["ss_benefit"] = select_ss_benefit(spec) # his and crossed_oamc_threshold are fixed params (constants per regime), @@ -117,14 +94,6 @@ def _build_functions( else: functions["hic_premium"] = health_insurance.premium_retired - if has_buy_private and fix_buy_private: - functions["hic_premium"] = functools.partial( - health_insurance.premium, buy_private=BuyPrivate.yes - ) - functions["primary_oop"] = functools.partial( - health_insurance.primary_oop, buy_private=BuyPrivate.yes - ) - functions.update(build_pension_functions(spec)) return functions @@ -156,18 +125,9 @@ def build_regime( if egm_solver is None else ("nbegm" if nbegm_solver is not None else "dcegm") ) - # Under NBEGM the M1 slice fixes `buy_private` (a second discrete action the - # branch compiler does not yet solve) to a single level. `labor_supply` is fixed - # too by default, leaving only continuous consumption; with - # `nbegm_live_labor_supply` it stays a live action and the branch compiler solves - # each labor level against the cliffed budget. - fix_for_nbegm = nbegm_solver is not None - fix_labor = fix_for_nbegm and not grids.grid_config.nbegm_live_labor_supply - functions = _build_functions( - spec, fix_buy_private=fix_for_nbegm, fix_labor_supply=fix_labor - ) + functions = _build_functions(spec) constraints: dict = {} - if fix_for_nbegm: + if nbegm_solver is not None: # NBEGM solves only this regime, so its solver-contract functions are # regime-level here rather than broadcast model-wide. The broadcast # borrowing constraint stays: the EGM solve enforces the limit through @@ -182,12 +142,7 @@ def build_regime( active=make_active_func(spec), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), - actions=build_actions( - spec, - grids, - drop_buy_private=fix_for_nbegm, - drop_labor_supply=fix_labor, - ), + actions=build_actions(spec, grids), functions=functions, constraints=constraints, **solver_kwargs, diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 47390c2..db5ed3e 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -151,18 +151,11 @@ class GridConfig: # value scans it in blocks of that many branches (identical result, per-branch # intermediates bounded by one block). Only consulted under `solver="nbegm"`. n_nbegm_branch_batch_size: int = 0 - # Keep `labor_supply` a live discrete action on the M1 regime under NBEGM (the - # branch compiler solves each labor level against its own continuation, utility, - # and breakpoint partition); `buy_private` stays fixed. `False` fixes both actions - # to a single level so the only choice is continuous consumption. Only consulted - # under `solver="nbegm"`. - # - # Requires `nbegm_jump_read="bridged"`. `labor_supply` enters `countable_income`, - # which carries the SSI income test, so each labor level puts that breakpoint at a - # different liquid level; the one-sided read publishes its cliff limits on a single - # query grid shared across branches, which the two cannot both satisfy. Building a - # model with live labor under `"one_sided"` raises `RegimeInitializationError`. - nbegm_live_labor_supply: bool = False + # `labor_supply` enters `countable_income`, which carries the SSI income test, so + # each labor level puts that breakpoint at a different liquid level. The one-sided + # read publishes its cliff limits on a single query grid shared across branches, + # which the two cannot both satisfy, so a regime carrying `labor_supply` builds + # only under `nbegm_jump_read="bridged"`. MODEL_CONFIG = ModelConfig() diff --git a/tests/test_nbegm_labor_live_validation.py b/tests/test_nbegm_labor_live_validation.py index 81eccf4..c9fbc74 100644 --- a/tests/test_nbegm_labor_live_validation.py +++ b/tests/test_nbegm_labor_live_validation.py @@ -1,15 +1,15 @@ -"""NBEGM solves the M1 regime with a live labor-supply choice, matching brute. - -The M1 regime `nongroup_nomc_inelig_canwork` carries `labor_supply` (5 levels) as a -genuine discrete action while `buy_private` is fixed. Labor supply feeds the `aime` -co-state (earnings accrual), the `lagged_labor_supply` co-state, and the leisure term -in period utility — every branch-dependent channel at once. NBEGM's ride-along -discrete envelope solves each labor branch against its own continuation and utility; -the value function must match a brute-force solve on the same state-action space. - -Both solvers run the full 18-regime model at the benchmark grid with `buy_private` -fixed and `labor_supply` live, in NBEGM's `"bridged"` cliff-read mode so the -comparison isolates the solver machinery from the asset-grid read convention. +"""NBEGM solves the M1 regime with its discrete choices live, matching brute. + +The M1 regime `nongroup_nomc_inelig_canwork` carries `labor_supply` (5 levels) and +`buy_private` as genuine discrete actions. Labor supply feeds the `aime` co-state +(earnings accrual), the `lagged_labor_supply` co-state, and the leisure term in period +utility — every branch-dependent channel at once. NBEGM's ride-along discrete envelope +solves each branch against its own continuation and utility; the value function must +match a brute-force solve on the same state-action space. + +Both solvers run the full 18-regime model at the benchmark grid in NBEGM's `"bridged"` +cliff-read mode, so the comparison isolates the solver machinery from the asset-grid +read convention. """ import dataclasses @@ -19,51 +19,19 @@ from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] from lcm import DiscreteGrid -import aca_model.baseline.regimes._nongroup as nongroup_mod from aca_model.agent.preferences import BenchmarkPrefType from aca_model.baseline.model import create_model from aca_model.baseline.regimes import SolverName -from aca_model.baseline.regimes._common import Grids, RegimeSpec from aca_model.benchmark import get_benchmark_params from aca_model.config import BENCHMARK_GRID_CONFIG _M1_REGIME = "nongroup_nomc_inelig_canwork" - -def _is_m1(spec: RegimeSpec) -> bool: - return spec["ss"] == "inelig" and spec["mc"] == "nomc" - - -@pytest.fixture -def m1_labor_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Keep `labor_supply` live and fix only `buy_private` on M1, under every solver. - - The NBEGM wiring fixes both discrete actions on its own; this override keeps - labor supply as a genuine action (so the branch compiler solves it) and fixes - `buy_private`, applying the same choice to the brute build so both solvers share - one state-action space. - """ - original_build_functions = nongroup_mod._build_functions # noqa: SLF001 - original_build_actions = nongroup_mod.build_actions - - def build_functions_labor_live(spec: RegimeSpec, **kwargs: bool) -> dict: - if _is_m1(spec): - return original_build_functions( - spec, fix_buy_private=True, fix_labor_supply=False - ) - return original_build_functions(spec, **kwargs) - - def build_actions_labor_live( - spec: RegimeSpec, grids: Grids, **kwargs: bool - ) -> dict: - if _is_m1(spec): - return original_build_actions( - spec, grids, drop_buy_private=True, drop_labor_supply=False - ) - return original_build_actions(spec, grids, **kwargs) - - monkeypatch.setattr(nongroup_mod, "_build_functions", build_functions_labor_live) - monkeypatch.setattr(nongroup_mod, "build_actions", build_actions_labor_live) +_MULTIPLE_DISCRETE_ACTIONS = ( + "pylcm's ride-along discrete envelope is written over one action's grid and " + "refuses a regime declaring several; the M1 regime declares both " + "`labor_supply` and `buy_private`." +) def _solve_m1(solver: SolverName) -> tuple[dict[int, np.ndarray], int]: @@ -127,7 +95,7 @@ def _cliff_band_mask(reference: np.ndarray, assets_axis: int) -> np.ndarray: @pytest.mark.long_running -@pytest.mark.usefixtures("m1_labor_live") +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_labor_live_agrees_with_brute_split_by_cliff_band() -> None: """The M1 value functions agree cell-wise with labor live, gated per region. diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index d98bebb..3074ae7 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -1,11 +1,17 @@ """NBEGM solver wiring: `solver="nbegm"` is a per-regime option. Unlike DC-EGM (a global Euler solver on every living regime), NBEGM solves a -single 1-D consumption/savings regime with at most one discrete action, so it -attaches only to the M1 vertical-slice regime `nongroup_nomc_inelig_canwork`; -every other living regime keeps brute force. The savings-form spec is shared -with DC-EGM (NBEGM's budget node is `resources`, the post-decision function is -`savings`). +single 1-D consumption/savings regime, so it attaches only to the vertical-slice +regime `nongroup_nomc_inelig_canwork`; every other living regime keeps brute +force. The savings-form spec is shared with DC-EGM (NBEGM's budget node is +`resources`, the post-decision function is `savings`). + +The regime declares every choice its structure affords — whether to buy +non-group coverage, and how many hours to work. pylcm's ride-along discrete +envelope is written over a single action's grid and refuses a regime declaring +more than one, so model build under NBEGM raises until that arity is widened. +The regime is not narrowed to fit: a solver that cannot carry a choice refuses +the regime rather than being handed a model that omits it. """ import dataclasses @@ -40,6 +46,20 @@ _M1_REGIME = "nongroup_nomc_inelig_canwork" _BRUTE_REGIME = "retiree_nomc_inelig_canwork" +_MULTIPLE_DISCRETE_ACTIONS = ( + "pylcm's ride-along discrete envelope is written over one action's grid and " + "refuses a regime declaring several; the M1 regime declares both " + "`labor_supply` and `buy_private`." +) + +# `labor_supply` enters `countable_income`, which carries the SSI income test, so each +# labor level puts that breakpoint at a different liquid level. The one-sided read +# publishes its cliff limits on a single query grid shared across branches, which the +# two cannot both satisfy, so a regime carrying `labor_supply` needs the bridged read. +_BRIDGED_GRID_CONFIG = dataclasses.replace( + BENCHMARK_GRID_CONFIG, nbegm_jump_read="bridged" +) + def _build_regimes(solver: SolverName) -> dict[str, Regime]: return build_all_regimes( @@ -64,7 +84,7 @@ def _build_model_with(solver: SolverName, grid_config: GridConfig) -> Model: def _build_model(solver: SolverName) -> Model: - return _build_model_with(solver, BENCHMARK_GRID_CONFIG) + return _build_model_with(solver, _BRIDGED_GRID_CONFIG) def _grids() -> Grids: @@ -120,30 +140,42 @@ def test_build_nbegm_solver_forwards_the_jump_read_mode() -> None: assert solver.jump_read == "bridged" -def test_nbegm_m1_regime_fixes_buy_private() -> None: - """The NBEGM M1 slice drops `buy_private` as an action (fixed to purchase), - so the only choice is continuous consumption; the brute M1 regime keeps it.""" +def test_nbegm_m1_regime_declares_the_same_actions_as_brute_force() -> None: + """Which solver runs a regime does not change the choices the household has. + + The M1 regime affords a coverage choice and an hours choice, so it declares + both under either solver. + """ nbegm_m1 = _build_regimes("nbegm")[_M1_REGIME] brute_m1 = _build_regimes("brute_force")[_M1_REGIME] - assert "buy_private" not in nbegm_m1.actions - assert "buy_private" in brute_m1.actions + assert set(nbegm_m1.actions) == set(brute_m1.actions) -def test_nbegm_m1_regime_fixes_labor_supply() -> None: - """The NBEGM M1 slice drops `labor_supply` as an action (fixed to full-time - work), so no discrete action remains and the only choice is continuous - consumption; the brute M1 regime keeps `labor_supply`.""" +def test_nbegm_m1_regime_declares_both_discrete_actions() -> None: + """The M1 regime's discrete choices are whether to buy non-group coverage and + how many hours to work.""" nbegm_m1 = _build_regimes("nbegm")[_M1_REGIME] - brute_m1 = _build_regimes("brute_force")[_M1_REGIME] - assert "labor_supply" not in nbegm_m1.actions - assert "labor_supply" in brute_m1.actions + discrete = { + name + for name, grid in nbegm_m1.actions.items() + if isinstance(grid, DiscreteGrid) + } + assert discrete == {"buy_private", "labor_supply"} -def test_nbegm_m1_regime_has_no_discrete_action() -> None: - """With both discrete actions fixed, the NBEGM M1 slice leaves only the - continuous consumption choice — no `DiscreteGrid` action remains.""" - nbegm_m1 = _build_regimes("nbegm")[_M1_REGIME] - assert not any(isinstance(grid, DiscreteGrid) for grid in nbegm_m1.actions.values()) +def test_nbegm_model_build_refuses_a_regime_with_several_discrete_actions() -> None: + """Model build under NBEGM refuses the M1 regime, naming its discrete actions. + + The ride-along discrete envelope is written over a single action's grid. The + regime is not narrowed to fit it — the refusal is the honest outcome until + the envelope carries several actions. + """ + with pytest.raises( + RegimeInitializationError, match="exactly one discrete action" + ) as excinfo: + _build_model("nbegm") + assert "labor_supply" in str(excinfo.value) + assert "buy_private" in str(excinfo.value) def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: @@ -162,6 +194,7 @@ def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: assert law is expected, target_name +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_savings_form_functions_are_scoped_to_the_m1_regime() -> None: """Under NBEGM only the M1 regime carries the savings-form budget functions (`resources`, `savings`); brute regimes keep the cash-on-hand form and carry @@ -173,6 +206,7 @@ def test_nbegm_savings_form_functions_are_scoped_to_the_m1_regime() -> None: assert "resources" not in model.user_regimes[_BRUTE_REGIME].functions +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: """NBEGM inverts the Euler equation internally, so the M1 regime never carries the DC-EGM `inverse_marginal_utility` function (whose @@ -182,6 +216,7 @@ def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: assert "inverse_marginal_utility" not in model.user_regimes[_M1_REGIME].functions +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_regime_keeps_the_borrowing_constraint() -> None: """The M1 regime declares the borrowing constraint like every brute regime. @@ -234,66 +269,28 @@ def test_ssi_benefit_declares_the_income_test_kink() -> None: assert income_test.indexed_by == "spousal_income" -def test_nbegm_keeps_labor_supply_live_when_configured() -> None: - """With `nbegm_live_labor_supply=True`, the M1 regime carries `labor_supply` - as a genuine discrete action under NBEGM while `buy_private` stays fixed, so - the branch compiler solves the labor choice against the cliffed budget.""" - grid_config = dataclasses.replace( - BENCHMARK_GRID_CONFIG, nbegm_live_labor_supply=True - ) - regimes = build_all_regimes( - grid_config=grid_config, - fixed_params=_FIXED_PARAMS, - wage_params=_WAGE_PARAMS, - pref_type_grid=DiscreteGrid(BenchmarkPrefType), - solver="nbegm", - ) - actions = regimes[_M1_REGIME].actions - assert "labor_supply" in actions - assert "buy_private" not in actions - - -def test_nbegm_live_labor_supply_requires_the_bridged_cliff_read() -> None: - """A live `labor_supply` action builds only under `nbegm_jump_read="bridged"`. +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) +def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: + """The M1 regime builds under NBEGM only with `nbegm_jump_read="bridged"`. `labor_supply` enters `countable_income`, which carries the SSI income test, so each labor level puts that breakpoint at a different liquid level. The one-sided read publishes its cliff limits on one query grid shared across branches, so the two cannot both hold and the build is refused. """ - live_labor = dataclasses.replace( - BENCHMARK_GRID_CONFIG, nbegm_live_labor_supply=True - ) with pytest.raises(RegimeInitializationError, match="must not enter any schedule"): - _build_model_with( - "nbegm", dataclasses.replace(live_labor, nbegm_jump_read="one_sided") - ) + _build_model_with("nbegm", BENCHMARK_GRID_CONFIG) - bridged = dataclasses.replace(live_labor, nbegm_jump_read="bridged") - assert isinstance(_build_model_with("nbegm", bridged), Model) - - -def test_nbegm_fixes_labor_supply_by_default() -> None: - """By default NBEGM fixes both discrete actions on the M1 regime, so the - only remaining choice is continuous consumption against the cliffed budget.""" - regimes = _build_regimes("nbegm") - actions = regimes[_M1_REGIME].actions - assert "labor_supply" not in actions - assert "buy_private" not in actions + assert isinstance(_build_model_with("nbegm", _BRIDGED_GRID_CONFIG), Model) +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: """Every ACA policy variant builds a model under NBEGM with the M1 regime on - the solver and labor live — the overlay's function swaps compose with the - branch compiler's per-regime wiring.""" - # Live labor requires the bridged cliff read — see - # `test_nbegm_live_labor_supply_requires_the_bridged_cliff_read`. - grid_config = dataclasses.replace( - BENCHMARK_GRID_CONFIG, - nbegm_live_labor_supply=True, - nbegm_jump_read="bridged", - ) + the solver and both discrete choices live — the overlay's function swaps + compose with the branch compiler's per-regime wiring.""" + grid_config = _BRIDGED_GRID_CONFIG model = create_aca_model( n_subjects=1, policy=policy, @@ -317,21 +314,18 @@ def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: assert "labor_supply" in regimes[_M1_REGIME].actions +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_aca_variants_leave_no_free_buy_private_params( policy: PolicyVariant, ) -> None: - """With `buy_private` fixed under the NBEGM M1 slice, no ACA-swapped - function may leave `buy_private` as a free parameter — the params template - holds no `buy_private` leaves, so solve/simulate never demand a - `*__buy_private` entry the pipeline cannot supply.""" - # Live labor requires the bridged cliff read — see - # `test_nbegm_live_labor_supply_requires_the_bridged_cliff_read`. - grid_config = dataclasses.replace( - BENCHMARK_GRID_CONFIG, - nbegm_live_labor_supply=True, - nbegm_jump_read="bridged", - ) + """`buy_private` is a choice, never a parameter. + + No ACA-swapped function may leave it as a free parameter — the params + template holds no `buy_private` leaves, so solve/simulate never demand a + `*__buy_private` entry the pipeline cannot supply. + """ + grid_config = _BRIDGED_GRID_CONFIG model = create_aca_model( n_subjects=1, policy=policy, diff --git a/tests/test_nbegm_solve_validation.py b/tests/test_nbegm_solve_validation.py index 6d4449d..14f2e45 100644 --- a/tests/test_nbegm_solve_validation.py +++ b/tests/test_nbegm_solve_validation.py @@ -1,9 +1,9 @@ """NBEGM vs brute-force agreement on the M1 slice value function. -Both solvers run the full 18-regime model at the benchmark grid with the M1 -regime's discrete actions fixed (labor supply to full-time, `buy_private` to -purchase) so the value functions are defined on identical state spaces and -differ only through the continuous-consumption solver. +Both solvers run the full 18-regime model at the benchmark grid on the same +state-action space — the M1 regime declares its `labor_supply` and `buy_private` +choices under either solver — so the value functions differ only through the +continuous-consumption solver. NBEGM runs its `"bridged"` cliff-read mode so both solvers share the same read convention (a finite brute reads child values by linear interpolation @@ -22,47 +22,19 @@ from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] from lcm import DiscreteGrid -import aca_model.baseline.regimes._nongroup as nongroup_mod from aca_model.agent.preferences import BenchmarkPrefType from aca_model.baseline.model import create_model from aca_model.baseline.regimes import SolverName -from aca_model.baseline.regimes._common import Grids, RegimeSpec from aca_model.benchmark import get_benchmark_params from aca_model.config import BENCHMARK_GRID_CONFIG _M1_REGIME = "nongroup_nomc_inelig_canwork" - -@pytest.fixture -def m1_actions_fixed_for_brute(monkeypatch: pytest.MonkeyPatch) -> None: - """Fix the M1 regime's discrete actions under every solver. - - The NBEGM wiring fixes `labor_supply` and `buy_private` on its own; this - fixture applies the same fixing to the brute build so both solvers produce - an M1 value function on the same state-action space. - """ - original_build_functions = nongroup_mod._build_functions # noqa: SLF001 - original_build_actions = nongroup_mod.build_actions - - def build_functions_fixed(spec: RegimeSpec, **kwargs: bool) -> dict: - if spec["ss"] == "inelig" and spec["mc"] == "nomc": - return original_build_functions( - spec, fix_buy_private=True, fix_labor_supply=True - ) - return original_build_functions(spec, **kwargs) - - def build_actions_fixed(spec: RegimeSpec, grids: Grids, **kwargs: bool) -> dict: - if spec["ss"] == "inelig" and spec["mc"] == "nomc": - return original_build_actions( - spec, - grids, - drop_buy_private=True, - drop_labor_supply=True, - ) - return original_build_actions(spec, grids, **kwargs) - - monkeypatch.setattr(nongroup_mod, "_build_functions", build_functions_fixed) - monkeypatch.setattr(nongroup_mod, "build_actions", build_actions_fixed) +_MULTIPLE_DISCRETE_ACTIONS = ( + "pylcm's ride-along discrete envelope is written over one action's grid and " + "refuses a regime declaring several; the M1 regime declares both " + "`labor_supply` and `buy_private`." +) def _solve_m1(solver: SolverName) -> dict[int, np.ndarray]: @@ -87,7 +59,7 @@ def _solve_m1(solver: SolverName) -> dict[int, np.ndarray]: @pytest.mark.long_running -@pytest.mark.usefixtures("m1_actions_fixed_for_brute") +@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_value_function_agrees_with_brute_in_the_bulk() -> None: """The M1 value functions agree cell-wise away from the cliff tail. From 3b2a0b1f1d10e20de4dd1a7f13eb51c69932294a Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sat, 15 Aug 2026 06:58:42 +0200 Subject: [PATCH 002/101] Consume pylcm's multi-action discrete envelope 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 Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm --- tests/test_nbegm_labor_live_validation.py | 7 ---- tests/test_nbegm_model_creation.py | 44 +++++++++-------------- tests/test_nbegm_solve_validation.py | 7 ---- 3 files changed, 16 insertions(+), 42 deletions(-) diff --git a/tests/test_nbegm_labor_live_validation.py b/tests/test_nbegm_labor_live_validation.py index c9fbc74..a0ef0f0 100644 --- a/tests/test_nbegm_labor_live_validation.py +++ b/tests/test_nbegm_labor_live_validation.py @@ -27,12 +27,6 @@ _M1_REGIME = "nongroup_nomc_inelig_canwork" -_MULTIPLE_DISCRETE_ACTIONS = ( - "pylcm's ride-along discrete envelope is written over one action's grid and " - "refuses a regime declaring several; the M1 regime declares both " - "`labor_supply` and `buy_private`." -) - def _solve_m1(solver: SolverName) -> tuple[dict[int, np.ndarray], int]: # The CPU XLA backend does not fuse the ride-cell fan-out and materialises the @@ -95,7 +89,6 @@ def _cliff_band_mask(reference: np.ndarray, assets_axis: int) -> np.ndarray: @pytest.mark.long_running -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_labor_live_agrees_with_brute_split_by_cliff_band() -> None: """The M1 value functions agree cell-wise with labor live, gated per region. diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 3074ae7..a8ed368 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -7,11 +7,10 @@ `resources`, the post-decision function is `savings`). The regime declares every choice its structure affords — whether to buy -non-group coverage, and how many hours to work. pylcm's ride-along discrete -envelope is written over a single action's grid and refuses a regime declaring -more than one, so model build under NBEGM raises until that arity is widened. -The regime is not narrowed to fit: a solver that cannot carry a choice refuses -the regime rather than being handed a model that omits it. +non-group coverage, and how many hours to work — and the discrete envelope +branches over the Cartesian product of those grids. The regime is never narrowed +to fit the solver: a solver that cannot carry a choice refuses the regime rather +than being handed a model that omits it. """ import dataclasses @@ -46,12 +45,6 @@ _M1_REGIME = "nongroup_nomc_inelig_canwork" _BRUTE_REGIME = "retiree_nomc_inelig_canwork" -_MULTIPLE_DISCRETE_ACTIONS = ( - "pylcm's ride-along discrete envelope is written over one action's grid and " - "refuses a regime declaring several; the M1 regime declares both " - "`labor_supply` and `buy_private`." -) - # `labor_supply` enters `countable_income`, which carries the SSI income test, so each # labor level puts that breakpoint at a different liquid level. The one-sided read # publishes its cliff limits on a single query grid shared across branches, which the @@ -163,19 +156,20 @@ def test_nbegm_m1_regime_declares_both_discrete_actions() -> None: assert discrete == {"buy_private", "labor_supply"} -def test_nbegm_model_build_refuses_a_regime_with_several_discrete_actions() -> None: - """Model build under NBEGM refuses the M1 regime, naming its discrete actions. +def test_nbegm_model_builds_a_regime_declaring_several_discrete_actions() -> None: + """The M1 regime builds under NBEGM with both of its discrete actions live. - The ride-along discrete envelope is written over a single action's grid. The - regime is not narrowed to fit it — the refusal is the honest outcome until - the envelope carries several actions. + The discrete envelope branches over the Cartesian product of the regime's + discrete action grids, so a regime is never narrowed to fit the solver. """ - with pytest.raises( - RegimeInitializationError, match="exactly one discrete action" - ) as excinfo: - _build_model("nbegm") - assert "labor_supply" in str(excinfo.value) - assert "buy_private" in str(excinfo.value) + model = _build_model("nbegm") + nbegm_m1 = model.user_regimes[_M1_REGIME] + discrete = { + name + for name, grid in nbegm_m1.actions.items() + if isinstance(grid, DiscreteGrid) + } + assert discrete == {"buy_private", "labor_supply"} def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: @@ -194,7 +188,6 @@ def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: assert law is expected, target_name -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_savings_form_functions_are_scoped_to_the_m1_regime() -> None: """Under NBEGM only the M1 regime carries the savings-form budget functions (`resources`, `savings`); brute regimes keep the cash-on-hand form and carry @@ -206,7 +199,6 @@ def test_nbegm_savings_form_functions_are_scoped_to_the_m1_regime() -> None: assert "resources" not in model.user_regimes[_BRUTE_REGIME].functions -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: """NBEGM inverts the Euler equation internally, so the M1 regime never carries the DC-EGM `inverse_marginal_utility` function (whose @@ -216,7 +208,6 @@ def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: assert "inverse_marginal_utility" not in model.user_regimes[_M1_REGIME].functions -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_regime_keeps_the_borrowing_constraint() -> None: """The M1 regime declares the borrowing constraint like every brute regime. @@ -269,7 +260,6 @@ def test_ssi_benefit_declares_the_income_test_kink() -> None: assert income_test.indexed_by == "spousal_income" -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: """The M1 regime builds under NBEGM only with `nbegm_jump_read="bridged"`. @@ -284,7 +274,6 @@ def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: assert isinstance(_build_model_with("nbegm", _BRIDGED_GRID_CONFIG), Model) -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: """Every ACA policy variant builds a model under NBEGM with the M1 regime on @@ -314,7 +303,6 @@ def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: assert "labor_supply" in regimes[_M1_REGIME].actions -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_aca_variants_leave_no_free_buy_private_params( policy: PolicyVariant, diff --git a/tests/test_nbegm_solve_validation.py b/tests/test_nbegm_solve_validation.py index 14f2e45..028fce7 100644 --- a/tests/test_nbegm_solve_validation.py +++ b/tests/test_nbegm_solve_validation.py @@ -30,12 +30,6 @@ _M1_REGIME = "nongroup_nomc_inelig_canwork" -_MULTIPLE_DISCRETE_ACTIONS = ( - "pylcm's ride-along discrete envelope is written over one action's grid and " - "refuses a regime declaring several; the M1 regime declares both " - "`labor_supply` and `buy_private`." -) - def _solve_m1(solver: SolverName) -> dict[int, np.ndarray]: grid_config = dataclasses.replace(BENCHMARK_GRID_CONFIG, nbegm_jump_read="bridged") @@ -59,7 +53,6 @@ def _solve_m1(solver: SolverName) -> dict[int, np.ndarray]: @pytest.mark.long_running -@pytest.mark.xfail(strict=True, reason=_MULTIPLE_DISCRETE_ACTIONS) def test_nbegm_m1_value_function_agrees_with_brute_in_the_bulk() -> None: """The M1 value functions agree cell-wise away from the cliff tail. From 9a5fe8a1610e03ac7676095c89a0726ba66ba527 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sat, 15 Aug 2026 08:39:02 +0200 Subject: [PATCH 003/101] Solve every living regime with NB-EGM `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 Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm --- src/aca_model/baseline/regimes/__init__.py | 7 +-- src/aca_model/baseline/regimes/_retiree.py | 9 +++- src/aca_model/baseline/regimes/_tied.py | 9 +++- tests/test_nbegm_model_creation.py | 53 ++++++++++++---------- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index a0c5543..b64543e 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -36,11 +36,6 @@ from aca_model.baseline.regimes._nbegm import build_nbegm_solver from aca_model.config import GridConfig -# NBEGM is a per-regime (not global) solver: it solves one 1-D -# consumption/savings regime with at most one discrete action, so it attaches -# only to the M1 vertical-slice regime. -_NBEGM_REGIME = "nongroup_nomc_inelig_canwork" - __all__ = [ "REGIME_SPECS", "RegimeId", @@ -115,7 +110,7 @@ def build_all_regimes( name, grids, dcegm_solver=dcegm_solver, - nbegm_solver=nbegm_solver if name == _NBEGM_REGIME else None, + nbegm_solver=nbegm_solver, ) regimes["dead"] = build_dead_regime(solver=solver) return regimes diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 3ac87d2..76e19ef 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -20,6 +20,7 @@ build_actions, build_common_functions, build_granular_regime_transition, + build_nbegm_functions, build_pension_functions, build_regime_probs, build_state_transitions, @@ -133,6 +134,12 @@ def build_regime( if egm_solver is None else ("nbegm" if nbegm_solver is not None else "dcegm") ) + functions = _build_functions(spec) + if nbegm_solver is not None: + # NBEGM's solver contract is stated per regime: it reads the budget in + # savings form off `resources` and the post-decision node off + # `savings`, neither of which the brute-force build needs. + functions = {**functions, **build_nbegm_functions()} return Regime( transition=build_granular_regime_transition( transition_func=transition_func, target_ids=(*own.values(), *ng.values()) @@ -141,6 +148,6 @@ def build_regime( states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), - functions=_build_functions(spec), + functions=functions, **solver_kwargs, ) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index 08a2196..348c34a 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -21,6 +21,7 @@ build_actions, build_common_functions, build_granular_regime_transition, + build_nbegm_functions, build_pension_functions, build_regime_probs, build_state_transitions, @@ -103,6 +104,12 @@ def build_regime( if egm_solver is None else ("nbegm" if nbegm_solver is not None else "dcegm") ) + functions = _build_functions(spec) + if nbegm_solver is not None: + # NBEGM's solver contract is stated per regime: it reads the budget in + # savings form off `resources` and the post-decision node off + # `savings`, neither of which the brute-force build needs. + functions = {**functions, **build_nbegm_functions()} return Regime( transition=build_granular_regime_transition( transition_func=transition_func, target_ids=(*own.values(), *ng.values()) @@ -111,6 +118,6 @@ def build_regime( states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), - functions=_build_functions(spec), + functions=functions, **solver_kwargs, ) diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index a8ed368..8efde5d 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -1,10 +1,9 @@ -"""NBEGM solver wiring: `solver="nbegm"` is a per-regime option. +"""NBEGM solver wiring. -Unlike DC-EGM (a global Euler solver on every living regime), NBEGM solves a -single 1-D consumption/savings regime, so it attaches only to the vertical-slice -regime `nongroup_nomc_inelig_canwork`; every other living regime keeps brute -force. The savings-form spec is shared with DC-EGM (NBEGM's budget node is -`resources`, the post-decision function is `savings`). +`solver="nbegm"` solves every living regime with NB-EGM, so each of them carries +the savings-form budget the solver reads: the budget node is `resources` and the +post-decision function is `savings`, the same spec DC-EGM uses. The `dead` regime +is terminal and keeps its own solver. The regime declares every choice its structure affords — whether to buy non-group coverage, and how many hours to work — and the discrete envelope @@ -21,7 +20,7 @@ from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] from lcm import DiscreteGrid, Model, Regime from lcm.exceptions import RegimeInitializationError -from lcm.solvers import NBEGM, GridSearch +from lcm.solvers import NBEGM from aca_model.aca import PolicyVariant from aca_model.aca.model import create_model as create_aca_model @@ -89,15 +88,18 @@ def _grids() -> Grids: ) -def test_nbegm_attaches_only_to_the_m1_regime() -> None: - """`solver="nbegm"` gives the M1 slice regime a `NBEGM` config and leaves - every other living regime on brute force.""" +def test_nbegm_attaches_to_every_living_regime() -> None: + """`solver="nbegm"` solves every living regime with NB-EGM. + + A solver that reached only some regimes would leave the rest on brute + force while still reporting itself as the model's solver, so the choice of + solver would not be visible in the result it produced. + """ regimes = _build_regimes("nbegm") - assert isinstance(regimes[_M1_REGIME].solver, NBEGM) - for name in REGIME_SPECS: - if name == _M1_REGIME: - continue - assert isinstance(regimes[name].solver, GridSearch), name + on_brute_force = [ + name for name in REGIME_SPECS if not isinstance(regimes[name].solver, NBEGM) + ] + assert on_brute_force == [] def test_build_nbegm_solver_uses_the_savings_form_resources_budget() -> None: @@ -188,15 +190,20 @@ def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: assert law is expected, target_name -def test_nbegm_savings_form_functions_are_scoped_to_the_m1_regime() -> None: - """Under NBEGM only the M1 regime carries the savings-form budget functions - (`resources`, `savings`); brute regimes keep the cash-on-hand form and carry - neither.""" +def test_nbegm_gives_every_living_regime_the_savings_form_budget() -> None: + """Under NBEGM every living regime carries `resources` and `savings`. + + They are the solver's budget contract, so a regime NB-EGM solves without + them cannot be built at all; a regime it does not solve has no use for + them, which is why the brute-force build omits them. + """ model = _build_model("nbegm") - m1_functions = model.user_regimes[_M1_REGIME].functions - assert "resources" in m1_functions - assert "savings" in m1_functions - assert "resources" not in model.user_regimes[_BRUTE_REGIME].functions + missing = [ + name + for name in REGIME_SPECS + if not {"resources", "savings"} <= set(model.user_regimes[name].functions) + ] + assert missing == [] def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: From ace111bcdf1e64162bc01f642e54490302d915cd Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 16 Aug 2026 12:52:28 +0200 Subject: [PATCH 004/101] Let pylcm's NBEGM probes check the ACA budget 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. --- src/aca_model/baseline/regimes/_nbegm.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/aca_model/baseline/regimes/_nbegm.py b/src/aca_model/baseline/regimes/_nbegm.py index d1cda4f..8f8685b 100644 --- a/src/aca_model/baseline/regimes/_nbegm.py +++ b/src/aca_model/baseline/regimes/_nbegm.py @@ -40,11 +40,6 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: continuous_state="assets", budget_target="resources", post_decision_function="savings", - # The budget DAG mixes scalar- and array-annotated parameters (tax - # tables, threshold schedules) that the build-time derivative probes - # cannot synthesize, so affinity/constancy is asserted here and - # validated by the full-model brute-agreement gates. - probe_failure="assume_declared", # Splay the child stochastic-node expectation per the grid config: `0` (the # default) reads the whole node mesh in one pass on a memory-rich device; a # positive value loops it in blocks to fit a tighter budget (a CPU run). From 4f258186c71304dc0bf2f102b299f5d062e263cb Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 19 Aug 2026 16:14:19 +0200 Subject: [PATCH 005/101] Declare the liquid Euler margin on the EGM regimes 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. --- src/aca_model/baseline/regimes/_common.py | 31 ++++++++++++++++ src/aca_model/baseline/regimes/_dcegm.py | 4 -- src/aca_model/baseline/regimes/_nbegm.py | 9 ++--- src/aca_model/baseline/regimes/_nongroup.py | 6 +-- src/aca_model/baseline/regimes/_retiree.py | 6 +-- src/aca_model/baseline/regimes/_tied.py | 6 +-- tests/test_dcegm_model_creation.py | 21 +++++++---- tests/test_nbegm_model_creation.py | 41 +++++++++++++++------ 8 files changed, 86 insertions(+), 38 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index eddfa52..5f24287 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -13,9 +13,11 @@ import jax.numpy as jnp import numpy as np from lcm import ( + ConsumptionSavingsRegime, DiscreteGrid, IrregSpacedGrid, LinSpacedGrid, + LiquidMargin, MarkovTransition, NormalIIDProcess, Phased, @@ -26,6 +28,7 @@ categorical, fixed_transition, ) +from lcm.solvers import OneMarginSolver from lcm.typing import BoolND, FloatND, IntND, RegimeName, ScalarInt, UserParams from aca_model.agent import ( @@ -44,6 +47,34 @@ SolverName = Literal["brute_force", "dcegm", "nbegm"] +# The liquid Euler margin every EGM-solved ACA regime shares. `resources` is +# post-transfer cash-on-hand (`max(cash_on_hand, floor)`) and `savings` is the +# post-decision assets node; both are supplied by the savings-form rewiring in +# `build_dcegm_functions` / `build_nbegm_functions`, so the margin is +# declarable only on a regime that carries them. +ACA_LIQUID_MARGIN = LiquidMargin( + state="assets", + action="consumption_dollars", + resources="resources", + post_decision_state="savings", +) + + +def build_alive_regime( + *, egm_solver: OneMarginSolver | None, **regime_kwargs: Any +) -> Regime: + """Build one alive regime, declaring the liquid margin when EGM solves it. + + A brute-force regime has no `resources` or `savings` node to name, so it + stays a plain `Regime`. An EGM-solved regime owns the four margin names + and hands them to the solver, which takes numerical configuration only. + """ + if egm_solver is None: + return Regime(**regime_kwargs) + return ConsumptionSavingsRegime( + solver=egm_solver, liquid=ACA_LIQUID_MARGIN, **regime_kwargs + ) + @categorical(ordered=False) class RegimeId: diff --git a/src/aca_model/baseline/regimes/_dcegm.py b/src/aca_model/baseline/regimes/_dcegm.py index b0ee129..76cf075 100644 --- a/src/aca_model/baseline/regimes/_dcegm.py +++ b/src/aca_model/baseline/regimes/_dcegm.py @@ -35,10 +35,6 @@ def build_dcegm_solver(grids: Grids) -> DCEGM: batch_size=grids.grid_config.n_savings_batch_size, ) return DCEGM( - continuous_state="assets", - continuous_action="consumption_dollars", - resources="resources", - post_decision_function="savings", savings_grid=savings_grid, stochastic_node_batch_size=grids.grid_config.n_stochastic_node_batch_size, ) diff --git a/src/aca_model/baseline/regimes/_nbegm.py b/src/aca_model/baseline/regimes/_nbegm.py index 8f8685b..61c656a 100644 --- a/src/aca_model/baseline/regimes/_nbegm.py +++ b/src/aca_model/baseline/regimes/_nbegm.py @@ -23,9 +23,9 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: The savings grid mirrors DC-EGM's: lower bound 0 (the borrowing constraint in post-decision form), upper bound the assets span, cubically clustered - toward the constraint. The budget node is `resources` (post-floor - cash-on-hand) and the post-decision function is `savings`, matching the - shared savings-form spec. + toward the constraint. Which DAG nodes play the liquid roles is the + regime's declaration (`ACA_LIQUID_MARGIN`), not the solver's; this config + carries numerical settings only. """ n_points = grids.grid_config.n_savings_gridpoints _fail_if_too_few_savings_gridpoints(n_points) @@ -37,9 +37,6 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: ) return NBEGM( savings_grid=savings_grid, - continuous_state="assets", - budget_target="resources", - post_decision_function="savings", # Splay the child stochastic-node expectation per the grid config: `0` (the # default) reads the whole node mesh in one pass on a memory-rich device; a # positive value loops it in blocks to fit a tighter budget (a CPU run). diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index 5189cf9..f13e3c9 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -17,6 +17,7 @@ Grids, RegimeSpec, build_actions, + build_alive_regime, build_common_functions, build_granular_regime_transition, build_nbegm_functions, @@ -119,7 +120,6 @@ def build_regime( states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver - solver_kwargs: dict = {} if egm_solver is None else {"solver": egm_solver} state_solver = ( "brute_force" if egm_solver is None @@ -135,7 +135,8 @@ def build_regime( # consumption by an argmax over the consumption grid and needs the # explicit feasibility mask. functions = {**functions, **build_nbegm_functions()} - return Regime( + return build_alive_regime( + egm_solver=egm_solver, transition=build_granular_regime_transition( transition_func=transition_func, target_ids=own.values() ), @@ -145,5 +146,4 @@ def build_regime( actions=build_actions(spec, grids), functions=functions, constraints=constraints, - **solver_kwargs, ) diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 76e19ef..309a71c 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -18,6 +18,7 @@ Grids, RegimeSpec, build_actions, + build_alive_regime, build_common_functions, build_granular_regime_transition, build_nbegm_functions, @@ -128,7 +129,6 @@ def build_regime( states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver - solver_kwargs: dict = {} if egm_solver is None else {"solver": egm_solver} state_solver = ( "brute_force" if egm_solver is None @@ -140,7 +140,8 @@ def build_regime( # savings form off `resources` and the post-decision node off # `savings`, neither of which the brute-force build needs. functions = {**functions, **build_nbegm_functions()} - return Regime( + return build_alive_regime( + egm_solver=egm_solver, transition=build_granular_regime_transition( transition_func=transition_func, target_ids=(*own.values(), *ng.values()) ), @@ -149,5 +150,4 @@ def build_regime( state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), functions=functions, - **solver_kwargs, ) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index 348c34a..ff13664 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -19,6 +19,7 @@ Grids, RegimeSpec, build_actions, + build_alive_regime, build_common_functions, build_granular_regime_transition, build_nbegm_functions, @@ -98,7 +99,6 @@ def build_regime( states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver - solver_kwargs: dict = {} if egm_solver is None else {"solver": egm_solver} state_solver = ( "brute_force" if egm_solver is None @@ -110,7 +110,8 @@ def build_regime( # savings form off `resources` and the post-decision node off # `savings`, neither of which the brute-force build needs. functions = {**functions, **build_nbegm_functions()} - return Regime( + return build_alive_regime( + egm_solver=egm_solver, transition=build_granular_regime_transition( transition_func=transition_func, target_ids=(*own.values(), *ng.values()) ), @@ -119,5 +120,4 @@ def build_regime( state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), functions=functions, - **solver_kwargs, ) diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index 2a4b8de..d46a825 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -12,7 +12,13 @@ import numpy as np import pytest from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] -from lcm import DiscreteGrid, IrregSpacedGrid, Model, Regime +from lcm import ( + ConsumptionSavingsRegime, + DiscreteGrid, + IrregSpacedGrid, + Model, + Regime, +) from lcm.solvers import DCEGM from aca_model.agent import assets_and_income @@ -56,14 +62,15 @@ def _build_model(solver: SolverName) -> Model: def test_every_living_regime_gets_the_dcegm_solver() -> None: - """`solver="dcegm"` attaches a `DCEGM` config with assets as the Euler - state to every living regime; the terminal regime keeps the default.""" + """`solver="dcegm"` attaches a `DCEGM` config to every living regime, each + declaring `assets` as its liquid margin; the terminal regime keeps the + default.""" regimes = _build_regimes("dcegm") for name in REGIME_SPECS: - solver = regimes[name].solver - assert isinstance(solver, DCEGM), name - assert solver.continuous_state == "assets" - assert solver.continuous_action == "consumption_dollars" + regime = cast("ConsumptionSavingsRegime", regimes[name]) + assert isinstance(regime.solver, DCEGM), name + assert regime.liquid.state == "assets", name + assert regime.liquid.action == "consumption_dollars", name assert not isinstance(regimes["dead"].solver, DCEGM) diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 8efde5d..0502370 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -18,7 +18,13 @@ import pytest from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] -from lcm import DiscreteGrid, Model, Regime +from lcm import ( + ConsumptionSavingsRegime, + DiscreteGrid, + LiquidMargin, + Model, + Regime, +) from lcm.exceptions import RegimeInitializationError from lcm.solvers import NBEGM @@ -88,6 +94,13 @@ def _grids() -> Grids: ) +def _liquid_margins(regimes: Mapping[str, Regime]) -> set[LiquidMargin]: + """Return the liquid margin every living regime declares.""" + return { + cast("ConsumptionSavingsRegime", regimes[name]).liquid for name in REGIME_SPECS + } + + def test_nbegm_attaches_to_every_living_regime() -> None: """`solver="nbegm"` solves every living regime with NB-EGM. @@ -102,20 +115,24 @@ def test_nbegm_attaches_to_every_living_regime() -> None: assert on_brute_force == [] -def test_build_nbegm_solver_uses_the_savings_form_resources_budget() -> None: - """The NBEGM config inverts against `resources` in post-decision savings +def test_nbegm_regimes_declare_the_savings_form_resources_budget() -> None: + """Every NB-EGM regime inverts against `resources` in post-decision savings form, matching the DC-EGM contract the regime is rewired into.""" - solver = build_nbegm_solver(_grids()) - assert isinstance(solver, NBEGM) - assert solver.budget_target == "resources" - assert solver.post_decision_function == "savings" + declared = { + (margin.resources, margin.post_decision_state) + for margin in _liquid_margins(_build_regimes("nbegm")) + } + assert declared == {("resources", "savings")} -def test_build_nbegm_solver_names_assets_as_the_euler_axis() -> None: - """`assets` is the liquid (Euler) axis; `aime` and the stochastic shock grids - ride along, so the solver names the Euler axis explicitly.""" - solver = build_nbegm_solver(_grids()) - assert solver.continuous_state == "assets" +def test_nbegm_regimes_declare_assets_as_the_liquid_euler_axis() -> None: + """`assets` paid down by `consumption_dollars` is the liquid margin of every + NB-EGM regime; `aime` and the stochastic shock grids ride along.""" + declared = { + (margin.state, margin.action) + for margin in _liquid_margins(_build_regimes("nbegm")) + } + assert declared == {("assets", "consumption_dollars")} def test_build_nbegm_solver_forwards_the_jump_read_mode() -> None: From 221bf4f0c5f69390bb90412b9c13e335e9356cbe Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 19 Aug 2026 18:07:15 +0200 Subject: [PATCH 006/101] Describe the borrowing constraint's actual solver coverage 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 Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm --- src/aca_model/baseline/regimes/__init__.py | 2 +- src/aca_model/baseline/regimes/_common.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index b64543e..6e2d68c 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -131,7 +131,7 @@ def build_model_slots( Both the baseline and the ACA `create_model` consume this — the ACA overlay swaps only regime-level functions, so the broadcast slots are policy-invariant. Under DC-EGM the solver-contract functions join the - broadcast set and no borrowing constraint is declared. + broadcast set; the borrowing constraint is declared under every solver. """ grids = build_grids( grid_config=grid_config, diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 5f24287..6de310d 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -567,8 +567,8 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: inputs (e.g. `pension_benefit`) don't surface as params in the dead template. - constraints: the borrowing constraint is masked — `dead` has no - consumption action. (Under DC-EGM no constraint is broadcast, so - there is nothing to mask.) + consumption action. It is broadcast under every solver, so there is + always exactly one mask to apply. - `pension_wealth` is masked explicitly: a carried state is rejected in terminal regimes before pruning could drop it. """ From 8466aacff7b654b9e2ba38ed51438c1b7c261e4f Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 19 Aug 2026 18:28:46 +0200 Subject: [PATCH 007/101] Name the DC-EGM blocker that actually fires 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 Claude-Session: https://claude.ai/code/session_018yHhuFzqsDw1MhdB1i2Ljm --- tests/test_dcegm_model_creation.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index d46a825..cbea60a 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -153,12 +153,19 @@ def test_benchmark_consumption_points_pin_both_floors() -> None: @pytest.mark.xfail( strict=False, reason=( - "pylcm's DC-EGM contract does not yet admit the ACA budget: the " - "assets law reaches `assets` outside the post-decision function — " - "through `oop_costs` (Medicaid eligibility → `countable_income` → " - "`capital_income`) and `pension_assets_adjustment` " - "(`marginal_tax_rate` → `gross_income` → `capital_income`). " - "Fixes land upstream in pylcm, not here." + "Two independent blockers, both upstream in pylcm. The one that " + "fires first: DC-EGM refuses the broadcast `borrowing_constraint` " + "because it reads the continuous `assets` and `consumption_dollars`, " + "while the solver enforces the borrowing limit through the savings " + "grid's lower bound instead. The model declares it under every " + "solver because forward simulation re-decides consumption by an " + "argmax over the consumption grid and needs the explicit mask, so " + "the two requirements conflict and neither side can drop its half " + "alone. Behind it: the assets law reaches `assets` outside the " + "post-decision function — through `oop_costs` (Medicaid eligibility " + "→ `countable_income` → `capital_income`) and " + "`pension_assets_adjustment` (`marginal_tax_rate` → `gross_income` " + "→ `capital_income`)." ), ) def test_dcegm_benchmark_model_builds() -> None: From a2ad9588e59744519a196e677852efef70658f27 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 20 Aug 2026 15:19:36 +0200 Subject: [PATCH 008/101] FIX Declare the ACA borrowing limit structurally --- src/aca_model/baseline/regimes/__init__.py | 2 +- src/aca_model/baseline/regimes/_common.py | 20 +++++++++++------ tests/test_dcegm_model_creation.py | 26 +--------------------- 3 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index 6e2d68c..dbbb5aa 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -141,7 +141,7 @@ def build_model_slots( ) return { "functions": build_model_functions(solver=solver), - "constraints": build_model_constraints(), + "constraints": build_model_constraints(solver=solver), "states": build_model_states(grids), "state_transitions": build_model_state_transitions(), } diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6de310d..33805d7 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -27,6 +27,7 @@ RouwenhorstAR1Process, categorical, fixed_transition, + post_decision_lower_bound, ) from lcm.solvers import OneMarginSolver from lcm.typing import BoolND, FloatND, IntND, RegimeName, ScalarInt, UserParams @@ -577,7 +578,7 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: for name in build_model_functions(solver=solver) if name not in _DEAD_KEEPS } - constraint_masks = dict.fromkeys(build_model_constraints()) + constraint_masks = dict.fromkeys(build_model_constraints(solver=solver)) return Regime( transition=None, functions={"utility": preferences.bequest, **function_masks}, @@ -693,16 +694,21 @@ def build_nbegm_functions() -> dict: } -def build_model_constraints() -> dict: +def build_model_constraints(*, solver: SolverName) -> dict: """Build the model-level constraints broadcast into every regime. `dead` masks the borrowing constraint — it has no consumption action. - The constraint is broadcast under every solver: an EGM solve (DC-EGM or - NBEGM) enforces the borrowing 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. + Grid search evaluates the action-level predicate directly. An EGM-family + solve proves the equivalent post-decision lower bound from its savings + grid, while forward simulation receives the solver's intrinsic budget + mask over the consumption grid. """ - return {"borrowing_constraint": assets_and_income.borrowing_constraint} + borrowing_constraint = ( + assets_and_income.borrowing_constraint + if solver == "brute_force" + else post_decision_lower_bound(margin=ACA_LIQUID_MARGIN, lower=0.0) + ) + return {"borrowing_constraint": borrowing_constraint} def build_model_states(grids: Grids) -> dict: diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index cbea60a..5a73f23 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -150,32 +150,8 @@ def test_benchmark_consumption_points_pin_both_floors() -> None: np.testing.assert_allclose(points[:2], [floor, floor * 2.0**exponent], rtol=1e-12) -@pytest.mark.xfail( - strict=False, - reason=( - "Two independent blockers, both upstream in pylcm. The one that " - "fires first: DC-EGM refuses the broadcast `borrowing_constraint` " - "because it reads the continuous `assets` and `consumption_dollars`, " - "while the solver enforces the borrowing limit through the savings " - "grid's lower bound instead. The model declares it under every " - "solver because forward simulation re-decides consumption by an " - "argmax over the consumption grid and needs the explicit mask, so " - "the two requirements conflict and neither side can drop its half " - "alone. Behind it: the assets law reaches `assets` outside the " - "post-decision function — through `oop_costs` (Medicaid eligibility " - "→ `countable_income` → `capital_income`) and " - "`pension_assets_adjustment` (`marginal_tax_rate` → `gross_income` " - "→ `capital_income`)." - ), -) def test_dcegm_benchmark_model_builds() -> None: - """The benchmark model accepts `solver="dcegm"` end to end. - - The acceptance criterion for the upstream DC-EGM stack: once pylcm's - contract admits the ACA budget chains, this builds without error. The - construction-time consumption points are supplied so the build reaches - the upstream limitation rather than the missing-points guard. - """ + """The benchmark model accepts `solver="dcegm"` end to end.""" model = create_model( n_subjects=1, fixed_params=_FIXED_PARAMS, From 328e3710cfafb19c28ebcd46a27090238415fdd6 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 21 Aug 2026 13:41:24 +0200 Subject: [PATCH 009/101] Migrate ACA benchmark to breakpoint grids --- src/aca_model/baseline/regimes/_common.py | 32 ++++++++++------------- tests/test_aime_grid.py | 11 +++++--- tests/test_benchmark.py | 16 +++++++++++- tests/test_dcegm_model_creation.py | 9 ++----- tests/test_nbegm_model_creation.py | 9 ++----- 5 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 33805d7..3168213 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -13,20 +13,22 @@ import jax.numpy as jnp import numpy as np from lcm import ( - ConsumptionSavingsRegime, DiscreteGrid, + GridBreakpoint, IrregSpacedGrid, LinSpacedGrid, - LiquidMargin, MarkovTransition, NormalIIDProcess, Phased, - PiecewiseGridSegment, PiecewiseLinSpacedGrid, Regime, RouwenhorstAR1Process, categorical, fixed_transition, +) +from lcm.consumption_savings_regime import ( + ConsumptionSavingsRegime, + LiquidMargin, post_decision_lower_bound, ) from lcm.solvers import OneMarginSolver @@ -372,22 +374,16 @@ def _build_aime_grid( total is fixed by the PIA structure (`sum(_AIME_PIECE_N_POINTS)`). """ kinks = [float(k) for k in np.asarray(fixed_params["pia_aime_grid"])] - segments = ( - PiecewiseGridSegment( - interval=f"[{kinks[0]}, {kinks[1]})", n_points=_AIME_PIECE_N_POINTS[0] - ), - PiecewiseGridSegment( - interval=f"[{kinks[1]}, {kinks[2]})", n_points=_AIME_PIECE_N_POINTS[1] - ), - PiecewiseGridSegment( - interval=f"[{kinks[2]}, {kinks[3]})", n_points=_AIME_PIECE_N_POINTS[2] - ), - PiecewiseGridSegment( - interval=f"[{kinks[3]}, {kinks[4]}]", n_points=_AIME_PIECE_N_POINTS[3] - ), - ) return PiecewiseLinSpacedGrid( - segments=segments, batch_size=grid_config.n_aime_batch_size + start=kinks[0], + stop=kinks[4], + breakpoints=( + GridBreakpoint(value=kinks[1]), + GridBreakpoint(value=kinks[2]), + GridBreakpoint(value=kinks[3]), + ), + points_per_segment=_AIME_PIECE_N_POINTS, + batch_size=grid_config.n_aime_batch_size, ) diff --git a/tests/test_aime_grid.py b/tests/test_aime_grid.py index a249ed3..368fe37 100644 --- a/tests/test_aime_grid.py +++ b/tests/test_aime_grid.py @@ -18,12 +18,17 @@ _FIXED_PARAMS = MappingProxyType({"pia_aime_grid": _PIA_AIME_GRID}) -def test_build_aime_grid_has_four_segments() -> None: - """The AIME grid spans all four PIA intervals, including the extension.""" +def test_build_aime_grid_owns_each_pia_breakpoint_on_the_right() -> None: + """The AIME grid preserves four PIA pieces with right-owned interiors.""" grid = _build_aime_grid( grid_config=BENCHMARK_GRID_CONFIG, fixed_params=_FIXED_PARAMS ) - assert len(grid.segments) == 4 + np.testing.assert_allclose( + [point.value for point in grid.breakpoints], + _PIA_AIME_GRID[1:-1], + ) + assert tuple(point.owner for point in grid.breakpoints) == ("right",) * 3 + assert grid.points_per_segment == _AIME_PIECE_N_POINTS def test_build_aime_grid_top_point_is_extension_aime() -> None: diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index fb1a9d6..564e729 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -2,7 +2,7 @@ import numpy as np import pytest -from lcm import DiscreteGrid +from lcm import DiscreteGrid, GridBreakpoint, PiecewiseLinSpacedGrid from aca_model.agent.preferences import BenchmarkPrefType from aca_model.benchmark import ( @@ -12,6 +12,20 @@ ) +def test_benchmark_model_builds_with_the_current_pylcm_grid_api() -> None: + """The frozen benchmark model constructs with breakpoint-first AIME grids.""" + model = create_benchmark_model( + n_subjects=1, + pref_type_grid=DiscreteGrid(BenchmarkPrefType), + ) + + aime = model.user_regimes["retiree_nomc_inelig_canwork"].states["aime"] + assert isinstance(aime, PiecewiseLinSpacedGrid) + assert all(isinstance(point, GridBreakpoint) for point in aime.breakpoints) + assert all(point.owner == "right" for point in aime.breakpoints) + assert aime.n_points == sum(aime.points_per_segment) + + @pytest.mark.long_running def test_benchmark_model_simulates_end_to_end() -> None: n_subjects = 20 diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index 5a73f23..492fdd2 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -12,13 +12,8 @@ import numpy as np import pytest from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] -from lcm import ( - ConsumptionSavingsRegime, - DiscreteGrid, - IrregSpacedGrid, - Model, - Regime, -) +from lcm import DiscreteGrid, IrregSpacedGrid, Model, Regime +from lcm.consumption_savings_regime import ConsumptionSavingsRegime from lcm.solvers import DCEGM from aca_model.agent import assets_and_income diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 0502370..ce70017 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -18,13 +18,8 @@ import pytest from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] -from lcm import ( - ConsumptionSavingsRegime, - DiscreteGrid, - LiquidMargin, - Model, - Regime, -) +from lcm import DiscreteGrid, Model, Regime +from lcm.consumption_savings_regime import ConsumptionSavingsRegime, LiquidMargin from lcm.exceptions import RegimeInitializationError from lcm.solvers import NBEGM From f61cff94fa02a075bb075194b8b983ffe66641ad Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 30 Aug 2026 17:18:36 +0200 Subject: [PATCH 010/101] ENH Forward NB-EGM workspace controls --- src/aca_model/baseline/regimes/_nbegm.py | 8 ++++++++ src/aca_model/config.py | 8 ++++++++ tests/test_nbegm_model_creation.py | 26 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/aca_model/baseline/regimes/_nbegm.py b/src/aca_model/baseline/regimes/_nbegm.py index 61c656a..3bd740b 100644 --- a/src/aca_model/baseline/regimes/_nbegm.py +++ b/src/aca_model/baseline/regimes/_nbegm.py @@ -50,12 +50,20 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: # "certified" is exact and can abstain, "ordinary" reads in the working # format at a fraction of the cost. envelope_arithmetic=grids.grid_config.nbegm_envelope_arithmetic, + # Stream continuation intervals, or let the active byte planner choose + # when the configured width is zero. + interval_batch_size=grids.grid_config.n_nbegm_interval_batch_size, # Stream both ride-along cores over ride-cell blocks per the grid config; # `0` vmaps the whole flattened mesh at once. cell_block_size=grids.grid_config.n_nbegm_cell_block_size, # Stream the discrete-action branch axis in blocks per the grid config; # `0` runs the whole axis in one vectorized pass. branch_batch_size=grids.grid_config.n_nbegm_branch_batch_size, + # Compile-only per-device memory preflight; None preserves the legacy + # manual block path. + max_device_workspace_bytes=( + grids.grid_config.n_nbegm_max_device_workspace_bytes + ), # Cliff-read mode: exact one-sided limits (default) or the fast bridged # read for inner estimation loops (see `GridConfig.nbegm_jump_read`). jump_read=grids.grid_config.nbegm_jump_read, diff --git a/src/aca_model/config.py b/src/aca_model/config.py index db5ed3e..38c6d64 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -107,6 +107,14 @@ class GridConfig: # either way — the knob trades peak device memory against a sequential scan. # Only consulted under `solver="nbegm"`. n_nbegm_envelope_segment_block_size: int = 0 + # Compiled batch width for the liquid-interval axis. With an active byte + # budget, 0 lets the planner choose up to the full axis; a positive value + # caps it. Only consulted under `solver="nbegm"`. + n_nbegm_interval_batch_size: int = 0 + # Authoritative busiest-device workspace budget. None preserves manual + # blocks; a positive value activates compile-only continuation/envelope + # planning before backward induction. Only consulted under `solver="nbegm"`. + n_nbegm_max_device_workspace_bytes: int | None = None # Which arithmetic decides ownership in the merged upper envelope: # - "certified" (default) — candidates are compared in double-double precision # and no winner is published where none is separated, so the reported owner is diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index ce70017..b47af6f 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -352,3 +352,29 @@ def test_nbegm_aca_variants_leave_no_free_buy_private_params( if isinstance(params, dict) and "buy_private" in params ] assert offenders == [], offenders + + +def test_nbegm_workspace_budget_and_all_streaming_axes_are_forwarded() -> None: + """ACA exposes every planner axis and the positive per-device byte budget.""" + grid_config = dataclasses.replace( + BENCHMARK_GRID_CONFIG, + n_nbegm_stochastic_node_batch_size=2, + n_nbegm_envelope_segment_block_size=3, + n_nbegm_interval_batch_size=4, + n_nbegm_cell_block_size=5, + n_nbegm_branch_batch_size=6, + n_nbegm_max_device_workspace_bytes=72 * 1024**3, + ) + grids = build_grids( + grid_config=grid_config, + fixed_params=_FIXED_PARAMS, + wage_params=_WAGE_PARAMS, + pref_type_grid=DiscreteGrid(BenchmarkPrefType), + ) + solver = build_nbegm_solver(grids) + assert solver.stochastic_node_batch_size == 2 + assert solver.envelope_segment_block_size == 3 + assert solver.interval_batch_size == 4 + assert solver.cell_block_size == 5 + assert solver.branch_batch_size == 6 + assert solver.max_device_workspace_bytes == 72 * 1024**3 From 5d1d4797474946dafc9509f9d83907f31dcb6dc6 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 2 Sep 2026 01:29:18 +0200 Subject: [PATCH 011/101] ENH Allow explicit fp32 ACA runs --- src/aca_model/__init__.py | 9 ++++++++- tests/test_precision.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/test_precision.py diff --git a/src/aca_model/__init__.py b/src/aca_model/__init__.py index 1fc43f7..d26e6e4 100644 --- a/src/aca_model/__init__.py +++ b/src/aca_model/__init__.py @@ -1,6 +1,13 @@ +import os + import jax -jax.config.update("jax_enable_x64", True) +_x64_requested = os.environ.get("ACA_JAX_ENABLE_X64", "1").lower() not in { + "0", + "false", + "no", +} +jax.config.update("jax_enable_x64", _x64_requested) # Import lcm before installing the claw so its `_jaxtyping_patch` (picklable # jaxtyping sentinel) and `MappingProxyType` pytree registration are in place. diff --git a/tests/test_precision.py b/tests/test_precision.py new file mode 100644 index 0000000..e807bd3 --- /dev/null +++ b/tests/test_precision.py @@ -0,0 +1,28 @@ +"""Process-level floating-point precision selection.""" + +import os +import subprocess +import sys + + +def test_aca_precision_environment_selects_fp32() -> None: + """`ACA_JAX_ENABLE_X64=0` makes ACA computations use 32-bit floats.""" + env = { + **os.environ, + "JAX_ENABLE_X64": "1", + "ACA_JAX_ENABLE_X64": "0", + } + + result = subprocess.run( + [ + sys.executable, + "-c", + "import aca_model, jax; print(jax.config.jax_enable_x64)", + ], + check=True, + capture_output=True, + env=env, + text=True, + ) + + assert result.stdout.strip() == "False" From 94977a39172a8b32bbf620a7e289a05cf5c20e16 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 2 Sep 2026 02:06:34 +0200 Subject: [PATCH 012/101] FIX Gate the experimental NB-EGM planner --- src/aca_model/baseline/regimes/_nbegm.py | 21 +++++++++++++++------ tests/test_nbegm_model_creation.py | 23 +++++++++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/aca_model/baseline/regimes/_nbegm.py b/src/aca_model/baseline/regimes/_nbegm.py index 3bd740b..89dcfa0 100644 --- a/src/aca_model/baseline/regimes/_nbegm.py +++ b/src/aca_model/baseline/regimes/_nbegm.py @@ -12,6 +12,8 @@ config. """ +import dataclasses + from lcm import IrregSpacedGrid from lcm.solvers import NBEGM @@ -35,7 +37,7 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: points=tuple(savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)), batch_size=grids.grid_config.n_savings_batch_size, ) - return NBEGM( + solver = NBEGM( savings_grid=savings_grid, # Splay the child stochastic-node expectation per the grid config: `0` (the # default) reads the whole node mesh in one pass on a memory-rich device; a @@ -59,15 +61,22 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: # Stream the discrete-action branch axis in blocks per the grid config; # `0` runs the whole axis in one vectorized pass. branch_batch_size=grids.grid_config.n_nbegm_branch_batch_size, - # Compile-only per-device memory preflight; None preserves the legacy - # manual block path. - max_device_workspace_bytes=( - grids.grid_config.n_nbegm_max_device_workspace_bytes - ), # Cliff-read mode: exact one-sided limits (default) or the fast bridged # read for inner estimation loops (see `GridConfig.nbegm_jump_read`). jump_read=grids.grid_config.nbegm_jump_read, ) + budget = grids.grid_config.n_nbegm_max_device_workspace_bytes + if budget is None: + return solver + + fields = {field.name for field in dataclasses.fields(NBEGM)} + if "max_device_workspace_bytes" not in fields: + msg = ( + "GridConfig.n_nbegm_max_device_workspace_bytes requires a pylcm " + "build with the experimental NB-EGM workspace planner." + ) + raise RuntimeError(msg) + return dataclasses.replace(solver, max_device_workspace_bytes=budget) def _fail_if_too_few_savings_gridpoints(n_savings_gridpoints: int) -> None: diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index b47af6f..fdd3a4b 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -354,8 +354,8 @@ def test_nbegm_aca_variants_leave_no_free_buy_private_params( assert offenders == [], offenders -def test_nbegm_workspace_budget_and_all_streaming_axes_are_forwarded() -> None: - """ACA exposes every planner axis and the positive per-device byte budget.""" +def test_nbegm_all_streaming_axes_are_forwarded() -> None: + """ACA forwards every streaming axis supported by pylcm.""" grid_config = dataclasses.replace( BENCHMARK_GRID_CONFIG, n_nbegm_stochastic_node_batch_size=2, @@ -363,7 +363,6 @@ def test_nbegm_workspace_budget_and_all_streaming_axes_are_forwarded() -> None: n_nbegm_interval_batch_size=4, n_nbegm_cell_block_size=5, n_nbegm_branch_batch_size=6, - n_nbegm_max_device_workspace_bytes=72 * 1024**3, ) grids = build_grids( grid_config=grid_config, @@ -377,4 +376,20 @@ def test_nbegm_workspace_budget_and_all_streaming_axes_are_forwarded() -> None: assert solver.interval_batch_size == 4 assert solver.cell_block_size == 5 assert solver.branch_batch_size == 6 - assert solver.max_device_workspace_bytes == 72 * 1024**3 + + +def test_nbegm_workspace_budget_requires_the_experimental_planner() -> None: + """A planner budget is refused when pylcm has no workspace planner.""" + grid_config = dataclasses.replace( + BENCHMARK_GRID_CONFIG, + n_nbegm_max_device_workspace_bytes=72 * 1024**3, + ) + grids = build_grids( + grid_config=grid_config, + fixed_params=_FIXED_PARAMS, + wage_params=_WAGE_PARAMS, + pref_type_grid=DiscreteGrid(BenchmarkPrefType), + ) + + with pytest.raises(RuntimeError, match="experimental NB-EGM workspace planner"): + build_nbegm_solver(grids) From 3af83d075feb0619a75169a2da957282df36bb1f Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 3 Sep 2026 09:04:28 +0200 Subject: [PATCH 013/101] MAINT Migrate the tests to pylcm's SolutionResult solve API 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) Claude-Session: https://claude.ai/code/session_01Y7QCx9tkGD3TqwNw681QwD --- tests/test_benchmark.py | 3 --- tests/test_dcegm_parity.py | 5 ++--- tests/test_nbegm_labor_live_validation.py | 2 +- tests/test_nbegm_solve_validation.py | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 564e729..ac9b525 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -41,7 +41,6 @@ def test_benchmark_model_simulates_end_to_end() -> None: result = model.simulate( params=params, initial_conditions=initial_conditions, - period_to_regime_to_V_arr=None, log_level="off", ) @@ -76,7 +75,6 @@ def test_benchmark_panel_exposes_hic_premium_and_wage_targets() -> None: result = model.simulate( params=params, initial_conditions=initial_conditions, - period_to_regime_to_V_arr=None, log_level="off", ) @@ -139,7 +137,6 @@ def test_benchmark_simulate_obeys_borrowing_constraint() -> None: result = model.simulate( params=params, initial_conditions=initial_conditions, - period_to_regime_to_V_arr=None, log_level="off", ) diff --git a/tests/test_dcegm_parity.py b/tests/test_dcegm_parity.py index 49e1695..4c83e2c 100644 --- a/tests/test_dcegm_parity.py +++ b/tests/test_dcegm_parity.py @@ -134,7 +134,6 @@ def _seeded_panel(model: Model) -> pd.DataFrame: result = model.simulate( params=params, initial_conditions=initial_conditions, - period_to_regime_to_V_arr=None, log_level="off", ) df = result.to_dataframe() @@ -235,7 +234,7 @@ def test_dcegm_solves_the_parity_model() -> None: (regime, period) cell carries a finite value-function array.""" model = _make_model(solver="dcegm", grid_config=PARITY_GRID_CONFIG) _, _, params = get_benchmark_params(model=model) - period_to_regime_to_v = model.solve(params=params, log_level="off") - for regime_to_v in period_to_regime_to_v.values(): + solution = model.solve(params=params, log_level="off") + for regime_to_v in solution.values.values(): for v_arr in regime_to_v.values(): assert bool(np.isfinite(np.asarray(v_arr)).any()) diff --git a/tests/test_nbegm_labor_live_validation.py b/tests/test_nbegm_labor_live_validation.py index a0ef0f0..53a732c 100644 --- a/tests/test_nbegm_labor_live_validation.py +++ b/tests/test_nbegm_labor_live_validation.py @@ -58,7 +58,7 @@ def _solve_m1(solver: SolverName) -> tuple[dict[int, np.ndarray], int]: ).index("assets") return { period: np.asarray(regimes[_M1_REGIME]) - for period, regimes in solution.items() + for period, regimes in solution.values.items() if _M1_REGIME in regimes }, assets_axis diff --git a/tests/test_nbegm_solve_validation.py b/tests/test_nbegm_solve_validation.py index 028fce7..af16729 100644 --- a/tests/test_nbegm_solve_validation.py +++ b/tests/test_nbegm_solve_validation.py @@ -47,7 +47,7 @@ def _solve_m1(solver: SolverName) -> dict[int, np.ndarray]: solution = model.solve(params=params, log_level="off") return { period: np.asarray(regimes[_M1_REGIME]) - for period, regimes in solution.items() + for period, regimes in solution.values.items() if _M1_REGIME in regimes } From b941507c14932b85b482d77ed8d7f3034c5dbfee Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Tue, 8 Sep 2026 20:37:37 +0200 Subject: [PATCH 014/101] ENH Move ACA execution controls to Model policy 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. --- README.md | 40 ++++++ src/aca_model/aca/model.py | 12 +- src/aca_model/baseline/model.py | 12 +- src/aca_model/baseline/regimes/_common.py | 25 +--- src/aca_model/baseline/regimes/_dcegm.py | 6 +- src/aca_model/baseline/regimes/_nbegm.py | 63 +-------- src/aca_model/benchmark.py | 13 +- src/aca_model/config.py | 150 ++-------------------- src/aca_model/execution.py | 53 ++++++++ tests/test_aime_grid.py | 9 +- tests/test_beartype_claw.py | 4 +- tests/test_benchmark.py | 28 +++- tests/test_dcegm_model_creation.py | 38 +----- tests/test_execution_budget.py | 62 +++++++++ tests/test_execution_config.py | 117 +++++++++++++++++ tests/test_grid_config.py | 35 +++++ tests/test_model_creation.py | 40 +----- tests/test_nbegm_labor_live_validation.py | 16 +-- tests/test_nbegm_model_creation.py | 43 +------ tests/test_nbegm_solve_validation.py | 2 +- tests/test_social_security.py | 2 +- tests/test_ss_benefit_integration.py | 2 +- 22 files changed, 403 insertions(+), 369 deletions(-) create mode 100644 src/aca_model/execution.py create mode 100644 tests/test_execution_budget.py create mode 100644 tests/test_execution_config.py create mode 100644 tests/test_grid_config.py diff --git a/README.md b/README.md index 5f52c1a..9edf892 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,43 @@ # aca-model Core lifecycle model for the ACA structural retirement project. + +Model factories accept `execution_config=ExecutionConfig(...)`. An omitted policy uses +the smallest JAX allocator `bytes_limit` among the selected accelerators as an explicit +per-device budget. This enables budget-aware width selection; CPU construction remains +unbudgeted. Missing accelerator limits require an explicit policy instead of silently +falling back to unbudgeted execution. + +To combine a measured budget with device selection and sharding: + +```python +from dataclasses import replace + +from aca_model.execution import execution_config_for_devices + +execution_config = replace( + execution_config_for_devices(devices=(0, 1, 2)), + sharded_states=("pref_type",), +) +``` + +Pass this policy to the baseline, ACA or benchmark factory. Record its exact +`device_memory_bytes`, the selected devices' allocator observations, executed precision +and selected program widths with performance evidence. The budget is an allocator +ceiling; it does not establish that all workload phases fit. + +Explicit policies pass through unchanged. In particular, `ExecutionConfig()` is an +unbudgeted control and retains pylcm's bootstrap width selection. Economic grids, +numerical solver settings and result retention are separate choices. + +`GridConfig` accepts economic grid sizes and numerical solver choices only. Execution +controls belong to `ExecutionConfig`: sharded state names go in `sharded_states`, +declared program widths in `axis_widths`, and the per-device byte ceiling in +`device_memory_bytes`. Obsolete grid execution keywords are rejected. A state-specific +batch size has no automatic conversion to a flattened program width; callers must choose +and record an explicit execution policy. + +For comparisons with an older pylcm version, retain its model/configuration pin and use +a separately reviewed API adapter. Preserve grids, transition declarations, numerical +solver choices, retention and seeds across the comparison. An unspecified width in the +new policy means planner selection, not a translation of an old zero-valued batch size. diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index 0db5ff6..94ad7bd 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -7,7 +7,7 @@ from collections.abc import Mapping from typing import Any -from lcm import AgeGrid, DiscreteGrid, Model +from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model from lcm.typing import UserParams from aca_model.aca import PolicyVariant @@ -15,6 +15,7 @@ from aca_model.baseline.model import _fail_if_dcegm_without_consumption_points from aca_model.baseline.regimes import RegimeId, SolverName, build_model_slots from aca_model.config import MODEL_CONFIG, GridConfig +from aca_model.execution import execution_config_for_devices def create_model( @@ -28,6 +29,7 @@ def create_model( pref_type_grid: DiscreteGrid, solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, + execution_config: ExecutionConfig | None = None, ) -> Model: """Create an ACA policy variant model. @@ -53,6 +55,9 @@ def create_model( consumption_dollars_points: Construction-time consumption action gridpoints; required under DC-EGM. See `aca_model.baseline.model.create_model`. + execution_config: Explicit hardware-local policy forwarded unchanged. + None uses the smallest selected accelerator allocator limit as the + device-memory budget; CPU construction remains unbudgeted. Returns: pylcm Model. @@ -92,6 +97,11 @@ def create_model( description=f"Structural retirement model ({policy.name})", fixed_params=fixed_params, derived_categoricals=derived_categoricals, + execution_config=( + execution_config_for_devices() + if execution_config is None + else execution_config + ), n_subjects=n_subjects, **model_slots, ) diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 87acf7c..5574155 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from typing import Any -from lcm import AgeGrid, DiscreteGrid, Model +from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model from lcm.typing import UserParams from aca_model.baseline.regimes import ( @@ -23,6 +23,7 @@ build_model_slots, ) from aca_model.config import MODEL_CONFIG, GridConfig +from aca_model.execution import execution_config_for_devices def create_model( @@ -35,6 +36,7 @@ def create_model( pref_type_grid: DiscreteGrid, solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, + execution_config: ExecutionConfig | None = None, ) -> Model: """Create the baseline structural retirement model. @@ -69,6 +71,9 @@ def create_model( continuous-action grid at model construction); `None` keeps the runtime-points grid completed per iteration via `inject_consumption_dollars_points`. + execution_config: Explicit hardware-local policy forwarded unchanged. + None uses the smallest selected accelerator allocator limit as the + device-memory budget; CPU construction remains unbudgeted. Returns: A pylcm Model with 19 regimes (18 non-terminal + dead) spanning @@ -106,6 +111,11 @@ def create_model( description="Baseline structural retirement model (pre-ACA)", fixed_params=fixed_params, derived_categoricals=derived_categoricals, + execution_config=( + execution_config_for_devices() + if execution_config is None + else execution_config + ), n_subjects=n_subjects, **model_slots, ) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 3168213..6fb7e5b 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -237,10 +237,7 @@ class Grids: hcc_transitory: Any pref_type: DiscreteGrid grid_config: GridConfig - """The originating `GridConfig`. Exposed on `Grids` so `build_states` - can read per-axis `batch_size` settings for the discrete states it - constructs inline (health, spousal_income, lagged_labor_supply, - claimed_ss) without changing the `build_states`/`build_regime` API.""" + """Grid sizes and numerical settings used to construct each solver.""" # AIME piecewise grid: number of points per segment between the PIA @@ -304,7 +301,6 @@ def build_grids( rho=_WAGE_RHO, sigma=(1.0 - _WAGE_RHO**2) ** 0.5, mu=0.0, - batch_size=grid_config.n_wage_res_batch_size, ) hcc_persistent = get_hcc_persistent_shock(grid_config=grid_config) hcc_transitory = NormalIIDProcess( @@ -323,9 +319,8 @@ def build_grids( start=assets_start, stop=500_000.0, n_points=grid_config.n_assets_gridpoints, - batch_size=grid_config.n_assets_batch_size, ), - aime=_build_aime_grid(grid_config=grid_config, fixed_params=fixed_params), + aime=_build_aime_grid(fixed_params=fixed_params), pension_wealth=_PENSION_WEALTH_GRID, consumption_dollars=( IrregSpacedGrid(n_points=grid_config.n_consumption_dollars_gridpoints) @@ -361,9 +356,7 @@ def get_hcc_persistent_grid_points(*, grid_config: GridConfig) -> FloatND: return get_hcc_persistent_shock(grid_config=grid_config).to_jax() -def _build_aime_grid( - *, grid_config: GridConfig, fixed_params: UserParams -) -> PiecewiseLinSpacedGrid: +def _build_aime_grid(*, fixed_params: UserParams) -> PiecewiseLinSpacedGrid: """Return the AIME grid. The grid is piecewise-linspaced with breakpoints at the PIA bends @@ -383,7 +376,6 @@ def _build_aime_grid( GridBreakpoint(value=kinks[3]), ), points_per_segment=_AIME_PIECE_N_POINTS, - batch_size=grid_config.n_aime_batch_size, ) @@ -460,24 +452,20 @@ def build_states(spec: RegimeSpec, grids: Grids) -> dict: living regime are broadcast from the model level (`build_model_states`). """ can_work = spec["canwork"] == "canwork" - gc = grids.grid_config states: dict = {} states["health"] = DiscreteGrid( Health if spec["mc"] == "oamc" else HealthWithDisability, - batch_size=gc.n_health_batch_size, ) if can_work: states["log_ft_wage_res"] = grids.wage_res if can_work and spec["his"] != "tied": states["lagged_labor_supply"] = DiscreteGrid( LaggedLaborSupply, - batch_size=gc.n_lagged_labor_supply_batch_size, ) if spec["ss"] == "choose": states["claimed_ss"] = DiscreteGrid( ClaimedSS, - batch_size=gc.n_claimed_ss_batch_size, ) return states @@ -712,10 +700,9 @@ def build_model_states(grids: Grids) -> dict: These are the states every living regime carries with an identical grid. pylcm prunes them per regime by DAG reachability, so `dead` keeps only - `assets` and `pref_type` (the bequest DAG). `spousal_income` carries the - `distributed` flag — sharding is legal only on model-level states. + `assets` and `pref_type` (the bequest DAG). Placement is configured + separately through the model execution policy. """ - gc = grids.grid_config return { "assets": grids.assets, "aime": grids.aime, @@ -727,8 +714,6 @@ def build_model_states(grids: Grids) -> dict: "hcc_transitory": grids.hcc_transitory, "spousal_income": DiscreteGrid( SpousalIncome, - batch_size=gc.n_spousal_income_batch_size, - distributed=gc.spousal_income_distributed, ), "pref_type": grids.pref_type, } diff --git a/src/aca_model/baseline/regimes/_dcegm.py b/src/aca_model/baseline/regimes/_dcegm.py index 76cf075..54e3ab2 100644 --- a/src/aca_model/baseline/regimes/_dcegm.py +++ b/src/aca_model/baseline/regimes/_dcegm.py @@ -32,12 +32,8 @@ def build_dcegm_solver(grids: Grids) -> DCEGM: savings_stop = float(assets_points[-1]) - float(assets_points[0]) savings_grid = IrregSpacedGrid( points=tuple(savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)), - batch_size=grids.grid_config.n_savings_batch_size, - ) - return DCEGM( - savings_grid=savings_grid, - stochastic_node_batch_size=grids.grid_config.n_stochastic_node_batch_size, ) + return DCEGM(savings_grid=savings_grid) def _fail_if_too_few_savings_gridpoints(n_savings_gridpoints: int) -> None: diff --git a/src/aca_model/baseline/regimes/_nbegm.py b/src/aca_model/baseline/regimes/_nbegm.py index 89dcfa0..23fb96a 100644 --- a/src/aca_model/baseline/regimes/_nbegm.py +++ b/src/aca_model/baseline/regimes/_nbegm.py @@ -1,19 +1,9 @@ -"""NBEGM solver configuration for the ACA M1 vertical-slice regime. +"""NB-EGM numerical configuration for the structural retirement model. -NBEGM is the case-piece endogenous-grid solver for a single 1-D -consumption/savings regime whose budget is split by institutional breakpoints -on a derived monotone income quantity. It shares DC-EGM's post-decision -(savings) spec — consumption is recovered from `resources = max(cash_on_hand, -floor)`, the assets laws are in savings form, the borrowing constraint is the -savings grid's lower bound — but solves only one regime with at most one -discrete action, so it attaches per regime rather than globally. The -function-level rewiring is shared with DC-EGM (`build_dcegm_functions`, the -savings-form assets laws in `_common`); this module holds only the solver -config. +Savings gridpoints, cliff reads, and envelope arithmetic belong to the solver. +Device placement and compiled program widths belong to the model execution policy. """ -import dataclasses - from lcm import IrregSpacedGrid from lcm.solvers import NBEGM @@ -21,62 +11,19 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: - """Build the per-regime NBEGM configuration. - - The savings grid mirrors DC-EGM's: lower bound 0 (the borrowing constraint - in post-decision form), upper bound the assets span, cubically clustered - toward the constraint. Which DAG nodes play the liquid roles is the - regime's declaration (`ACA_LIQUID_MARGIN`), not the solver's; this config - carries numerical settings only. - """ + """Build NB-EGM with the model's savings grid and numerical read rules.""" n_points = grids.grid_config.n_savings_gridpoints _fail_if_too_few_savings_gridpoints(n_points) assets_points = grids.assets.to_jax() savings_stop = float(assets_points[-1]) - float(assets_points[0]) savings_grid = IrregSpacedGrid( points=tuple(savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)), - batch_size=grids.grid_config.n_savings_batch_size, ) - solver = NBEGM( + return NBEGM( savings_grid=savings_grid, - # Splay the child stochastic-node expectation per the grid config: `0` (the - # default) reads the whole node mesh in one pass on a memory-rich device; a - # positive value loops it in blocks to fit a tighter budget (a CPU run). - stochastic_node_batch_size=grids.grid_config.n_nbegm_stochastic_node_batch_size, - # Stream the per-interval upper envelope over candidate-segment blocks per - # the grid config; `0` keeps the one-shot dense envelope. - envelope_segment_block_size=( - grids.grid_config.n_nbegm_envelope_segment_block_size - ), - # Which arithmetic decides envelope ownership per the grid config; - # "certified" is exact and can abstain, "ordinary" reads in the working - # format at a fraction of the cost. envelope_arithmetic=grids.grid_config.nbegm_envelope_arithmetic, - # Stream continuation intervals, or let the active byte planner choose - # when the configured width is zero. - interval_batch_size=grids.grid_config.n_nbegm_interval_batch_size, - # Stream both ride-along cores over ride-cell blocks per the grid config; - # `0` vmaps the whole flattened mesh at once. - cell_block_size=grids.grid_config.n_nbegm_cell_block_size, - # Stream the discrete-action branch axis in blocks per the grid config; - # `0` runs the whole axis in one vectorized pass. - branch_batch_size=grids.grid_config.n_nbegm_branch_batch_size, - # Cliff-read mode: exact one-sided limits (default) or the fast bridged - # read for inner estimation loops (see `GridConfig.nbegm_jump_read`). jump_read=grids.grid_config.nbegm_jump_read, ) - budget = grids.grid_config.n_nbegm_max_device_workspace_bytes - if budget is None: - return solver - - fields = {field.name for field in dataclasses.fields(NBEGM)} - if "max_device_workspace_bytes" not in fields: - msg = ( - "GridConfig.n_nbegm_max_device_workspace_bytes requires a pylcm " - "build with the experimental NB-EGM workspace planner." - ) - raise RuntimeError(msg) - return dataclasses.replace(solver, max_device_workspace_bytes=budget) def _fail_if_too_few_savings_gridpoints(n_savings_gridpoints: int) -> None: diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index 52b8691..cdeb522 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -29,7 +29,7 @@ import jax.numpy as jnp import numpy as np from jax import Array -from lcm import DiscreteGrid, Model +from lcm import DiscreteGrid, ExecutionConfig, Model from aca_model.agent.health import GoodHealth from aca_model.agent.labor_market import IsMarried @@ -73,19 +73,21 @@ def create_benchmark_model( *, n_subjects: int, pref_type_grid: DiscreteGrid, + execution_config: ExecutionConfig | None = None, ) -> Model: """Create the aca baseline with `BENCHMARK_GRID_CONFIG` and frozen fixed_params. - The benchmark uses a 2-type `BenchmarkPrefType`. No `batch_size != 0` - on any grid (continuous grids inherit - `BENCHMARK_GRID_CONFIG.n_assets_batch_size = 0` and - `n_aime_batch_size = 0`). + The benchmark uses a 2-type `BenchmarkPrefType`. Grids describe economic + outcomes; the execution policy selects devices and program widths. Args: n_subjects: Forwarded to `lcm.Model(n_subjects=...)`. When set, the first matching `simulate(...)` call AOT-compiles all simulate functions for that batch shape. pref_type_grid: Pref-type grid; pass `DiscreteGrid(BenchmarkPrefType)`. + execution_config: Explicit hardware-local policy forwarded unchanged. + None uses the smallest selected accelerator allocator limit as the + device-memory budget; CPU construction remains unbudgeted. """ fixed_params, wage_params, _ = get_benchmark_params(model=None) return create_model( @@ -94,6 +96,7 @@ def create_benchmark_model( wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, pref_type_grid=pref_type_grid, + execution_config=execution_config, n_subjects=n_subjects, ) diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 38c6d64..4d65551 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -1,4 +1,4 @@ -"""Configuration for the aca_model package.""" +"""Economic grids and numerical solver choices for the aca_model package.""" from dataclasses import dataclass from pathlib import Path @@ -21,149 +21,27 @@ class ModelConfig: @dataclass(frozen=True) class GridConfig: + """Economic resolution and numerical choices, independent of device execution.""" + n_assets_gridpoints: int = 24 n_aime_gridpoints: int = 12 n_consumption_dollars_gridpoints: int = 70 n_wage_res_gridpoints: int = 5 n_hcc_persistent_gridpoints: int = 3 n_hcc_transitory_gridpoints: int = 5 - # `batch_size` on the assets / AIME grids: chunked vmap stride for the - # outer state loop. `1` shrinks the per-period Q intermediate by that - # axis's cardinality on hosts where the unsplayed kernel doesn't fit; - # `0` lets a single kernel span the axis. - n_assets_batch_size: int = 0 - n_aime_batch_size: int = 0 - # Sharding flags for discrete state grids. pylcm distributes the - # grid across available devices when the flag is `True`. Sharding - # is only supported on discrete state grids; continuous axes - # (`assets`, `aime`, `wage_res`, `hcc_*`) compile to an all-gather - # of the full V-array per device and are rejected at grid - # construction. Mutually exclusive with `batch_size>0` on the same - # axis (pylcm rejects the combination). `spousal_income_distributed` - # routes through `baseline/regimes/_common.py:build_states` to its - # inline-built `DiscreteGrid(...)` call. - pref_type_distributed: bool = False - spousal_income_distributed: bool = False - # `batch_size` on the inline-constructed discrete state grids — - # health, spousal_income, lagged_labor_supply, claimed_ss. These - # are read in `build_states` via `grids.grid_config`. Setting any - # of them to `1` puts that axis in a Python-level outer loop within - # the discrete-states block of the productmap - # (`_ordered_state_action_names`), shrinking the per-call Q - # intermediate by that axis's cardinality at the cost of one extra - # lax.scan layer. Defaults to `0`; production overrides set to `1` - # to compound the splay across the unsharded discretes. - n_health_batch_size: int = 0 - n_spousal_income_batch_size: int = 0 - n_lagged_labor_supply_batch_size: int = 0 - n_claimed_ss_batch_size: int = 0 - # `batch_size` on the `pref_type` discrete grid: chunked vmap stride - # for the pref-type axis during solve. `1` (one pref-type per Python - # dispatch) shrinks the per-period Q intermediate by `n_pref_types` - # at the cost of an outer Python loop; `0` lets a single kernel span - # all pref-types. Defaults to `0` — the production overrides set it - # to `1` on hardware where the unsplayed kernel doesn't fit. - n_pref_type_batch_size: int = 0 - # `batch_size` on the `wage_res` stochastic shock process: chunked - # productmap stride along the wage-residual stoch axis inside Q_and_F. - # `1` shrinks the per-target Q intermediate by `n_wage_res_gridpoints` - # at the cost of an inner Python loop; `0` lets the productmap span - # the full axis. Defaults to `0` — production overrides set it to `1` - # on hardware where the ACA-overlay per-cell DAG blows the kernel's - # compile-time working set past device HBM. - n_wage_res_batch_size: int = 0 - # Number of nodes on the DC-EGM savings grid (the post-decision endogenous - # grid), cubically clustered toward the borrowing constraint. Drives the - # padded-grid dimension the egm_step kernel carries, so it scales both the - # rolling carry and the gather mesh roughly linearly — the dominant lever on - # DC-EGM device memory. Only consulted under `solver="dcegm"`. + # The post-decision savings grid is cubically clustered toward the borrowing + # constraint. Its node count controls resolution under DC-EGM and NB-EGM. n_savings_gridpoints: int = 200 - # `batch_size` on the DC-EGM savings grid (the post-decision endogenous - # grid). pylcm splays the per-savings-node continuation into `lax.map` - # blocks of this size, shrinking the binding `egm_step` working buffer by - # roughly the block factor while the upper envelope still runs on the full - # gathered grid (value function unchanged). `0` keeps the whole grid in one - # kernel. Only consulted under `solver="dcegm"`. - n_savings_batch_size: int = 0 - # `batch_size` on the DC-EGM child stochastic-node expectation (the - # process-state mesh the egm_step kernel sums over). pylcm splays that - # expectation into `lax.map` blocks of this size, collapsing the gather - # buffer's node axis by roughly the block factor (value function - # unchanged) — the grid-independent lever for the residual egm_step - # transient that savings-batching leaves behind. `0` evaluates the whole - # mesh in one kernel. Only consulted under `solver="dcegm"`. - n_stochastic_node_batch_size: int = 0 - # Block size for splaying the NBEGM continuation's child stochastic-node - # expectation (health, health-cost shocks, the wage residual). `0` reads the - # whole joint node mesh in one pass — fast, but its peak intermediate scales - # with the full ride-along × node × child-grid product. A positive value loops - # the mesh in blocks of that size, trading runtime for a much smaller peak; `1` - # (one node at a time) is the memory-minimal setting for a CPU validation grid. - # Only consulted under `solver="nbegm"`. - n_nbegm_stochastic_node_batch_size: int = 0 - # Streams the per-interval upper envelope over candidate-segment blocks of this - # size instead of materialising the full (query x candidate) bracket matrix per - # ride cell. `0` keeps the one-shot dense envelope; the result is identical - # either way — the knob trades peak device memory against a sequential scan. - # Only consulted under `solver="nbegm"`. - n_nbegm_envelope_segment_block_size: int = 0 - # Compiled batch width for the liquid-interval axis. With an active byte - # budget, 0 lets the planner choose up to the full axis; a positive value - # caps it. Only consulted under `solver="nbegm"`. - n_nbegm_interval_batch_size: int = 0 - # Authoritative busiest-device workspace budget. None preserves manual - # blocks; a positive value activates compile-only continuation/envelope - # planning before backward induction. Only consulted under `solver="nbegm"`. - n_nbegm_max_device_workspace_bytes: int | None = None - # Which arithmetic decides ownership in the merged upper envelope: - # - "certified" (default) — candidates are compared in double-double precision - # and no winner is published where none is separated, so the reported owner is - # one the arithmetic could prove. Ordering survives the cancellation a nearly - # tied crossing produces, at roughly an order of magnitude more arithmetic per - # read — and the envelope read is the dominant per-cell cost of a case-piece - # solve, so this is the setting that decides the solver's runtime. - # - "ordinary" — each candidate is read in the working format and the largest - # owns the query. Adequate wherever candidate values are separated by much - # more than the format's resolution at their own magnitude. - # Incompatible with a positive `n_nbegm_envelope_segment_block_size`, which - # selects a blocked scan that carries the certified arithmetic only. - # Only consulted under `solver="nbegm"`. + # Arithmetic used to compare upper-envelope candidates: + # - "certified": double-double comparisons publish only separated winners. + # - "ordinary": comparisons use the working floating-point format. nbegm_envelope_arithmetic: Literal["certified", "ordinary"] = "certified" - # Streams both NBEGM ride-along cores (continuation fan-out and envelope - # solve) over ride-cell blocks of this size instead of vmapping the whole - # flattened ride mesh at once — the dominant peak-memory term at production - # mesh sizes. `0` keeps the whole-mesh vmap; the result is identical either - # way. Only consulted under `solver="nbegm"`. - # - # Backend-dependent tuning at production ride-mesh sizes (under both cliff-read - # modes). On GPU the whole-mesh vmap stays within a few GiB, so `0` is fine. The - # CPU XLA backend does not fuse the fan-out and materialises the whole flattened - # ride mesh at once — a production-grid solve then needs hundreds of GiB even at - # a small asset grid (the blow-up rides the aime/shock/health mesh, not assets). - # Set this to a positive block (e.g. 64) for a CPU solve; it bounds the peak to - # the GPU's few-GiB footprint at the cost of serialising the mesh into a - # `lax.map` scan. - n_nbegm_cell_block_size: int = 0 - # How NBEGM parents read the child value's institutional cliffs: - # - "one_sided" (default) — carry rows hold each cliff preimage as a duplicated - # abscissa with exact one-sided limits; reads never average across a cliff, - # but publishing the topology gates the stochastic-dim fold off (slower). - # - "bridged" — plain carry rows; interpolation may bridge a cliff like any - # finite-grid solver, and the fold stays available. The fast setting for - # inner estimation loops, polished afterwards under "one_sided". - # Only consulted under `solver="nbegm"`. + # How NB-EGM parents read institutional cliffs in the child value: + # - "one_sided": duplicated abscissae preserve one-sided limits. + # - "bridged": interpolation may bridge finite-grid discontinuities. + # Live labor choices require "bridged" because branch-dependent income + # thresholds cannot share one query grid of one-sided cliff limits. nbegm_jump_read: Literal["one_sided", "bridged"] = "one_sided" - # Block size for streaming the discrete-action branch axis in both NBEGM - # ride-along cores (one continuation row / one continuous subproblem per live - # labor level). `0` runs the whole branch axis in one vectorized pass; a positive - # value scans it in blocks of that many branches (identical result, per-branch - # intermediates bounded by one block). Only consulted under `solver="nbegm"`. - n_nbegm_branch_batch_size: int = 0 - # `labor_supply` enters `countable_income`, which carries the SSI income test, so - # each labor level puts that breakpoint at a different liquid level. The one-sided - # read publishes its cliff limits on a single query grid shared across branches, - # which the two cannot both satisfy, so a regime carrying `labor_supply` builds - # only under `nbegm_jump_read="bridged"`. MODEL_CONFIG = ModelConfig() @@ -176,6 +54,4 @@ class GridConfig: n_wage_res_gridpoints=3, n_hcc_persistent_gridpoints=3, n_hcc_transitory_gridpoints=3, - n_assets_batch_size=0, - n_aime_batch_size=0, ) diff --git a/src/aca_model/execution.py b/src/aca_model/execution.py new file mode 100644 index 0000000..b940674 --- /dev/null +++ b/src/aca_model/execution.py @@ -0,0 +1,53 @@ +"""Hardware-local execution policies derived from allocator observations.""" + +import jax +from lcm import ExecutionConfig + + +def execution_config_for_devices( + *, devices: tuple[int, ...] | None = None +) -> ExecutionConfig: + """Create an execution policy bounded by the selected accelerator pools. + + The smallest allocator limit is a total per-device ceiling. Pylcm accounts + for its resident arrays within that ceiling, so current allocator usage is + not subtracted here. CPU devices do not supply an accelerator-pool budget. + + Args: + devices: Visible device IDs to select, in order; None selects all devices + on JAX's default backend. + + Returns: + ExecutionConfig with explicit device IDs and the measured accelerator + budget. Axis widths and sharded states remain unspecified. + + Raises: + ValueError: A selected device is not visible or an accelerator has no + positive integer allocator limit. Missing limits require an explicit + policy. + """ + policy = ExecutionConfig(devices=devices) + visible = {device.id: device for device in jax.devices()} + selected = tuple(visible) if policy.devices is None else policy.devices + missing = tuple(device_id for device_id in selected if device_id not in visible) + if missing: + msg = f"Selected devices are not visible: {missing}." + raise ValueError(msg) + limits = [] + for device_id in selected: + device = visible[device_id] + if device.platform == "cpu": + continue + stats = device.memory_stats() or {} + limit = stats.get("bytes_limit") + if type(limit) is not int or limit <= 0: + msg = ( + "No positive integer allocator byte limit reported for " + f"device {device_id}." + ) + raise ValueError(msg) + limits.append(limit) + return ExecutionConfig( + devices=selected, + device_memory_bytes=min(limits) if limits else None, + ) diff --git a/tests/test_aime_grid.py b/tests/test_aime_grid.py index 368fe37..d63da86 100644 --- a/tests/test_aime_grid.py +++ b/tests/test_aime_grid.py @@ -9,7 +9,6 @@ _AIME_PIECE_N_POINTS, _build_aime_grid, ) -from aca_model.config import BENCHMARK_GRID_CONFIG # Production SSA bend points plus the delayed-retirement-credit extension: # 0, kink_0, kink_1, taxable-max, and the extension point that carries the @@ -20,9 +19,7 @@ def test_build_aime_grid_owns_each_pia_breakpoint_on_the_right() -> None: """The AIME grid preserves four PIA pieces with right-owned interiors.""" - grid = _build_aime_grid( - grid_config=BENCHMARK_GRID_CONFIG, fixed_params=_FIXED_PARAMS - ) + grid = _build_aime_grid(fixed_params=_FIXED_PARAMS) np.testing.assert_allclose( [point.value for point in grid.breakpoints], _PIA_AIME_GRID[1:-1], @@ -33,9 +30,7 @@ def test_build_aime_grid_owns_each_pia_breakpoint_on_the_right() -> None: def test_build_aime_grid_top_point_is_extension_aime() -> None: """The grid reaches the delayed-credit extension AIME at its top.""" - grid = _build_aime_grid( - grid_config=BENCHMARK_GRID_CONFIG, fixed_params=_FIXED_PARAMS - ) + grid = _build_aime_grid(fixed_params=_FIXED_PARAMS) np.testing.assert_allclose(float(grid.to_jax().max()), 187954.752, rtol=1e-5) diff --git a/tests/test_beartype_claw.py b/tests/test_beartype_claw.py index e4130be..2fb7873 100644 --- a/tests/test_beartype_claw.py +++ b/tests/test_beartype_claw.py @@ -12,7 +12,7 @@ import pytest from beartype.roar import BeartypeCallHintViolation -from helpers.model import make_baseline_model # ty: ignore[unresolved-import] +from helpers.model import make_baseline_model def test_claw_checks_aca_model() -> None: @@ -22,4 +22,4 @@ def test_claw_checks_aca_model() -> None: by the claw before the value reaches pylcm's own `Model` perimeter. """ with pytest.raises(BeartypeCallHintViolation): - make_baseline_model(n_subjects="not an int") + make_baseline_model(n_subjects="not an int") # ty: ignore[invalid-argument-type] diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index ac9b525..56922ef 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -2,7 +2,14 @@ import numpy as np import pytest -from lcm import DiscreteGrid, GridBreakpoint, PiecewiseLinSpacedGrid +from lcm import ( + DiscreteGrid, + GridBreakpoint, + IrregSpacedGrid, + LinSpacedGrid, + PiecewiseLinSpacedGrid, + RouwenhorstAR1Process, +) from aca_model.agent.preferences import BenchmarkPrefType from aca_model.benchmark import ( @@ -13,7 +20,7 @@ def test_benchmark_model_builds_with_the_current_pylcm_grid_api() -> None: - """The frozen benchmark model constructs with breakpoint-first AIME grids.""" + """The frozen benchmark preserves its state and action grid extents.""" model = create_benchmark_model( n_subjects=1, pref_type_grid=DiscreteGrid(BenchmarkPrefType), @@ -23,7 +30,22 @@ def test_benchmark_model_builds_with_the_current_pylcm_grid_api() -> None: assert isinstance(aime, PiecewiseLinSpacedGrid) assert all(isinstance(point, GridBreakpoint) for point in aime.breakpoints) assert all(point.owner == "right" for point in aime.breakpoints) - assert aime.n_points == sum(aime.points_per_segment) + assert aime.n_points == 38 + regime = model.user_regimes["retiree_nomc_inelig_canwork"] + assert len(model.user_regimes) == 19 + assert model.n_periods == 45 + assets = regime.states["assets"] + wage_res = regime.states["log_ft_wage_res"] + pref_type = regime.states["pref_type"] + consumption = regime.actions["consumption_dollars"] + assert isinstance(assets, LinSpacedGrid) + assert isinstance(wage_res, RouwenhorstAR1Process) + assert isinstance(pref_type, DiscreteGrid) + assert isinstance(consumption, IrregSpacedGrid) + assert assets.n_points == 3 + assert wage_res.n_points == 3 + assert len(pref_type.categories) == 2 + assert consumption.n_points == 5 @pytest.mark.long_running diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index 492fdd2..45274ff 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -11,7 +11,7 @@ import numpy as np import pytest -from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] +from helpers.model import _DERIVED_CATEGORICALS from lcm import DiscreteGrid, IrregSpacedGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime from lcm.solvers import DCEGM @@ -162,42 +162,6 @@ def test_dcegm_benchmark_model_builds() -> None: assert isinstance(model.user_regimes["retiree_nomc_inelig_canwork"].solver, DCEGM) -def test_savings_grid_batch_size_follows_grid_config() -> None: - """`GridConfig.n_savings_batch_size` sets the `batch_size` on every - living regime's DC-EGM savings grid, so the post-decision continuation - splays into `lax.map` blocks of that width.""" - grid_config = dataclasses.replace(BENCHMARK_GRID_CONFIG, n_savings_batch_size=50) - regimes = build_all_regimes( - grid_config=grid_config, - fixed_params=_FIXED_PARAMS, - wage_params=_WAGE_PARAMS, - pref_type_grid=DiscreteGrid(BenchmarkPrefType), - solver="dcegm", - ) - for name in REGIME_SPECS: - solver = cast("DCEGM", regimes[name].solver) - assert solver.savings_grid.batch_size == 50, name - - -def test_stochastic_node_batch_size_follows_grid_config() -> None: - """`GridConfig.n_stochastic_node_batch_size` sets `stochastic_node_batch_size` - on every living regime's DC-EGM solver, so the child stochastic-node - expectation splays into `lax.map` blocks of that width.""" - grid_config = dataclasses.replace( - BENCHMARK_GRID_CONFIG, n_stochastic_node_batch_size=7 - ) - regimes = build_all_regimes( - grid_config=grid_config, - fixed_params=_FIXED_PARAMS, - wage_params=_WAGE_PARAMS, - pref_type_grid=DiscreteGrid(BenchmarkPrefType), - solver="dcegm", - ) - for name in REGIME_SPECS: - solver = cast("DCEGM", regimes[name].solver) - assert solver.stochastic_node_batch_size == 7, name - - def test_savings_grid_length_follows_grid_config() -> None: """`GridConfig.n_savings_gridpoints` sets the number of nodes on every living regime's DC-EGM savings grid.""" diff --git a/tests/test_execution_budget.py b/tests/test_execution_budget.py new file mode 100644 index 0000000..0dafef2 --- /dev/null +++ b/tests/test_execution_budget.py @@ -0,0 +1,62 @@ +"""Allocator observations determine the execution ceiling on selected devices.""" + +from dataclasses import dataclass + +import jax +import pytest + +from aca_model import execution + + +@dataclass +class _Device: + id: int + platform: str + stats: dict[str, int] | None + + def memory_stats(self): + return self.stats + + +def test_budget_uses_smallest_selected_allocator_limit(monkeypatch): + """Only selected GPU pools bound execution; current usage is not subtracted.""" + devices = [ + _Device(0, "gpu", {"bytes_limit": 1000, "bytes_in_use": 200}), + _Device(1, "gpu", {"bytes_limit": 800, "bytes_in_use": 100}), + _Device(2, "gpu", None), + ] + monkeypatch.setattr(jax, "devices", lambda: devices) + + config = execution.execution_config_for_devices(devices=(0, 1)) + + assert config.devices == (0, 1) + assert config.device_memory_bytes == 800 + assert config.axis_widths == {} + assert config.sharded_states == () + + +@pytest.mark.parametrize("stats", [None, {}, {"bytes_limit": 0}]) +def test_missing_gpu_limit_is_refused(monkeypatch, stats): + """An unknown selected GPU ceiling must not silently select bootstrap widths.""" + monkeypatch.setattr(jax, "devices", lambda: [_Device(0, "gpu", stats)]) + + with pytest.raises(ValueError, match=r"allocator.*limit.*device 0"): + execution.execution_config_for_devices() + + +def test_cpu_construction_does_not_invent_an_allocator_budget(monkeypatch): + """CPU devices retain an unspecified byte budget when no pool limit exists.""" + monkeypatch.setattr(jax, "devices", lambda: [_Device(0, "cpu", None)]) + + config = execution.execution_config_for_devices() + + assert config.devices == (0,) + assert config.device_memory_bytes is None + + +def test_selected_device_must_be_visible(monkeypatch): + """An unavailable selected device is rejected before reading allocator stats.""" + monkeypatch.setattr(jax, "devices", lambda: [_Device(0, "gpu", None)]) + + with pytest.raises(ValueError, match="Selected devices are not visible"): + execution.execution_config_for_devices(devices=(1,)) diff --git a/tests/test_execution_config.py b/tests/test_execution_config.py new file mode 100644 index 0000000..04f19a7 --- /dev/null +++ b/tests/test_execution_config.py @@ -0,0 +1,117 @@ +"""Model factories expose pylcm's execution policy without changing economic grids.""" + +from functools import partial +from types import SimpleNamespace + +import jax +import pytest +from lcm import DiscreteGrid, ExecutionConfig +from lcm.exceptions import ExecutionPlanningError + +from aca_model.aca import model as aca_model_module +from aca_model.aca.health_insurance import PolicyVariant +from aca_model.aca.model import create_model as create_aca_model +from aca_model.agent.health import GoodHealth +from aca_model.agent.labor_market import IsMarried +from aca_model.agent.preferences import BenchmarkPrefType +from aca_model.baseline import model as baseline_model_module +from aca_model.baseline.health_insurance import HealthInsuranceState +from aca_model.baseline.model import create_model +from aca_model.benchmark import create_benchmark_model, get_benchmark_params +from aca_model.config import BENCHMARK_GRID_CONFIG + + +def _factory(kind): + if kind == "benchmark": + return partial( + create_benchmark_model, + n_subjects=1, + pref_type_grid=DiscreteGrid(BenchmarkPrefType), + ) + fixed_params, wage_params, _ = get_benchmark_params(model=None) + factory = ( + partial(create_aca_model, policy=PolicyVariant.ACA) + if kind == "aca" + else create_model + ) + return partial( + factory, + n_subjects=1, + fixed_params=fixed_params, + wage_params=wage_params, + derived_categoricals={ + "good_health": DiscreteGrid(GoodHealth), + "is_married": DiscreteGrid(IsMarried), + "his": DiscreteGrid(HealthInsuranceState), + "target_his": DiscreteGrid(HealthInsuranceState), + "pref_type": DiscreteGrid(BenchmarkPrefType), + }, + grid_config=BENCHMARK_GRID_CONFIG, + pref_type_grid=DiscreteGrid(BenchmarkPrefType), + ) + + +@pytest.mark.parametrize("kind", ["baseline", "aca", "benchmark"]) +def test_factory_preserves_explicit_device_selection(kind): + """A factory uses exactly the selected device and preserves the model grids.""" + devices = (jax.devices()[-1].id,) + model = _factory(kind)(execution_config=ExecutionConfig(devices=devices)) + + assert model.execution_devices == devices + assert len(model.user_regimes) == 19 + regime = model.user_regimes["retiree_nomc_inelig_canwork"] + assert regime.states["assets"].n_points == 3 + assert regime.states["aime"].n_points == 38 + assert regime.actions["consumption_dollars"].n_points == 5 + + +@pytest.mark.parametrize("kind", ["baseline", "aca", "benchmark"]) +def test_factory_preserves_axis_width_validation(kind): + """A width for an undeclared program axis is refused by the model.""" + config = ExecutionConfig(axis_widths={"aca_undeclared_axis": 1}) + with pytest.raises(ExecutionPlanningError, match="aca_undeclared_axis"): + _factory(kind)(execution_config=config) + + +@pytest.mark.parametrize("kind", ["baseline", "aca", "benchmark"]) +@pytest.mark.parametrize( + ("requested_policy", "expected_budget"), + [ + (None, 800), + ( + ExecutionConfig( + devices=(0,), + device_memory_bytes=400, + sharded_states=("pref_type",), + axis_widths={"cell": 32}, + ), + 400, + ), + (ExecutionConfig(devices=(0,)), None), + ], +) +def test_factory_supplies_the_requested_or_measured_budget( + monkeypatch, kind, requested_policy, expected_budget +): + """Defaults use measured limits; explicit budgets and unbudgeted controls survive.""" + factory = _factory(kind) + device = SimpleNamespace( + id=0, platform="gpu", memory_stats=lambda: {"bytes_limit": 800} + ) + monkeypatch.setattr(jax, "devices", lambda: [device]) + + class ConstructionObservedError(Exception): + pass + + def observe_pylcm_constructor(**kwargs): + policy = kwargs["execution_config"] + assert policy.device_memory_bytes == expected_budget + assert policy.devices == (0,) + if requested_policy is not None: + assert policy == requested_policy + raise ConstructionObservedError + + monkeypatch.setattr(baseline_model_module, "Model", observe_pylcm_constructor) + monkeypatch.setattr(aca_model_module, "Model", observe_pylcm_constructor) + with pytest.raises(ConstructionObservedError): + factory(execution_config=requested_policy) diff --git a/tests/test_grid_config.py b/tests/test_grid_config.py new file mode 100644 index 0000000..7eb6564 --- /dev/null +++ b/tests/test_grid_config.py @@ -0,0 +1,35 @@ +"""Economic-grid configuration rejects hardware execution controls.""" + +import pytest + +from aca_model.config import GridConfig + + +@pytest.mark.parametrize( + "field", + [ + "n_assets_batch_size", + "n_aime_batch_size", + "pref_type_distributed", + "spousal_income_distributed", + "n_health_batch_size", + "n_spousal_income_batch_size", + "n_lagged_labor_supply_batch_size", + "n_claimed_ss_batch_size", + "n_pref_type_batch_size", + "n_wage_res_batch_size", + "n_savings_batch_size", + "n_stochastic_node_batch_size", + "n_nbegm_stochastic_node_batch_size", + "n_nbegm_envelope_segment_block_size", + "n_nbegm_interval_batch_size", + "n_nbegm_max_device_workspace_bytes", + "n_nbegm_cell_block_size", + "n_nbegm_branch_batch_size", + ], +) +def test_grid_config_refuses_execution_controls(field): + """A misplaced execution control fails visibly instead of being ignored.""" + value = True if field.endswith("distributed") else 1 + with pytest.raises(TypeError, match=field): + GridConfig(**{field: value}) # ty: ignore[invalid-argument-type] diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 5b2415c..68b7d77 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -2,10 +2,9 @@ import inspect from collections.abc import Mapping -from dataclasses import replace import pytest -from helpers.model import ( # ty: ignore[unresolved-import] +from helpers.model import ( make_aca_model, make_baseline_model, ) @@ -367,43 +366,6 @@ def test_baseline_model_creates() -> None: assert len(model.user_regimes) == 19 -@pytest.mark.parametrize( - ("config_field", "state_name"), - [ - ("spousal_income_distributed", "spousal_income"), - ], -) -def test_discrete_state_distributed_flag_propagates_to_model_states( - config_field: str, state_name: str -) -> None: - """`GridConfig._distributed=True` sets `distributed=True` on the - model-level `DiscreteGrid` for that axis (sharding is legal only on - model-level states).""" - gc = replace(BENCHMARK_GRID_CONFIG, **{config_field: True}) - grids = build_grids( - grid_config=gc, - fixed_params=_FIXED_PARAMS, - wage_params=_WAGE_PARAMS, - pref_type_grid=DiscreteGrid(BenchmarkPrefType), - ) - model_states = build_model_states(grids) - assert model_states[state_name].distributed is True - - -@pytest.mark.parametrize( - "state_name", - ["lagged_labor_supply", "claimed_ss", "spousal_income"], -) -def test_discrete_state_distributed_flag_defaults_to_false(state_name: str) -> None: - """`distributed` on inline-built discrete states defaults to `False` so - configurations that do not opt in see no behaviour change.""" - if state_name == "spousal_income": - grid = build_model_states(_GRIDS)[state_name] - else: - grid = build_regime("retiree_dimc_choose_canwork").states[state_name] - assert grid.distributed is False - - def test_dead_regime_prunes_unused_broadcast_states() -> None: """States every living regime shares are declared once at the model level; `dead` keeps only what the bequest DAG reads (`assets`, `pref_type`). diff --git a/tests/test_nbegm_labor_live_validation.py b/tests/test_nbegm_labor_live_validation.py index 53a732c..279c6fd 100644 --- a/tests/test_nbegm_labor_live_validation.py +++ b/tests/test_nbegm_labor_live_validation.py @@ -16,8 +16,8 @@ import numpy as np import pytest -from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] -from lcm import DiscreteGrid +from helpers.model import _DERIVED_CATEGORICALS +from lcm import DiscreteGrid, ExecutionConfig from aca_model.agent.preferences import BenchmarkPrefType from aca_model.baseline.model import create_model @@ -29,16 +29,11 @@ def _solve_m1(solver: SolverName) -> tuple[dict[int, np.ndarray], int]: - # The CPU XLA backend does not fuse the ride-cell fan-out and materialises the - # whole flattened ride mesh at once, so a full-model solve needs hundreds of GiB on - # host even at a tiny asset grid. `n_nbegm_cell_block_size` streams the mesh in - # blocks (identical result) to bound the peak to the GPU's few-GiB footprint; the - # live-labor branch axis makes this essential on CPU. A coarser savings grid keeps - # the check quick. On GPU the whole-mesh vmap stays small, so production sets 0. + # A bounded ride-cell width limits continuation fan-out on CPU. + # Savings resolution is shared by both numerical comparison arms. grid_config = dataclasses.replace( BENCHMARK_GRID_CONFIG, nbegm_jump_read="bridged", - n_nbegm_cell_block_size=32, n_savings_gridpoints=50, ) fixed_params, wage_params, _ = get_benchmark_params(model=None) @@ -50,6 +45,9 @@ def _solve_m1(solver: SolverName) -> tuple[dict[int, np.ndarray], int]: grid_config=grid_config, pref_type_grid=DiscreteGrid(BenchmarkPrefType), solver=solver, + execution_config=ExecutionConfig( + axis_widths={"cell": 32} if solver == "nbegm" else {} + ), ) _, _, params = get_benchmark_params(model=model) solution = model.solve(params=params, log_level="off") diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index fdd3a4b..79d0265 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -17,7 +17,7 @@ from typing import cast import pytest -from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] +from helpers.model import _DERIVED_CATEGORICALS from lcm import DiscreteGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime, LiquidMargin from lcm.exceptions import RegimeInitializationError @@ -352,44 +352,3 @@ def test_nbegm_aca_variants_leave_no_free_buy_private_params( if isinstance(params, dict) and "buy_private" in params ] assert offenders == [], offenders - - -def test_nbegm_all_streaming_axes_are_forwarded() -> None: - """ACA forwards every streaming axis supported by pylcm.""" - grid_config = dataclasses.replace( - BENCHMARK_GRID_CONFIG, - n_nbegm_stochastic_node_batch_size=2, - n_nbegm_envelope_segment_block_size=3, - n_nbegm_interval_batch_size=4, - n_nbegm_cell_block_size=5, - n_nbegm_branch_batch_size=6, - ) - grids = build_grids( - grid_config=grid_config, - fixed_params=_FIXED_PARAMS, - wage_params=_WAGE_PARAMS, - pref_type_grid=DiscreteGrid(BenchmarkPrefType), - ) - solver = build_nbegm_solver(grids) - assert solver.stochastic_node_batch_size == 2 - assert solver.envelope_segment_block_size == 3 - assert solver.interval_batch_size == 4 - assert solver.cell_block_size == 5 - assert solver.branch_batch_size == 6 - - -def test_nbegm_workspace_budget_requires_the_experimental_planner() -> None: - """A planner budget is refused when pylcm has no workspace planner.""" - grid_config = dataclasses.replace( - BENCHMARK_GRID_CONFIG, - n_nbegm_max_device_workspace_bytes=72 * 1024**3, - ) - grids = build_grids( - grid_config=grid_config, - fixed_params=_FIXED_PARAMS, - wage_params=_WAGE_PARAMS, - pref_type_grid=DiscreteGrid(BenchmarkPrefType), - ) - - with pytest.raises(RuntimeError, match="experimental NB-EGM workspace planner"): - build_nbegm_solver(grids) diff --git a/tests/test_nbegm_solve_validation.py b/tests/test_nbegm_solve_validation.py index af16729..4c840da 100644 --- a/tests/test_nbegm_solve_validation.py +++ b/tests/test_nbegm_solve_validation.py @@ -19,7 +19,7 @@ import numpy as np import pytest -from helpers.model import _DERIVED_CATEGORICALS # ty: ignore[unresolved-import] +from helpers.model import _DERIVED_CATEGORICALS from lcm import DiscreteGrid from aca_model.agent.preferences import BenchmarkPrefType diff --git a/tests/test_social_security.py b/tests/test_social_security.py index 2ee11a0..30fc799 100644 --- a/tests/test_social_security.py +++ b/tests/test_social_security.py @@ -6,7 +6,7 @@ import jax.numpy as jnp import numpy as np import pandas as pd -from helpers.social_security import ( # ty: ignore[unresolved-import] +from helpers.social_security import ( compute_di_dropout_scale, compute_pia_table, ) diff --git a/tests/test_ss_benefit_integration.py b/tests/test_ss_benefit_integration.py index 0b70990..e111b96 100644 --- a/tests/test_ss_benefit_integration.py +++ b/tests/test_ss_benefit_integration.py @@ -5,7 +5,7 @@ """ import jax.numpy as jnp -from helpers.social_security import compute_pia_table # ty: ignore[unresolved-import] +from helpers.social_security import compute_pia_table from aca_model.agent.labor_market import LaborSupply from aca_model.environment import social_security From 735a25090ec647ef8d42cbb919369acc46f1a493 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 13 Sep 2026 00:11:05 +0200 Subject: [PATCH 015/101] API Remove constructor population hints from model factories --- src/aca_model/aca/model.py | 3 --- src/aca_model/baseline/model.py | 5 +---- src/aca_model/benchmark.py | 5 ----- tests/helpers/model.py | 6 ++---- tests/test_beartype_claw.py | 9 +++++---- tests/test_benchmark.py | 20 +++++++++++++++---- tests/test_dcegm_model_creation.py | 3 --- tests/test_dcegm_parity.py | 1 - tests/test_execution_config.py | 9 +++++++-- .../test_initial_conditions_extreme_assets.py | 3 +-- tests/test_model_creation.py | 20 +++++++++---------- tests/test_nbegm_labor_live_validation.py | 1 - tests/test_nbegm_model_creation.py | 3 --- tests/test_nbegm_solve_validation.py | 1 - 14 files changed, 42 insertions(+), 47 deletions(-) diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index 94ad7bd..5cc6139 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -20,7 +20,6 @@ def create_model( *, - n_subjects: int, policy: PolicyVariant, fixed_params: UserParams, wage_params: Mapping[str, Any], @@ -34,7 +33,6 @@ def create_model( """Create an ACA policy variant model. Args: - n_subjects: Forwarded to `lcm.Model(n_subjects=...)`. policy: Which ACA policy combination to apply (e.g. `PolicyVariant.ACA`). fixed_params: Parameters to fix at model creation time. Pass @@ -102,6 +100,5 @@ def create_model( if execution_config is None else execution_config ), - n_subjects=n_subjects, **model_slots, ) diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 5574155..6040c45 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -5,7 +5,7 @@ Usage: from aca_model.baseline.model import create_model - model = create_model(n_subjects=..., fixed_params=..., wage_params=..., ...) + model = create_model(fixed_params=..., wage_params=..., ...) params = get_default_params() V = model.solve(params) """ @@ -28,7 +28,6 @@ def create_model( *, - n_subjects: int, fixed_params: UserParams, wage_params: Mapping[str, Any], derived_categoricals: Mapping[str, DiscreteGrid], @@ -41,7 +40,6 @@ def create_model( """Create the baseline structural retirement model. Args: - n_subjects: Forwarded to `lcm.Model(n_subjects=...)`. fixed_params: Parameters to fix at model creation time. Fixed params are partialled into compiled functions and removed from the params template. Pass data-derived constants here; @@ -116,7 +114,6 @@ def create_model( if execution_config is None else execution_config ), - n_subjects=n_subjects, **model_slots, ) diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index cdeb522..54fe41c 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -71,7 +71,6 @@ def create_benchmark_model( *, - n_subjects: int, pref_type_grid: DiscreteGrid, execution_config: ExecutionConfig | None = None, ) -> Model: @@ -81,9 +80,6 @@ def create_benchmark_model( outcomes; the execution policy selects devices and program widths. Args: - n_subjects: Forwarded to `lcm.Model(n_subjects=...)`. When set, the - first matching `simulate(...)` call AOT-compiles all simulate - functions for that batch shape. pref_type_grid: Pref-type grid; pass `DiscreteGrid(BenchmarkPrefType)`. execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the @@ -97,7 +93,6 @@ def create_benchmark_model( derived_categoricals=_DERIVED_CATEGORICALS, pref_type_grid=pref_type_grid, execution_config=execution_config, - n_subjects=n_subjects, ) diff --git a/tests/helpers/model.py b/tests/helpers/model.py index 55571e9..4818827 100644 --- a/tests/helpers/model.py +++ b/tests/helpers/model.py @@ -26,11 +26,10 @@ } -def make_baseline_model(*, n_subjects: int) -> Model: +def make_baseline_model() -> Model: """Baseline model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params.""" fixed_params, wage_params, _ = get_benchmark_params(model=None) return _create_baseline_model( - n_subjects=n_subjects, fixed_params=fixed_params, wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, @@ -39,11 +38,10 @@ def make_baseline_model(*, n_subjects: int) -> Model: ) -def make_aca_model(*, n_subjects: int, policy: PolicyVariant) -> Model: +def make_aca_model(*, policy: PolicyVariant) -> Model: """ACA model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params.""" fixed_params, wage_params, _ = get_benchmark_params(model=None) return _create_aca_model( - n_subjects=n_subjects, policy=policy, fixed_params=fixed_params, wage_params=wage_params, diff --git a/tests/test_beartype_claw.py b/tests/test_beartype_claw.py index 2fb7873..b5824d1 100644 --- a/tests/test_beartype_claw.py +++ b/tests/test_beartype_claw.py @@ -12,14 +12,15 @@ import pytest from beartype.roar import BeartypeCallHintViolation -from helpers.model import make_baseline_model + +from aca_model.benchmark import create_benchmark_model def test_claw_checks_aca_model() -> None: """An ill-typed argument to an `aca_model` function is rejected by beartype. - `create_model` annotates `n_subjects` as `int`; passing a string is caught - by the claw before the value reaches pylcm's own `Model` perimeter. + The benchmark factory requires a DiscreteGrid for preference types. + Invalid types are rejected before model construction. """ with pytest.raises(BeartypeCallHintViolation): - make_baseline_model(n_subjects="not an int") # ty: ignore[invalid-argument-type] + create_benchmark_model(pref_type_grid="not a grid") # ty: ignore[invalid-argument-type] diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 56922ef..c3d54f6 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -22,7 +22,6 @@ def test_benchmark_model_builds_with_the_current_pylcm_grid_api() -> None: """The frozen benchmark preserves its state and action grid extents.""" model = create_benchmark_model( - n_subjects=1, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) @@ -52,7 +51,6 @@ def test_benchmark_model_builds_with_the_current_pylcm_grid_api() -> None: def test_benchmark_model_simulates_end_to_end() -> None: n_subjects = 20 model = create_benchmark_model( - n_subjects=n_subjects, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) _, _, params = get_benchmark_params(model=model) @@ -86,7 +84,6 @@ def test_benchmark_panel_exposes_hic_premium_and_wage_targets() -> None: """ n_subjects = 20 model = create_benchmark_model( - n_subjects=n_subjects, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) _, _, params = get_benchmark_params(model=model) @@ -148,7 +145,6 @@ def test_benchmark_simulate_obeys_borrowing_constraint() -> None: """ n_subjects = 4 model = create_benchmark_model( - n_subjects=n_subjects, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) _, _, params = get_benchmark_params(model=model) @@ -172,3 +168,19 @@ def test_benchmark_simulate_obeys_borrowing_constraint() -> None: f"borrowing_constraint violated on {int((slack < 0).sum())} row(s); " f"min slack = {slack.min():.6g}" ) + + +def test_initial_conditions_choose_population_size_after_model_construction() -> None: + """One model supports reproducible initial conditions with different row counts.""" + model = create_benchmark_model(pref_type_grid=DiscreteGrid(BenchmarkPrefType)) + small = get_benchmark_initial_conditions(model=model, n_subjects=2, seed=17) + repeated = get_benchmark_initial_conditions(model=model, n_subjects=2, seed=17) + large = get_benchmark_initial_conditions(model=model, n_subjects=5, seed=17) + + assert small.keys() == repeated.keys() == large.keys() + for name, values in small.items(): + assert values.shape == (2,) + assert large[name].shape == (5,) + np.testing.assert_array_equal(values, repeated[name]) + np.testing.assert_array_equal(small["age"], [51.0, 51.0]) + np.testing.assert_array_equal(small["claimed_ss"], [0, 0]) diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index 45274ff..6670eba 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -46,7 +46,6 @@ def _build_regimes(solver: SolverName) -> dict[str, Regime]: def _build_model(solver: SolverName) -> Model: return create_model( - n_subjects=1, fixed_params=_FIXED_PARAMS, wage_params=_WAGE_PARAMS, derived_categoricals=_DERIVED_CATEGORICALS, @@ -124,7 +123,6 @@ def test_dcegm_requires_construction_time_consumption_points() -> None: at model construction, so the runtime-injection path cannot be used.""" with pytest.raises(ValueError, match="consumption_dollars_points"): create_model( - n_subjects=1, fixed_params=_FIXED_PARAMS, wage_params=_WAGE_PARAMS, derived_categoricals=_DERIVED_CATEGORICALS, @@ -148,7 +146,6 @@ def test_benchmark_consumption_points_pin_both_floors() -> None: def test_dcegm_benchmark_model_builds() -> None: """The benchmark model accepts `solver="dcegm"` end to end.""" model = create_model( - n_subjects=1, fixed_params=_FIXED_PARAMS, wage_params=_WAGE_PARAMS, derived_categoricals=_DERIVED_CATEGORICALS, diff --git a/tests/test_dcegm_parity.py b/tests/test_dcegm_parity.py index 4c83e2c..70bfe05 100644 --- a/tests/test_dcegm_parity.py +++ b/tests/test_dcegm_parity.py @@ -114,7 +114,6 @@ def _make_model(*, solver: SolverName, grid_config: GridConfig) -> Model: else None ) return create_model( - n_subjects=N_SUBJECTS, fixed_params=fixed_params, wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, diff --git a/tests/test_execution_config.py b/tests/test_execution_config.py index 04f19a7..77bd52d 100644 --- a/tests/test_execution_config.py +++ b/tests/test_execution_config.py @@ -25,7 +25,6 @@ def _factory(kind): if kind == "benchmark": return partial( create_benchmark_model, - n_subjects=1, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) fixed_params, wage_params, _ = get_benchmark_params(model=None) @@ -36,7 +35,6 @@ def _factory(kind): ) return partial( factory, - n_subjects=1, fixed_params=fixed_params, wage_params=wage_params, derived_categoricals={ @@ -115,3 +113,10 @@ def observe_pylcm_constructor(**kwargs): monkeypatch.setattr(aca_model_module, "Model", observe_pylcm_constructor) with pytest.raises(ConstructionObservedError): factory(execution_config=requested_policy) + + +@pytest.mark.parametrize("kind", ["baseline", "aca", "benchmark"]) +def test_factory_rejects_construction_subject_count(kind): + """Population size is supplied by initial conditions, not model construction.""" + with pytest.raises(TypeError, match="n_subjects"): + _factory(kind)(n_subjects=1) diff --git a/tests/test_initial_conditions_extreme_assets.py b/tests/test_initial_conditions_extreme_assets.py index 407b1cd..6455bee 100644 --- a/tests/test_initial_conditions_extreme_assets.py +++ b/tests/test_initial_conditions_extreme_assets.py @@ -8,8 +8,8 @@ """ import jax.numpy as jnp +from _lcm.simulation.initial_conditions import validate_initial_conditions from lcm import DiscreteGrid -from lcm.model import validate_initial_conditions from aca_model.agent.assets_and_income import borrowing_constraint from aca_model.agent.preferences import BenchmarkPrefType @@ -101,7 +101,6 @@ def test_extreme_negative_assets_subject_passes_validation() -> None: """ n_subjects = 1 model = create_benchmark_model( - n_subjects=n_subjects, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) _, _, params = get_benchmark_params(model=model) diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 68b7d77..8e4398f 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -51,24 +51,24 @@ def build_regime(name: str): def test_model_creates_successfully() -> None: - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() assert len(model.user_regimes) == 19 assert model.n_periods == 45 def test_model_age_range() -> None: - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() assert model.ages.values[0] == 51.0 assert model.ages.values[-1] == 95.0 def test_dead_regime_is_terminal() -> None: - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() assert model.user_regimes["dead"].terminal def test_non_terminal_regimes_not_terminal() -> None: - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() for name in REGIME_SPECS: assert not model.user_regimes[name].terminal @@ -233,7 +233,7 @@ def test_all_non_terminal_regimes_carry_pension_wealth_as_carried_state() -> Non build_model_state_transitions()["pension_wealth"] is pensions.wealth_next_before_adjustment ) - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() for name in REGIME_SPECS: assert isinstance(model.user_regimes[name].states["pension_wealth"], Phased), ( name @@ -281,7 +281,7 @@ def test_hcc_persistent_and_transitory_are_shock_grids() -> None: def test_aca_model_creates_successfully() -> None: - model = make_aca_model(n_subjects=1, policy=PolicyVariant.ACA) + model = make_aca_model(policy=PolicyVariant.ACA) assert len(model.user_regimes) == 19 assert model.n_periods == 45 @@ -322,7 +322,7 @@ def test_aca_other_regimes_have_no_aca_policy_keys() -> None: @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_all_policy_variants_create(policy: PolicyVariant) -> None: """All policy variants create valid models.""" - model = make_aca_model(n_subjects=1, policy=policy) + model = make_aca_model(policy=policy) assert len(model.user_regimes) == 19 @@ -362,7 +362,7 @@ def test_aca_only_medicaid_expansion() -> None: def test_baseline_model_creates() -> None: """Baseline model creates successfully without PolicyVariant.""" - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() assert len(model.user_regimes) == 19 @@ -371,7 +371,7 @@ def test_dead_regime_prunes_unused_broadcast_states() -> None: `dead` keeps only what the bequest DAG reads (`assets`, `pref_type`). `pension_wealth` is masked (carried states are illegal in terminal regimes); the other unused broadcast states are pruned by reachability.""" - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() assert model.pruned_variables["dead"] == frozenset( {"aime", "spousal_income", "hcc_persistent", "hcc_transitory"} ) @@ -381,6 +381,6 @@ def test_dead_regime_prunes_unused_broadcast_states() -> None: def test_living_regimes_keep_every_broadcast_state() -> None: """Every model-level state is read by each living regime's DAG, so pruning removes nothing outside `dead`.""" - model = make_baseline_model(n_subjects=1) + model = make_baseline_model() for name in REGIME_SPECS: assert model.pruned_variables[name] == frozenset() diff --git a/tests/test_nbegm_labor_live_validation.py b/tests/test_nbegm_labor_live_validation.py index 279c6fd..62b4f9e 100644 --- a/tests/test_nbegm_labor_live_validation.py +++ b/tests/test_nbegm_labor_live_validation.py @@ -38,7 +38,6 @@ def _solve_m1(solver: SolverName) -> tuple[dict[int, np.ndarray], int]: ) fixed_params, wage_params, _ = get_benchmark_params(model=None) model = create_model( - n_subjects=1, fixed_params=fixed_params, wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 79d0265..f853fcc 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -66,7 +66,6 @@ def _build_regimes(solver: SolverName) -> dict[str, Regime]: def _build_model_with(solver: SolverName, grid_config: GridConfig) -> Model: return create_model( - n_subjects=1, fixed_params=_FIXED_PARAMS, wage_params=_WAGE_PARAMS, derived_categoricals=_DERIVED_CATEGORICALS, @@ -300,7 +299,6 @@ def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: compose with the branch compiler's per-regime wiring.""" grid_config = _BRIDGED_GRID_CONFIG model = create_aca_model( - n_subjects=1, policy=policy, fixed_params=_FIXED_PARAMS, wage_params=_WAGE_PARAMS, @@ -334,7 +332,6 @@ def test_nbegm_aca_variants_leave_no_free_buy_private_params( """ grid_config = _BRIDGED_GRID_CONFIG model = create_aca_model( - n_subjects=1, policy=policy, fixed_params=_FIXED_PARAMS, wage_params=_WAGE_PARAMS, diff --git a/tests/test_nbegm_solve_validation.py b/tests/test_nbegm_solve_validation.py index 4c840da..e8e6484 100644 --- a/tests/test_nbegm_solve_validation.py +++ b/tests/test_nbegm_solve_validation.py @@ -35,7 +35,6 @@ def _solve_m1(solver: SolverName) -> dict[int, np.ndarray]: grid_config = dataclasses.replace(BENCHMARK_GRID_CONFIG, nbegm_jump_read="bridged") fixed_params, wage_params, _ = get_benchmark_params(model=None) model = create_model( - n_subjects=1, fixed_params=fixed_params, wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, From 4ec7ce0425cf364920630f24a1c3caff3fc7736e Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 25 Sep 2026 11:42:17 +0200 Subject: [PATCH 016/101] TST Pin float32 felicity at the leisure floor against a 60-digit reference Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_leisure_floor.py | 190 ++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/test_leisure_floor.py diff --git a/tests/test_leisure_floor.py b/tests/test_leisure_floor.py new file mode 100644 index 0000000..2c1e470 --- /dev/null +++ b/tests/test_leisure_floor.py @@ -0,0 +1,190 @@ +"""Float32 felicity at the leisure floor, against a 60-digit reference. + +The class covers the canwork cells at production preference values: every pref +type, health, lagged labor supply, hours choice and canwork age, at consumption +points spanning the NB-EGM numeric-inverse bracket (`1e-8` to the top of the +action range). Marginal utility is `jax.grad` of the consumption-dollar +felicity, the route pylcm's NB-EGM uses. +""" + +import itertools +from decimal import Decimal, getcontext + +import jax +import jax.numpy as jnp +import numpy as np +import pytest + +from aca_model.agent import preferences + +getcontext().prec = 60 + +CONSUMPTION_WEIGHTS = (0.6776541629520845, 0.8805772328591686, 0.0718086283445225) +COEFFICIENTS_RRA = (3.841252231680976, 0.9990771146810682, 3.8328505891095137) +TIME_ENDOWMENT = 3926.9478390365557 +BAD_HEALTH_COST = 408.9190313043897 +REENTRY_COST = 119.61095526191421 +FIXED_COST_INTERCEPT = 337.5223349543413 +FIXED_COST_AGE_TREND = 84.09242960636178 +REFERENCE_AGE = 50 +REFERENCE_HOURS = 1000.0 +AVERAGE_CONSUMPTION = 20000.0 +HOURS = (0.0, 1000.0, 1500.0, 2000.0, 2500.0) +CANWORK_AGES = tuple(range(51, 72)) +CONSUMPTION = (1e-8, 1597.0921419521899, 20000.0, 300_000.0, 721271853.846792) +FLOAT32_REL_TOL = 1e-4 +# Leisure-map widths as fractions of the endowment: smoothing width and floor. +SMOOTHING_FRACTION = 0.01 +FLOOR_FRACTION = 1e-5 + + +def _f32(x: float) -> jnp.ndarray: + return jnp.asarray(x, dtype=jnp.float32) + + +def _felicity_of_dollars(consumption, leisure, weight, rra, scale): + return preferences.u_alive( + consumption_equiv=preferences.consumption_equiv( + consumption_dollars=consumption, + equivalence_scale=jnp.ones_like(consumption), + ), + leisure=leisure, + consumption_weight=weight, + coefficient_rra=rra, + utility_scale_factor=scale, + ) + + +def _leisure_available(good: int, lagged: int, hours: float, age: int) -> float: + fixed_cost = FIXED_COST_INTERCEPT + FIXED_COST_AGE_TREND * (age - REFERENCE_AGE) + reentry = REENTRY_COST if lagged == 0 else 0.0 + work = hours + fixed_cost + reentry if hours > 0 else 0.0 + return TIME_ENDOWMENT - (0.0 if good else BAD_HEALTH_COST) - work + + +def _cells() -> list[tuple[float, float]]: + """All `(leisure_available, consumption)` pairs of the class.""" + return [ + (_leisure_available(good, lagged, hours, age), c) + for good, lagged, hours, age, c in itertools.product( + (0, 1), (0, 1), HOURS, CANWORK_AGES, CONSUMPTION + ) + ] + + +def _float32_u_and_marginal( + pref_type: int, cells: list[tuple[float, float]] +) -> tuple[np.ndarray, np.ndarray]: + available = jnp.asarray([a for a, _ in cells], dtype=jnp.float32) + consumption = jnp.asarray([c for _, c in cells], dtype=jnp.float32) + weight = _f32(CONSUMPTION_WEIGHTS[pref_type]) + rra = _f32(COEFFICIENTS_RRA[pref_type]) + leisure = _floored_leisure(available, _f32(TIME_ENDOWMENT)) + scale = preferences.utility_scale_factor( + average_consumption_equiv=_f32(AVERAGE_CONSUMPTION), + consumption_weight=weight, + coefficient_rra=rra, + time_endowment=_f32(TIME_ENDOWMENT), + fixed_cost_of_work_intercept=_f32(FIXED_COST_INTERCEPT), + reference_hours=_f32(REFERENCE_HOURS), + ) + args = (weight, rra, scale) + u = jax.vmap(_felicity_of_dollars, in_axes=(0, 0, None, None, None))( + consumption, leisure, *args + ) + marginal = jax.vmap( + jax.grad(_felicity_of_dollars), in_axes=(0, 0, None, None, None) + )(consumption, leisure, *args) + assert u.dtype == marginal.dtype == jnp.float32 + return np.asarray(u, dtype=np.float64), np.asarray(marginal, dtype=np.float64) + + +def _floored_leisure(available, endowment): + """Production leisure at a given `leisure_available`, via the tied-regime map. + + Good health and no fixed cost make the work loss the only deduction, so hours of + `endowment - available` leave exactly `available`. + """ + return preferences.leisure_canwork_tied( + working_hours_value=endowment - available, + good_health=jnp.ones(available.shape, dtype=jnp.int32), + time_endowment=endowment, + leisure_cost_of_bad_health=jnp.zeros_like(endowment), + fixed_cost_of_work=jnp.zeros_like(endowment), + ) + + +def _d(x: float) -> Decimal: + return Decimal(repr(float(x))) + + +def _reference_u_and_marginal( + pref_type: int, cells: list[tuple[float, float]] +) -> tuple[np.ndarray, np.ndarray]: + """Closed forms in 60-digit log space, independent of the JAX code path.""" + weight, rra = _d(CONSUMPTION_WEIGHTS[pref_type]), _d(COEFFICIENTS_RRA[pref_type]) + endowment = _d(TIME_ENDOWMENT) + smoothing = _d(SMOOTHING_FRACTION) * endowment + floor = _d(FLOOR_FRACTION) * endowment + average_leisure = endowment - _d(REFERENCE_HOURS) - _d(FIXED_COST_INTERCEPT) + log_average = weight * _d(AVERAGE_CONSUMPTION).ln() + (1 - weight) * ( + average_leisure.ln() + ) + scale = abs((1 - rra) / ((1 - rra) * log_average).exp()) + u, marginal = [], [] + for available, c in cells: + leisure = ( + smoothing + * ((floor / smoothing).exp() + (_d(available) / smoothing).exp()).ln() + ) + log_composite = weight * _d(c).ln() + (1 - weight) * leisure.ln() + power = ((1 - rra) * log_composite).exp() + u.append(float(scale * power / (1 - rra))) + marginal.append(float(scale * weight / _d(c) * power)) + return np.array(u), np.array(marginal) + + +OUTPUTS = {"u": 0, "marginal": 1} +WITNESS = [(_leisure_available(0, 0, 2500.0, 71), c) for c in CONSUMPTION] + + +@pytest.mark.parametrize("output", ["u", "marginal"]) +def test_float32_felicity_at_witness_cell_matches_reference(output: str) -> None: + """Pref type 2, bad health, did not work, 2500 h, age 71: finite and accurate.""" + got = _float32_u_and_marginal(2, WITNESS)[OUTPUTS[output]] + ref = _reference_u_and_marginal(2, WITNESS)[OUTPUTS[output]] + np.testing.assert_allclose(got, ref, rtol=FLOAT32_REL_TOL) + + +@pytest.mark.parametrize("output", ["u", "marginal"]) +@pytest.mark.parametrize("pref_type", [0, 1, 2]) +def test_float32_felicity_over_canwork_class_matches_reference( + pref_type: int, output: str +) -> None: + """Every canwork cell and consumption point: float32 equals the reference.""" + cells = _cells() + got = _float32_u_and_marginal(pref_type, cells)[OUTPUTS[output]] + ref = _reference_u_and_marginal(pref_type, cells)[OUTPUTS[output]] + np.testing.assert_allclose(got, ref, rtol=FLOAT32_REL_TOL) + + +@pytest.mark.parametrize("pref_type", [0, 1, 2]) +def test_leisure_floor_leaves_utility_unchanged_away_from_the_floor( + pref_type: int, +) -> None: + """With leisure_available above 10% of the endowment, u moves by at most 1e-7. + + Compared against the unfloored softplus `s * log(1 + e^(x/s))`. + """ + available = np.array([a for a, _ in _cells() if a > 0.1 * TIME_ENDOWMENT]) + assert available.size > 0 + smoothing = SMOOTHING_FRACTION * TIME_ENDOWMENT + unfloored = smoothing * np.logaddexp(0.0, available / smoothing) + floored = np.asarray( + _floored_leisure(jnp.asarray(available), jnp.asarray(TIME_ENDOWMENT)) + ) + exponent = (1.0 - CONSUMPTION_WEIGHTS[pref_type]) * ( + 1.0 - COEFFICIENTS_RRA[pref_type] + ) + relative_change = np.abs((floored / unfloored) ** exponent - 1.0) + np.testing.assert_array_less(relative_change, 1e-7) From 4f37ec2d0945592a641f4bb62129dae49608edc6 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 25 Sep 2026 11:42:17 +0200 Subject: [PATCH 017/101] ENH Bend leisure onto a positive floor with a smooth max 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/agent/preferences.py | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/aca_model/agent/preferences.py b/src/aca_model/agent/preferences.py index 438b2c4..3bb4b80 100644 --- a/src/aca_model/agent/preferences.py +++ b/src/aca_model/agent/preferences.py @@ -23,6 +23,13 @@ # endowment; it only bends the map near and beyond the endowment. _LEISURE_SMOOTHING_FRACTION = 0.01 +# Positive floor that leisure approaches as work costs exceed the endowment, as a +# fraction of the time endowment. It keeps felicity and its consumption derivative +# inside float32's range when leisure_available is far below zero, down to the +# NB-EGM inverse bracket's lowest consumption; away from the floor its effect decays +# exponentially. +_LEISURE_FLOOR_FRACTION = 1e-5 + @categorical(ordered=False) class PrefType: @@ -71,17 +78,25 @@ def fixed_cost_of_work( def _smooth_leisure_floor( leisure_available: FloatND, time_endowment: ScalarFloat ) -> FloatND: - """Bend leisure to a strictly positive floor as work costs approach the endowment. - - `softplus(x) = log(1 + e^x)` via `jnp.logaddexp(0, x)`, scaled by a small fraction - of the endowment. Where `leisure_available` is large relative to the smoothing width - the map reduces to `leisure_available` (bulk unchanged); as it falls to zero leisure - bends to `0⁺` — never negative, never a kinked clamp — so the CRRA aggregator never - receives a non-positive base. The smoothing width scales with the endowment, so the - map is scale-invariant. + """Bend leisure smoothly onto a positive floor as work costs approach the endowment. + + Leisure is a smooth maximum of `leisure_available` and the floor + `F = _LEISURE_FLOOR_FRACTION * time_endowment`: + `s * logaddexp(F / s, leisure_available / s)`, with smoothing width + `s = _LEISURE_SMOOTHING_FRACTION * time_endowment`. + + - `leisure_available` large relative to `s`: leisure equals `leisure_available` + up to a term that decays like `e^(-leisure_available / s)`. + - `leisure_available` at or below zero: leisure bends to `F⁺` — never below the + floor, never a kinked clamp — so every work choice stays feasible and the CRRA + aggregator's base stays large enough for float32 felicity and marginal + felicity. + + Both widths scale with the endowment, so the map is scale-invariant. """ smoothing = _LEISURE_SMOOTHING_FRACTION * time_endowment - return smoothing * jnp.logaddexp(0.0, leisure_available / smoothing) + floor = _LEISURE_FLOOR_FRACTION * time_endowment + return smoothing * jnp.logaddexp(floor / smoothing, leisure_available / smoothing) def leisure_canwork_retiree_or_nongroup( From 05c4a4331d787234a0ba0d32b801f5e4cd065991 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 25 Sep 2026 13:47:51 +0200 Subject: [PATCH 018/101] CI Install pylcm at the commit aca-dev pins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 632668d..897c37f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,12 +30,12 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install pylcm from feat/nb-egm - # This slice depends on the NB-EGM solver, which lives on pylcm's - # feat/nb-egm branch; revert to @main once that branch merges. + - name: Install pylcm at the revision aca-dev pins + # Pinned by commit so a deleted or rewritten branch cannot break the + # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@feat/nb-egm" + git+https://github.com/OpenSourceEconomics/pylcm.git@7e8e4c223a63a07725015ebdd5af84cd9092cee2" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 3bc89f74572fe48c9ff5401bf022bf585b47d02b Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sat, 26 Sep 2026 12:27:34 +0200 Subject: [PATCH 019/101] Floor leisure at a small positive value so float32 utility stays finite (#18) Co-authored-by: Claude Opus 5.5 --- .github/workflows/main.yml | 8 +- src/aca_model/agent/preferences.py | 33 +++-- tests/test_leisure_floor.py | 190 +++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 tests/test_leisure_floor.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 632668d..897c37f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,12 +30,12 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install pylcm from feat/nb-egm - # This slice depends on the NB-EGM solver, which lives on pylcm's - # feat/nb-egm branch; revert to @main once that branch merges. + - name: Install pylcm at the revision aca-dev pins + # Pinned by commit so a deleted or rewritten branch cannot break the + # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@feat/nb-egm" + git+https://github.com/OpenSourceEconomics/pylcm.git@7e8e4c223a63a07725015ebdd5af84cd9092cee2" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest diff --git a/src/aca_model/agent/preferences.py b/src/aca_model/agent/preferences.py index 438b2c4..3bb4b80 100644 --- a/src/aca_model/agent/preferences.py +++ b/src/aca_model/agent/preferences.py @@ -23,6 +23,13 @@ # endowment; it only bends the map near and beyond the endowment. _LEISURE_SMOOTHING_FRACTION = 0.01 +# Positive floor that leisure approaches as work costs exceed the endowment, as a +# fraction of the time endowment. It keeps felicity and its consumption derivative +# inside float32's range when leisure_available is far below zero, down to the +# NB-EGM inverse bracket's lowest consumption; away from the floor its effect decays +# exponentially. +_LEISURE_FLOOR_FRACTION = 1e-5 + @categorical(ordered=False) class PrefType: @@ -71,17 +78,25 @@ def fixed_cost_of_work( def _smooth_leisure_floor( leisure_available: FloatND, time_endowment: ScalarFloat ) -> FloatND: - """Bend leisure to a strictly positive floor as work costs approach the endowment. - - `softplus(x) = log(1 + e^x)` via `jnp.logaddexp(0, x)`, scaled by a small fraction - of the endowment. Where `leisure_available` is large relative to the smoothing width - the map reduces to `leisure_available` (bulk unchanged); as it falls to zero leisure - bends to `0⁺` — never negative, never a kinked clamp — so the CRRA aggregator never - receives a non-positive base. The smoothing width scales with the endowment, so the - map is scale-invariant. + """Bend leisure smoothly onto a positive floor as work costs approach the endowment. + + Leisure is a smooth maximum of `leisure_available` and the floor + `F = _LEISURE_FLOOR_FRACTION * time_endowment`: + `s * logaddexp(F / s, leisure_available / s)`, with smoothing width + `s = _LEISURE_SMOOTHING_FRACTION * time_endowment`. + + - `leisure_available` large relative to `s`: leisure equals `leisure_available` + up to a term that decays like `e^(-leisure_available / s)`. + - `leisure_available` at or below zero: leisure bends to `F⁺` — never below the + floor, never a kinked clamp — so every work choice stays feasible and the CRRA + aggregator's base stays large enough for float32 felicity and marginal + felicity. + + Both widths scale with the endowment, so the map is scale-invariant. """ smoothing = _LEISURE_SMOOTHING_FRACTION * time_endowment - return smoothing * jnp.logaddexp(0.0, leisure_available / smoothing) + floor = _LEISURE_FLOOR_FRACTION * time_endowment + return smoothing * jnp.logaddexp(floor / smoothing, leisure_available / smoothing) def leisure_canwork_retiree_or_nongroup( diff --git a/tests/test_leisure_floor.py b/tests/test_leisure_floor.py new file mode 100644 index 0000000..2c1e470 --- /dev/null +++ b/tests/test_leisure_floor.py @@ -0,0 +1,190 @@ +"""Float32 felicity at the leisure floor, against a 60-digit reference. + +The class covers the canwork cells at production preference values: every pref +type, health, lagged labor supply, hours choice and canwork age, at consumption +points spanning the NB-EGM numeric-inverse bracket (`1e-8` to the top of the +action range). Marginal utility is `jax.grad` of the consumption-dollar +felicity, the route pylcm's NB-EGM uses. +""" + +import itertools +from decimal import Decimal, getcontext + +import jax +import jax.numpy as jnp +import numpy as np +import pytest + +from aca_model.agent import preferences + +getcontext().prec = 60 + +CONSUMPTION_WEIGHTS = (0.6776541629520845, 0.8805772328591686, 0.0718086283445225) +COEFFICIENTS_RRA = (3.841252231680976, 0.9990771146810682, 3.8328505891095137) +TIME_ENDOWMENT = 3926.9478390365557 +BAD_HEALTH_COST = 408.9190313043897 +REENTRY_COST = 119.61095526191421 +FIXED_COST_INTERCEPT = 337.5223349543413 +FIXED_COST_AGE_TREND = 84.09242960636178 +REFERENCE_AGE = 50 +REFERENCE_HOURS = 1000.0 +AVERAGE_CONSUMPTION = 20000.0 +HOURS = (0.0, 1000.0, 1500.0, 2000.0, 2500.0) +CANWORK_AGES = tuple(range(51, 72)) +CONSUMPTION = (1e-8, 1597.0921419521899, 20000.0, 300_000.0, 721271853.846792) +FLOAT32_REL_TOL = 1e-4 +# Leisure-map widths as fractions of the endowment: smoothing width and floor. +SMOOTHING_FRACTION = 0.01 +FLOOR_FRACTION = 1e-5 + + +def _f32(x: float) -> jnp.ndarray: + return jnp.asarray(x, dtype=jnp.float32) + + +def _felicity_of_dollars(consumption, leisure, weight, rra, scale): + return preferences.u_alive( + consumption_equiv=preferences.consumption_equiv( + consumption_dollars=consumption, + equivalence_scale=jnp.ones_like(consumption), + ), + leisure=leisure, + consumption_weight=weight, + coefficient_rra=rra, + utility_scale_factor=scale, + ) + + +def _leisure_available(good: int, lagged: int, hours: float, age: int) -> float: + fixed_cost = FIXED_COST_INTERCEPT + FIXED_COST_AGE_TREND * (age - REFERENCE_AGE) + reentry = REENTRY_COST if lagged == 0 else 0.0 + work = hours + fixed_cost + reentry if hours > 0 else 0.0 + return TIME_ENDOWMENT - (0.0 if good else BAD_HEALTH_COST) - work + + +def _cells() -> list[tuple[float, float]]: + """All `(leisure_available, consumption)` pairs of the class.""" + return [ + (_leisure_available(good, lagged, hours, age), c) + for good, lagged, hours, age, c in itertools.product( + (0, 1), (0, 1), HOURS, CANWORK_AGES, CONSUMPTION + ) + ] + + +def _float32_u_and_marginal( + pref_type: int, cells: list[tuple[float, float]] +) -> tuple[np.ndarray, np.ndarray]: + available = jnp.asarray([a for a, _ in cells], dtype=jnp.float32) + consumption = jnp.asarray([c for _, c in cells], dtype=jnp.float32) + weight = _f32(CONSUMPTION_WEIGHTS[pref_type]) + rra = _f32(COEFFICIENTS_RRA[pref_type]) + leisure = _floored_leisure(available, _f32(TIME_ENDOWMENT)) + scale = preferences.utility_scale_factor( + average_consumption_equiv=_f32(AVERAGE_CONSUMPTION), + consumption_weight=weight, + coefficient_rra=rra, + time_endowment=_f32(TIME_ENDOWMENT), + fixed_cost_of_work_intercept=_f32(FIXED_COST_INTERCEPT), + reference_hours=_f32(REFERENCE_HOURS), + ) + args = (weight, rra, scale) + u = jax.vmap(_felicity_of_dollars, in_axes=(0, 0, None, None, None))( + consumption, leisure, *args + ) + marginal = jax.vmap( + jax.grad(_felicity_of_dollars), in_axes=(0, 0, None, None, None) + )(consumption, leisure, *args) + assert u.dtype == marginal.dtype == jnp.float32 + return np.asarray(u, dtype=np.float64), np.asarray(marginal, dtype=np.float64) + + +def _floored_leisure(available, endowment): + """Production leisure at a given `leisure_available`, via the tied-regime map. + + Good health and no fixed cost make the work loss the only deduction, so hours of + `endowment - available` leave exactly `available`. + """ + return preferences.leisure_canwork_tied( + working_hours_value=endowment - available, + good_health=jnp.ones(available.shape, dtype=jnp.int32), + time_endowment=endowment, + leisure_cost_of_bad_health=jnp.zeros_like(endowment), + fixed_cost_of_work=jnp.zeros_like(endowment), + ) + + +def _d(x: float) -> Decimal: + return Decimal(repr(float(x))) + + +def _reference_u_and_marginal( + pref_type: int, cells: list[tuple[float, float]] +) -> tuple[np.ndarray, np.ndarray]: + """Closed forms in 60-digit log space, independent of the JAX code path.""" + weight, rra = _d(CONSUMPTION_WEIGHTS[pref_type]), _d(COEFFICIENTS_RRA[pref_type]) + endowment = _d(TIME_ENDOWMENT) + smoothing = _d(SMOOTHING_FRACTION) * endowment + floor = _d(FLOOR_FRACTION) * endowment + average_leisure = endowment - _d(REFERENCE_HOURS) - _d(FIXED_COST_INTERCEPT) + log_average = weight * _d(AVERAGE_CONSUMPTION).ln() + (1 - weight) * ( + average_leisure.ln() + ) + scale = abs((1 - rra) / ((1 - rra) * log_average).exp()) + u, marginal = [], [] + for available, c in cells: + leisure = ( + smoothing + * ((floor / smoothing).exp() + (_d(available) / smoothing).exp()).ln() + ) + log_composite = weight * _d(c).ln() + (1 - weight) * leisure.ln() + power = ((1 - rra) * log_composite).exp() + u.append(float(scale * power / (1 - rra))) + marginal.append(float(scale * weight / _d(c) * power)) + return np.array(u), np.array(marginal) + + +OUTPUTS = {"u": 0, "marginal": 1} +WITNESS = [(_leisure_available(0, 0, 2500.0, 71), c) for c in CONSUMPTION] + + +@pytest.mark.parametrize("output", ["u", "marginal"]) +def test_float32_felicity_at_witness_cell_matches_reference(output: str) -> None: + """Pref type 2, bad health, did not work, 2500 h, age 71: finite and accurate.""" + got = _float32_u_and_marginal(2, WITNESS)[OUTPUTS[output]] + ref = _reference_u_and_marginal(2, WITNESS)[OUTPUTS[output]] + np.testing.assert_allclose(got, ref, rtol=FLOAT32_REL_TOL) + + +@pytest.mark.parametrize("output", ["u", "marginal"]) +@pytest.mark.parametrize("pref_type", [0, 1, 2]) +def test_float32_felicity_over_canwork_class_matches_reference( + pref_type: int, output: str +) -> None: + """Every canwork cell and consumption point: float32 equals the reference.""" + cells = _cells() + got = _float32_u_and_marginal(pref_type, cells)[OUTPUTS[output]] + ref = _reference_u_and_marginal(pref_type, cells)[OUTPUTS[output]] + np.testing.assert_allclose(got, ref, rtol=FLOAT32_REL_TOL) + + +@pytest.mark.parametrize("pref_type", [0, 1, 2]) +def test_leisure_floor_leaves_utility_unchanged_away_from_the_floor( + pref_type: int, +) -> None: + """With leisure_available above 10% of the endowment, u moves by at most 1e-7. + + Compared against the unfloored softplus `s * log(1 + e^(x/s))`. + """ + available = np.array([a for a, _ in _cells() if a > 0.1 * TIME_ENDOWMENT]) + assert available.size > 0 + smoothing = SMOOTHING_FRACTION * TIME_ENDOWMENT + unfloored = smoothing * np.logaddexp(0.0, available / smoothing) + floored = np.asarray( + _floored_leisure(jnp.asarray(available), jnp.asarray(TIME_ENDOWMENT)) + ) + exponent = (1.0 - CONSUMPTION_WEIGHTS[pref_type]) * ( + 1.0 - COEFFICIENTS_RRA[pref_type] + ) + relative_change = np.abs((floored / unfloored) ** exponent - 1.0) + np.testing.assert_array_less(relative_change, 1e-7) From fd308231f1ff122fdbb03fd5cb141e53e5ae2aa9 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Mon, 28 Sep 2026 08:36:39 +0200 Subject: [PATCH 020/101] Declare regime transitions by age with `regime_transitions` 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- docs/dag-migration.md | 31 ++++ src/aca_model/aca/model.py | 15 +- src/aca_model/baseline/model.py | 15 +- src/aca_model/baseline/regimes/_common.py | 158 +++++++++++++------- src/aca_model/baseline/regimes/_nongroup.py | 15 +- src/aca_model/baseline/regimes/_retiree.py | 15 +- src/aca_model/baseline/regimes/_tied.py | 12 +- src/aca_model/config.py | 3 + tests/test_dag_coverage.py | 50 +++++++ 10 files changed, 225 insertions(+), 91 deletions(-) create mode 100644 docs/dag-migration.md create mode 100644 tests/test_dag_coverage.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 897c37f..f80a8f1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@7e8e4c223a63a07725015ebdd5af84cd9092cee2" + git+https://github.com/OpenSourceEconomics/pylcm.git@3775cbb6ea559eeecb7f4df8e03c3f8c1e50f086" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest diff --git a/docs/dag-migration.md b/docs/dag-migration.md new file mode 100644 index 0000000..67c1e46 --- /dev/null +++ b/docs/dag-migration.md @@ -0,0 +1,31 @@ +# ACA: one structural stage table, existing numerical programs + +Requires the proposed pylcm v3 schedule/support API, not unchanged PR #474. +Both factories forward optional `initial_regimes` unchanged. Default coverage +requires no separate population/activity table; an explicit entry restriction +changes admission, never solved values. Coverage remains exactly **182** nodes. + +`_AGE_STAGES` and the host-side clock supply both numerical next-age routing and +source-age support schedules. Each source's existing numerical transition body and +per-target probability cell are shared across age cases. There is no closure +specialization per boundary. Code order/global regime IDs and existing parameter +paths remain; different target schemas can still need different continuation +programs, so no backend compile-time improvement is claimed. + +The dead regime remains `regime_transitions=None` at every age, as in the supplied base. +Age 94 retains its living consumption/saving problem and handoff into the separate +age-95 bequest. The three-to-two health grid, claim/lagged-work entry and exit, +carried pension state and phase semantics are not simplified away. + +Use `model.reachability.nodes` and `model.initial_nodes`; keep the existing phase +target queries and period-keyed solutions. Normalize schedules before existing +solver validation. A wrapper alone does not make a supported solver case invalid; +genuine unsupported variation must raise via current errors, without silently +changing actions or solver. GridSearch is the first numerical acceptance target. + +The original transition test module is retained byte-for-byte; new coverage tests +check nodes, boundary support, shared probability cells and terminal declarations. +Offline JAX row equality is not real model construction, Bellman/panel equivalence, +compiled validation/handoff/replay acceptance or GPU evidence. Run those gates and +matched build/compile/warm timings in the supported package environment. Annual +ACA calibration is not automatically valid on a differently spaced clock. diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index 5cc6139..30408cb 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -8,13 +8,13 @@ from typing import Any from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model -from lcm.typing import UserParams +from lcm.typing import InitialRegimes, UserParams from aca_model.aca import PolicyVariant from aca_model.aca.regimes import build_all_regimes from aca_model.baseline.model import _fail_if_dcegm_without_consumption_points from aca_model.baseline.regimes import RegimeId, SolverName, build_model_slots -from aca_model.config import MODEL_CONFIG, GridConfig +from aca_model.config import MODEL_AGES, GridConfig from aca_model.execution import execution_config_for_devices @@ -29,6 +29,7 @@ def create_model( solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, execution_config: ExecutionConfig | None = None, + initial_regimes: InitialRegimes | None = None, ) -> Model: """Create an ACA policy variant model. @@ -53,6 +54,9 @@ def create_model( consumption_dollars_points: Construction-time consumption action gridpoints; required under DC-EGM. See `aca_model.baseline.model.create_model`. + initial_regimes: Optional simulation-entry contract, not a solve-domain + restriction. None permits every covered node; an empty mapping makes + the model solve-only. Empirical starting pairs stay in the data/recipe. execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. @@ -61,11 +65,7 @@ def create_model( pylcm Model. """ - ages = AgeGrid( - start=MODEL_CONFIG.start_age, - stop=MODEL_CONFIG.end_age - 1, - step="Y", - ) + ages = AgeGrid(exact_values=MODEL_AGES) _fail_if_dcegm_without_consumption_points( solver=solver, consumption_dollars_points=consumption_dollars_points ) @@ -90,6 +90,7 @@ def create_model( return Model( regimes=regimes, + initial_regimes=initial_regimes, ages=ages, regime_id_class=RegimeId, description=f"Structural retirement model ({policy.name})", diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 6040c45..06cdc08 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -14,7 +14,7 @@ from typing import Any from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model -from lcm.typing import UserParams +from lcm.typing import InitialRegimes, UserParams from aca_model.baseline.regimes import ( RegimeId, @@ -22,7 +22,7 @@ build_all_regimes, build_model_slots, ) -from aca_model.config import MODEL_CONFIG, GridConfig +from aca_model.config import MODEL_AGES, GridConfig from aca_model.execution import execution_config_for_devices @@ -36,6 +36,7 @@ def create_model( solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, execution_config: ExecutionConfig | None = None, + initial_regimes: InitialRegimes | None = None, ) -> Model: """Create the baseline structural retirement model. @@ -69,6 +70,9 @@ def create_model( continuous-action grid at model construction); `None` keeps the runtime-points grid completed per iteration via `inject_consumption_dollars_points`. + initial_regimes: Optional simulation-entry contract, not a solve-domain + restriction. None permits every covered node; an empty mapping makes + the model solve-only. Empirical starting pairs stay in the data/recipe. execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. @@ -78,11 +82,7 @@ def create_model( ages 51-95. Regime names follow the `___` scheme. """ - ages = AgeGrid( - start=MODEL_CONFIG.start_age, - stop=MODEL_CONFIG.end_age - 1, - step="Y", - ) + ages = AgeGrid(exact_values=MODEL_AGES) _fail_if_dcegm_without_consumption_points( solver=solver, consumption_dollars_points=consumption_dollars_points ) @@ -104,6 +104,7 @@ def create_model( return Model( regimes=regimes, + initial_regimes=initial_regimes, ages=ages, regime_id_class=RegimeId, description="Baseline structural retirement model (pre-ACA)", diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6fb7e5b..260c6cd 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -5,6 +5,7 @@ """ import functools +import itertools from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass from types import MappingProxyType @@ -13,6 +14,8 @@ import jax.numpy as jnp import numpy as np from lcm import ( + AgeRange, + ByAge, DiscreteGrid, GridBreakpoint, IrregSpacedGrid, @@ -32,7 +35,7 @@ post_decision_lower_bound, ) from lcm.solvers import OneMarginSolver -from lcm.typing import BoolND, FloatND, IntND, RegimeName, ScalarInt, UserParams +from lcm.typing import BoolND, FloatND, IntND, Period, RegimeName, ScalarInt, UserParams from aca_model.agent import ( assets_and_income, @@ -44,7 +47,7 @@ from aca_model.agent.labor_market import LaborSupply, LaggedLaborSupply, SpousalIncome from aca_model.baseline import health_insurance from aca_model.baseline.health_insurance import BuyPrivate -from aca_model.config import MODEL_CONFIG, GridConfig +from aca_model.config import MODEL_AGES, MODEL_CONFIG, GridConfig from aca_model.environment import pensions, social_security, taxes from aca_model.environment.social_security import ClaimedSS @@ -414,35 +417,100 @@ def _compute_max_annual_labor_income( ) -_ACTIVE_PREDICATES: dict[tuple[str, str, str], Callable[..., Any]] = { - ("nomc", "inelig", "canwork"): lambda age: age < config.ss_early_age, - ("dimc", "inelig", "canwork"): lambda age: age < config.ss_early_age, - ("nomc", "choose", "canwork"): lambda age: ( - (age >= config.ss_early_age) & (age < config.medicare_age) +# Structural stage data, shared by scheduled support and the numerical router. +# Intervals select existing grid points, not integer-year ranges. +_AGE_STAGES = ( + (AgeRange(config.start_age, config.ss_early_age), ("nomc_inelig", "dimc_inelig")), + ( + AgeRange(config.ss_early_age, config.medicare_age), + ("nomc_choose", "dimc_choose"), ), - ("dimc", "choose", "canwork"): lambda age: ( - (age >= config.ss_early_age) & (age < config.medicare_age) - ), - ("oamc", "choose", "canwork"): lambda age: ( - (age >= config.medicare_age) & (age < config.ss_forced_age) - ), - ("oamc", "forced", "canwork"): lambda age: ( - (age >= config.ss_forced_age) & (age < config.work_forced_out_age) - ), - ("oamc", "forced", "forcedout"): lambda age: ( - (age >= config.work_forced_out_age) & (age < config.end_age - 1) + (AgeRange(config.medicare_age, config.ss_forced_age), ("forced_choose",) * 2), + ( + AgeRange(config.ss_forced_age, config.work_forced_out_age), + ("forced_forced",) * 2, ), + (AgeRange(config.work_forced_out_age, MODEL_AGES[-1]), ("forcedout",) * 2), +) +_STAGE_KEY = { + ("nomc", "inelig", "canwork"): "nomc_inelig", + ("dimc", "inelig", "canwork"): "dimc_inelig", + ("nomc", "choose", "canwork"): "nomc_choose", + ("dimc", "choose", "canwork"): "dimc_choose", + ("oamc", "choose", "canwork"): "forced_choose", + ("oamc", "forced", "canwork"): "forced_forced", + ("oamc", "forced", "forcedout"): "forcedout", } +# Dynamic JAX period lookup and construction-time schedule use the same clock. +_NEXT_AGES = np.asarray([float(a) for a in MODEL_AGES[1:]]) + + +def transition_ages(spec: RegimeSpec) -> tuple[int, ...]: + """Exact source coordinates at which the template declares its law.""" + key = _STAGE_KEY.get((spec["mc"], spec["ss"], spec["canwork"])) + for interval, branches in _AGE_STAGES: + if key in branches: + return tuple( + age for age in MODEL_AGES if interval.start <= age < interval.stop + ) + raise ValueError(f"Unknown regime spec: {spec}") + +def next_model_age(period: Period) -> FloatND: + """Advance by one grid position, never by an assumed number of years. -def make_active_func(spec: RegimeSpec) -> Callable[..., Any]: - """Return the age predicate for a regime spec.""" - key = (spec["mc"], spec["ss"], spec["canwork"]) - predicate = _ACTIVE_PREDICATES.get(key) - if predicate is None: - msg = f"Unknown regime spec: {spec}" - raise ValueError(msg) - return predicate + Only covered nonterminal source periods call this function; no clipping + or extrapolation of invalid period indices is part of the contract. + This does not by itself recalibrate ACA to a different period length. + """ + return jnp.asarray(_NEXT_AGES)[period] + + +def _target_pair(next_age: int, group: dict[str, int]) -> tuple[int, int]: + if next_age == MODEL_AGES[-1]: + return (int(RegimeId.dead),) * 2 + for interval, branches in _AGE_STAGES: + if interval.start <= next_age < interval.stop: + return group[branches[0]], group[branches[1]] + raise ValueError(f"Next age {next_age} is outside the declared stages") + + +def build_scheduled_regime_transition( + *, + spec: RegimeSpec, + transition_func: Callable[..., FloatND], + target_groups: tuple[dict[str, int], ...], +) -> ByAge: + """Schedule support while sharing the original numerical law and cells. + + Unlike the first proposal, no boundary-specific numeric closure is made. + Parameter paths and global regime-code order stay unchanged. Only declared + target subsets vary; upstream may still need distinct continuation kernels + when destination schemas differ, so this is not a compile-time claim. + """ + branches = (0,) if spec["mc"] == "nomc" else (0, 1) + grouped: dict[tuple[int, ...], list[int]] = {} + next_age_by_age = dict(itertools.pairwise(MODEL_AGES)) + for age in transition_ages(spec): + next_age = next_age_by_age[age] + pairs = tuple(_target_pair(next_age, group) for group in target_groups) + supported_ids = {pair[branch] for pair in pairs for branch in branches} + target_ids = tuple(sorted(supported_ids | {int(RegimeId.dead)})) + grouped.setdefault(target_ids, []).append(age) + all_ids = {i for target_ids in grouped for i in target_ids} + # One probability-cell wrapper per target, reused at every applicable age. + cells = build_granular_regime_transition( + transition_func=transition_func, target_ids=all_ids + ) + id_to_name = { + int(getattr(RegimeId, name)): name for name in (*REGIME_SPECS, "dead") + } + return ByAge( + { + tuple(source_ages): {id_to_name[i]: cells[id_to_name[i]] for i in ids} + for ids, source_ages in grouped.items() + } + ) def build_states(spec: RegimeSpec, grids: Grids) -> dict: @@ -564,11 +632,10 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: } constraint_masks = dict.fromkeys(build_model_constraints(solver=solver)) return Regime( - transition=None, + regime_transitions=None, functions={"utility": preferences.bequest, **function_masks}, constraints=constraint_masks, states={"pension_wealth": None}, - active=lambda _age: True, ) @@ -925,34 +992,13 @@ def select_target_for_age( mc_next: bool | BoolND, tgts: dict[str, int], ) -> IntND: - """Select target regime ID based on next-period age bracket.""" - ss_choose = jnp.where( - jnp.array(mc_next), - tgts["dimc_choose"], - tgts["nomc_choose"], - ) - ss_inelig = jnp.where( - jnp.array(mc_next), - tgts["dimc_inelig"], - tgts["nomc_inelig"], - ) - return jnp.where( - next_age >= config.end_age - 1, - RegimeId.dead, - jnp.where( - next_age >= config.work_forced_out_age, - tgts["forcedout"], - jnp.where( - next_age >= config.ss_forced_age, - tgts["forced_forced"], - jnp.where( - next_age >= config.medicare_age, - tgts["forced_choose"], - jnp.where(next_age >= config.ss_early_age, ss_choose, ss_inelig), - ), - ), - ), - ) + """Select with the same stage table that supplies the schedule's support.""" + result = jnp.asarray(RegimeId.dead) + for interval, branches in reversed(_AGE_STAGES): + target = jnp.where(jnp.array(mc_next), tgts[branches[1]], tgts[branches[0]]) + inside = (next_age >= interval.start) & (next_age < interval.stop) + result = jnp.where(inside, target, result) + return result def build_state_transitions( diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index f13e3c9..25e9716 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -19,14 +19,14 @@ build_actions, build_alive_regime, build_common_functions, - build_granular_regime_transition, build_nbegm_functions, build_pension_functions, build_regime_probs, + build_scheduled_regime_transition, build_state_transitions, build_states, - make_active_func, make_targets, + next_model_age, select_ss_benefit, select_target_for_age, ) @@ -48,9 +48,10 @@ def transition( labor_supply: DiscreteAction, survival_probs: FloatND, ) -> FloatND: + del age # Keep the legacy signature; period owns the clock lookup. sp = survival_probs[period] mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) - target = select_target_for_age(age + 1, mc_next, own) + target = select_target_for_age(next_model_age(period), mc_next, own) return build_regime_probs(target, sp) return transition @@ -71,7 +72,8 @@ def transition( period: Period, survival_probs: FloatND, ) -> FloatND: - target = select_target_for_age(age + 1, gets_medicare, own) + del age # Keep the legacy signature; period owns the clock lookup. + target = select_target_for_age(next_model_age(period), gets_medicare, own) return build_regime_probs(target, survival_probs[period]) return transition @@ -137,10 +139,9 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - transition=build_granular_regime_transition( - transition_func=transition_func, target_ids=own.values() + regime_transitions=build_scheduled_regime_transition( + spec=spec, transition_func=transition_func, target_groups=(own,) ), - active=make_active_func(spec), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 309a71c..92d55c0 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -20,14 +20,14 @@ build_actions, build_alive_regime, build_common_functions, - build_granular_regime_transition, build_nbegm_functions, build_pension_functions, build_regime_probs, + build_scheduled_regime_transition, build_state_transitions, build_states, - make_active_func, make_targets, + next_model_age, select_ss_benefit, select_target_for_age, ) @@ -51,8 +51,9 @@ def transition( is_medicaid_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: + del age # Keep the legacy signature; period owns the clock lookup. sp = survival_probs[period] - next_age = age + 1 + next_age = next_model_age(period) mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) target = select_target_for_age(next_age, mc_next, own) # Medicaid eligibility overrides to nongroup @@ -79,8 +80,9 @@ def transition( is_medicaid_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: + del age # Keep the legacy signature; period owns the clock lookup. sp = survival_probs[period] - next_age = age + 1 + next_age = next_model_age(period) target = select_target_for_age(next_age, gets_medicare, own) ng_ssi = select_target_for_age(next_age, gets_medicare, ng) target = jnp.where(is_medicaid_eligible, ng_ssi, target) @@ -142,10 +144,9 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - transition=build_granular_regime_transition( - transition_func=transition_func, target_ids=(*own.values(), *ng.values()) + regime_transitions=build_scheduled_regime_transition( + spec=spec, transition_func=transition_func, target_groups=(own, ng) ), - active=make_active_func(spec), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index ff13664..c939287 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -21,14 +21,14 @@ build_actions, build_alive_regime, build_common_functions, - build_granular_regime_transition, build_nbegm_functions, build_pension_functions, build_regime_probs, + build_scheduled_regime_transition, build_state_transitions, build_states, - make_active_func, make_targets, + next_model_age, select_ss_benefit, select_target_for_age, ) @@ -52,8 +52,9 @@ def transition( is_medicaid_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: + del age # Keep the legacy signature; period owns the clock lookup. sp = survival_probs[period] - next_age = age + 1 + next_age = next_model_age(period) mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) target = select_target_for_age(next_age, mc_next, own) # Tied agents who stop working become nongroup @@ -112,10 +113,9 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - transition=build_granular_regime_transition( - transition_func=transition_func, target_ids=(*own.values(), *ng.values()) + regime_transitions=build_scheduled_regime_transition( + spec=spec, transition_func=transition_func, target_groups=(own, ng) ), - active=make_active_func(spec), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 4d65551..0f5885c 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -45,6 +45,9 @@ class GridConfig: MODEL_CONFIG = ModelConfig() +# ACA is calibrated annually. Keep the exact clock on the host until Model build; +# importing a task or regime builder must not initialize a JAX device allocator. +MODEL_AGES: tuple[int, ...] = tuple(range(MODEL_CONFIG.start_age, MODEL_CONFIG.end_age)) GRID_CONFIG = GridConfig() BENCHMARK_GRID_CONFIG = GridConfig( diff --git a/tests/test_dag_coverage.py b/tests/test_dag_coverage.py new file mode 100644 index 0000000..d865905 --- /dev/null +++ b/tests/test_dag_coverage.py @@ -0,0 +1,50 @@ +"""Real-package acceptance tests; requires the new upstream law-coverage API.""" + +import pytest +from helpers.model import make_baseline_model + + +@pytest.fixture(scope="module") +def model(): + return make_baseline_model() + + +def test_full_coverage_without_a_duplicate_entry_table(model): + assert len(model.reachability.nodes) == 182 + assert model.initial_nodes == model.reachability.nodes + assert (51, "dead") in model.reachability.nodes + assert (64, "retiree_nomc_choose_canwork") in model.initial_nodes + assert (65, "retiree_nomc_choose_canwork") not in model.initial_nodes + assert not any(hasattr(r, "active") for r in model.user_regimes.values()) + + +def test_boundary_targets_change_but_numeric_cells_are_shared(model): + schedule = model.user_regimes[ + "retiree_nomc_inelig_canwork" + ].regime_transitions.resolve(model.ages) + assert set(schedule.at(60)) == { + "retiree_nomc_inelig_canwork", + "nongroup_nomc_inelig_canwork", + "dead", + } + assert set(schedule.at(61)) == { + "retiree_nomc_choose_canwork", + "nongroup_nomc_choose_canwork", + "dead", + } + assert schedule.at(60)["dead"] is schedule.at(61)["dead"] + + +def test_bequest_continuation_is_not_removed(model): + regime = model.user_regimes["retiree_oamc_forced_forcedout"] + assert set(regime.regime_transitions.resolve(model.ages).at(94)) == {"dead"} + assert "consumption_dollars" in regime.actions + assert (94, "retiree_oamc_forced_forcedout") in model.reachability.nodes + assert (95, "dead") in model.reachability.nodes + + +def test_terminal_regime_keeps_the_original_none_declaration(model): + assert model.user_regimes["dead"].regime_transitions is None + assert { + (age, "dead") for age in model.ages.exact_values + } <= model.reachability.nodes From bf4c65480b097577dfc2affcad2f29b33ce328c3 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Mon, 28 Sep 2026 10:07:20 +0200 Subject: [PATCH 021/101] Test against pylcm without Regime.active Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f80a8f1..bf24f5c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@3775cbb6ea559eeecb7f4df8e03c3f8c1e50f086" + git+https://github.com/OpenSourceEconomics/pylcm.git@4f73efe34fd929b119cd10c9722bea5c10c2caf2" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 8f6ea5d18da78b55ab8f388dda359cc309f7fdef Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Mon, 28 Sep 2026 17:08:45 +0200 Subject: [PATCH 022/101] Declare the 51-61 entry regimes as the model's starting pairs 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- docs/dag-migration.md | 35 ++++++------ src/aca_model/aca/model.py | 15 ++--- src/aca_model/baseline/model.py | 9 +-- src/aca_model/baseline/regimes/__init__.py | 4 ++ src/aca_model/baseline/regimes/_common.py | 45 +++++++++++---- src/aca_model/benchmark.py | 16 ++---- src/aca_model/simulation.py | 41 +++++++++++++- tests/test_dag_coverage.py | 65 +++++++++++++++++++--- tests/test_nbegm_solve_validation.py | 2 +- tests/test_simulation.py | 33 ++++++++++- 10 files changed, 199 insertions(+), 66 deletions(-) diff --git a/docs/dag-migration.md b/docs/dag-migration.md index 67c1e46..a977f27 100644 --- a/docs/dag-migration.md +++ b/docs/dag-migration.md @@ -1,9 +1,14 @@ # ACA: one structural stage table, existing numerical programs -Requires the proposed pylcm v3 schedule/support API, not unchanged PR #474. -Both factories forward optional `initial_regimes` unchanged. Default coverage -requires no separate population/activity table; an explicit entry restriction -changes admission, never solved values. Coverage remains exactly **182** nodes. +Both factories declare the same admissible starts, `INITIAL_REGIMES`: ages 51–61 +(`AgeRange(start=start_age, stop=ss_early_age)`) in the five `*_inelig_canwork` +regimes, 55 pairs. Every later age, and `dead` at any age, is reached only through +transitions; the engine derives the solved domain from these roots, **181** nodes. +`(51, "dead")` is not solved. Starts at age ≥ 62 or in `dead` are rejected at +admission. + +`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`. `_AGE_STAGES` and the host-side clock supply both numerical next-age routing and source-age support schedules. Each source's existing numerical transition body and @@ -12,20 +17,12 @@ specialization per boundary. Code order/global regime IDs and existing parameter paths remain; different target schemas can still need different continuation programs, so no backend compile-time improvement is claimed. -The dead regime remains `regime_transitions=None` at every age, as in the supplied base. -Age 94 retains its living consumption/saving problem and handoff into the separate -age-95 bequest. The three-to-two health grid, claim/lagged-work entry and exit, -carried pension state and phase semantics are not simplified away. +The dead regime remains `regime_transitions=None` at every age. Age 94 retains its +living consumption/saving problem and handoff into the separate age-95 bequest. The +three-to-two health grid, claim/lagged-work entry and exit, carried pension state +and phase semantics are not simplified away. Use `model.reachability.nodes` and `model.initial_nodes`; keep the existing phase -target queries and period-keyed solutions. Normalize schedules before existing -solver validation. A wrapper alone does not make a supported solver case invalid; -genuine unsupported variation must raise via current errors, without silently -changing actions or solver. GridSearch is the first numerical acceptance target. - -The original transition test module is retained byte-for-byte; new coverage tests -check nodes, boundary support, shared probability cells and terminal declarations. -Offline JAX row equality is not real model construction, Bellman/panel equivalence, -compiled validation/handoff/replay acceptance or GPU evidence. Run those gates and -matched build/compile/warm timings in the supported package environment. Annual -ACA calibration is not automatically valid on a differently spaced clock. +target queries and period-keyed solutions. GridSearch is the first numerical +acceptance target. Annual ACA calibration is not automatically valid on a +differently spaced clock. diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index 30408cb..a8af7a6 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -8,12 +8,17 @@ from typing import Any from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model -from lcm.typing import InitialRegimes, UserParams +from lcm.typing import UserParams from aca_model.aca import PolicyVariant from aca_model.aca.regimes import build_all_regimes from aca_model.baseline.model import _fail_if_dcegm_without_consumption_points -from aca_model.baseline.regimes import RegimeId, SolverName, build_model_slots +from aca_model.baseline.regimes import ( + INITIAL_REGIMES, + RegimeId, + SolverName, + build_model_slots, +) from aca_model.config import MODEL_AGES, GridConfig from aca_model.execution import execution_config_for_devices @@ -29,7 +34,6 @@ def create_model( solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, execution_config: ExecutionConfig | None = None, - initial_regimes: InitialRegimes | None = None, ) -> Model: """Create an ACA policy variant model. @@ -54,9 +58,6 @@ def create_model( consumption_dollars_points: Construction-time consumption action gridpoints; required under DC-EGM. See `aca_model.baseline.model.create_model`. - initial_regimes: Optional simulation-entry contract, not a solve-domain - restriction. None permits every covered node; an empty mapping makes - the model solve-only. Empirical starting pairs stay in the data/recipe. execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. @@ -90,7 +91,7 @@ def create_model( return Model( regimes=regimes, - initial_regimes=initial_regimes, + initial_regimes=INITIAL_REGIMES, ages=ages, regime_id_class=RegimeId, description=f"Structural retirement model ({policy.name})", diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 06cdc08..abe4ea8 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -14,9 +14,10 @@ from typing import Any from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model -from lcm.typing import InitialRegimes, UserParams +from lcm.typing import UserParams from aca_model.baseline.regimes import ( + INITIAL_REGIMES, RegimeId, SolverName, build_all_regimes, @@ -36,7 +37,6 @@ def create_model( solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, execution_config: ExecutionConfig | None = None, - initial_regimes: InitialRegimes | None = None, ) -> Model: """Create the baseline structural retirement model. @@ -70,9 +70,6 @@ def create_model( continuous-action grid at model construction); `None` keeps the runtime-points grid completed per iteration via `inject_consumption_dollars_points`. - initial_regimes: Optional simulation-entry contract, not a solve-domain - restriction. None permits every covered node; an empty mapping makes - the model solve-only. Empirical starting pairs stay in the data/recipe. execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. @@ -104,7 +101,7 @@ def create_model( return Model( regimes=regimes, - initial_regimes=initial_regimes, + initial_regimes=INITIAL_REGIMES, ages=ages, regime_id_class=RegimeId, description="Baseline structural retirement model (pre-ACA)", diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index dbbb5aa..f11e95f 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -21,6 +21,8 @@ from aca_model.baseline.regimes import _retiree as retiree from aca_model.baseline.regimes import _tied as tied from aca_model.baseline.regimes._common import ( + ENTRY_REGIMES, + INITIAL_REGIMES, REGIME_SPECS, Grids, RegimeId, @@ -37,6 +39,8 @@ from aca_model.config import GridConfig __all__ = [ + "ENTRY_REGIMES", + "INITIAL_REGIMES", "REGIME_SPECS", "RegimeId", "build_all_regimes", diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 260c6cd..1fde127 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -420,18 +420,43 @@ def _compute_max_annual_labor_income( # Structural stage data, shared by scheduled support and the numerical router. # Intervals select existing grid points, not integer-year ranges. _AGE_STAGES = ( - (AgeRange(config.start_age, config.ss_early_age), ("nomc_inelig", "dimc_inelig")), ( - AgeRange(config.ss_early_age, config.medicare_age), + AgeRange(start=config.start_age, stop=config.ss_early_age), + ("nomc_inelig", "dimc_inelig"), + ), + ( + AgeRange(start=config.ss_early_age, stop=config.medicare_age), ("nomc_choose", "dimc_choose"), ), - (AgeRange(config.medicare_age, config.ss_forced_age), ("forced_choose",) * 2), ( - AgeRange(config.ss_forced_age, config.work_forced_out_age), + AgeRange(start=config.medicare_age, stop=config.ss_forced_age), + ("forced_choose",) * 2, + ), + ( + AgeRange(start=config.ss_forced_age, stop=config.work_forced_out_age), ("forced_forced",) * 2, ), - (AgeRange(config.work_forced_out_age, MODEL_AGES[-1]), ("forcedout",) * 2), + ( + AgeRange(start=config.work_forced_out_age, stop=MODEL_AGES[-1]), + ("forcedout",) * 2, + ), ) +# The living regimes a subject may start in: pre-claiming, pre-Medicare. +ENTRY_REGIMES = ( + "retiree_nomc_inelig_canwork", + "tied_nomc_inelig_canwork", + "nongroup_nomc_inelig_canwork", + "retiree_dimc_inelig_canwork", + "nongroup_dimc_inelig_canwork", +) + +# Admissible starting (age, regime) pairs, shared by every model variant: ages +# before early claiming. Later ages, and `dead` at any age, are reached only +# through transitions. +INITIAL_REGIMES = MappingProxyType( + {AgeRange(start=config.start_age, stop=config.ss_early_age): ENTRY_REGIMES} +) + _STAGE_KEY = { ("nomc", "inelig", "canwork"): "nomc_inelig", ("dimc", "inelig", "canwork"): "dimc_inelig", @@ -506,7 +531,7 @@ def build_scheduled_regime_transition( int(getattr(RegimeId, name)): name for name in (*REGIME_SPECS, "dead") } return ByAge( - { + cases={ tuple(source_ages): {id_to_name[i]: cells[id_to_name[i]] for i in ids} for ids, source_ages in grouped.items() } @@ -583,7 +608,7 @@ def build_granular_regime_transition( declared = sorted({*(int(i) for i in target_ids), int(RegimeId.dead)}) return { id_to_name[target_id]: MarkovTransition( - _prob_of_target(transition_func=transition_func, target_id=target_id) + func=_prob_of_target(transition_func=transition_func, target_id=target_id) ) for target_id in declared } @@ -796,7 +821,7 @@ def build_model_state_transitions() -> dict: """ return { "pref_type": fixed_transition("pref_type"), - "spousal_income": MarkovTransition(labor_market.next_spousal_income), + "spousal_income": MarkovTransition(func=labor_market.next_spousal_income), # Carried state: evolved only in simulate (in solve, `pension_wealth` # is re-imputed from AIME each period and has no transition). "pension_wealth": pensions.wealth_next_before_adjustment, @@ -1115,9 +1140,9 @@ def _build_per_target_regime_health( target_is_post65 = target_spec["mc"] == "oamc" if spec["mc"] != "oamc" and target_is_post65: - result[target_name] = MarkovTransition(health.next_health_cross) + result[target_name] = MarkovTransition(func=health.next_health_cross) else: - result[target_name] = MarkovTransition(health.next_health) + result[target_name] = MarkovTransition(func=health.next_health) return result diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index 54fe41c..b392a87 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -36,6 +36,7 @@ from aca_model.agent.preferences import BenchmarkPrefType from aca_model.baseline.health_insurance import HealthInsuranceState from aca_model.baseline.model import create_model +from aca_model.baseline.regimes import ENTRY_REGIMES from aca_model.config import BENCHMARK_GRID_CONFIG from aca_model.consumption_dollars_grid import ( compute_consumption_dollars_points, @@ -57,16 +58,9 @@ "pref_type": DiscreteGrid(BenchmarkPrefType), } -# Five regimes active at start_age=51 (inelig + canwork). All have +# The five entry regimes (inelig + canwork). All have # HealthWithDisability (3-state); four have lagged_labor_supply (tied # does not — it's implied by the regime). -_INITIAL_REGIMES = ( - "retiree_nomc_inelig_canwork", - "tied_nomc_inelig_canwork", - "nongroup_nomc_inelig_canwork", - "retiree_dimc_inelig_canwork", - "nongroup_dimc_inelig_canwork", -) def create_benchmark_model( @@ -157,19 +151,19 @@ def get_benchmark_initial_conditions( ) -> dict[str, Array]: """Draw random feasible initial conditions across five age-51 regimes. - Every subject gets a random regime from `_INITIAL_REGIMES`; continuous + Every subject gets a random regime from `ENTRY_REGIMES`; continuous states are drawn uniformly over the regime's grid range, discrete states uniformly over categories. States absent from a subject's regime are filled with 0 (pylcm ignores them for that regime). """ rng = np.random.default_rng(seed) - regime_ids = tuple(model.regime_names_to_ids[n] for n in _INITIAL_REGIMES) + regime_ids = tuple(model.regime_names_to_ids[n] for n in ENTRY_REGIMES) regime = rng.choice(regime_ids, size=n_subjects).astype(np.int32) # Grid ranges come from any of the five regimes (shared structure). # Use to_jax() so the helper handles both LinSpacedGrid and # PiecewiseLinSpacedGrid (the latter has no `.start` / `.stop`). - ref_regime = model.user_regimes[_INITIAL_REGIMES[0]] + ref_regime = model.user_regimes[ENTRY_REGIMES[0]] grids = ref_regime.states # Every state read here is a plain `Grid`; `pension_wealth` (a # carried state) is never indexed, so the union widening diff --git a/src/aca_model/simulation.py b/src/aca_model/simulation.py index 6760bab..a71bec7 100644 --- a/src/aca_model/simulation.py +++ b/src/aca_model/simulation.py @@ -40,12 +40,49 @@ def simulate_with_dense_index( on the result panel. """ - original_ids = np.asarray(initial_conditions.index) - dense = initial_conditions.reset_index(drop=True) + admissible = select_admissible_starts( + model=model, initial_conditions=initial_conditions + ) + original_ids = np.asarray(admissible.index) + dense = admissible.reset_index(drop=True) result = model.simulate(initial_conditions=dense, **simulate_kwargs) return result, original_ids +def select_admissible_starts( + *, model: Any, initial_conditions: pd.DataFrame +) -> pd.DataFrame: + """Keep the rows that start at or before the model's last admissible start age. + + Rows older than every age in `model.initial_nodes` are outside the sample + the model is meant to simulate and are dropped. Every kept row must start at + an admissible `(age, regime_name)` pair; any other kept row raises. + + Args: + model: A pylcm `Model` (anything exposing `initial_nodes`). + initial_conditions: Seed DataFrame with `age` and `regime_name` columns. + + Returns: + The rows of `initial_conditions` inside the model's entry ages. + + Raises: + ValueError: If a kept row starts at a pair outside `model.initial_nodes`. + + """ + admitted = {(float(age), regime) for age, regime in model.initial_nodes} + last_entry_age = max(age for age, _ in admitted) + kept = initial_conditions.loc[initial_conditions["age"] <= last_entry_age] + pairs = set(zip(kept["age"].astype(float), kept["regime_name"], strict=True)) + _fail_if_starts_not_admitted(refused=pairs - admitted) + return kept + + +def _fail_if_starts_not_admitted(*, refused: set[tuple[float, str]]) -> None: + if refused: + msg = f"Initial conditions start at inadmissible pairs: {sorted(refused)}" + raise ValueError(msg) + + def restore_subject_ids( panel: pd.DataFrame, original_ids: NDArray[Any], diff --git a/tests/test_dag_coverage.py b/tests/test_dag_coverage.py index d865905..c2b8310 100644 --- a/tests/test_dag_coverage.py +++ b/tests/test_dag_coverage.py @@ -1,7 +1,13 @@ -"""Real-package acceptance tests; requires the new upstream law-coverage API.""" +"""The baseline's solved domain derives from its declared starting pairs.""" +import jax.numpy as jnp import pytest from helpers.model import make_baseline_model +from lcm.exceptions import InvalidInitialConditionsError + +from aca_model.baseline.regimes import ENTRY_REGIMES +from aca_model.benchmark import get_benchmark_initial_conditions, get_benchmark_params +from aca_model.config import MODEL_CONFIG @pytest.fixture(scope="module") @@ -9,15 +15,58 @@ def model(): return make_baseline_model() -def test_full_coverage_without_a_duplicate_entry_table(model): - assert len(model.reachability.nodes) == 182 - assert model.initial_nodes == model.reachability.nodes - assert (51, "dead") in model.reachability.nodes - assert (64, "retiree_nomc_choose_canwork") in model.initial_nodes - assert (65, "retiree_nomc_choose_canwork") not in model.initial_nodes +def test_initial_nodes_are_the_age_51_to_61_entry_regimes(model): + expected = { + (age, regime) + for age in range(MODEL_CONFIG.start_age, MODEL_CONFIG.ss_early_age) + for regime in ENTRY_REGIMES + } + assert {(float(a), r) for a, r in model.initial_nodes} == { + (float(a), r) for a, r in expected + } + + +def test_solved_domain_has_181_nodes(model): + assert len(model.reachability.nodes) == 181 + + +def test_dead_at_the_first_age_is_not_solved(model): + assert (51, "dead") not in model.reachability.nodes + + +def test_later_living_nodes_are_solved_but_not_admissible(model): + node = (64, "retiree_nomc_choose_canwork") + assert (node in model.reachability.nodes, node in model.initial_nodes) == ( + True, + False, + ) + + +def test_regimes_carry_no_active_attribute(model): assert not any(hasattr(r, "active") for r in model.user_regimes.values()) +@pytest.mark.parametrize( + ("age", "regime"), + [ + (62.0, "retiree_nomc_choose_canwork"), + (69.0, "retiree_oamc_choose_canwork"), + (51.0, "dead"), + (60.0, "dead"), + ], +) +def test_simulate_rejects_starts_outside_the_entry_contract(model, age, regime): + _, _, params = get_benchmark_params(model=model) + ic = get_benchmark_initial_conditions(model=model, n_subjects=2, seed=0) + ic = { + **ic, + "age": jnp.full(2, age), + "regime_id": jnp.full(2, model.regime_names_to_ids[regime], dtype=jnp.int32), + } + with pytest.raises(InvalidInitialConditionsError): + model.simulate(params=params, initial_conditions=ic, log_level="off") + + def test_boundary_targets_change_but_numeric_cells_are_shared(model): schedule = model.user_regimes[ "retiree_nomc_inelig_canwork" @@ -46,5 +95,5 @@ def test_bequest_continuation_is_not_removed(model): def test_terminal_regime_keeps_the_original_none_declaration(model): assert model.user_regimes["dead"].regime_transitions is None assert { - (age, "dead") for age in model.ages.exact_values + (age, "dead") for age in model.ages.exact_values[1:] } <= model.reachability.nodes diff --git a/tests/test_nbegm_solve_validation.py b/tests/test_nbegm_solve_validation.py index e8e6484..bbe2265 100644 --- a/tests/test_nbegm_solve_validation.py +++ b/tests/test_nbegm_solve_validation.py @@ -66,7 +66,7 @@ def test_nbegm_m1_value_function_agrees_with_brute_in_the_bulk() -> None: bq = _solve_m1("nbegm") brute = _solve_m1("brute_force") - assert bq.keys() == brute.keys() + assert bq.keys() == brute.keys() == set(range(11)) rel_diffs = [] for period in bq: finite_bq = np.isfinite(bq[period]) diff --git a/tests/test_simulation.py b/tests/test_simulation.py index fd50755..f74458d 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +import pytest from aca_model.simulation import restore_subject_ids, simulate_with_dense_index @@ -11,6 +12,7 @@ class _StubModel: def __init__(self) -> None: self.seen_index: list[int] = [] + self.initial_nodes = frozenset({(51, "a"), (51, "b"), (51, "c"), (52, "a")}) def simulate(self, *, initial_conditions: pd.DataFrame, **_kwargs: object) -> str: self.seen_index = list(initial_conditions.index) @@ -24,7 +26,7 @@ def test_simulate_with_dense_index_hands_pylcm_a_dense_range() -> None: `subject_id` positionally, so a sparse id index would index out of bounds. """ ic = pd.DataFrame( - {"regime_name": ["a", "b", "c"]}, + {"regime_name": ["a", "b", "c"], "age": [51, 51, 51]}, index=pd.Index([3010, 500_000_000, 959_738_010], name="id"), ) model = _StubModel() @@ -35,7 +37,7 @@ def test_simulate_with_dense_index_hands_pylcm_a_dense_range() -> None: def test_simulate_with_dense_index_returns_ids_aligned_to_subject() -> None: """Returned ids align to subject_id: position i is the id of subject i.""" ic = pd.DataFrame( - {"regime_name": ["a", "b"]}, + {"regime_name": ["a", "b"], "age": [51, 51]}, index=pd.Index([3010, 959_738_010], name="id"), ) _result, ids = simulate_with_dense_index( @@ -52,3 +54,30 @@ def test_restore_subject_ids_maps_positional_subject_back_to_original() -> None: np.testing.assert_array_equal( out["id"].to_numpy(), [3010, 959_738_010, 3010, 959_738_010] ) + + +def test_simulate_with_dense_index_drops_starts_after_the_last_entry_age() -> None: + """Rows older than every admissible start age never reach the simulator.""" + ic = pd.DataFrame( + {"regime_name": ["a", "b", "a"], "age": [51, 62, 52]}, + index=pd.Index([10, 20, 30], name="id"), + ) + _result, ids = simulate_with_dense_index( + model=_StubModel(), initial_conditions=ic, params={} + ) + np.testing.assert_array_equal(ids, [10, 30]) + + +@pytest.mark.parametrize( + ("age", "regime"), [(51, "dead"), (52, "b")], ids=["dead", "off-contract"] +) +def test_simulate_with_dense_index_rejects_inadmissible_pairs_within_entry_ages( + age: int, regime: str +) -> None: + """A row inside the entry ages but outside the entry pairs raises.""" + ic = pd.DataFrame( + {"regime_name": ["a", regime], "age": [51, age]}, + index=pd.Index([10, 20], name="id"), + ) + with pytest.raises(ValueError, match=regime): + simulate_with_dense_index(model=_StubModel(), initial_conditions=ic, params={}) From ad38653696ec366e318ac61b9a81b597a4ecb700 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Mon, 28 Sep 2026 19:06:28 +0200 Subject: [PATCH 023/101] Narrow the admissible starting ages to 51-60 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- docs/dag-migration.md | 8 ++++---- src/aca_model/baseline/regimes/_common.py | 6 +++--- src/aca_model/config.py | 1 + tests/test_dag_coverage.py | 20 ++++++++++++++++++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/dag-migration.md b/docs/dag-migration.md index a977f27..189f1e8 100644 --- a/docs/dag-migration.md +++ b/docs/dag-migration.md @@ -1,10 +1,10 @@ # ACA: one structural stage table, existing numerical programs -Both factories declare the same admissible starts, `INITIAL_REGIMES`: ages 51–61 -(`AgeRange(start=start_age, stop=ss_early_age)`) in the five `*_inelig_canwork` -regimes, 55 pairs. Every later age, and `dead` at any age, is reached only through +Both factories declare the same admissible starts, `INITIAL_REGIMES`: ages 51–60 +(`AgeRange(start=start_age, stop=last_start_age + 1)`) in the five `*_inelig_canwork` +regimes, 50 pairs. Every later age, and `dead` at any age, is reached only through transitions; the engine derives the solved domain from these roots, **181** nodes. -`(51, "dead")` is not solved. Starts at age ≥ 62 or in `dead` are rejected at +`(51, "dead")` is not solved. Starts at age ≥ 61 or in `dead` are rejected at admission. `simulate_with_dense_index` drops initial-condition rows older than the last diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 1fde127..e76a008 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -450,11 +450,11 @@ def _compute_max_annual_labor_income( "nongroup_dimc_inelig_canwork", ) -# Admissible starting (age, regime) pairs, shared by every model variant: ages -# before early claiming. Later ages, and `dead` at any age, are reached only +# Admissible starting (age, regime) pairs, shared by every model variant: the +# baseline estimation-sample ages. Later ages, and `dead` at any age, are reached only # through transitions. INITIAL_REGIMES = MappingProxyType( - {AgeRange(start=config.start_age, stop=config.ss_early_age): ENTRY_REGIMES} + {AgeRange(start=config.start_age, stop=config.last_start_age + 1): ENTRY_REGIMES} ) _STAGE_KEY = { diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 0f5885c..e81b45c 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -12,6 +12,7 @@ @dataclass(frozen=True) class ModelConfig: start_age: int = 51 + last_start_age: int = 60 end_age: int = 96 ss_early_age: int = 62 ss_forced_age: int = 70 diff --git a/tests/test_dag_coverage.py b/tests/test_dag_coverage.py index c2b8310..ae845b8 100644 --- a/tests/test_dag_coverage.py +++ b/tests/test_dag_coverage.py @@ -1,6 +1,7 @@ """The baseline's solved domain derives from its declared starting pairs.""" import jax.numpy as jnp +import pandas as pd import pytest from helpers.model import make_baseline_model from lcm.exceptions import InvalidInitialConditionsError @@ -8,6 +9,7 @@ from aca_model.baseline.regimes import ENTRY_REGIMES from aca_model.benchmark import get_benchmark_initial_conditions, get_benchmark_params from aca_model.config import MODEL_CONFIG +from aca_model.simulation import select_admissible_starts @pytest.fixture(scope="module") @@ -15,10 +17,10 @@ def model(): return make_baseline_model() -def test_initial_nodes_are_the_age_51_to_61_entry_regimes(model): +def test_initial_nodes_are_the_age_51_to_60_entry_regimes(model): expected = { (age, regime) - for age in range(MODEL_CONFIG.start_age, MODEL_CONFIG.ss_early_age) + for age in range(MODEL_CONFIG.start_age, MODEL_CONFIG.last_start_age + 1) for regime in ENTRY_REGIMES } assert {(float(a), r) for a, r in model.initial_nodes} == { @@ -49,6 +51,7 @@ def test_regimes_carry_no_active_attribute(model): @pytest.mark.parametrize( ("age", "regime"), [ + (61.0, "retiree_nomc_inelig_canwork"), (62.0, "retiree_nomc_choose_canwork"), (69.0, "retiree_oamc_choose_canwork"), (51.0, "dead"), @@ -97,3 +100,16 @@ def test_terminal_regime_keeps_the_original_none_declaration(model): assert { (age, "dead") for age in model.ages.exact_values[1:] } <= model.reachability.nodes + + +def test_select_admissible_starts_drops_starts_at_61_and_above(model): + ic = pd.DataFrame( + { + "age": [60.0, 61.0, 62.0], + "regime_name": ["retiree_nomc_inelig_canwork"] * 2 + + ["retiree_nomc_choose_canwork"], + }, + index=pd.Index([1, 2, 3], name="id"), + ) + kept = select_admissible_starts(model=model, initial_conditions=ic) + assert list(kept.index) == [1] From 57eda2223e11e4d1f86a4b338e7bf85de9f28744 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:04:53 +0200 Subject: [PATCH 024/101] Test the per-type transformed bequest weight in the dead regime Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_preferences.py | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_preferences.py b/tests/test_preferences.py index 26aa941..5cdb090 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -3,10 +3,16 @@ Parameter values from struct-ret PreferenceParameters fixture. """ +from collections.abc import Callable +from typing import Any + import jax.numpy as jnp import numpy as np +import pytest +from dags import concatenate_functions from aca_model.agent import preferences +from aca_model.baseline.regimes._common import build_dead_regime, build_model_functions # Struct-ret preference parameters. Tests call DAG functions directly, so # every scalar fixed_param is supplied as a 0-d jax array (the type pylcm @@ -296,3 +302,64 @@ def test_bequest_floors_estate_base_when_debt_exceeds_the_shifter() -> None: expected = scale * bwt * 1.0 / (1.0 - coefficient_rra) np.testing.assert_allclose(result, expected, atol=1e-9) + + +# --- dead-regime bequest utility per preference type --- + +# Draft estimates (struct-ret original_data/baseline/final/prefs.json). +_DRAFT_CONSUMPTION_WEIGHTS = jnp.asarray( + [0.6776541629520845, 0.8805772328591686, 0.0718086283445225] +) +_DRAFT_COEFFICIENTS_RRA = jnp.asarray( + [3.841252231680976, 0.9990771146810682, 3.8328505891095137] +) +_DRAFT_DISCOUNT_FACTORS = jnp.asarray( + [0.839255583514979, 0.9121549199895648, 1.0595836603747046] +) + + +def _dead_regime_utility() -> Callable[..., Any]: + """Compose the dead regime's utility DAG as pylcm merges it. + + Model-level functions come first; the dead regime's own entries override + them, and `None` entries mask a model-level function out of the regime. + """ + functions = dict(build_model_functions()) + for name, func in build_dead_regime().functions.items(): + if func is None: + functions.pop(name, None) + else: + functions[name] = func + return concatenate_functions(functions, targets="utility") + + +@pytest.mark.parametrize( + ("pref_type", "expected"), + [ + (0, -75.59884789663738), + (1, 39.12342314306149), + (2, -12.47026859098475), + ], +) +def test_dead_regime_bequest_uses_per_type_transformed_weight( + pref_type: int, expected: float +) -> None: + """At the draft estimates and r = 0.05, the bequest utility of 100k assets + equals `θ_B(type) · (A + κ)^((1-ν)γ) / (1-ν) · scale(type)` with the + per-type `θ_B = T^ξ (bw / (1 + r - bw))^ξ₂ / β(type)`.""" + utility = _dead_regime_utility() + result = utility( + assets=jnp.asarray(100_000.0), + pref_type=jnp.asarray(pref_type), + bequest_shifter=jnp.asarray(334460.20744719007), + bequest_weight=jnp.asarray(0.02861082652580584), + consumption_weights=_DRAFT_CONSUMPTION_WEIGHTS, + coefficients_rra=_DRAFT_COEFFICIENTS_RRA, + discount_factor_by_type=_DRAFT_DISCOUNT_FACTORS, + time_endowment=jnp.asarray(3926.9478390365557), + rate_of_return=jnp.asarray(0.05), + average_consumption_equiv=jnp.asarray(20_000.0), + fixed_cost_of_work_intercept=jnp.asarray(337.5223349543413), + reference_hours=jnp.asarray(1000.0), + ) + np.testing.assert_allclose(result, expected, rtol=1e-9) From a13aeca6dab8cb5135feebf4b4a340c1441a38a3 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:05:33 +0200 Subject: [PATCH 025/101] TST Expect the ACA Medicaid expansion to reach the disabled under 65 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_aca_policies.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_aca_policies.py b/tests/test_aca_policies.py index 8392e90..b93f944 100644 --- a/tests/test_aca_policies.py +++ b/tests/test_aca_policies.py @@ -303,14 +303,18 @@ def test_medicaid_eligible_aca_expansion_excludes_aged() -> None: assert not result -def test_medicaid_eligible_aca_expansion_excludes_disabled() -> None: - """Disabled households are not reached by the expansion track.""" +def test_medicaid_eligible_aca_expansion_includes_disabled_under_65() -> None: + """A disabled under-65 household below the MAGI threshold gets expansion cover. + + The expansion has no asset test, so the household qualifies even though + it fails the categorical track (`is_ssi_eligible` is False). + """ result = aca_hi.is_medicaid_eligible( **_aca_medicaid_kwargs( is_disabled=jnp.asarray(True), aca_magi=jnp.array(10000.0) ) ) - assert not result + assert result def test_medicaid_eligible_aca_ignores_assets() -> None: From b69fe0c8d6af61a89e1f5a1e636581e76bbb7be9 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:06:57 +0200 Subject: [PATCH 026/101] Extend the ACA Medicaid expansion to the disabled under 65 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 5 +++-- src/aca_model/aca/health_insurance.py | 17 ++++++----------- src/aca_model/aca/regimes/_overrides.py | 7 +++---- tests/test_aca_policies.py | 4 +++- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a11a970..2866e0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,8 +122,9 @@ ACA variants don't create new regimes — they swap functions on baseline regime *categorical* track — `(crossed_oamc_threshold OR is_disabled)` AND the SSI asset and income tests (on SSI countable income) — and, under the ACA Medicaid-expansion variant, an *income-only* track — `aca_magi < 138% FPL`, scoped to the under-65 - non-disabled population. The expansion uses MAGI (full income via `aca_magi`), - distinct from the half-counted SSI countable income of the categorical track. + population whatever their health (the disabled under 65 included, without the asset + test). The expansion uses MAGI (full income via `aca_magi`), distinct from the + half-counted SSI countable income of the categorical track. - **`crossed_oamc_threshold`**: per-regime constant fixed param (`= spec["mc"] == "oamc"`, i.e. age ≥ 65), the *aged* indicator in eligibility. It replaced the `gets_medicare` gate there; `is_disabled` (= `health == disabled`, a DAG diff --git a/src/aca_model/aca/health_insurance.py b/src/aca_model/aca/health_insurance.py index 9e23c71..10885b2 100644 --- a/src/aca_model/aca/health_insurance.py +++ b/src/aca_model/aca/health_insurance.py @@ -116,7 +116,6 @@ def is_medicaid_eligible( aca_magi: FloatND, spousal_income: DiscreteState, crossed_oamc_threshold: ScalarBool, - is_disabled: BoolND, medicaid_schedule: MappingLeaf, ) -> BoolND: """Determine Medicaid eligibility on two tracks under ACA expansion. @@ -125,22 +124,18 @@ def is_medicaid_eligible( - **Categorical** (SSI-linked): `is_ssi_eligible`, which applies the aged-or-disabled gate plus the SSI asset and income tests. - - **Expansion** (ACA): the under-65, non-disabled population with MAGI - below the expansion threshold (138% FPL encoded in - `medicaid_schedule["income_threshold"]`). Expansion never reaches the - aged or the disabled; they stay on the categorical track with its - asset test. + - **Expansion** (ACA): every under-65 household with MAGI below the + expansion threshold (138% FPL encoded in + `medicaid_schedule["income_threshold"]`), regardless of assets or + health, so the disabled under 65 qualify without the asset test. The + aged stay on the categorical track with its asset test. Expansion uses full-count MAGI, not the half-counted SSI `countable_income`. """ sched = cast("Mapping[str, Any]", medicaid_schedule.data) threshold = sched["income_threshold"] - expansion = ( - (~crossed_oamc_threshold) - & (~is_disabled) - & (aca_magi < threshold[spousal_income]) - ) + expansion = (~crossed_oamc_threshold) & (aca_magi < threshold[spousal_income]) return is_ssi_eligible | expansion diff --git a/src/aca_model/aca/regimes/_overrides.py b/src/aca_model/aca/regimes/_overrides.py index ff78245..9c10ba5 100644 --- a/src/aca_model/aca/regimes/_overrides.py +++ b/src/aca_model/aca/regimes/_overrides.py @@ -21,10 +21,9 @@ def apply_aca_overrides( Three orthogonal feature flags derived from the policy variant: - **Medicaid expansion**: two-track eligibility (categorical SSI plus the - under-65 non-disabled MAGI expansion) installed on all regimes. The - expansion arm is internally scoped to the under-65 non-disabled - population, so post-65 and disabled households keep the categorical - track with its asset test. + under-65 MAGI expansion) installed on all regimes. The expansion arm is + internally scoped to the under-65 population, disabled or not, so only + post-65 households keep the categorical track with its asset test. - **Subsidies**: premium credits, cost-sharing reductions, and their consuming functions (nongroup+nomc only). All mask to their neutral value when Medicaid-eligible (minimum-essential coverage). diff --git a/tests/test_aca_policies.py b/tests/test_aca_policies.py index b93f944..1c327d6 100644 --- a/tests/test_aca_policies.py +++ b/tests/test_aca_policies.py @@ -254,6 +254,7 @@ def _aca_medicaid_kwargs(**overrides: object) -> dict: Defaults describe an under-65, non-disabled household that is not categorically eligible; tests override the expansion-relevant fields. + Only the arguments `is_medicaid_eligible` reads are passed on. """ base = { "is_ssi_eligible": jnp.array(False), @@ -264,7 +265,8 @@ def _aca_medicaid_kwargs(**overrides: object) -> dict: "medicaid_schedule": MEDICAID_SCHEDULE, } base.update(overrides) - return base + read = inspect.signature(aca_hi.is_medicaid_eligible).parameters + return {name: value for name, value in base.items() if name in read} def test_medicaid_eligible_aca_expansion_below_threshold() -> None: From 28e4d72ab552731482a57f1eddab85a6d1e3b168 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:07:47 +0200 Subject: [PATCH 027/101] TST Expect employer coverage to survive expansion-only Medicaid eligibility Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_aca_employer_coverage.py | 110 ++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_aca_employer_coverage.py diff --git a/tests/test_aca_employer_coverage.py b/tests/test_aca_employer_coverage.py new file mode 100644 index 0000000..3acc207 --- /dev/null +++ b/tests/test_aca_employer_coverage.py @@ -0,0 +1,110 @@ +"""The ACA Medicaid expansion leaves employer-provided coverage untouched. + +A retiree or tied household whose only Medicaid route is the ACA MAGI +expansion keeps its employer coverage next period. Only categorical (SSI) +eligibility moves an employer-covered household to non-group. +""" + +from collections.abc import Callable + +import jax.numpy as jnp +import pytest +from dags import concatenate_functions +from lcm.params import MappingLeaf + +from aca_model.aca import health_insurance as aca_hi +from aca_model.agent.labor_market import LaborSupply +from aca_model.baseline import health_insurance +from aca_model.baseline.health_insurance import HealthInsuranceState +from aca_model.baseline.regimes._common import RegimeId, make_targets +from aca_model.baseline.regimes._retiree import ( + _make_transition_canwork as retiree_canwork, +) +from aca_model.baseline.regimes._tied import _make_transition_canwork as tied_canwork +from aca_model.config import MODEL_CONFIG + +N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age +SURVIVAL = jnp.ones(N_PERIODS) * 0.99 +MEDICAID_SCHEDULE = MappingLeaf( + {"income_threshold": jnp.array([16104.6, 21707.4, 21707.4])} +) + +# Under 65, not categorically eligible, MAGI below 138% FPL: eligible only +# through the expansion. +_EXPANSION_ONLY_INPUTS = { + "is_ssi_eligible": jnp.array(False), + "aca_magi": jnp.array(10000.0), + "spousal_income": jnp.int32(0), + "crossed_oamc_threshold": jnp.asarray(False), + "medicaid_schedule": MEDICAID_SCHEDULE, +} + + +def _live_target(probs: jnp.ndarray) -> int: + return int(jnp.argmax(probs.at[RegimeId.dead].set(0.0))) + + +def _compose_with_aca_eligibility( + target_name: str, target_func: Callable, inputs: dict +) -> dict: + """Evaluate `target_func` in a DAG that also carries ACA Medicaid eligibility.""" + combined = concatenate_functions( + {"is_medicaid_eligible": aca_hi.is_medicaid_eligible, target_name: target_func}, + targets=[target_name, "is_medicaid_eligible"], + return_type="dict", + ) + return combined(**inputs) + + +@pytest.mark.parametrize( + ("regime_name", "make_transition", "extra_inputs", "expected"), + [ + ( + "retiree_nomc_inelig_canwork", + retiree_canwork, + {"labor_supply": jnp.array(LaborSupply.h2000)}, + RegimeId.retiree_nomc_inelig_canwork, + ), + ( + "tied_nomc_inelig_canwork", + tied_canwork, + {"labor_supply": jnp.array(LaborSupply.h2000)}, + RegimeId.tied_nomc_inelig_canwork, + ), + ], +) +def test_expansion_only_medicaid_keeps_employer_coverage( + regime_name: str, + make_transition: Callable, + extra_inputs: dict, + expected: int, +) -> None: + """An employer-covered household eligible only via the expansion keeps it.""" + own, ng = make_targets(regime_name) + transition = make_transition(gets_medicare=False, own=own, ng=ng) + result = _compose_with_aca_eligibility( + "next_regime", + transition, + { + **_EXPANSION_ONLY_INPUTS, + **extra_inputs, + "age": jnp.int32(55), + "period": jnp.int32(55 - MODEL_CONFIG.start_age), + "survival_probs": SURVIVAL, + }, + ) + assert _live_target(result["next_regime"]) == expected + + +def test_expansion_only_medicaid_keeps_retiree_his_for_pension_imputation() -> None: + """The pension imputation's target HIS stays retiree for an expansion-only case.""" + result = _compose_with_aca_eligibility( + "target_his", + health_insurance.target_his, + { + **_EXPANSION_ONLY_INPUTS, + "his": jnp.int32(HealthInsuranceState.retiree), + "labor_supply": jnp.array(LaborSupply.h2000), + }, + ) + assert int(result["target_his"]) == HealthInsuranceState.retiree From b0b5ed104a93a5f65a8199cbb0e6d3bfcfb1a402 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:08:33 +0200 Subject: [PATCH 028/101] Keep employer coverage for households eligible only through the ACA expansion 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 6 +++++- src/aca_model/baseline/health_insurance.py | 13 +++++++------ src/aca_model/baseline/regimes/_retiree.py | 19 +++++++++++-------- src/aca_model/baseline/regimes/_tied.py | 12 +++++++----- tests/test_regime_transitions.py | 18 +++++++++--------- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2866e0b..c2e5c14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,11 @@ ACA variants don't create new regimes — they swap functions on baseline regime variant, an *income-only* track — `aca_magi < 138% FPL`, scoped to the under-65 population whatever their health (the disabled under 65 included, without the asset test). The expansion uses MAGI (full income via `aca_magi`), distinct from the - half-counted SSI countable income of the categorical track. + half-counted SSI countable income of the categorical track. Regime (HIS) transitions + and the pension `target_his` read the categorical track only (`is_ssi_eligible`): the + ACA leaves employer coverage untouched, so a retiree/tied household eligible only + through the expansion keeps its employer coverage, with Medicaid paying on top of its + OOP. - **`crossed_oamc_threshold`**: per-regime constant fixed param (`= spec["mc"] == "oamc"`, i.e. age ≥ 65), the *aged* indicator in eligibility. It replaced the `gets_medicare` gate there; `is_disabled` (= `health == disabled`, a DAG diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index 91f2503..b7c598f 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -333,13 +333,14 @@ def is_medicaid_eligible(is_ssi_eligible: BoolND) -> BoolND: def target_his( his: IntND, labor_supply: DiscreteAction, - is_medicaid_eligible: BoolND, + is_ssi_eligible: BoolND, ) -> IntND: """Return the HIS class of the surviving target regime. Mirrors the cross-HIS branches inside `_make_transition_canwork` (retiree, tied, nongroup): tied agents who stop working become nongroup, and - Medicaid-eligible agents are overridden to nongroup. Used by + categorically (SSI-) Medicaid-eligible agents are overridden to nongroup. + The ACA Medicaid expansion leaves employer coverage untouched. Used by `imputed_pension_wealth_next_period` to look up next-period imputation coefficients at the target's HIS. """ @@ -347,7 +348,7 @@ def target_his( labor_supply == LaborSupply.do_not_work ) return jnp.where( - tied_to_ng | is_medicaid_eligible, + tied_to_ng | is_ssi_eligible, HealthInsuranceState.nongroup, his, ).astype(jnp.int32) @@ -355,18 +356,18 @@ def target_his( def target_his_forcedout( his: IntND, - is_medicaid_eligible: BoolND, + is_ssi_eligible: BoolND, ) -> IntND: """Return the HIS class of the surviving target regime in forced-out regimes. Forced-out regimes have no labor-supply choice, and tied agents have already moved to nongroup before the forced-out age, so the only HIS - override is Medicaid eligibility → nongroup. Used by + override is categorical (SSI) Medicaid eligibility → nongroup. Used by `imputed_pension_wealth_next_period` to look up next-period imputation coefficients at the target's HIS. """ return jnp.where( - is_medicaid_eligible, + is_ssi_eligible, HealthInsuranceState.nongroup, his, ).astype(jnp.int32) diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 309a71c..58ed524 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -1,7 +1,8 @@ """Regime transitions and builder for retiree HIS regimes. Retiree regimes: agents with employer-sponsored retiree health insurance. -Medicaid-eligible agents are overridden to nongroup. +Categorically (SSI-) Medicaid-eligible agents are overridden to nongroup; the +ACA Medicaid expansion leaves employer coverage untouched. """ from collections.abc import Callable @@ -41,23 +42,24 @@ def _make_transition_canwork( """Create transition for canwork retiree regimes. Retirees who stop working get Medicare (if gets_medicare). - Medicaid-eligible agents are overridden to nongroup targets. + Categorically (SSI-) Medicaid-eligible agents are overridden to nongroup + targets. """ def transition( age: Age, period: Period, labor_supply: DiscreteAction, - is_medicaid_eligible: BoolND, + is_ssi_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: sp = survival_probs[period] next_age = age + 1 mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) target = select_target_for_age(next_age, mc_next, own) - # Medicaid eligibility overrides to nongroup + # Categorical Medicaid eligibility overrides to nongroup ng_ssi = select_target_for_age(next_age, mc_next, ng) - target = jnp.where(is_medicaid_eligible, ng_ssi, target) + target = jnp.where(is_ssi_eligible, ng_ssi, target) return build_regime_probs(target, sp) return transition @@ -70,20 +72,21 @@ def _make_transition_forcedout( ) -> Callable[..., FloatND]: """Create transition for forcedout retiree regimes. - No labor supply action. Medicaid-eligible agents are overridden to nongroup. + No labor supply action. Categorically (SSI-) Medicaid-eligible agents are + overridden to nongroup. """ def transition( age: Age, period: Period, - is_medicaid_eligible: BoolND, + is_ssi_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: sp = survival_probs[period] next_age = age + 1 target = select_target_for_age(next_age, gets_medicare, own) ng_ssi = select_target_for_age(next_age, gets_medicare, ng) - target = jnp.where(is_medicaid_eligible, ng_ssi, target) + target = jnp.where(is_ssi_eligible, ng_ssi, target) return build_regime_probs(target, sp) return transition diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index ff13664..a5d952d 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -2,7 +2,8 @@ Tied regimes: agents with employer-tied health insurance. Tied agents who stop working become nongroup. -Medicaid-eligible agents are also overridden to nongroup. +Categorically (SSI-) Medicaid-eligible agents are also overridden to nongroup; +the ACA Medicaid expansion leaves employer coverage untouched. """ from collections.abc import Callable @@ -42,14 +43,15 @@ def _make_transition_canwork( """Create transition for canwork tied regimes. Tied agents who stop working become nongroup (lose employer coverage). - Medicaid-eligible agents are also overridden to nongroup targets. + Categorically (SSI-) Medicaid-eligible agents are also overridden to + nongroup targets. """ def transition( age: Age, period: Period, labor_supply: DiscreteAction, - is_medicaid_eligible: BoolND, + is_ssi_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: sp = survival_probs[period] @@ -60,9 +62,9 @@ def transition( stopped = labor_supply == LaborSupply.do_not_work ng_target = select_target_for_age(next_age, mc_next, ng) target = jnp.where(stopped, ng_target, target) - # Medicaid eligibility overrides to nongroup + # Categorical Medicaid eligibility overrides to nongroup ng_ssi = select_target_for_age(next_age, mc_next, ng) - target = jnp.where(is_medicaid_eligible, ng_ssi, target) + target = jnp.where(is_ssi_eligible, ng_ssi, target) return build_regime_probs(target, sp) return transition diff --git a/tests/test_regime_transitions.py b/tests/test_regime_transitions.py index 9040fdc..6a97d93 100644 --- a/tests/test_regime_transitions.py +++ b/tests/test_regime_transitions.py @@ -48,7 +48,7 @@ def test_tied_stop_working_becomes_nongroup() -> None: age=jnp.int32(55), period=jnp.int32(4), labor_supply=jnp.array(LaborSupply.do_not_work), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -65,7 +65,7 @@ def test_tied_keeps_working_stays_tied() -> None: age=jnp.int32(55), period=jnp.int32(4), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -84,7 +84,7 @@ def test_retiree_medicaid_override_to_nongroup() -> None: age=jnp.int32(55), period=jnp.int32(4), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(True), + is_ssi_eligible=jnp.array(True), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -100,7 +100,7 @@ def test_retiree_not_medicaid_stays_retiree() -> None: age=jnp.int32(55), period=jnp.int32(4), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -115,7 +115,7 @@ def test_retiree_forcedout_medicaid_override() -> None: probs = transition( age=jnp.int32(80), period=jnp.int32(29), - is_medicaid_eligible=jnp.array(True), + is_ssi_eligible=jnp.array(True), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -155,7 +155,7 @@ def test_retiree_age_bracket_transitions( age=jnp.asarray(age), period=period, labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -206,7 +206,7 @@ def test_tied_medicaid_override_to_nongroup() -> None: age=jnp.int32(55), period=jnp.int32(4), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(True), + is_ssi_eligible=jnp.array(True), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -223,7 +223,7 @@ def test_tied_at_medicare_age_with_medicaid() -> None: age=jnp.int32(64), period=period, labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(True), + is_ssi_eligible=jnp.array(True), survival_probs=SURVIVAL, ) target = _target_from_probs(probs) @@ -241,7 +241,7 @@ def test_survival_prob_determines_death_weight() -> None: age=jnp.int32(55), period=jnp.int32(4), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=survival, ) assert jnp.isclose(probs[RegimeId.dead], 0.15, atol=1e-6) From 3bbcc5a8e629b1740b06c4088edf0d3f76112f1b Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:10:05 +0200 Subject: [PATCH 029/101] TST Pin budget, Social Security and pension rules at production values 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/helpers/production_rules.py | 731 ++++++++++++++++++++++++++++++ tests/test_budget_rules.py | 387 ++++++++++++++++ 2 files changed, 1118 insertions(+) create mode 100644 tests/helpers/production_rules.py create mode 100644 tests/test_budget_rules.py diff --git a/tests/helpers/production_rules.py b/tests/helpers/production_rules.py new file mode 100644 index 0000000..83ac3a0 --- /dev/null +++ b/tests/helpers/production_rules.py @@ -0,0 +1,731 @@ +"""Social Security and pension inputs at their production values (2014 rules). + +Built from the raw aca-data inputs (`social_security_rules.json`, `AIMEfrac_012207.txt`, +the pension coefficient CSVs). Every per-age tuple is period-indexed: entry `i` holds +age `51 + i`, ages 51-95. +""" + +START_AGE = 51 + +PIA_AIME_GRID = (0.0, 9792.0, 59004.0, 117000.0, 187954.75) +PIA_TABLE = (0.0, 8812.7998046875, 24560.640625, 33260.0390625, 43903.25390625) +NORMAL_RETIREMENT_AGE = 66 +EARNINGS_TEST_REPEALED_AGE = 67 +AIME_KINK_2 = 117000.0 +AIME_ACCRUAL_FACTOR = 0.02857142857142857 +AGGREGATE_WAGE_GROWTH = 0.008 +SSDI_SUBSTANTIAL_GAINFUL_ACTIVITY = 12840.0 +AIME_LAST_AGE_WITH_INDEXING = 60 +MEDICARE_AGE = 65 +EARLY_RET_ADJUSTMENT = ( + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.75, + 0.8, + 0.866666666667, + 0.933333333333, + 1.0, + 1.08, + 1.16, + 1.24, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, + 1.32, +) +EARNINGS_TEST_THRESHOLD = ( + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 15480.0, + 41400.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, + 1000000000000.0, +) +EARNINGS_TEST_FRACTION = ( + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.33333333333333, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, +) +EARNINGS_TEST_CREDITED_BACK = ( + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.06666666666666665, + 0.08333333333374981, + 0.07692307692227796, + 0.0714285714289542, + 0.08000000000000007, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, +) +RATIO_LOWEST_EARNINGS = ( + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.10672, + 0.21343, + 0.32015, + 0.42687, + 0.53358, + 0.56986, + 0.58915, + 0.60016, + 0.60782, + 0.61424, + 0.61571, + 0.61694, + 0.61769, + 0.61821, + 0.61873, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, + 0.61925, +) +DI_DROPOUT_SCALE = ( + 1.2083333333333333, + 1.2, + 1.1923076923076923, + 1.1851851851851851, + 1.1785714285714286, + 1.1724137931034482, + 1.1666666666666667, + 1.1631093333333333, + 1.1560166926186666, + 1.1454424427860417, + 1.1314722994845539, + 1.1142228426400118, + 1.099433715124906, + 1.087559039482935, + 1.0787779344134025, + 1.0731317905082343, + 1.0706203950623945, + 1.0691946690817968, + 1.0683311937577702, + 1.0677867771027343, + 1.0674641949164902, + 1.0673248083147346, + 1.0672149229623997, + 1.0671432229312787, + 1.0670948043279977, + 1.0670625262355393, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, + 1.0670463874334053, +) +DI_DROPOUT_NEXT_PERIOD_RATIO = ( + 1.0069444444444444, + 1.0064516129032257, + 1.0060096153846154, + 1.0056116722783388, + 1.0052521008403363, + 1.0049261083743841, + 1.0030584685647208, + 1.0061354137530663, + 1.0092315854884033, + 1.0123468716890833, + 1.0154811552809953, + 1.0134515863136193, + 1.0109186492051196, + 1.0081398634412255, + 1.0052613704627036, + 1.0023457384684824, + 1.0013334578087842, + 1.0008082468517927, + 1.000509855213335, + 1.0003021948537292, + 1.000130594361407, + 1.0001029645950132, + 1.0000671887611525, + 1.0000453742283109, + 1.0000302494855406, + 1.0000151247427704, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, +) +IMP_INTERCEPT = ( + (-12910.289654931415, -11444.233693705028, -13263.514860994796), + (-12109.012655612423, -11277.312062238974, -12910.546833118284), + (-11279.981383814986, -11120.553833266282, -12536.714764203674), + (-10423.195839539105, -10973.959006786954, -12142.01865425097), + (-9538.656022784777, -10837.527582800987, -11726.458503260168), + (-8626.361933552005, -10711.25956130839, -11290.034311231271), + (-7686.313571840789, -10595.154942309153, -10832.74607816428), + (-6718.510937651128, -10489.21372580328, -10354.593804059192), + (-5722.954030983024, -10393.43591179077, -9855.577488916007), + (-4699.642851836475, -10307.821500271624, -9335.697132734727), + (-3648.57740021148, -10232.370491245842, -8794.952735515351), + (-2569.757676108042, -10167.082884713423, -8233.34429725788), + (-1463.1836795261593, -10111.958680674372, -7650.871817962313), + (-328.85541046583194, -10066.99787912868, -7047.5352976286495), + (833.2271310729416, -10032.200480076352, -6423.334736256889), + (2023.0639450901576, -10007.566483517388, -5778.270133847034), + (3240.6550315858185, -9993.09588945179, -5112.341490399083), + (4486.000390559924, -9988.788697879554, -4425.548805913037), + (5759.100022012473, -9994.64490880068, -3717.892080388894), + (7059.95392594347, -10010.664522215173, -2989.371313826653), + (8388.56210235291, -10036.847538123026, -2239.986506226318), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), + (9744.92455124079, -10073.193956524248, -1469.7376575878916), +) +IMP_PIA_COEFF = ( + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), + (1.877047452602557, 1.877047452602557, 1.877047452602557), +) +IMP_PIA_KINK_0_COEFF = ( + (-2.6823413559119853, -2.6823413559119853, -2.6823413559119853), + (-2.5062022601604563, -2.5062022601604563, -2.5062022601604563), + (-2.321110595527509, -2.321110595527509, -2.321110595527509), + (-2.127066362013144, -2.127066362013144, -2.127066362013144), + (-1.92406955961736, -1.92406955961736, -1.92406955961736), + (-1.7121201883401571, -1.7121201883401571, -1.7121201883401571), + (-1.4912182481815368, -1.4912182481815368, -1.4912182481815368), + (-1.261363739141498, -1.261363739141498, -1.261363739141498), + (-1.0225566612200407, -1.0225566612200407, -1.0225566612200407), + (-0.7747970144171654, -0.7747970144171654, -0.7747970144171654), + (-0.5180847987328717, -0.5180847987328717, -0.5180847987328717), + (-0.2524200141671596, -0.2524200141671596, -0.2524200141671596), + (0.0221973392799706, 0.0221973392799706, 0.0221973392799706), + (0.3057672616085193, 0.3057672616085193, 0.3057672616085193), + (0.5982897528184863, 0.5982897528184863, 0.5982897528184863), + (0.8997648129098714, 0.8997648129098714, 0.8997648129098714), + (1.210192441882675, 1.210192441882675, 1.210192441882675), + (1.529572639736897, 1.529572639736897, 1.529572639736897), + (1.857905406472537, 1.857905406472537, 1.857905406472537), + (2.1951907420895957, 2.1951907420895957, 2.1951907420895957), + (2.541428646588072, 2.541428646588072, 2.541428646588072), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), + (2.8966191199679674, 2.8966191199679674, 2.8966191199679674), +) +IMP_PIA_KINK_1_COEFF = ( + (-3.1582574731568576, -3.1582574731568576, -3.1582574731568576), + (-2.3263985293592504, -2.3263985293592504, -2.3263985293592504), + (-1.586173944257751, -1.586173944257751, -1.586173944257751), + (-0.9375837178523592, -0.9375837178523592, -0.9375837178523592), + (-0.3806278501430754, -0.3806278501430754, -0.3806278501430754), + (0.084693658870101, 0.084693658870101, 0.084693658870101), + (0.4583808091871695, 0.4583808091871695, 0.4583808091871695), + (0.7404336008081298, 0.7404336008081298, 0.7404336008081298), + (0.9308520337329828, 0.9308520337329828, 0.9308520337329828), + (1.029636107961728, 1.029636107961728, 1.029636107961728), + (1.036785823494366, 1.036785823494366, 1.036785823494366), + (0.9523011803308954, 0.9523011803308954, 0.9523011803308954), + (0.7761821784713181, 0.7761821784713181, 0.7761821784713181), + (0.5084288179156298, 0.5084288179156298, 0.5084288179156298), + (0.1490410986638366, 0.1490410986638366, 0.1490410986638366), + (-0.3019809792840622, -0.3019809792840622, -0.3019809792840622), + (-0.8446374159280712, -0.8446374159280712, -0.8446374159280712), + (-1.4789282112681867, -1.4789282112681867, -1.4789282112681867), + (-2.2048533653044142, -2.2048533653044142, -2.2048533653044142), + (-3.0224128780367465, -3.0224128780367465, -3.0224128780367465), + (-3.9316067494651854, -3.9316067494651854, -3.9316067494651854), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), + (-4.932434979589736, -4.932434979589736, -4.932434979589736), +) +IMP_KINK_0 = ( + 8575.974479744078, + 8731.448831139582, + 8886.923182535085, + 9042.397533930589, + 9256.587112476413, + 9402.182994638755, + 9319.930704810571, + 9524.355960657978, + 9617.03283358649, + 9799.654678462908, + 10423.8659145107, + 10691.70033560747, + 11394.924408430852, + 12025.115878719977, + 12173.105198328823, + 12722.358154802065, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, + 12565.048054151552, +) +IMP_KINK_1 = ( + 11572.45542008772, + 11713.599948182897, + 11854.744476278069, + 11995.889004373244, + 12238.228113539872, + 12363.52639198924, + 12385.693839410424, + 12563.353470856087, + 12714.252790399893, + 12996.920909081768, + 13858.934848147588, + 14514.609937658775, + 15516.08194652413, + 16305.915470536642, + 16454.00202972665, + 17087.67191139636, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, + 17329.483780991442, +) +ACCRUAL_INTERCEPT = ( + (-0.2810633901901035, -0.3583711901901036, -0.3345061901901036), + (-0.2810633901901035, -0.3583711901901036, -0.3345061901901036), + (-0.1727824073816185, -0.2373314073816186, -0.2020886073816186), + (-0.0725590702150753, -0.1238664702150753, -0.0813164702150753), + (0.0203416095257619, -0.017375690474238, 0.0289008095257619), + (0.1065212124433541, 0.0826084124433541, 0.1295206124433542), + (0.1864475575493455, 0.1764196575493454, 0.2213666575493454), + (0.2604555946507882, 0.2642586946507883, 0.3051294946507882), + (0.328746373554735, 0.346192673554735, 0.3813660735547349), + (0.3913877132728298, 0.4221552132728298, 0.4504999132728298), + (0.4483137944075337, 0.4919463944075336, 0.5128209944075337), + (0.3206231352723865, 0.3696765352723865, 0.3405695352723865), + (0.3653881562752405, 0.4259920562752404, 0.3896019562752404), + (0.4034406373204525, 0.4747365373204524, 0.4318915373204525), + (0.074266228649094, 0.118775128649094, 0.144751328649094), + (0.096955948404771, 0.150037648404771, 0.172685148404771), + (0.1107455160331468, 0.1711392160331468, 0.1927560160331468), + (0.1146131121366821, 0.1809239121366821, 0.2042977121366821), + (0.1074476557270211, 0.1781465557270211, 0.2065551557270211), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), + (0.0879604966112162, 0.1613843966112162, 0.1985952966112162), +) +ACCRUAL_LOG_EARNINGS = ( + (0.0438052, 0.0438052, 0.0438052), + (0.0438052, 0.0438052, 0.0438052), + (0.0339093, 0.0339093, 0.0339093), + (0.0240996, 0.0240996, 0.0240996), + (0.0144505, 0.0144505, 0.0144505), + (0.0050361, 0.0050361, 0.0050361), + (-0.0040693, -0.0040693, -0.0040693), + (-0.0127915, -0.0127915, -0.0127915), + (-0.0210563, -0.0210563, -0.0210563), + (-0.0287894, -0.0287894, -0.0287894), + (-0.0359167, -0.0359167, -0.0359167), + (-0.0271691, -0.0271691, -0.0271691), + (-0.0328619, -0.0328619, -0.0328619), + (-0.0377262, -0.0377262, -0.0377262), + (-0.009307, -0.009307, -0.009307), + (-0.0122916, -0.0122916, -0.0122916), + (-0.0142249, -0.0142249, -0.0142249), + (-0.0150328, -0.0150328, -0.0150328), + (-0.014641, -0.014641, -0.014641), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), + (-0.0129753, -0.0129753, -0.0129753), +) +ACCRUAL_PROB_INTERCEPT = (-21.141658072405324, -24.804149517460925, -17.228156917864453) +ACCRUAL_PROB_LOG_EARNINGS = (2.9740129249502, 3.6243613249502, 2.8730428249502) +ACCRUAL_PROB_LOG_EARNINGS_SQ = ( + -0.1123136999999999, + -0.1123136999999999, + -0.1123136999999999, +) diff --git a/tests/test_budget_rules.py b/tests/test_budget_rules.py new file mode 100644 index 0000000..a4790ea --- /dev/null +++ b/tests/test_budget_rules.py @@ -0,0 +1,387 @@ +"""Budget, Social Security and pension rules at their production values. + +Expected values come from a literal scalar transcription of struct-ret's +formulas (or of the draft's formula where struct-ret departs from it), +evaluated at the same inputs. +""" + +import inspect +from collections.abc import Callable +from typing import Any + +import jax.numpy as jnp +import numpy as np +import pytest +from helpers import production_rules as pr + +from aca_model.agent import labor_market +from aca_model.agent.labor_market import LaborSupply, SpousalIncome +from aca_model.baseline.regimes._common import ( + REGIME_SPECS, + _select_aime_law, + build_model_constraints, +) +from aca_model.environment import pensions, social_security, taxes +from aca_model.environment.social_security import ClaimedSS + +PIA_AIME_GRID = jnp.asarray(pr.PIA_AIME_GRID) +PIA_TABLE = jnp.asarray(pr.PIA_TABLE) +EARLY_RET_ADJUSTMENT = jnp.asarray(pr.EARLY_RET_ADJUSTMENT) +EARNINGS_TEST_CREDITED_BACK = jnp.asarray(pr.EARNINGS_TEST_CREDITED_BACK) +RATIO_LOWEST_EARNINGS = jnp.asarray(pr.RATIO_LOWEST_EARNINGS) +DI_DROPOUT_SCALE = jnp.asarray(pr.DI_DROPOUT_SCALE) +DI_DROPOUT_NEXT_PERIOD_RATIO = jnp.asarray(pr.DI_DROPOUT_NEXT_PERIOD_RATIO) + + +def _shift_one_period_forward(values: tuple) -> jnp.ndarray: + """Return the period-indexed array whose row `t` carries the value at `t + 1`.""" + arr = jnp.asarray(values) + return jnp.concatenate([arr[1:], arr[-1:]], axis=0) + + +IMP_NEXT_PERIOD = { + "imp_intercept_next_period": _shift_one_period_forward(pr.IMP_INTERCEPT), + "imp_pia_coeff_next_period": _shift_one_period_forward(pr.IMP_PIA_COEFF), + "imp_pia_kink_0_coeff_next_period": _shift_one_period_forward( + pr.IMP_PIA_KINK_0_COEFF + ), + "imp_pia_kink_1_coeff_next_period": _shift_one_period_forward( + pr.IMP_PIA_KINK_1_COEFF + ), + "imp_kink_0_next_period": _shift_one_period_forward(pr.IMP_KINK_0), + "imp_kink_1_next_period": _shift_one_period_forward(pr.IMP_KINK_1), +} + +SS_RULES = { + "pia_table": PIA_TABLE, + "pia_aime_grid": PIA_AIME_GRID, + "normal_retirement_age": jnp.int32(pr.NORMAL_RETIREMENT_AGE), + "early_ret_adjustment": EARLY_RET_ADJUSTMENT, + "earnings_test_credited_back": EARNINGS_TEST_CREDITED_BACK, + "earnings_test_repealed_age": jnp.int32(pr.EARNINGS_TEST_REPEALED_AGE), + "aime_accrual_factor": jnp.asarray(pr.AIME_ACCRUAL_FACTOR), + "aggregate_wage_growth": jnp.asarray(pr.AGGREGATE_WAGE_GROWTH), + "aime_last_age_with_indexing": jnp.int32(pr.AIME_LAST_AGE_WITH_INDEXING), + "aime_kink_2": jnp.asarray(pr.AIME_KINK_2), + "ratio_lowest_earnings": RATIO_LOWEST_EARNINGS, + "medicare_age": jnp.int32(pr.MEDICARE_AGE), + "di_dropout_scale": DI_DROPOUT_SCALE, + "di_dropout_next_period_ratio": DI_DROPOUT_NEXT_PERIOD_RATIO, +} + + +def _period(age: int) -> jnp.ndarray: + return jnp.int32(age - pr.START_AGE) + + +def _call(func: Callable[..., Any], **kwargs: Any) -> Any: + """Call `func` with the subset of `kwargs` its signature names.""" + names = inspect.signature(func).parameters + return func(**{k: v for k, v in kwargs.items() if k in names}) + + +@pytest.mark.parametrize( + ("age", "pia_unadjusted", "pia_adjusted", "full_benefit_next", "expected"), + [ + (62, 18479.36, 17324.4, 35680.75116959574, 18117.163555158222), + (63, 18479.36, 17057.87076922421, 37436.31858689668, 18035.38135778553), + (66, 18479.36, 19957.7088, 44113.51778285309, 19000.883559951508), + (68, 24710.04, 26414.180689655175, 58432.219638177565, 25466.839034926863), + ], +) +def test_total_to_pia_keeps_social_security_plus_imputed_pension_total( + age: int, + pia_unadjusted: float, + pia_adjusted: float, + full_benefit_next: float, + expected: float, +) -> None: + """The PIA carried into the next period reproduces the claim-adjusted total. + + For a retiree-HIS household at a 18.19% marginal tax rate, + `PIA* + (1 - τ) pbmax_{t+1}(PIA*) = PIA_adj + (1 - τ) pbmax_{t+1}(PIA_unadj)`. + """ + result = pensions.total_to_pia( + pia_adjusted_next_period=jnp.asarray(pia_adjusted), + pia_unadjusted_next_period=jnp.asarray(pia_unadjusted), + full_benefit_next_period=jnp.asarray(full_benefit_next), + target_his=jnp.int32(0), + period=_period(age), + marginal_tax_rate=jnp.asarray(0.1819), + **IMP_NEXT_PERIOD, + ) + np.testing.assert_allclose(result, expected, rtol=1e-10) + + +def test_total_to_pia_without_claim_adjustment_returns_unadjusted_pia() -> None: + """Without a claim adjustment or credit the carried PIA is the accrued PIA.""" + result = pensions.total_to_pia( + pia_adjusted_next_period=jnp.asarray(5000.0), + pia_unadjusted_next_period=jnp.asarray(5000.0), + full_benefit_next_period=jnp.asarray(0.0), + target_his=jnp.int32(0), + period=_period(62), + marginal_tax_rate=jnp.asarray(0.1819), + **IMP_NEXT_PERIOD, + ) + np.testing.assert_allclose(result, 5000.0, rtol=1e-12) + + +@pytest.mark.parametrize( + ("labor_supply", "ss_benefit", "expected"), + [ + # Claims at 62 and does not work: nothing is withheld. + (LaborSupply.do_not_work, 13859.52, 0.0), + # Claims at 62 and earns 10,000, below the 15,480 threshold. + (LaborSupply.h1000, 13859.52, 0.0), + # Claims at 62 and earns 30,000: 7,260 of the 13,859.52 benefit withheld. + (LaborSupply.h1000, 6599.52, 0.5238276650273602), + ], +) +def test_benefit_withheld_fraction_counts_only_earnings_test_withholding( + labor_supply: int, ss_benefit: float, expected: float +) -> None: + """The withheld fraction is the earnings-test share of the claimed benefit. + + AIME is credited back only for benefits the earnings test withheld, not + for the early-claiming reduction. AIME 40,000 (PIA 18,479.36), age 62. + """ + result = _call( + social_security.benefit_withheld_fraction, + pia=jnp.asarray(18479.36), + ss_benefit=jnp.asarray(ss_benefit), + age=jnp.int32(62), + period=_period(62), + claim_ss=jnp.int32(ClaimedSS.yes), + claimed_ss=jnp.int32(ClaimedSS.no), + labor_supply=jnp.int32(labor_supply), + normal_retirement_age=jnp.int32(pr.NORMAL_RETIREMENT_AGE), + early_ret_adjustment=EARLY_RET_ADJUSTMENT, + ) + np.testing.assert_allclose(result, expected, atol=1e-6) + + +SPOUSAL_INCOME_TABLE = jnp.zeros((45, 2, 3)) +SPOUSAL_INCOME_TABLE = SPOUSAL_INCOME_TABLE.at[4, :, SpousalIncome.married_has_inc].set( + jnp.asarray([20299.103515625, 21513.8984375]) +) +SPOUSAL_INCOME_TABLE = SPOUSAL_INCOME_TABLE.at[ + 11, :, SpousalIncome.married_has_inc +].set(jnp.asarray([22837.513671875, 23848.978515625])) + + +@pytest.mark.parametrize( + ("age", "good_health", "spousal_income", "expected"), + [ + (55, 0, SpousalIncome.married_has_inc, 20299.103515625), + (55, 1, SpousalIncome.married_has_inc, 21513.8984375), + (62, 1, SpousalIncome.married_has_inc, 23848.978515625), + (62, 1, SpousalIncome.married_no_inc, 0.0), + (62, 0, SpousalIncome.single, 0.0), + ], +) +def test_spousal_income_amount_varies_with_age_and_own_health( + age: int, good_health: int, spousal_income: int, expected: float +) -> None: + """Spousal income is read from the age × own-health × spousal-income table.""" + result = labor_market.spousal_income_amount( + period=_period(age), + good_health=jnp.int32(good_health), + spousal_income=jnp.int32(spousal_income), + spousal_income_amounts=SPOUSAL_INCOME_TABLE, + ) + np.testing.assert_allclose(result, expected, rtol=1e-12) + + +@pytest.mark.parametrize( + ("labor_income", "expected"), + [ + # Indexed by 0.8%, no earnings: 40,320 times the DI continuity ratio. + (0.0, 40531.764705882364), + # Indexed and accrues 12,000/35 of earnings, below the SGA threshold. + (12000.0, 40876.42256902762), + ], +) +def test_disabled_aime_accrues_indexing_and_earnings( + labor_income: float, expected: float +) -> None: + """A disabled agent's AIME is indexed and accrues earnings like anyone's. + + The DI continuity ratio then scales the accrued AIME. Age 55, AIME 40,000. + """ + result = social_security.next_aime_disabled_plain( + aime=jnp.asarray(40000.0), + labor_income=jnp.asarray(labor_income), + period=_period(55), + age=jnp.int32(55), + health=jnp.int32(0), + benefit_withheld_fraction=jnp.asarray(0.0), + earnings_test_credited_back=EARNINGS_TEST_CREDITED_BACK, + earnings_test_repealed_age=jnp.int32(pr.EARNINGS_TEST_REPEALED_AGE), + pia_table=PIA_TABLE, + pia_aime_grid=PIA_AIME_GRID, + aime_accrual_factor=jnp.asarray(pr.AIME_ACCRUAL_FACTOR), + aggregate_wage_growth=jnp.asarray(pr.AGGREGATE_WAGE_GROWTH), + aime_last_age_with_indexing=jnp.int32(pr.AIME_LAST_AGE_WITH_INDEXING), + aime_kink_2=jnp.asarray(pr.AIME_KINK_2), + ratio_lowest_earnings=RATIO_LOWEST_EARNINGS, + medicare_age=jnp.int32(pr.MEDICARE_AGE), + di_dropout_scale=DI_DROPOUT_SCALE, + di_dropout_next_period_ratio=DI_DROPOUT_NEXT_PERIOD_RATIO, + ) + np.testing.assert_allclose(result, expected, rtol=1e-10) + + +@pytest.mark.parametrize( + ("age", "aime", "labor_income"), + [ + (70, 40000.0, 60000.0), + (71, 131305.0, 0.0), + ], +) +def test_aime_is_frozen_once_claiming_is_forced( + age: int, aime: float, labor_income: float +) -> None: + """From the forced-claim age on, AIME neither accrues nor is capped.""" + law = _select_aime_law(REGIME_SPECS["retiree_oamc_forced_canwork"]) + result = _call( + law, + aime=jnp.asarray(aime), + labor_income=jnp.asarray(labor_income), + period=_period(age), + age=jnp.int32(age), + benefit_withheld_fraction=jnp.asarray(0.0), + **SS_RULES, + ) + np.testing.assert_allclose(result, aime, rtol=1e-12) + + +def test_accrued_aime_is_capped_at_the_taxable_maximum() -> None: + """Accrual from earnings cannot lift AIME above the taxable maximum. + + Age 66, AIME 116,000, earnings 117,000: AIME accrues to the 117,000 cap, + whose PIA is 33,260.04. + """ + result = social_security.pia_unadjusted_next_period( + aime=jnp.asarray(116000.0), + labor_income=jnp.asarray(117000.0), + period=_period(66), + age=jnp.int32(66), + pia_table=PIA_TABLE, + pia_aime_grid=PIA_AIME_GRID, + aime_accrual_factor=jnp.asarray(pr.AIME_ACCRUAL_FACTOR), + aggregate_wage_growth=jnp.asarray(pr.AGGREGATE_WAGE_GROWTH), + aime_last_age_with_indexing=jnp.int32(pr.AIME_LAST_AGE_WITH_INDEXING), + aime_kink_2=jnp.asarray(pr.AIME_KINK_2), + ratio_lowest_earnings=RATIO_LOWEST_EARNINGS, + ) + np.testing.assert_allclose(result, 33260.04, atol=0.01) + + +@pytest.mark.parametrize( + ("age", "his", "expected"), + [ + (70, 0, -190.86435823930114), + (70, 1, 696.7748524892276), + (71, 0, 0.0), + (71, 1, 0.0), + ], +) +def test_pension_accrual_stops_at_the_pension_claiming_age( + age: int, his: int, expected: float +) -> None: + """Pension wealth accrues from earnings through age 70 and not at 71. + + Earnings 40,000. + """ + result = _call( + pensions.accrual, + labor_income=jnp.asarray(40000.0), + period=_period(age), + age=jnp.int32(age), + his=jnp.int32(his), + accrual_intercept=jnp.asarray(pr.ACCRUAL_INTERCEPT), + accrual_log_earnings=jnp.asarray(pr.ACCRUAL_LOG_EARNINGS), + accrual_prob_intercept=jnp.asarray(pr.ACCRUAL_PROB_INTERCEPT), + accrual_prob_log_earnings=jnp.asarray(pr.ACCRUAL_PROB_LOG_EARNINGS), + accrual_prob_log_earnings_sq=jnp.asarray(pr.ACCRUAL_PROB_LOG_EARNINGS_SQ), + ) + np.testing.assert_allclose(result, expected, rtol=1e-8, atol=1e-8) + + +SINGLE_FLOOR = 1597.09 +MARRIED_FLOOR = SINGLE_FLOOR * 2.0**0.7 + + +@pytest.mark.parametrize( + ("consumption", "expected"), + [ + (SINGLE_FLOOR, False), + (MARRIED_FLOOR, True), + ], +) +def test_married_household_cannot_consume_below_its_own_floor( + consumption: float, expected: bool +) -> None: + """A married household at the transfer floor consumes exactly its own floor. + + Cash on hand 500 is topped up to the married floor; consuming the lower + single floor is infeasible. + """ + constraints = build_model_constraints(solver="brute_force") + feasible = all( + bool( + _call( + constraint, + consumption_dollars=jnp.asarray(consumption), + cash_on_hand=jnp.asarray(500.0), + consumption_dollars_floor=jnp.asarray(MARRIED_FLOOR), + ) + ) + for constraint in constraints.values() + ) + assert feasible is expected + + +@pytest.mark.parametrize( + ("gross_income", "labor_income", "expected_surtax"), + [ + # Unearned income 250,000: 3.8% on the 50,000 above 200,000. + (250000.0, 0.0, 1900.0), + # Earnings 250,000: 0.9% on the 50,000 above 200,000. + (250000.0, 250000.0, 450.0), + # Earnings 250,000 and unearned income 250,000. + (500000.0, 250000.0, 2350.0), + # Unearned income 150,000 and earnings 150,000: both below the thresholds. + (300000.0, 150000.0, 0.0), + ], +) +def test_aca_surtaxes_reduce_after_tax_income( + gross_income: float, labor_income: float, expected_surtax: float +) -> None: + """The ACA adds a 3.8% tax on unearned and 0.9% on earned income above 200,000.""" + result = taxes.after_tax_income_with_aca_surtaxes( + after_tax_income_before_aca_surtaxes=jnp.asarray(100000.0), + gross_income=jnp.asarray(gross_income), + labor_income=jnp.asarray(labor_income), + ) + np.testing.assert_allclose(result, 100000.0 - expected_surtax, rtol=1e-12) + + +@pytest.mark.parametrize( + ("gross_income", "labor_income", "expected_surtax"), + [ + (250000.0, 0.0, 1900.0), + (250000.0, 250000.0, 0.0), + ], +) +def test_aca_investment_surtax_alone_spares_earnings( + gross_income: float, labor_income: float, expected_surtax: float +) -> None: + """Without the payroll surtax only unearned income above 200,000 is taxed.""" + result = taxes.after_tax_income_with_aca_investment_surtax( + after_tax_income_before_aca_surtaxes=jnp.asarray(100000.0), + gross_income=jnp.asarray(gross_income), + labor_income=jnp.asarray(labor_income), + ) + np.testing.assert_allclose(result, 100000.0 - expected_surtax, rtol=1e-12) From 7fd408c5506914cf4d5635d502eba19f8ac3de66 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:12:30 +0200 Subject: [PATCH 030/101] Test health-dependent mortality, terminal age 95 and the sinh asset grid Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_assets_grid.py | 49 +++++++++++++++ tests/test_mortality.py | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/test_assets_grid.py create mode 100644 tests/test_mortality.py diff --git a/tests/test_assets_grid.py b/tests/test_assets_grid.py new file mode 100644 index 0000000..6d61223 --- /dev/null +++ b/tests/test_assets_grid.py @@ -0,0 +1,49 @@ +"""Tests for the asset state grid.""" + +import numpy as np +import pytest + +from aca_model.baseline.regimes._common import build_assets_grid + +FLOOR = -221_270.85 + + +def _points(n_points: int = 24) -> np.ndarray: + return np.asarray(build_assets_grid(floor=FLOOR, n_points=n_points).to_jax()) + + +def test_assets_grid_top_covers_largest_initial_assets() -> None: + """The top node is 12 million, the largest initial asset holding.""" + assert _points()[-1] == 12_000_000.0 + + +def test_assets_grid_bottom_is_the_borrowing_limit() -> None: + """The bottom node is the borrowing limit passed in.""" + assert _points()[0] == FLOOR + + +def test_assets_grid_has_a_node_at_zero() -> None: + """Zero assets is a node: the asset tests and the floor bite next to it.""" + assert np.any(_points() == 0.0) + + +def test_assets_grid_is_dense_near_zero() -> None: + """At least five nodes lie in [0, 30k], as in struct-ret's grid.""" + points = _points() + assert int(((points >= 0.0) & (points <= 30_000.0)).sum()) >= 5 + + +@pytest.mark.parametrize("n_points", [3, 8, 24]) +def test_assets_grid_has_requested_number_of_points(n_points: int) -> None: + """The node count is the configured `n_assets_gridpoints`.""" + assert len(_points(n_points)) == n_points + + +@pytest.mark.parametrize( + ("index", "expected"), + [(5, 0.0), (6, 4_460.02), (9, 27_305.37), (15, 377_388.77), (19, 2_128_241.24)], +) +def test_assets_grid_nodes_are_sinh_spaced(index: int, expected: float) -> None: + """Positive nodes are `10k * sinh(u)` with `u` evenly spaced from 0 to + `asinh(12M / 10k)` over 19 nodes.""" + np.testing.assert_allclose(_points()[index], expected, rtol=1e-6) diff --git a/tests/test_mortality.py b/tests/test_mortality.py new file mode 100644 index 0000000..03b2f73 --- /dev/null +++ b/tests/test_mortality.py @@ -0,0 +1,126 @@ +"""Tests for health-dependent mortality and the terminal age.""" + +from collections.abc import Callable +from typing import Any + +import jax.numpy as jnp +import numpy as np +import pytest + +from aca_model.agent.labor_market import LaborSupply +from aca_model.baseline.regimes._common import ( + REGIME_SPECS, + RegimeId, + make_active_func, + make_targets, + select_target_for_age, +) +from aca_model.baseline.regimes._nongroup import ( + _make_transition_canwork as nongroup_canwork, +) +from aca_model.baseline.regimes._nongroup import ( + _make_transition_forcedout as nongroup_forcedout, +) +from aca_model.baseline.regimes._retiree import ( + _make_transition_canwork as retiree_canwork, +) +from aca_model.baseline.regimes._retiree import ( + _make_transition_forcedout as retiree_forcedout, +) +from aca_model.baseline.regimes._tied import _make_transition_canwork as tied_canwork +from aca_model.config import MODEL_CONFIG + +N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age +# Survival by (period, health): distinct per health state. +SURVIVAL = jnp.tile(jnp.array([0.90, 0.95, 0.99]), (N_PERIODS, 1)) + + +def _tied(health: int) -> jnp.ndarray: + own, ng = make_targets("tied_nomc_inelig_canwork") + return tied_canwork(gets_medicare=False, own=own, ng=ng)( + age=jnp.int32(55), + period=jnp.int32(4), + health=jnp.int32(health), + labor_supply=jnp.array(LaborSupply.h2000), + is_medicaid_eligible=jnp.array(False), + survival_probs=SURVIVAL, + ) + + +def _retiree(health: int) -> jnp.ndarray: + own, ng = make_targets("retiree_nomc_inelig_canwork") + return retiree_canwork(gets_medicare=False, own=own, ng=ng)( + age=jnp.int32(55), + period=jnp.int32(4), + health=jnp.int32(health), + labor_supply=jnp.array(LaborSupply.h2000), + is_medicaid_eligible=jnp.array(False), + survival_probs=SURVIVAL, + ) + + +def _retiree_forcedout(health: int) -> jnp.ndarray: + own, ng = make_targets("retiree_oamc_forced_forcedout") + return retiree_forcedout(gets_medicare=True, own=own, ng=ng)( + age=jnp.int32(80), + period=jnp.int32(29), + health=jnp.int32(health), + is_medicaid_eligible=jnp.array(False), + survival_probs=SURVIVAL, + ) + + +def _nongroup(health: int) -> jnp.ndarray: + own, _ng = make_targets("nongroup_nomc_inelig_canwork") + return nongroup_canwork(gets_medicare=False, own=own)( + age=jnp.int32(55), + period=jnp.int32(4), + health=jnp.int32(health), + labor_supply=jnp.array(LaborSupply.h2000), + survival_probs=SURVIVAL, + ) + + +def _nongroup_forcedout(health: int) -> jnp.ndarray: + own, _ng = make_targets("nongroup_oamc_forced_forcedout") + return nongroup_forcedout(gets_medicare=True, own=own)( + age=jnp.int32(80), + period=jnp.int32(29), + health=jnp.int32(health), + survival_probs=SURVIVAL, + ) + + +@pytest.mark.parametrize( + "transition", + [_tied, _retiree, _retiree_forcedout, _nongroup, _nongroup_forcedout], +) +@pytest.mark.parametrize(("health", "expected_death"), [(0, 0.10), (1, 0.05)]) +def test_death_probability_is_one_minus_survival_at_current_health( + transition: Callable[[int], Any], health: int, expected_death: float +) -> None: + """The dead regime gets `1 - survival_probs[period, health]`.""" + probs = transition(health) + np.testing.assert_allclose(probs[RegimeId.dead], expected_death, atol=1e-12) + + +def test_survivor_at_94_moves_to_forcedout_regime_at_95() -> None: + """95 is an age at which people can be alive.""" + own, _ng = make_targets("nongroup_oamc_forced_forcedout") + target = select_target_for_age(95, True, own) + assert int(target) == int(RegimeId.nongroup_oamc_forced_forcedout) + + +def test_everybody_is_dead_at_96() -> None: + """Nobody is alive at `end_age` (96).""" + own, _ng = make_targets("nongroup_oamc_forced_forcedout") + target = select_target_for_age(MODEL_CONFIG.end_age, True, own) + assert int(target) == int(RegimeId.dead) + + +@pytest.mark.parametrize( + "regime", ["nongroup_oamc_forced_forcedout", "retiree_oamc_forced_forcedout"] +) +def test_forcedout_regimes_are_active_at_95(regime: str) -> None: + """The work-forced-out regimes are active through the last alive age, 95.""" + assert bool(make_active_func(REGIME_SPECS[regime])(95)) From 1de2466fca2a192d5287a48c29378526f8b37fdf Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:13:44 +0200 Subject: [PATCH 031/101] Derive the per-type bequest weight from the bequest MPC in the dead regime The dead regime computes `scaled_bequest_weight` from the scalar bequest MPC `bequest_weight` and the type's consumption weight, CRRA coefficient and discount factor, so each preference type gets its own bequest weight. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 69519 -> 68945 bytes src/aca_model/agent/preferences.py | 16 ++++++++++------ src/aca_model/baseline/regimes/_common.py | 17 ++++++++++++++--- tests/test_preferences.py | 16 ++++++++-------- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 0447289b3312aa24583ec56331776a3e05e8a523..49d76798148c651130a6ff730dc8a6c52fcb0dd3 100644 GIT binary patch delta 11881 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 delta 12299 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 FloatND: - """Transform raw bequest weight into the form used in the bequest function. + """Per-type bequest weight `θ_B` from the estimated bequest MPC. result = T^ξ * (bw / (1+r-bw))^ξ₂ / β where ξ = (1-α)(1-γ) and ξ₂ = α(1-γ) - 1. + + `bw` is the marginal propensity to consume out of final-period wealth, one + scalar for all types; α, γ and β are the type's consumption weight, CRRA + coefficient and discount factor, so `θ_B` differs by preference type. """ xi = (1.0 - consumption_weight) * (1.0 - coefficient_rra) xi2 = consumption_weight * (1.0 - coefficient_rra) - 1.0 @@ -302,7 +306,7 @@ def scaled_bequest_weight( bequest_weight > 0.0, time_endowment**xi * (safe_bw / (1.0 + rate_of_return - safe_bw)) ** xi2 - / time_discount_factor, + / discount_factor, 0.0, ) @@ -310,7 +314,7 @@ def scaled_bequest_weight( def bequest( assets: ContinuousState, bequest_shifter: ScalarFloat, - scaled_bequest_weight: ScalarFloat, + scaled_bequest_weight: FloatND, consumption_weight: FloatND, coefficient_rra: FloatND, utility_scale_factor: FloatND, diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6fb7e5b..b11c0db 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -536,14 +536,21 @@ def cell(*args: Any, **kwargs: Any) -> FloatND: # Broadcast functions the bequest DAG reads: the pref-type-indexed scalars # resolve their per-cell value from the broadcast `pref_type` state. _DEAD_KEEPS = frozenset( - {"consumption_weight", "coefficient_rra", "utility_scale_factor"} + { + "consumption_weight", + "coefficient_rra", + "discount_factor", + "utility_scale_factor", + } ) def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: """Build the terminal dead regime. - Everything `dead` carries arrives via the model-level broadcast: + `dead` owns two functions, the bequest `utility` and the per-type bequest + weight `scaled_bequest_weight`; everything else arrives via the + model-level broadcast: - states: `assets` and `pref_type` survive DAG pruning (the bequest utility reads them); the remaining broadcast states are pruned. @@ -565,7 +572,11 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: constraint_masks = dict.fromkeys(build_model_constraints(solver=solver)) return Regime( transition=None, - functions={"utility": preferences.bequest, **function_masks}, + functions={ + "utility": preferences.bequest, + "scaled_bequest_weight": preferences.scaled_bequest_weight, + **function_masks, + }, constraints=constraint_masks, states={"pension_wealth": None}, active=lambda _age: True, diff --git a/tests/test_preferences.py b/tests/test_preferences.py index 5cdb090..b83760b 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -64,7 +64,7 @@ def test_scaled_bequest_weight_positive() -> None: consumption_weight=CONSUMPTION_WEIGHT, coefficient_rra=jnp.asarray(5.0), time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) assert jnp.isclose(result, 0.820_137_639_127_977_3, rtol=1e-6) @@ -76,7 +76,7 @@ def test_scaled_bequest_weight_log() -> None: consumption_weight=CONSUMPTION_WEIGHT, coefficient_rra=jnp.asarray(1.0), time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) assert jnp.isclose(result, 58.235_294_117_647_05, rtol=1e-6) @@ -88,7 +88,7 @@ def test_scaled_bequest_weight_zero() -> None: consumption_weight=CONSUMPTION_WEIGHT, coefficient_rra=jnp.asarray(5.0), time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) assert result == 0.0 @@ -179,7 +179,7 @@ def test_bequest_log_regression() -> None: consumption_weight=CONSUMPTION_WEIGHT, coefficient_rra=jnp.asarray(1.0), time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) result = preferences.bequest( @@ -207,7 +207,7 @@ def test_bequest_crra_regression() -> None: consumption_weight=CONSUMPTION_WEIGHT, coefficient_rra=jnp.asarray(5.0), time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) result = preferences.bequest( @@ -244,7 +244,7 @@ def test_bequest_uses_signed_assets_for_indebted_decedent() -> None: consumption_weight=consumption_weight, coefficient_rra=coefficient_rra, time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) result = preferences.bequest( @@ -288,7 +288,7 @@ def test_bequest_floors_estate_base_when_debt_exceeds_the_shifter() -> None: consumption_weight=consumption_weight, coefficient_rra=coefficient_rra, time_endowment=TIME_ENDOWMENT, - time_discount_factor=TIME_DISCOUNT_FACTOR, + discount_factor=TIME_DISCOUNT_FACTOR, rate_of_return=RATE_OF_RETURN, ) result = preferences.bequest( @@ -350,7 +350,7 @@ def test_dead_regime_bequest_uses_per_type_transformed_weight( utility = _dead_regime_utility() result = utility( assets=jnp.asarray(100_000.0), - pref_type=jnp.asarray(pref_type), + pref_type=jnp.asarray(pref_type, dtype=jnp.int32), bequest_shifter=jnp.asarray(334460.20744719007), bequest_weight=jnp.asarray(0.02861082652580584), consumption_weights=_DRAFT_CONSUMPTION_WEIGHTS, From e7925adfcff127ed8b635d4ee0a32334cb2a510d Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:16:31 +0200 Subject: [PATCH 032/101] TST Expect Medicaid to replace non-group cover and pay Medicare premia Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_medicaid_nongroup.py | 98 +++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/test_medicaid_nongroup.py diff --git a/tests/test_medicaid_nongroup.py b/tests/test_medicaid_nongroup.py new file mode 100644 index 0000000..b8aa0af --- /dev/null +++ b/tests/test_medicaid_nongroup.py @@ -0,0 +1,98 @@ +"""Medicaid in the non-group regimes: last-resort payer and Medicare premia. + +The tests evaluate the real non-group regime DAG with the upstream cost and +premium nodes pinned to constants, so they check how Medicaid eligibility +routes those quantities rather than how the quantities themselves are built. + +- A Medicaid-eligible non-group household before Medicare is covered by + Medicaid alone: it can neither hold private cover nor stay uninsured, so + its OOP is Medicaid's cost-sharing on total costs and it pays no premium + whatever its `buy_private` choice. +- Medicaid pays the Medicare premium of a Medicaid-eligible household. +""" + +import inspect + +import jax.numpy as jnp +import pytest +from dags import concatenate_functions + +from aca_model.agent.labor_market import LaborSupply +from aca_model.baseline import health_insurance +from aca_model.baseline.health_insurance import BuyPrivate +from aca_model.baseline.regimes._common import REGIME_SPECS, build_model_functions +from aca_model.baseline.regimes._nongroup import _build_functions + +TOTAL_COSTS = 20000.0 +PRIVATE_OOP = 5285.0 +PREMIUM_BEFORE_MEDICAID = 4620.0 +MEDICAID_COINSURANCE = 0.0352 + +_MEDICAID_INPUTS = { + "deductible_medicaid": jnp.asarray(0.0), + "coinsurance_rate_medicaid": jnp.asarray(MEDICAID_COINSURANCE), + "oop_max_medicaid": jnp.asarray(jnp.inf), +} + + +def _is_base_premium(func: object) -> bool: + """Whether `func` is one of the baseline premium formulas.""" + return getattr(func, "__module__", "") == health_insurance.__name__ and getattr( + func, "__name__", "" + ).startswith("premium") + + +def _evaluate_nongroup(regime_name: str, buy_private: int) -> dict: + """Evaluate `oop_costs` and `hic_premium` of a Medicaid-eligible household.""" + functions = { + **build_model_functions(), + **_build_functions(REGIME_SPECS[regime_name]), + } + functions = { + name: (lambda: jnp.asarray(PREMIUM_BEFORE_MEDICAID)) + if _is_base_premium(func) + else func + for name, func in functions.items() + if callable(func) + } + functions["total_health_costs"] = lambda: jnp.asarray(TOTAL_COSTS) + functions["primary_oop"] = lambda: jnp.asarray(PRIVATE_OOP) + functions["is_medicaid_eligible"] = lambda: jnp.asarray(True) + combined = concatenate_functions( + functions, + targets=["oop_costs", "hic_premium"], + return_type="dict", + ) + inputs = { + **_MEDICAID_INPUTS, + "buy_private": jnp.asarray(buy_private), + "labor_supply": jnp.asarray(LaborSupply.do_not_work), + } + needed = inspect.signature(combined).parameters + return combined(**{k: v for k, v in inputs.items() if k in needed}) + + +def test_medicaid_eligible_buyer_pays_medicaid_cost_sharing_on_total_costs() -> None: + """With `buy_private=yes`, OOP is Medicaid's 3.52% of total costs: 704.""" + result = _evaluate_nongroup("nongroup_nomc_inelig_canwork", BuyPrivate.yes) + assert jnp.isclose(result["oop_costs"], MEDICAID_COINSURANCE * TOTAL_COSTS) + + +def test_medicaid_eligible_buyer_pays_no_private_premium() -> None: + """With `buy_private=yes`, a Medicaid-eligible household pays no premium.""" + result = _evaluate_nongroup("nongroup_nomc_inelig_canwork", BuyPrivate.yes) + assert jnp.isclose(result["hic_premium"], 0.0) + + +@pytest.mark.parametrize( + "regime_name", + [ + "nongroup_dimc_inelig_canwork", + "nongroup_oamc_choose_canwork", + "nongroup_oamc_forced_forcedout", + ], +) +def test_medicaid_pays_the_medicare_premium(regime_name: str) -> None: + """A Medicaid-eligible household with Medicare pays no Medicare premium.""" + result = _evaluate_nongroup(regime_name, BuyPrivate.no) + assert jnp.isclose(result["hic_premium"], 0.0) From b9358a6542d8a5b480abf43aa6838f2616b5ee54 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:17:00 +0200 Subject: [PATCH 033/101] TEST Pin private premium pricing, couples' cost sharing, DI Medicare entry - Private premium: intercept from the insurer's zero-profit condition on the data sample, `max(minimum[married], p0 + b (1 + markup) E[insurer cost])`, and private cover only when the premium is paid in full. - Predicted insurer cost read by period, marital status and health. - Couples' deductible and OOP maximum are doubled. - Pre-65 Medicare: disabled next period and not working this period. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_baseline_equivalence.py | 1 + tests/test_di_medicare_transitions.py | 206 ++++++++++++++++++++++++ tests/test_health_insurance.py | 47 ++---- tests/test_private_premium.py | 212 +++++++++++++++++++++++++ tests/test_regime_transitions.py | 42 +++-- tests/test_ssi_medicaid_integration.py | 2 + 6 files changed, 463 insertions(+), 47 deletions(-) create mode 100644 tests/test_di_medicare_transitions.py create mode 100644 tests/test_private_premium.py diff --git a/tests/test_baseline_equivalence.py b/tests/test_baseline_equivalence.py index e7da710..61a9367 100644 --- a/tests/test_baseline_equivalence.py +++ b/tests/test_baseline_equivalence.py @@ -92,6 +92,7 @@ def test_baseline_primary_oop_no_cost_sharing_scale() -> None: result = health_insurance.primary_oop( total_health_costs=costs, buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(0), deductible=deductible, coinsurance_rate=coinsurance, oop_max=oop_max_val, diff --git a/tests/test_di_medicare_transitions.py b/tests/test_di_medicare_transitions.py new file mode 100644 index 0000000..57861df --- /dev/null +++ b/tests/test_di_medicare_transitions.py @@ -0,0 +1,206 @@ +"""Disability Medicare before 65: disabled and not working last year. + +A pre-65 household is on disability Medicare next period exactly when it does +not work this period and is disabled next period. The regime transition splits +the surviving mass by next-period disability; each target's health law is the +health distribution conditional on landing there. +""" + +import jax.numpy as jnp +import numpy as np +import pytest + +from aca_model.agent import health +from aca_model.agent.health import HealthWithDisability +from aca_model.agent.labor_market import LaborSupply +from aca_model.baseline.regimes._common import ( + REGIME_SPECS, + RegimeId, + _build_per_target_regime_health, + make_targets, +) +from aca_model.baseline.regimes._nongroup import ( + _make_transition_canwork as nongroup_canwork, +) +from aca_model.baseline.regimes._retiree import ( + _make_transition_canwork as retiree_canwork, +) +from aca_model.baseline.regimes._tied import _make_transition_canwork as tied_canwork +from aca_model.config import MODEL_CONFIG + +N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age +SURVIVAL = jnp.full(N_PERIODS, 0.99) +# Rows: current health (disabled, bad, good); columns: next health. +_HEALTH_ROWS = jnp.array([[0.98, 0.01, 0.01], [0.05, 0.75, 0.20], [0.002, 0.058, 0.94]]) +HEALTH_TRANS = jnp.broadcast_to(_HEALTH_ROWS, (N_PERIODS, 3, 3)) + + +def _nongroup_probs(regime: str, health_state: int, labor: int) -> jnp.ndarray: + own, _ = make_targets(regime) + return nongroup_canwork(own=own)( + age=jnp.int32(55), + period=jnp.int32(4), + health=jnp.int32(health_state), + labor_supply=jnp.array(labor), + survival_probs=SURVIVAL, + health_trans_probs=HEALTH_TRANS, + ) + + +@pytest.mark.parametrize( + ("regime", "health_state", "labor", "target", "expected"), + [ + # Bad health, not working: disabled next year with prob 0.05. + ( + "nongroup_nomc_inelig_canwork", + HealthWithDisability.bad, + LaborSupply.do_not_work, + "nongroup_dimc_inelig_canwork", + 0.99 * 0.05, + ), + ( + "nongroup_nomc_inelig_canwork", + HealthWithDisability.bad, + LaborSupply.do_not_work, + "nongroup_nomc_inelig_canwork", + 0.99 * 0.95, + ), + # Working: no disability Medicare next year. + ( + "nongroup_nomc_inelig_canwork", + HealthWithDisability.bad, + LaborSupply.h2000, + "nongroup_dimc_inelig_canwork", + 0.0, + ), + # On disability Medicare and recovering: leaves it. + ( + "nongroup_dimc_inelig_canwork", + HealthWithDisability.disabled, + LaborSupply.do_not_work, + "nongroup_nomc_inelig_canwork", + 0.99 * 0.02, + ), + ( + "nongroup_dimc_inelig_canwork", + HealthWithDisability.disabled, + LaborSupply.do_not_work, + "nongroup_dimc_inelig_canwork", + 0.99 * 0.98, + ), + # On disability Medicare and working: leaves it. + ( + "nongroup_dimc_inelig_canwork", + HealthWithDisability.disabled, + LaborSupply.h2000, + "nongroup_nomc_inelig_canwork", + 0.99, + ), + ], +) +def test_nongroup_transition_di_medicare_entry_and_exit( + regime: str, health_state: int, labor: int, target: str, expected: float +) -> None: + """Probability of each target splits survival by next-period disability.""" + probs = _nongroup_probs(regime, health_state, labor) + np.testing.assert_allclose(probs[getattr(RegimeId, target)], expected, atol=1e-12) + + +def test_retiree_transition_newly_disabled_non_worker_keeps_retiree_cover() -> None: + """A retiree who stops working and becomes disabled holds EPHI-Medicare.""" + own, ng = make_targets("retiree_nomc_inelig_canwork") + probs = retiree_canwork(own=own, ng=ng)( + age=jnp.int32(55), + period=jnp.int32(4), + health=jnp.int32(HealthWithDisability.good), + labor_supply=jnp.array(LaborSupply.do_not_work), + is_medicaid_eligible=jnp.array(False), + survival_probs=SURVIVAL, + health_trans_probs=HEALTH_TRANS, + ) + np.testing.assert_allclose( + probs[RegimeId.retiree_dimc_inelig_canwork], 0.99 * 0.002, atol=1e-12 + ) + + +def test_tied_transition_newly_disabled_non_worker_moves_to_nongroup_medicare() -> None: + """A tied worker who stops and becomes disabled moves to non-group Medicare.""" + own, ng = make_targets("tied_nomc_inelig_canwork") + probs = tied_canwork(own=own, ng=ng)( + age=jnp.int32(55), + period=jnp.int32(4), + health=jnp.int32(HealthWithDisability.bad), + labor_supply=jnp.array(LaborSupply.do_not_work), + is_medicaid_eligible=jnp.array(False), + survival_probs=SURVIVAL, + health_trans_probs=HEALTH_TRANS, + ) + np.testing.assert_allclose( + probs[RegimeId.nongroup_dimc_inelig_canwork], 0.99 * 0.05, atol=1e-12 + ) + + +def test_next_health_into_dimc_is_disabled() -> None: + """Landing on disability Medicare means being disabled.""" + result = health.next_health_into_dimc( + health=jnp.int32(HealthWithDisability.bad), + period=jnp.int32(4), + health_trans_probs=HEALTH_TRANS, + ) + np.testing.assert_allclose(result, [1.0, 0.0, 0.0]) + + +@pytest.mark.parametrize( + ("labor", "expected"), + [ + (LaborSupply.do_not_work, [0.0, 0.75 / 0.95, 0.20 / 0.95]), + (LaborSupply.h2000, [0.05, 0.75, 0.20]), + ], +) +def test_next_health_into_nomc_excludes_disability_for_non_workers( + labor: int, expected: list[float] +) -> None: + """A non-worker without disability Medicare next year is not disabled then.""" + result = health.next_health_into_nomc( + health=jnp.int32(HealthWithDisability.bad), + period=jnp.int32(4), + labor_supply=jnp.array(labor), + health_trans_probs=HEALTH_TRANS, + ) + np.testing.assert_allclose(result, expected, atol=1e-12) + + +@pytest.mark.parametrize( + ("source", "target", "law"), + [ + ( + "nongroup_nomc_inelig_canwork", + "nongroup_dimc_inelig_canwork", + "next_health_into_dimc", + ), + ( + "nongroup_nomc_inelig_canwork", + "nongroup_nomc_inelig_canwork", + "next_health_into_nomc", + ), + ( + "retiree_dimc_choose_canwork", + "retiree_nomc_choose_canwork", + "next_health_into_nomc", + ), + ( + "retiree_dimc_choose_canwork", + "retiree_oamc_choose_canwork", + "next_health_cross", + ), + ( + "nongroup_oamc_choose_canwork", + "nongroup_oamc_forced_canwork", + "next_health", + ), + ], +) +def test_per_target_health_law(source: str, target: str, law: str) -> None: + """Pre-65 targets condition next health on the Medicare outcome.""" + laws = _build_per_target_regime_health(REGIME_SPECS[source]) + assert laws[target].func is getattr(health, law) diff --git a/tests/test_health_insurance.py b/tests/test_health_insurance.py index a3546de..15d379d 100644 --- a/tests/test_health_insurance.py +++ b/tests/test_health_insurance.py @@ -3,7 +3,6 @@ import jax.numpy as jnp import pytest -from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance from aca_model.baseline.health_insurance import BuyPrivate @@ -103,11 +102,14 @@ def test_ssi_benefit_not_eligible() -> None: # --- predicted_hcc_insurer (DAG interpolation function) --- GRID = jnp.array([-1.0, 0.0, 1.0]) -TABLE = jnp.array([100.0, 200.0, 400.0]) +TABLE = jnp.zeros((1, 2, 2, 3)).at[0, 0, 1].set(jnp.array([100.0, 200.0, 400.0])) def test_predicted_hcc_insurer_on_grid() -> None: result = health_insurance.hcc_insurer_predicted( + period=jnp.int32(0), + is_married=jnp.int32(0), + good_health=jnp.int32(1), hcc_persistent=jnp.array(0.0), predicted_hcc_insurer_table=TABLE, hcc_persistent_grid=GRID, @@ -117,6 +119,9 @@ def test_predicted_hcc_insurer_on_grid() -> None: def test_predicted_hcc_insurer_off_grid() -> None: result = health_insurance.hcc_insurer_predicted( + period=jnp.int32(0), + is_married=jnp.int32(0), + good_health=jnp.int32(1), hcc_persistent=jnp.array(-0.5), predicted_hcc_insurer_table=TABLE, hcc_persistent_grid=GRID, @@ -163,42 +168,6 @@ def test_compute_table_uniform_transition(table_inputs: dict) -> None: assert jnp.allclose(result, result[0], atol=ATOL) -# --- premium: buy_private conditioning --- - - -_PREMIUM_KWARGS: dict = { - "age": jnp.int32(60), - "good_health": jnp.int32(1), - "is_married": jnp.int32(0), - "labor_supply": jnp.array(LaborSupply.h2000), - "premium_intercept": jnp.asarray(1000.0), - "premium_age": jnp.asarray(0.0), - "premium_age_sq": jnp.asarray(0.0), - "premium_age_cub": jnp.asarray(0.0), - "premium_predicted_hcc": jnp.asarray(0.0), - "premium_good_health": jnp.asarray(0.0), - "premium_married": jnp.asarray(0.0), - "premium_works": jnp.asarray(0.0), - "premium_married_works": jnp.asarray(0.0), - "premium_minimum": jnp.asarray(500.0), - "predicted_hcc_insurer": jnp.array(0.0), -} - - -def test_premium_insured_positive() -> None: - result = health_insurance.premium( - buy_private=jnp.array(BuyPrivate.yes), **_PREMIUM_KWARGS - ) - assert result > 0.0 - - -def test_premium_uninsured_zero() -> None: - result = health_insurance.premium( - buy_private=jnp.array(BuyPrivate.no), **_PREMIUM_KWARGS - ) - assert jnp.isclose(result, 0.0) - - # --- primary_oop: buy_private conditioning --- @@ -206,6 +175,7 @@ def test_primary_oop_insured_applies_deductible_coinsurance() -> None: result = health_insurance.primary_oop( total_health_costs=jnp.array(10000.0), buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(0), deductible=jnp.asarray(500.0), coinsurance_rate=jnp.asarray(0.2), oop_max=jnp.asarray(5000.0), @@ -219,6 +189,7 @@ def test_primary_oop_uninsured_equals_total_costs() -> None: result = health_insurance.primary_oop( total_health_costs=total, buy_private=jnp.array(BuyPrivate.no), + is_married=jnp.int32(0), deductible=jnp.asarray(500.0), coinsurance_rate=jnp.asarray(0.2), oop_max=jnp.asarray(5000.0), diff --git a/tests/test_private_premium.py b/tests/test_private_premium.py new file mode 100644 index 0000000..9c3e109 --- /dev/null +++ b/tests/test_private_premium.py @@ -0,0 +1,212 @@ +"""Private non-group premium: zero-profit intercept, pricing rule, and floor rule.""" + +import jax.numpy as jnp +import numpy as np +import pytest + +from aca_model.baseline import health_insurance +from aca_model.baseline.health_insurance import BuyPrivate +from aca_model.baseline.regimes import _nongroup +from aca_model.baseline.regimes._common import REGIME_SPECS + +_MARKUP = jnp.asarray(0.1696) +_MINIMUM = jnp.array([1000.0, 2000.0]) +_SAMPLE_COST = jnp.array([1000.0, 3000.0, 8000.0]) +_SAMPLE_MARRIED = jnp.array([0, 1, 0]) + + +@pytest.mark.parametrize( + ("coeff", "expected"), + [ + # 3 p0 + 0.5 * 1.1696 * 12000 = 1.1696 * 12000; no minimum binds. + (0.5, 2339.2), + # The single 1000-cost buyer sits at the 1000 minimum: + # 1000 + 2 p0 + 1.5 * 1.1696 * 11000 = 1.1696 * 12000. + (1.5, -3131.6), + ], +) +def test_private_premium_intercept_concrete_values( + coeff: float, expected: float +) -> None: + """The intercept solves mean premium = (1 + markup) * mean insurer cost.""" + intercept = health_insurance.private_premium_intercept( + premium_predicted_hcc=jnp.asarray(coeff), + premium_markup=_MARKUP, + premium_minimum=_MINIMUM, + premium_sample_insurer_cost=_SAMPLE_COST, + premium_sample_is_married=_SAMPLE_MARRIED, + ) + np.testing.assert_allclose(intercept, expected, atol=1e-6) + + +def test_private_premium_intercept_zero_profit_on_sample() -> None: + """At the solved intercept, premium revenue covers insurer cost plus markup. + + The sample mean of `max(minimum[married], p0 + b (1 + markup) cost)` + equals `(1 + markup)` times the sample mean of the insurer cost. + """ + rng = np.random.default_rng(seed=3) + cost = jnp.asarray(np.exp(rng.normal(loc=7.5, scale=1.2, size=2000))) + married = jnp.asarray(rng.integers(0, 2, size=2000)) + coeff = jnp.asarray(1.161245437600243) + intercept = health_insurance.private_premium_intercept( + premium_predicted_hcc=coeff, + premium_markup=_MARKUP, + premium_minimum=_MINIMUM, + premium_sample_insurer_cost=cost, + premium_sample_is_married=married, + ) + premiums = jnp.maximum(_MINIMUM[married], intercept + coeff * (1 + _MARKUP) * cost) + assert bool((premiums == _MINIMUM[married]).any()) + np.testing.assert_allclose(premiums.mean(), (1 + _MARKUP) * cost.mean(), rtol=1e-10) + + +def test_private_premium_intercept_without_root_in_bracket_is_nan() -> None: + """No intercept in [-50000, 10000] balances the books: the result is NaN.""" + intercept = health_insurance.private_premium_intercept( + premium_predicted_hcc=jnp.asarray(100.0), + premium_markup=_MARKUP, + premium_minimum=_MINIMUM, + premium_sample_insurer_cost=_SAMPLE_COST, + premium_sample_is_married=_SAMPLE_MARRIED, + ) + assert bool(jnp.isnan(intercept)) + + +@pytest.mark.parametrize( + ("is_married", "insurer_cost", "expected"), + [ + (0, 1913.49, 1529.488080), + (1, 1913.49, 2000.0), + (0, 371.13, 1000.0), + (1, 8319.48, 10230.056703), + ], +) +def test_premium_private_buyer( + is_married: int, insurer_cost: float, expected: float +) -> None: + """Premium is `max(minimum[married], p0 + b (1 + markup) E[insurer cost])`. + + No marital-status shifter: couples differ only through the minimum and + their insurer cost. + """ + result = health_insurance.premium( + buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(is_married), + predicted_hcc_insurer=jnp.asarray(insurer_cost), + private_premium_intercept=jnp.asarray(-1069.4), + premium_predicted_hcc=jnp.asarray(1.161245437600243), + premium_markup=_MARKUP, + premium_minimum=_MINIMUM, + ) + np.testing.assert_allclose(result, expected, atol=1e-6) + + +def test_premium_uninsured_pays_nothing() -> None: + """A household that does not buy private cover pays no premium.""" + result = health_insurance.premium( + buy_private=jnp.array(BuyPrivate.no), + is_married=jnp.int32(0), + predicted_hcc_insurer=jnp.asarray(1913.49), + private_premium_intercept=jnp.asarray(-1069.4), + premium_predicted_hcc=jnp.asarray(1.161245437600243), + premium_markup=_MARKUP, + premium_minimum=_MINIMUM, + ) + assert float(result) == 0.0 + + +@pytest.mark.parametrize( + ("buy_private", "premium_default", "expected"), + [ + (BuyPrivate.yes, 0.0, True), + (BuyPrivate.yes, 0.01, False), + (BuyPrivate.no, 500.0, True), + (BuyPrivate.no, 0.0, True), + ], +) +def test_private_cover_paid_in_full( + buy_private: int, premium_default: float, expected: bool +) -> None: + """Private cover is a valid choice only if the household pays its premium. + + A household at the consumption floor cannot hold private cover financed by + transfers or by not paying the premium. + """ + result = health_insurance.private_cover_paid_in_full( + buy_private=jnp.array(buy_private), + premium_default=jnp.asarray(premium_default), + ) + assert bool(result) is expected + + +def test_predicted_hcc_insurer_reads_age_marriage_health_cell() -> None: + """Predicted insurer cost interpolates the (period, married, health) row.""" + table = jnp.zeros((2, 2, 2, 3)).at[1, 1, 0].set(jnp.array([100.0, 200.0, 400.0])) + result = health_insurance.hcc_insurer_predicted( + period=jnp.int32(1), + is_married=jnp.int32(1), + good_health=jnp.int32(0), + hcc_persistent=jnp.asarray(0.5), + predicted_hcc_insurer_table=table, + hcc_persistent_grid=jnp.array([-1.0, 0.0, 1.0]), + ) + np.testing.assert_allclose(result, 300.0, atol=1e-9) + + +@pytest.mark.parametrize( + ("is_married", "total", "expected"), + [ + (0, 60000.0, 7472.3), + (1, 60000.0, 2652.2 + (60000.0 - 2652.2) * 0.2120), + (1, 2000.0, 2000.0), + (1, 200000.0, 14944.6), + ], +) +def test_insured_oop_doubles_couples_deductible_and_oop_max( + is_married: int, total: float, expected: float +) -> None: + """Couples' deductible and OOP maximum are twice the single ones.""" + result = health_insurance.insured_oop( + total_health_costs=jnp.asarray(total), + is_married=jnp.int32(is_married), + deductible=jnp.asarray(1326.1), + coinsurance_rate=jnp.asarray(0.2120), + oop_max=jnp.asarray(7472.3), + ) + np.testing.assert_allclose(result, expected, atol=1e-6) + + +@pytest.mark.parametrize( + ("buy_private", "expected"), + [(BuyPrivate.yes, 2652.2 + (20000.0 - 2652.2) * 0.2120), (BuyPrivate.no, 20000.0)], +) +def test_primary_oop_married_private_buyer(buy_private: int, expected: float) -> None: + """A married private buyer faces the doubled deductible; uninsured pay all.""" + result = health_insurance.primary_oop( + total_health_costs=jnp.asarray(20000.0), + buy_private=jnp.array(buy_private), + is_married=jnp.int32(1), + deductible=jnp.asarray(1326.1), + coinsurance_rate=jnp.asarray(0.2120), + oop_max=jnp.asarray(7472.3), + ) + np.testing.assert_allclose(result, expected, atol=1e-6) + + +@pytest.mark.parametrize( + ("regime", "expected"), + [ + ("nongroup_nomc_choose_canwork", ("private_cover_paid_in_full",)), + ("nongroup_dimc_choose_canwork", ()), + ("nongroup_oamc_choose_canwork", ()), + ], +) +def test_nongroup_regimes_constrain_private_cover_to_paid_premiums( + regime: str, expected: tuple[str, ...] +) -> None: + """Regimes offering private cover require its premium to be paid in full.""" + constraints = _nongroup.build_constraints(REGIME_SPECS[regime]) + assert {name: func.__name__ for name, func in constraints.items()} == { + name: name for name in expected + } diff --git a/tests/test_regime_transitions.py b/tests/test_regime_transitions.py index 9040fdc..2259782 100644 --- a/tests/test_regime_transitions.py +++ b/tests/test_regime_transitions.py @@ -27,6 +27,12 @@ N_REGIMES = 19 N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age SURVIVAL = jnp.ones(N_PERIODS) * 0.99 +# Pre-65 health rows (disabled, bad, good); a good-health household stays +# off disability with probability 0.998. +HEALTH_TRANS = jnp.broadcast_to( + jnp.array([[0.98, 0.01, 0.01], [0.05, 0.75, 0.20], [0.002, 0.058, 0.94]]), + (N_PERIODS, 3, 3), +) def _target_from_probs(probs: jnp.ndarray) -> int: @@ -42,11 +48,13 @@ def _target_from_probs(probs: jnp.ndarray) -> int: def test_tied_stop_working_becomes_nongroup() -> None: """Tied agent who stops working loses employer coverage → nongroup.""" own, ng = make_targets("tied_nomc_inelig_canwork") - transition = tied_canwork(gets_medicare=False, own=own, ng=ng) + transition = tied_canwork(own=own, ng=ng) probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.do_not_work), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -59,11 +67,13 @@ def test_tied_stop_working_becomes_nongroup() -> None: def test_tied_keeps_working_stays_tied() -> None: """Tied agent who keeps working retains employer coverage.""" own, ng = make_targets("tied_nomc_inelig_canwork") - transition = tied_canwork(gets_medicare=False, own=own, ng=ng) + transition = tied_canwork(own=own, ng=ng) probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -78,11 +88,13 @@ def test_tied_keeps_working_stays_tied() -> None: def test_retiree_medicaid_override_to_nongroup() -> None: """Medicaid-eligible retiree is overridden to nongroup.""" own, ng = make_targets("retiree_nomc_inelig_canwork") - transition = retiree_canwork(gets_medicare=False, own=own, ng=ng) + transition = retiree_canwork(own=own, ng=ng) probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -94,11 +106,13 @@ def test_retiree_medicaid_override_to_nongroup() -> None: def test_retiree_not_medicaid_stays_retiree() -> None: """Non-Medicaid retiree stays retiree.""" own, ng = make_targets("retiree_nomc_inelig_canwork") - transition = retiree_canwork(gets_medicare=False, own=own, ng=ng) + transition = retiree_canwork(own=own, ng=ng) probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -148,12 +162,14 @@ def test_retiree_age_bracket_transitions( # Use nomc+inelig as starting point — the transition function resolves # the target based on next_age, not current spec. own, ng = make_targets("retiree_nomc_inelig_canwork") - transition = retiree_canwork(gets_medicare=False, own=own, ng=ng) + transition = retiree_canwork(own=own, ng=ng) period = jnp.int32(age - MODEL_CONFIG.start_age) probs = transition( age=jnp.asarray(age), period=period, + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -168,11 +184,13 @@ def test_retiree_age_bracket_transitions( def test_nongroup_canwork_valid_probs() -> None: """Nongroup canwork produces valid probability vector.""" own, _ng = make_targets("nongroup_nomc_inelig_canwork") - transition = nongroup_canwork(gets_medicare=False, own=own) + transition = nongroup_canwork(own=own) probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), survival_probs=SURVIVAL, ) @@ -200,11 +218,13 @@ def test_nongroup_forcedout_valid_probs() -> None: def test_tied_medicaid_override_to_nongroup() -> None: """Tied + Medicaid-eligible → nongroup (SSI override).""" own, ng = make_targets("tied_nomc_inelig_canwork") - transition = tied_canwork(gets_medicare=False, own=own, ng=ng) + transition = tied_canwork(own=own, ng=ng) probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -216,12 +236,14 @@ def test_tied_medicaid_override_to_nongroup() -> None: def test_tied_at_medicare_age_with_medicaid() -> None: """Tied at age 64→65 (Medicare onset) + Medicaid → nongroup+oamc.""" own, ng = make_targets("tied_nomc_choose_canwork") - transition = tied_canwork(gets_medicare=False, own=own, ng=ng) + transition = tied_canwork(own=own, ng=ng) period = jnp.int32(64 - MODEL_CONFIG.start_age) probs = transition( age=jnp.int32(64), period=period, + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -234,12 +256,14 @@ def test_tied_at_medicare_age_with_medicaid() -> None: def test_survival_prob_determines_death_weight() -> None: """Dead regime gets (1 - survival) probability weight.""" own, ng = make_targets("retiree_nomc_inelig_canwork") - transition = retiree_canwork(gets_medicare=False, own=own, ng=ng) + transition = retiree_canwork(own=own, ng=ng) survival = jnp.ones(N_PERIODS) * 0.85 probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), + health_trans_probs=HEALTH_TRANS, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=survival, diff --git a/tests/test_ssi_medicaid_integration.py b/tests/test_ssi_medicaid_integration.py index 20572f9..b08acac 100644 --- a/tests/test_ssi_medicaid_integration.py +++ b/tests/test_ssi_medicaid_integration.py @@ -175,6 +175,7 @@ def test_medicaid_reduces_oop() -> None: oop_medicaid = combined( total_health_costs=jnp.array(10000.0), buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(0), deductible=jnp.asarray(500.0), coinsurance_rate=jnp.asarray(0.2), oop_max=jnp.asarray(5000.0), @@ -188,6 +189,7 @@ def test_medicaid_reduces_oop() -> None: oop_no_medicaid = combined( total_health_costs=jnp.array(10000.0), buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(0), deductible=jnp.asarray(500.0), coinsurance_rate=jnp.asarray(0.2), oop_max=jnp.asarray(5000.0), From 5a5806f4b5c20d6f1aa9765d92eb78b4b935122a Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:17:17 +0200 Subject: [PATCH 034/101] Let Medicaid replace non-group cover and pay Medicare premia In the non-group regimes a Medicaid-eligible household pays no premium: before Medicare its only cover is Medicaid (payer of last resort, no private premia covered), and with Medicare, Medicaid pays the Medicare premium. Before Medicare its OOP is Medicaid's cost-sharing on total costs, whatever its `buy_private` choice, so it can neither hold private cover nor stay uninsured. `oop_costs` becomes a regime-level node so the non-group regimes before Medicare can carry the replacing rule; every other regime keeps Medicaid as secondary payer on the primary OOP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/health_insurance.py | 38 +++++++++++++++++++++ src/aca_model/baseline/regimes/_common.py | 8 ++++- src/aca_model/baseline/regimes/_nongroup.py | 9 +++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index b7c598f..e9fd61a 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -394,6 +394,44 @@ def oop_with_medicaid( return jnp.where(is_medicaid_eligible, medicaid_oop, primary_oop) +def oop_with_medicaid_replacing_private( + total_health_costs: FloatND, + primary_oop: FloatND, + is_medicaid_eligible: BoolND, + deductible_medicaid: ScalarFloat, + coinsurance_rate_medicaid: ScalarFloat, + oop_max_medicaid: ScalarFloat, +) -> FloatND: + """Compute OOP costs in the non-group regimes before Medicare. + + A Medicaid-eligible household is covered by Medicaid alone: Medicaid is + the payer of last resort and does not cover private premia, so it can + neither hold private cover nor stay uninsured. Its OOP is Medicaid's + cost-sharing on total costs, whatever its `buy_private` choice. Other + households pay the primary (private or uninsured) OOP. + """ + medicaid_oop = oop_costs( + total_health_costs=total_health_costs, + deductible=deductible_medicaid, + coinsurance_rate=coinsurance_rate_medicaid, + oop_max=oop_max_medicaid, + ) + return jnp.where(is_medicaid_eligible, medicaid_oop, primary_oop) + + +def premium_net_of_medicaid( + plan_premium: FloatND, + is_medicaid_eligible: BoolND, +) -> FloatND: + """Compute the premium a non-group household pays. + + A Medicaid-eligible household pays no premium: before Medicare its only + cover is Medicaid, whose premium is zero; with Medicare, Medicaid pays + the Medicare premium. + """ + return jnp.where(is_medicaid_eligible, 0.0, plan_premium) + + def hcc_insurer_predicted( hcc_persistent: ContinuousState, predicted_hcc_insurer_table: FloatND, diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6fb7e5b..8192400 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -614,7 +614,6 @@ def build_model_functions(*, solver: SolverName = "brute_force") -> dict: # `marginal_continuation`. functions |= build_dcegm_functions() functions["total_health_costs"] = health_insurance.total_costs - functions["oop_costs"] = health_insurance.oop_with_medicaid functions["capital_income"] = assets_and_income.capital_income # spousal_income_amounts is a lookup table param, not a DAG function functions["is_married"] = labor_market.is_married @@ -754,6 +753,13 @@ def build_common_functions(spec: RegimeSpec) -> dict: functions["primary_oop"] = ( health_insurance.primary_oop if has_buy_private else health_insurance.oop_costs ) + # Medicaid pays on top of the primary cover, except in the non-group + # regimes before Medicare, where it replaces private or no cover. + functions["oop_costs"] = ( + health_insurance.oop_with_medicaid_replacing_private + if has_buy_private + else health_insurance.oop_with_medicaid + ) if can_work: functions["working_hours_value"] = labor_market.working_hours_value diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index f13e3c9..4d4c321 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -89,11 +89,14 @@ def _build_functions(spec: RegimeSpec) -> dict: has_buy_private = spec["his"] == "nongroup" and spec["mc"] == "nomc" if has_buy_private: - functions["hic_premium"] = health_insurance.premium + functions["plan_premium"] = health_insurance.premium elif can_work: - functions["hic_premium"] = health_insurance.premium_insured + functions["plan_premium"] = health_insurance.premium_insured else: - functions["hic_premium"] = health_insurance.premium_retired + functions["plan_premium"] = health_insurance.premium_retired + # Medicaid-eligible households pay no premium: Medicaid replaces private + # cover and pays the Medicare premium. + functions["hic_premium"] = health_insurance.premium_net_of_medicaid functions.update(build_pension_functions(spec)) From b3e852f86a83447491a080967d2d723523727e76 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:26:23 +0200 Subject: [PATCH 035/101] FIX Read spousal income by age and own health A `spousal_income_amount` DAG node looks up the age x own-health x spousal-income table; gross income, taxable SS, SSI countable income and MAGI take the per-household amount. The benchmark snapshot carries the new table, the 3% return and the annuity factor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 69519 -> 72629 bytes src/aca_model/agent/labor_market.py | 15 +++++++++++++++ src/aca_model/baseline/health_insurance.py | 10 ++++------ src/aca_model/baseline/regimes/_common.py | 2 +- src/aca_model/environment/taxes.py | 9 ++++----- tests/test_budget_chain_integration.py | 4 ++-- tests/test_ssi_medicaid_integration.py | 8 ++++---- tests/test_taxes.py | 11 ++++------- 8 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 0447289b3312aa24583ec56331776a3e05e8a523..38cd92ba033fe564bc6d3ddb89c3f66a15128224 100644 GIT binary patch literal 72629 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 literal 69519 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 FloatND: """Stochastic spousal income transition.""" return spousal_income_trans_probs[period, spousal_income] + + +def spousal_income_amount( + spousal_income: DiscreteState, + good_health: IntND, + period: Period, + spousal_income_amounts: FloatND, +) -> FloatND: + """Return the household's spousal income this period. + + `spousal_income_amounts` is indexed by period, the respondent's own health + (good or not) and the `SpousalIncome` state; only a working spouse + (`married_has_inc`) brings income. + """ + return spousal_income_amounts[period, good_health, spousal_income] diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index 91f2503..1aa97b8 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -49,8 +49,7 @@ class HealthInsuranceState: def countable_income( labor_income: FloatND, capital_income: FloatND, - spousal_income: DiscreteState, - spousal_income_amounts: FloatND, + spousal_income_amount: FloatND, ss_benefit: FloatND, pension_benefit: FloatND, ssi_ignored_overall: ScalarFloat, @@ -64,7 +63,7 @@ def countable_income( return ( earned + capital_income - + spousal_income_amounts[spousal_income] + + spousal_income_amount + ss_benefit + pension_benefit - ssi_ignored_overall @@ -131,8 +130,7 @@ def is_disabled_never() -> BoolND: def aca_magi( labor_income: FloatND, capital_income: FloatND, - spousal_income: DiscreteState, - spousal_income_amounts: FloatND, + spousal_income_amount: FloatND, ss_benefit: FloatND, pension_benefit: FloatND, ) -> FloatND: @@ -145,7 +143,7 @@ def aca_magi( return ( labor_income + capital_income - + spousal_income_amounts[spousal_income] + + spousal_income_amount + ss_benefit + pension_benefit ) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6fb7e5b..9704a28 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -616,7 +616,7 @@ def build_model_functions(*, solver: SolverName = "brute_force") -> dict: functions["total_health_costs"] = health_insurance.total_costs functions["oop_costs"] = health_insurance.oop_with_medicaid functions["capital_income"] = assets_and_income.capital_income - # spousal_income_amounts is a lookup table param, not a DAG function + functions["spousal_income_amount"] = labor_market.spousal_income_amount functions["is_married"] = labor_market.is_married functions["equivalence_scale"] = preferences.equivalence_scale functions["utility_scale_factor"] = preferences.utility_scale_factor diff --git a/src/aca_model/environment/taxes.py b/src/aca_model/environment/taxes.py index dced975..44a07c4 100644 --- a/src/aca_model/environment/taxes.py +++ b/src/aca_model/environment/taxes.py @@ -25,8 +25,7 @@ def gross_income( capital_income: FloatND, labor_income: FloatND, - spousal_income: DiscreteState, - spousal_income_amounts: FloatND, + spousal_income_amount: FloatND, taxable_ss_benefit: FloatND, pension_benefit: FloatND, ) -> FloatND: @@ -34,7 +33,7 @@ def gross_income( return ( capital_income + labor_income - + spousal_income_amounts[spousal_income] + + spousal_income_amount + taxable_ss_benefit + pension_benefit ) @@ -44,7 +43,7 @@ def taxable_ss_benefit( capital_income: FloatND, labor_income: FloatND, spousal_income: DiscreteState, - spousal_income_amounts: FloatND, + spousal_income_amount: FloatND, ss_benefit: FloatND, pension_benefit: FloatND, ss_tax_schedule: MappingLeaf, @@ -58,7 +57,7 @@ def taxable_ss_benefit( prov_income = ( capital_income + labor_income - + spousal_income_amounts[spousal_income] + + spousal_income_amount + sched["ben_fraction_prov_income"] * ss_benefit + pension_benefit ) diff --git a/tests/test_budget_chain_integration.py b/tests/test_budget_chain_integration.py index 9d38f86..8388061 100644 --- a/tests/test_budget_chain_integration.py +++ b/tests/test_budget_chain_integration.py @@ -61,7 +61,7 @@ def test_working_agent_cash_on_hand() -> None: assets=jnp.array(50000.0), rate_of_return=jnp.asarray(0.03), labor_income=jnp.array(40000.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(0.0), pension_benefit=jnp.array(0.0), spousal_income=jnp.int32(0), @@ -97,7 +97,7 @@ def test_retired_agent_with_pension() -> None: assets=jnp.array(200000.0), rate_of_return=jnp.asarray(0.03), labor_income=jnp.array(0.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(15000.0), pension_benefit=jnp.array(10000.0), spousal_income=jnp.int32(0), diff --git a/tests/test_ssi_medicaid_integration.py b/tests/test_ssi_medicaid_integration.py index 20572f9..6dd8ad3 100644 --- a/tests/test_ssi_medicaid_integration.py +++ b/tests/test_ssi_medicaid_integration.py @@ -37,7 +37,7 @@ def test_low_income_qualifies_for_ssi_and_medicaid() -> None: result = combined( labor_income=jnp.array(0.0), capital_income=jnp.array(0.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(500.0), pension_benefit=jnp.array(0.0), ssi_ignored_overall=jnp.asarray(20.0), @@ -69,7 +69,7 @@ def test_high_income_ineligible_for_ssi() -> None: result = combined( labor_income=jnp.array(50000.0), capital_income=jnp.array(5000.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(2000.0), pension_benefit=jnp.array(0.0), ssi_ignored_overall=jnp.asarray(20.0), @@ -105,7 +105,7 @@ def test_disabled_under_65_qualifies_for_ssi_and_medicaid() -> None: result = combined( labor_income=jnp.array(0.0), capital_income=jnp.array(0.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(0.0), pension_benefit=jnp.array(0.0), ssi_ignored_overall=jnp.asarray(20.0), @@ -144,7 +144,7 @@ def test_aca_expansion_uses_magi_not_countable_income() -> None: # but full MAGI 28000 > 15000 threshold. labor_income=jnp.array(28000.0), capital_income=jnp.array(0.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(0.0), pension_benefit=jnp.array(0.0), ssi_ignored_overall=jnp.asarray(20.0), diff --git a/tests/test_taxes.py b/tests/test_taxes.py index 862c024..d9cab16 100644 --- a/tests/test_taxes.py +++ b/tests/test_taxes.py @@ -221,7 +221,7 @@ def test_taxable_ss_benefit_below_threshold() -> None: result = taxes.taxable_ss_benefit( capital_income=jnp.array(0.0), labor_income=jnp.array(10000.0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), ss_benefit=jnp.array(5000.0), pension_benefit=jnp.array(0.0), spousal_income=jnp.int32(0), @@ -235,8 +235,7 @@ def test_gross_income_basic() -> None: result = taxes.gross_income( capital_income=jnp.array(1000.0), labor_income=jnp.array(5000.0), - spousal_income=jnp.int32(1), - spousal_income_amounts=jnp.array([0.0, 2000.0, 20000.0]), + spousal_income_amount=jnp.array(2000.0), taxable_ss_benefit=jnp.array(500.0), pension_benefit=jnp.array(300.0), ) @@ -247,8 +246,7 @@ def test_after_tax_income_zero() -> None: gi = taxes.gross_income( capital_income=jnp.array(0.0), labor_income=jnp.array(0.0), - spousal_income=jnp.int32(0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), taxable_ss_benefit=jnp.array(0.0), pension_benefit=jnp.array(0.0), ) @@ -269,8 +267,7 @@ def test_after_tax_income_low_bracket() -> None: gi = taxes.gross_income( capital_income=jnp.array(0.0), labor_income=jnp.array(gross), - spousal_income=jnp.int32(0), - spousal_income_amounts=jnp.array([0.0, 0.0, 20000.0]), + spousal_income_amount=jnp.array(0.0), taxable_ss_benefit=jnp.array(0.0), pension_benefit=jnp.array(0.0), ) From e9436fc1a580d7ce3abec1c88b3e291ab5bbfdb6 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:27:06 +0200 Subject: [PATCH 036/101] FIX Credit back only benefits the earnings test withholds The withheld fraction is the earnings-test share of the claimed (early- reduced) benefit, so a claimant who does not work, or earns below the threshold, gets no AIME credit for the early-claim reduction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/environment/social_security.py | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/aca_model/environment/social_security.py b/src/aca_model/environment/social_security.py index 412e44b..0f34303 100644 --- a/src/aca_model/environment/social_security.py +++ b/src/aca_model/environment/social_security.py @@ -202,18 +202,30 @@ def benefit_inelig_pre65( def benefit_withheld_fraction( pia: FloatND, ss_benefit: FloatND, + age: Age, + period: Period, claim_ss: DiscreteAction, claimed_ss: DiscreteState, + early_ret_adjustment: FloatND, + normal_retirement_age: ScalarInt, ) -> FloatND: - """Fraction of raw PIA withheld (early retirement + earnings test). + """Fraction of the claimed benefit the earnings test withholds. - Zero when not claiming regular SS. Used by `next_aime` to credit back - future AIME for benefit withholding during the earnings test. + The claimed benefit is the PIA times the early-claim factor below the + normal retirement age. Only earnings-test withholding is credited back to + future AIME, so the fraction is zero when not claiming regular SS, when not + working, and when earnings stay below the threshold; the early-claim + reduction itself is never credited. """ is_claiming = jnp.maximum(claim_ss, claimed_ss) > 0 + claimed_benefit = jnp.where( + age < normal_retirement_age, pia * early_ret_adjustment[period], pia + ) + has_benefit = claimed_benefit > 0 + safe_benefit = jnp.where(has_benefit, claimed_benefit, 1.0) return jnp.where( - is_claiming & (pia > 0), - jnp.maximum(0.0, 1.0 - ss_benefit / pia), + is_claiming & has_benefit, + jnp.maximum(0.0, 1.0 - ss_benefit / safe_benefit), 0.0, ) From 1975cea85ae62f92bfdc427604822fb49e41296d Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:27:59 +0200 Subject: [PATCH 037/101] FIX Cap AIME at the taxable maximum after labor accrual Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/environment/social_security.py | 7 +++++-- tests/test_social_security.py | 15 +++------------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/aca_model/environment/social_security.py b/src/aca_model/environment/social_security.py index 0f34303..f6811ad 100644 --- a/src/aca_model/environment/social_security.py +++ b/src/aca_model/environment/social_security.py @@ -432,7 +432,10 @@ def _accrue_aime( aime_kink_2: ScalarFloat, ratio_lowest_earnings: FloatND, ) -> FloatND: - """Accrue AIME from labor income (indexing, taxable cap, lowest-year drop).""" + """Accrue AIME from labor income (indexing, taxable cap, lowest-year drop). + + The accrued AIME is capped at the taxable maximum. + """ indexed_aime = jnp.where( age <= aime_last_age_with_indexing, aime * (1.0 + aggregate_wage_growth), @@ -444,7 +447,7 @@ def _accrue_aime( accrual = ( jnp.maximum(0.0, capped_labor - lowest_year_earnings) * aime_accrual_factor ) - return capped_aime + accrual + return jnp.minimum(capped_aime + accrual, aime_kink_2) def _accrue_and_credit_back_pia( diff --git a/tests/test_social_security.py b/tests/test_social_security.py index 30fc799..331d2f2 100644 --- a/tests/test_social_security.py +++ b/tests/test_social_security.py @@ -251,14 +251,8 @@ def test_next_aime_no_indexing_low_income() -> None: assert jnp.isclose(result, 1000, atol=ATOL) -def test_next_aime_high_aime_high_income_accrues_above_taxable_max() -> None: - """Within-period labor accrual above the taxable-max-indexed base is preserved. - - `_accrue_aime` caps the *indexed* base at the taxable max; the small extra - accrual from current labor earnings then rides on top. The carried AIME is - the round-trip of that accrued PIA — not re-clamped at the taxable max — so - it lands just above `aime_kink_2`. - """ +def test_next_aime_high_aime_high_income_is_capped_at_taxable_max() -> None: + """Labor accrual cannot lift an AIME at the taxable max above it.""" result = social_security.next_aime( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), @@ -279,10 +273,7 @@ def test_next_aime_high_aime_high_income_accrues_above_taxable_max() -> None: aime_kink_2=AIME_KINK_2_SCALAR, ratio_lowest_earnings=RATIO, ) - capped_base = AIME_KINK_2 - lowest_year = _RATIO_NP[62] * capped_base - expected = capped_base + (20000.0 - lowest_year) * float(AIME_ACCRUAL_FACTOR) - assert jnp.isclose(result, expected, atol=ATOL) + assert jnp.isclose(result, AIME_KINK_2, atol=ATOL) def test_next_aime_cap_high_aime_low_income() -> None: From 1c06b656aaf0d404b9f86eef9539302434aa0e84 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:29:02 +0200 Subject: [PATCH 038/101] FIX Stop pension accrual at the pension claiming age 71 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/config.py | 3 +++ src/aca_model/environment/pensions.py | 11 +++++++++-- tests/test_pension_integration.py | 18 +++++++++++++++--- tests/test_pensions.py | 2 ++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 4d65551..d831efa 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -17,6 +17,9 @@ class ModelConfig: ss_forced_age: int = 70 work_forced_out_age: int = 72 medicare_age: int = 65 + # Pension benefits must be drawn from this age on; pension wealth no longer + # accrues from earnings. + pension_must_receive_age: int = 71 @dataclass(frozen=True) diff --git a/src/aca_model/environment/pensions.py b/src/aca_model/environment/pensions.py index 5318b9d..c38d335 100644 --- a/src/aca_model/environment/pensions.py +++ b/src/aca_model/environment/pensions.py @@ -4,7 +4,9 @@ """ import jax.numpy as jnp -from lcm.typing import FloatND, IntND, Period, ScalarFloat +from lcm.typing import Age, FloatND, IntND, Period, ScalarFloat + +from aca_model.config import MODEL_CONFIG def full_benefit( @@ -106,6 +108,7 @@ def total_to_pia( def accrual( labor_income: FloatND, + age: Age, period: Period, his: IntND, accrual_intercept: FloatND, @@ -119,6 +122,9 @@ def accrual( Accrual has two components: - Accrual rate among holders (linear in log earnings) - Probability of accrual (logistic in log earnings) + + Accrual stops at `MODEL_CONFIG.pension_must_receive_age`, from which + pension benefits must be drawn. """ lli = jnp.log(jnp.maximum(1.0, labor_income)) @@ -134,7 +140,8 @@ def accrual( ) prob = jnp.exp(logit) / (1.0 + jnp.exp(logit)) - return jnp.where(labor_income > 0.0, rate * prob * labor_income, 0.0) + accrues = (labor_income > 0.0) & (age < MODEL_CONFIG.pension_must_receive_age) + return jnp.where(accrues, rate * prob * labor_income, 0.0) def wealth( diff --git a/tests/test_pension_integration.py b/tests/test_pension_integration.py index b6af1c3..1219d9f 100644 --- a/tests/test_pension_integration.py +++ b/tests/test_pension_integration.py @@ -126,7 +126,11 @@ def test_zero_adjustment_when_his_unchanged() -> None: period=PERIOD, ) accrual_val = pensions.accrual( - labor_income=labor_income, period=PERIOD, his=his, **ACCRUAL_KWARGS + labor_income=labor_income, + age=jnp.int32(60), + period=PERIOD, + his=his, + **ACCRUAL_KWARGS, ) next_exact = pensions.wealth_next_before_adjustment( @@ -173,7 +177,11 @@ def test_rebalancing_preserves_total_wealth_across_his_change() -> None: period=PERIOD, ) accrual_val = pensions.accrual( - labor_income=labor_income, period=PERIOD, his=old_his, **ACCRUAL_KWARGS + labor_income=labor_income, + age=jnp.int32(60), + period=PERIOD, + his=old_his, + **ACCRUAL_KWARGS, ) next_exact = pensions.wealth_next_before_adjustment( @@ -224,7 +232,11 @@ def _solve_phase_adjustment_across_his_change() -> jnp.ndarray: period=PERIOD, ) accrual_val = pensions.accrual( - labor_income=labor_income, period=PERIOD, his=old_his, **ACCRUAL_KWARGS + labor_income=labor_income, + age=jnp.int32(60), + period=PERIOD, + his=old_his, + **ACCRUAL_KWARGS, ) next_exact = pensions.wealth_next_before_adjustment( pension_wealth=pw_old, diff --git a/tests/test_pensions.py b/tests/test_pensions.py index 3da46ff..1c4bfee 100644 --- a/tests/test_pensions.py +++ b/tests/test_pensions.py @@ -125,6 +125,7 @@ def test_imputed_benefit_round_trips_through_wealth() -> None: def test_pension_accrual_no_income() -> None: result = pensions.accrual( labor_income=jnp.array(-1000.0), + age=jnp.int32(60), period=jnp.int32(20), his=jnp.int32(0), accrual_intercept=ACCRUAL_INTERCEPT, @@ -139,6 +140,7 @@ def test_pension_accrual_no_income() -> None: def test_pension_accrual_positive() -> None: result = pensions.accrual( labor_income=jnp.array(10000.0), + age=jnp.int32(60), period=jnp.int32(20), his=jnp.int32(0), accrual_intercept=ACCRUAL_INTERCEPT, From 83f2015d223c70a69b45c0656abccad693128ac7 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:29:31 +0200 Subject: [PATCH 039/101] Name the non-group premium after Medicaid `medicaid_adjusted_premium` The name no longer starts with `premium`, the prefix shared by the baseline premium formulas it wraps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/health_insurance.py | 2 +- src/aca_model/baseline/regimes/_nongroup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index e9fd61a..0587fc9 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -419,7 +419,7 @@ def oop_with_medicaid_replacing_private( return jnp.where(is_medicaid_eligible, medicaid_oop, primary_oop) -def premium_net_of_medicaid( +def medicaid_adjusted_premium( plan_premium: FloatND, is_medicaid_eligible: BoolND, ) -> FloatND: diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index 4d4c321..89b457a 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -96,7 +96,7 @@ def _build_functions(spec: RegimeSpec) -> dict: functions["plan_premium"] = health_insurance.premium_retired # Medicaid-eligible households pay no premium: Medicaid replaces private # cover and pays the Medicare premium. - functions["hic_premium"] = health_insurance.premium_net_of_medicaid + functions["hic_premium"] = health_insurance.medicaid_adjusted_premium functions.update(build_pension_functions(spec)) From 5b56aac779ad9c31538c8f336d496679bbb85a36 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:29:39 +0200 Subject: [PATCH 040/101] FIX Index and accrue a disabled agent's AIME before the DI continuity scaling Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/environment/social_security.py | 40 ++++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/aca_model/environment/social_security.py b/src/aca_model/environment/social_security.py index f6811ad..4baca7a 100644 --- a/src/aca_model/environment/social_security.py +++ b/src/aca_model/environment/social_security.py @@ -600,8 +600,19 @@ def next_aime_disabled( aime_kink_2, ) - return _select_disabled_or_regular( + accrued_aime = _accrue_aime( aime=aime, + labor_income=labor_income, + period=period, + age=age, + aime_accrual_factor=aime_accrual_factor, + aggregate_wage_growth=aggregate_wage_growth, + aime_last_age_with_indexing=aime_last_age_with_indexing, + aime_kink_2=aime_kink_2, + ratio_lowest_earnings=ratio_lowest_earnings, + ) + return _select_disabled_or_regular( + accrued_aime=accrued_aime, regular=regular, period=period, age=age, @@ -662,8 +673,19 @@ def next_aime_disabled_plain( aime_kink_2, ) - return _select_disabled_or_regular( + accrued_aime = _accrue_aime( aime=aime, + labor_income=labor_income, + period=period, + age=age, + aime_accrual_factor=aime_accrual_factor, + aggregate_wage_growth=aggregate_wage_growth, + aime_last_age_with_indexing=aime_last_age_with_indexing, + aime_kink_2=aime_kink_2, + ratio_lowest_earnings=ratio_lowest_earnings, + ) + return _select_disabled_or_regular( + accrued_aime=accrued_aime, regular=regular, period=period, age=age, @@ -676,7 +698,7 @@ def next_aime_disabled_plain( def _select_disabled_or_regular( *, - aime: ContinuousState, + accrued_aime: FloatND, regular: FloatND, period: Period, age: Age, @@ -687,14 +709,16 @@ def _select_disabled_or_regular( ) -> ContinuousState: """Route the disabled DI-continuity AIME against the non-disabled AIME. - The disabled path reads the un-baked `aime`, scaling it so the DI dropout-year - factor change leaves the benefit unchanged. At the Medicare transition it - switches to the dropout-adjusted AIME (OA from then on). + The disabled path starts from the un-baked AIME after indexing and labor + accrual, the same accrual as for anyone else, and scales it so the DI + dropout-year factor change alone leaves the benefit unchanged. At the + Medicare transition it switches to the dropout-adjusted AIME (OA from then + on). """ disabled_next = jnp.where( age + 1 < medicare_age, - aime * di_dropout_next_period_ratio[period], - aime * di_dropout_scale[period], + accrued_aime * di_dropout_next_period_ratio[period], + accrued_aime * di_dropout_scale[period], ) is_disabled = health == 0 return jnp.where(is_disabled, disabled_next, regular) From a4eccf2f6938798cbf37e7b54fa2677f2ff88d1f Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:30:30 +0200 Subject: [PATCH 041/101] FIX Freeze AIME once claiming is forced From 70 on AIME neither accrues from earnings nor is capped at the taxable maximum, so a delayed-retirement credit above the cap survives; pension imputation in these regimes reads the current PIA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/regimes/_common.py | 17 +++--- src/aca_model/environment/social_security.py | 55 +++++--------------- 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 9704a28..716a43b 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -849,7 +849,11 @@ def build_pension_functions(spec: RegimeSpec) -> dict: if can_work else health_insurance.target_his_forcedout ) - functions["pia_unadjusted_next_period"] = social_security.pia_unadjusted_next_period + functions["pia_unadjusted_next_period"] = ( + social_security.pia_unadjusted_next_period_forced + if spec["ss"] == "forced" + else social_security.pia_unadjusted_next_period + ) functions["imputed_pension_wealth_next_period"] = ( pensions.imputed_pension_wealth_next_period ) @@ -984,20 +988,19 @@ def _select_aime_law(spec: RegimeSpec) -> Callable[..., FloatND]: The claim-age actuarial bake applies only where the agent chooses when to claim (`ss=choose`), so only those regimes carry the `claim_ss`/`claimed_ss` - inputs. `ss=inelig` (cannot claim) and `ss=forced` (claims by rule) use the - plain-accrual variant: no claim adjustment, no claim inputs. A forced - claimant who claimed early carries the reduction in from the choose regime; - plain accrual preserves it. + inputs. `ss=inelig` (cannot claim) uses plain accrual with no claim inputs; + in `ss=forced` (claims by rule) AIME stays as carried in, with any early + reduction or delayed credit from the choose regime. - post-65 (`oamc`), `choose` → `next_aime` (claim-adjusted) - - post-65 (`oamc`), `forced` → `next_aime_plain` + - post-65 (`oamc`), `forced` → `next_aime_forced` (AIME frozen) - pre-65 (`nomc`/`dimc`), `choose` → `next_aime_disabled` (claim-adjusted) - pre-65 (`nomc`/`dimc`), `inelig` → `next_aime_disabled_plain` """ is_choose = spec["ss"] == "choose" if spec["mc"] == "oamc": return ( - social_security.next_aime if is_choose else social_security.next_aime_plain + social_security.next_aime if is_choose else social_security.next_aime_forced ) return ( social_security.next_aime_disabled diff --git a/src/aca_model/environment/social_security.py b/src/aca_model/environment/social_security.py index 4baca7a..bc5d96e 100644 --- a/src/aca_model/environment/social_security.py +++ b/src/aca_model/environment/social_security.py @@ -342,49 +342,15 @@ def next_aime( return jnp.interp(adjusted_pia, pia_table, pia_aime_grid) -def next_aime_plain( - aime: ContinuousState, - labor_income: FloatND, - period: Period, - age: Age, - benefit_withheld_fraction: FloatND, - earnings_test_credited_back: FloatND, - earnings_test_repealed_age: ScalarInt, - pia_table: FloatND, - pia_aime_grid: FloatND, - aime_accrual_factor: ScalarFloat, - aggregate_wage_growth: ScalarFloat, - aime_last_age_with_indexing: ScalarInt, - aime_kink_2: ScalarFloat, - ratio_lowest_earnings: FloatND, -) -> ContinuousState: - """Compute next period's AIME without any claim-age adjustment. - - Used by post-65 `ss=forced` regimes, where the agent cannot choose when to - claim and so carries no `claim_ss` action / `claimed_ss` state. A forced - claimant who claimed early already has the actuarial reduction baked into - the AIME carried in from the `ss=choose` regime; plain accrual preserves it. - A forced claimant who never claimed early keeps a pristine AIME, so the - flat-PIA benefit equals the full PIA, which is correct. +def next_aime_forced(aime: ContinuousState) -> ContinuousState: + """AIME law of the post-65 `ss=forced` regimes: AIME stays as it is. + + Once claiming is forced, earnings no longer accrue to AIME and nothing is + capped. A forced claimant who claimed early carries the reduction in from + the `ss=choose` regime; one who delayed carries the delayed-retirement + credit, including the part above the taxable maximum. """ - credited_pia = _accrue_and_credit_back_pia( - aime=aime, - labor_income=labor_income, - period=period, - age=age, - benefit_withheld_fraction=benefit_withheld_fraction, - earnings_test_credited_back=earnings_test_credited_back, - earnings_test_repealed_age=earnings_test_repealed_age, - pia_table=pia_table, - pia_aime_grid=pia_aime_grid, - aime_accrual_factor=aime_accrual_factor, - aggregate_wage_growth=aggregate_wage_growth, - aime_last_age_with_indexing=aime_last_age_with_indexing, - aime_kink_2=aime_kink_2, - ratio_lowest_earnings=ratio_lowest_earnings, - ) - accrued_aime = jnp.interp(credited_pia, pia_table, pia_aime_grid) - return jnp.minimum(accrued_aime, aime_kink_2) + return aime def pia_unadjusted_next_period( @@ -420,6 +386,11 @@ def pia_unadjusted_next_period( return jnp.interp(accrued_aime, pia_aime_grid, pia_table) +def pia_unadjusted_next_period_forced(pia: FloatND) -> FloatND: + """Next-period PIA in the `ss=forced` regimes, where AIME stays as it is.""" + return pia + + def _accrue_aime( *, aime: ContinuousState, From 3ae5518fb344a3c8890f81f8dccf3f2caba88c72 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:31:13 +0200 Subject: [PATCH 042/101] TST Expect a community-rated ACA non-group premium Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_aca_plan.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/test_aca_plan.py diff --git a/tests/test_aca_plan.py b/tests/test_aca_plan.py new file mode 100644 index 0000000..9847aa1 --- /dev/null +++ b/tests/test_aca_plan.py @@ -0,0 +1,83 @@ +"""The ACA private non-group plan is community-rated. + +Under every ACA variant with the reformed non-group market, the premium of +a private non-group policy before premium credits no longer depends on the +household's expected medical costs: it is a flat amount per household +size. The tests evaluate the real non-group regime DAG after the ACA +overlay, with the baseline risk-rated premium pinned to a constant, so they +check which premium the overlay wires in. +""" + +import inspect + +import jax.numpy as jnp +import pytest +from dags import concatenate_functions + +from aca_model.aca.health_insurance import PolicyVariant +from aca_model.aca.regimes._overrides import apply_aca_overrides +from aca_model.agent.labor_market import LaborSupply +from aca_model.baseline import health_insurance +from aca_model.baseline.health_insurance import BuyPrivate +from aca_model.baseline.regimes._common import REGIME_SPECS, build_model_functions +from aca_model.baseline.regimes._nongroup import _build_functions + +RISK_RATED_PREMIUM = 4620.0 +ACA_PREMIUM_SINGLE = 6000.0 +ACA_PREMIUM_MARRIED_EXTRA = 3000.0 +REGIME = "nongroup_nomc_inelig_canwork" + + +def _is_base_premium(func: object) -> bool: + """Whether `func` is one of the baseline premium formulas.""" + return getattr(func, "__module__", "") == health_insurance.__name__ and getattr( + func, "__name__", "" + ).startswith("premium") + + +def _aca_premium(policy: PolicyVariant, spousal_income: int) -> float: + """Evaluate `hic_premium` of a non-Medicaid household buying private cover.""" + spec = REGIME_SPECS[REGIME] + functions = {**build_model_functions(), **_build_functions(spec)} + functions = { + name: (lambda: jnp.asarray(RISK_RATED_PREMIUM)) + if _is_base_premium(func) + else func + for name, func in functions.items() + if callable(func) + } + apply_aca_overrides(functions, spec, policy) + functions["is_medicaid_eligible"] = lambda: jnp.asarray(False) + combined = concatenate_functions( + functions, targets=["hic_premium"], return_type="tuple" + ) + inputs = { + "buy_private": jnp.asarray(BuyPrivate.yes), + "labor_supply": jnp.asarray(LaborSupply.do_not_work), + "spousal_income": jnp.int32(spousal_income), + "aca_premium_single": jnp.asarray(ACA_PREMIUM_SINGLE), + "aca_premium_married_extra": jnp.asarray(ACA_PREMIUM_MARRIED_EXTRA), + } + needed = inspect.signature(combined).parameters + return float(combined(**{k: v for k, v in inputs.items() if k in needed})[0]) + + +@pytest.mark.parametrize( + ("policy", "spousal_income", "expected"), + [ + (PolicyVariant.ACA, 0, ACA_PREMIUM_SINGLE), + (PolicyVariant.ACA, 2, ACA_PREMIUM_SINGLE + ACA_PREMIUM_MARRIED_EXTRA), + (PolicyVariant.ACA_NO_MANDATE, 1, 9000.0), + (PolicyVariant.ACA_NO_MEDICAID_EXPANSION, 0, 6000.0), + (PolicyVariant.ACA_NO_MEDICAID_EXPANSION_NO_MANDATE, 2, 9000.0), + (PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION, 0, RISK_RATED_PREMIUM), + ], +) +def test_aca_non_group_premium_is_community_rated( + policy: PolicyVariant, spousal_income: int, expected: float +) -> None: + """The ACA plan costs 6000 for a single and 9000 for a married household. + + The variant without the non-group reform keeps the risk-rated premium. + """ + assert _aca_premium(policy, spousal_income) == pytest.approx(expected) From 14b312e14ad19b7c5c280fc35bd5d1d551cbd9dc Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:31:18 +0200 Subject: [PATCH 043/101] FIX Require consumption at or above the household's own floor Under grid search a married household can no longer consume the lower single floor and save part of a floor-topping transfer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/agent/assets_and_income.py | 14 ++++++++++++++ src/aca_model/baseline/regimes/_common.py | 19 ++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/aca_model/agent/assets_and_income.py b/src/aca_model/agent/assets_and_income.py index 147adf5..ea11a1a 100644 --- a/src/aca_model/agent/assets_and_income.py +++ b/src/aca_model/agent/assets_and_income.py @@ -211,3 +211,17 @@ def borrowing_constraint( The `max` form returns `floor` exactly. """ return consumption_dollars <= jnp.maximum(cash_on_hand, consumption_dollars_floor) + + +def consumption_above_floor( + consumption_dollars: ContinuousAction, + consumption_dollars_floor: FloatND, +) -> BoolND: + """Consumption cannot fall below the household's own $-floor. + + The consumption grid starts at the single floor; a married household's + floor is higher, and consuming below it would let the household save part + of a floor-topping transfer. With cash on hand below the floor, this and + the borrowing constraint together pin consumption at the floor. + """ + return consumption_dollars >= consumption_dollars_floor diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 716a43b..a738c85 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -551,9 +551,8 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: other broadcast function is masked with `None` so its unresolvable inputs (e.g. `pension_benefit`) don't surface as params in the dead template. - - constraints: the borrowing constraint is masked — `dead` has no - consumption action. It is broadcast under every solver, so there is - always exactly one mask to apply. + - constraints: every broadcast constraint is masked — `dead` has no + consumption action. - `pension_wealth` is masked explicitly: a carried state is rejected in terminal regimes before pruning could drop it. """ @@ -681,18 +680,24 @@ def build_nbegm_functions() -> dict: def build_model_constraints(*, solver: SolverName) -> dict: """Build the model-level constraints broadcast into every regime. - `dead` masks the borrowing constraint — it has no consumption action. - Grid search evaluates the action-level predicate directly. An EGM-family + `dead` masks every constraint — it has no consumption action. + Grid search evaluates the action-level predicates directly: the borrowing + constraint and the household's own consumption floor. An EGM-family solve proves the equivalent post-decision lower bound from its savings grid, while forward simulation receives the solver's intrinsic budget - mask over the consumption grid. + mask over the consumption grid; it does not carry the own-floor bound. """ borrowing_constraint = ( assets_and_income.borrowing_constraint if solver == "brute_force" else post_decision_lower_bound(margin=ACA_LIQUID_MARGIN, lower=0.0) ) - return {"borrowing_constraint": borrowing_constraint} + constraints = {"borrowing_constraint": borrowing_constraint} + if solver == "brute_force": + constraints["consumption_floor_constraint"] = ( + assets_and_income.consumption_above_floor + ) + return constraints def build_model_states(grids: Grids) -> dict: From c84e56bbe637f3e74841a09d78ed5ac4a061cd5d Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:31:46 +0200 Subject: [PATCH 044/101] FIX Add the ACA surtaxes on unearned income and earnings Two after-tax-income nodes for the ACA scenarios: 3.8% on unearned income above 200,000 plus 0.9% on earnings above 200,000, and the unearned-income surtax alone. They take the baseline after-tax income as input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/environment/taxes.py | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/aca_model/environment/taxes.py b/src/aca_model/environment/taxes.py index 44a07c4..c61d014 100644 --- a/src/aca_model/environment/taxes.py +++ b/src/aca_model/environment/taxes.py @@ -178,3 +178,43 @@ def _payroll_tax( taxes_at_lower[bracket_id] + (labor_income - bracket_lower) * marginal_rates[bracket_id] ) + + +# ACA surtaxes (the 2013 net investment income tax and additional Medicare tax). +ACA_INVESTMENT_SURTAX_RATE = 0.038 +ACA_INVESTMENT_SURTAX_THRESHOLD = 200_000.0 +ACA_PAYROLL_SURTAX_RATE = 0.009 +ACA_PAYROLL_SURTAX_THRESHOLD = 200_000.0 + + +def after_tax_income_with_aca_surtaxes( + after_tax_income_before_aca_surtaxes: FloatND, + gross_income: FloatND, + labor_income: FloatND, +) -> FloatND: + """Subtract both ACA surtaxes from after-tax income. + + - 3.8% on unearned income (`gross_income - labor_income`: asset income, + spousal income, taxable SS benefits and pension benefits) above 200,000; + - 0.9% on earnings above 200,000. + """ + unearned_income = gross_income - labor_income + return ( + after_tax_income_before_aca_surtaxes + - ACA_INVESTMENT_SURTAX_RATE + * jnp.maximum(0.0, unearned_income - ACA_INVESTMENT_SURTAX_THRESHOLD) + - ACA_PAYROLL_SURTAX_RATE + * jnp.maximum(0.0, labor_income - ACA_PAYROLL_SURTAX_THRESHOLD) + ) + + +def after_tax_income_with_aca_investment_surtax( + after_tax_income_before_aca_surtaxes: FloatND, + gross_income: FloatND, + labor_income: FloatND, +) -> FloatND: + """Subtract the 3.8% ACA surtax on unearned income above 200,000.""" + unearned_income = gross_income - labor_income + return after_tax_income_before_aca_surtaxes - ACA_INVESTMENT_SURTAX_RATE * ( + jnp.maximum(0.0, unearned_income - ACA_INVESTMENT_SURTAX_THRESHOLD) + ) From e76ee7a3e83ed25162063b0e9e1015d214cc19b5 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:31:55 +0200 Subject: [PATCH 045/101] Community-rate the ACA private non-group premium Under every ACA variant with the reformed non-group market, the plan premium before credits is `aca_premium_single` plus `aca_premium_married_extra` for a married household, independent of expected medical costs. The variant with only the Medicaid expansion keeps the risk-rated premium. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/aca/health_insurance.py | 18 ++++++++++++++++++ src/aca_model/aca/regimes/_overrides.py | 9 ++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/aca_model/aca/health_insurance.py b/src/aca_model/aca/health_insurance.py index 10885b2..299da48 100644 --- a/src/aca_model/aca/health_insurance.py +++ b/src/aca_model/aca/health_insurance.py @@ -18,6 +18,7 @@ DiscreteAction, DiscreteState, FloatND, + IntND, ScalarBool, ScalarFloat, ) @@ -35,6 +36,23 @@ class PolicyVariant(Enum): ACA_ONLY_MEDICAID_EXPANSION = auto() +def community_rated_premium( + is_married: IntND, + buy_private: DiscreteAction, + aca_premium_single: ScalarFloat, + aca_premium_married_extra: ScalarFloat, +) -> FloatND: + """Compute the ACA private non-group premium before premium credits. + + With community rating the premium varies only by household size, not + with expected medical costs: `aca_premium_single` for a single + household plus `aca_premium_married_extra` for a married one. Return 0 + when not buying (`buy_private=no`). + """ + premium = aca_premium_single + aca_premium_married_extra * is_married + return jnp.where(buy_private == BuyPrivate.yes, premium, 0.0) + + def mandate_penalty( gross_income: FloatND, spousal_income: DiscreteState, diff --git a/src/aca_model/aca/regimes/_overrides.py b/src/aca_model/aca/regimes/_overrides.py index 9c10ba5..ed5cd53 100644 --- a/src/aca_model/aca/regimes/_overrides.py +++ b/src/aca_model/aca/regimes/_overrides.py @@ -24,9 +24,11 @@ def apply_aca_overrides( under-65 MAGI expansion) installed on all regimes. The expansion arm is internally scoped to the under-65 population, disabled or not, so only post-65 households keep the categorical track with its asset test. - - **Subsidies**: premium credits, cost-sharing reductions, and their - consuming functions (nongroup+nomc only). All mask to their neutral - value when Medicaid-eligible (minimum-essential coverage). + - **Subsidies**: the reformed non-group market (nongroup+nomc only): the + community-rated plan premium, premium credits, cost-sharing + reductions, and their consuming functions. Credits and cost-sharing + mask to their neutral value when Medicaid-eligible (minimum-essential + coverage). - **Mandate**: individual mandate penalty (nongroup+nomc only, requires subsidies), waived when Medicaid-eligible. """ @@ -48,6 +50,7 @@ def apply_aca_overrides( functions["mandate_penalty"] = aca_hi.mandate_penalty # No mandate: mandate_penalty is a fixed param (0.0) in the params # dict, not a DAG function — no entry needed here. + functions["plan_premium"] = aca_hi.community_rated_premium functions["hic_premium_subsidy"] = aca_hi.premium_subsidy functions["cost_sharing_scale"] = aca_hi.cost_sharing functions["premium_default"] = aca_hi.premium_default From fb842c559488307bb26d93c3beeccf26f1a6cd55 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:36:34 +0200 Subject: [PATCH 046/101] DOC Describe the ACA plan premium and Medicaid in the non-group regimes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c2e5c14..3b3d734 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,10 @@ ACA variants don't create new regimes — they swap functions on baseline regime - ACA replaces stubs with real policy functions from `aca/policies.py` - `PolicyVariant` enum controls which policies are active (full ACA, no mandate, Medicaid only) +- Every variant with the reformed non-group market (all but Medicaid-only) swaps the + non-group-nomc `plan_premium` for the community-rated + `aca_premium_single + aca_premium_married_extra * is_married`; aca-slurm puts the ACA + plan's copay parameters into those regimes and drops the risk-rated premium's params ### Key State Variables @@ -107,6 +111,13 @@ ACA variants don't create new regimes — they swap functions on baseline regime an action. Those use `premium()` and `primary_oop()` which condition on it. All other regimes use `premium_insured()` / `premium_retired()` and `oop_costs` directly — no `buy_private` parameter at all. +- **Medicaid in the non-group regimes**: their `hic_premium` is + `medicaid_adjusted_premium(plan_premium)`, zero when Medicaid-eligible (Medicaid + replaces private cover before Medicare and pays the Medicare premium after). Before + Medicare, `oop_costs` is Medicaid's cost-sharing on total costs when eligible, + whatever `buy_private` says, so a Medicaid-eligible household can neither hold private + cover nor stay uninsured; both `buy_private` values give the same outcome. Elsewhere + Medicaid pays on top of the primary OOP (`oop_with_medicaid`). - **`reference_age` parameter**: Fixed cost of work uses `age - reference_age` (not a hardcoded constant). Same parameter appears in `leisure()`, `tied()`, `with_hours()`, and `utility_scale_factor()`. From 117b7bfd4dc27a4a7ad46cf4bec37eefc0db6c72 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:37:06 +0200 Subject: [PATCH 047/101] FIX Price private cover per the draft; double couples' cost sharing; DI Medicare - Private premium `max(minimum[married], p0 + b (1 + markup) E[insurer cost])`; `private_premium_intercept` solves p0 by bisection from the insurer's zero-profit condition on the data sample, so it moves with b. - Private cover requires the premium to be paid in full (brute-force constraint): no private cover financed by the consumption floor. - Predicted insurer cost read by period, marital status and health. - Couples' deductible and OOP maximum are doubled (`insured_oop`). - Pre-65 households that do not work and are disabled next period hold disability Medicare; regime transitions split by next-period disability and per-target health laws condition on the outcome. - Benchmark snapshot regenerated for the new parameters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 23 ++- .../_benchmark_data/benchmark_params.pkl | Bin 69519 -> 97357 bytes src/aca_model/agent/health.py | 45 ++++- src/aca_model/baseline/health_insurance.py | 158 +++++++++++++----- src/aca_model/baseline/regimes/_common.py | 60 ++++++- src/aca_model/baseline/regimes/_nongroup.py | 53 ++++-- src/aca_model/baseline/regimes/_retiree.py | 44 +++-- src/aca_model/baseline/regimes/_tied.py | 48 ++++-- tests/test_private_premium.py | 4 +- 9 files changed, 339 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a11a970..e570302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,8 +116,18 @@ ACA variants don't create new regimes — they swap functions on baseline regime `premium_default = max(0, hic_premium − max(0, resources − consumption_dollars_floor))` is a tracked DAG node; `cash_on_hand` subtracts only the affordable part. The floor is therefore on pre-premium resources; OOP stays the post-decision shock in - `next_assets`. Coverage is unchanged on default (the defaulted premium is - uncompensated care). + `next_assets`. Employer and Medicare coverage is unchanged on default (the defaulted + premium is uncompensated care). Private non-group cover is not: under the brute-force + solver the `private_cover_paid_in_full` constraint makes `buy_private = yes` + infeasible whenever `premium_default > 0`, so no household holds private cover + financed by the floor. +- **Private non-group premium**: + `max(premium_minimum[is_married], p0 + b (1 + markup) E[insurer cost])`, with `b` + estimated. The intercept `p0` is the DAG node `private_premium_intercept`, solved by + bisection from the insurer's zero-profit condition on the data sample of private + buyers under 65 (`premium_sample_*`), so it moves with `b`. +- **Couples' cost sharing**: `deductible` and `oop_max` are the single household's; + `insured_oop` doubles both for married households. - **Two-track Medicaid eligibility**: `is_medicaid_eligible` is the union of a *categorical* track — `(crossed_oamc_threshold OR is_disabled)` AND the SSI asset and income tests (on SSI countable income) — and, under the ACA Medicaid-expansion @@ -127,9 +137,12 @@ ACA variants don't create new regimes — they swap functions on baseline regime - **`crossed_oamc_threshold`**: per-regime constant fixed param (`= spec["mc"] == "oamc"`, i.e. age ≥ 65), the *aged* indicator in eligibility. It replaced the `gets_medicare` gate there; `is_disabled` (= `health == disabled`, a DAG - function in `nomc`/`dimc` regimes) supplies the disabled arm. The Medicare - *transition* still uses its own build-time `gets_medicare` constant (`mc != nomc`) — - distinct from this. + function in `nomc`/`dimc` regimes) supplies the disabled arm. +- **Disability Medicare before 65**: a household is on Medicare (`dimc`) next period + exactly when it does not work this period and is disabled next period. The canwork + regime transitions split survivors between the `dimc` and `nomc` targets by + `P(disabled next | health)`, and the per-target health laws condition on the outcome + (`next_health_into_dimc`, `next_health_into_nomc`). - **SS claim-age adjustment baked into AIME**: at the voluntary claim the early-retirement reduction / delayed-retirement credit is applied to PIA and converted back to AIME via `find_aime` (the exact inverse of `pia`), so the permanent adjustment diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 0447289b3312aa24583ec56331776a3e05e8a523..e87b64c8a63be48bd131905044240643d15b251b 100644 GIT binary patch literal 97357 zcmeEP30zEF|DUvyWS0_!l%*1d6z9m2q-w_%p@_Cgi)pcxBqT)}Nz`ax zDHYiZ+1jlk{Lj62MjCk^&*OdH|MNbd&g1Dj=YH=w_uO-T_uSw8{eI^j57E$-DncYI z?ck`e`&-dnsg`613X@{T;$T~(m{cn&lWK24B~z@a9K&hs0ZfV`mCUdrGpUYFOnZ(# zIDHUHhuHn?sFrjK3X`|%JhmK#Zbv2CQdo{yVX_O|kw&K5TT)%=_SPIjUA6?()q!D8 zwRc37Jhj3TdpOI1;l!fYq8%+5s5Hfn;biZ~;;_XWDE5{V?8ho9lTKxE4sa%V;%yWm z+gUR??9s#yY8DJ8RgG@%NM+hnY*}h{6nk`VCMr016*{ghRnL}TvBikkN>rk`?pYNGR+X*d^iTXcQKf#fwlkaf}`DUu-e5CDvH1 zlO&0AUx4RPlf`*w%F*A|K^$-&YPOR(Tg0AXhkAKH_Y2Q)!e|P*GDGa0>>S+G$oO?d zztPpVKpjW6VlwQ$ouo>b4DT#@fI7eF6_CRg#VbNcv=OzbkJT)Wua7r6kcy!&TS%8< zY;3&bD@ynuLy{xM+)#Kghb=Up<4ClIHk>Adt~yPYCQnEoO@TH}g{JhcugK*8;x(Yn zpy{ytB0-{9GVE>LIKEEiG+o*pqC0n>+h?kphNhYZapzEwlyhi9*kb0`O~K-9!Z(XK zQ*50uVmVs^-CAVCA&HhSccR-m((TbtQI;*;f{J7d$(*pE5Y~ZC6ekwyF1836U2t?1 z7IBlJme^4pDfm5tuB9j$zt|MED8azOQC!;CG( zoNCMB_^@bh#z<7;oY0_PZ%KEgGtj+FW;rpP>8Pz_2PVUuLzCoPkpZ+p=oh-F(Z%Y^ z?zfU+Z%zH~j-~0cg|V>=orNQX>Buph&K5y8odd_v4EsSvKZN+gN1Lw_MkB^Y60_F0 zpFu(^IKP2bIQeudkck~)mT;mK%vrhoRnDk&pd6sRKdULZ81>A*luAdut_Eg|vW4)gjPtYYWJ>ln6;#+5#kpX85#>YXMpc zAC+p~H3LiMMmM4SW}wzGIR8yRGbs9Ka9eXlGuYz+OXdt{2Ir)c15#} zGkPz;iP42*xf9QU@Dk0Tr#qj4Pq*@RvkaaAqlpiki~OGg4o$!NjY%`?;kH!W%b}rB z2OlEPb>55Jl)5x;nvdahny;Z5&5wT(cW`Ld7)ht?q1oW_O|zqg((Gwlk?^W^6JDAV z&6(yxbER!Vh1}8qJNbeq-SrrxF`SU#>cc>Yk2LI&6lW?Ed%PjZWTBA?lT9*ps}tQB z?NsGM>?G&F#GZ|gXdI7g(J&{ZVyI@=2mL4P z_)8t_s|N>>NV%89Ljrs}p@)oQ;-avVaM0Yb=hxX5L*^4Zn+elgpq;aPpAQX@aP0zy z{}sT6Pes(qBX{}0FMVc-P+$UVZ%kIEyeWluw#T!oNAHB8Q)k2kUpWTPuG^$5U2_$- zd5(?Uf8GkFKcUR%5a9cIY4o@f@ zVkq_g!%3*JVB4jHbH(tj{nD{1*Ii(?tlsCTTcY5cB-veg9$dInZl`FPk}ve&lx>YI zNPt3_S{%DsWw2u9@ePS$yI^fpwTJuA7#KsjllF0Z4m>kFUu3r)07N$t~0gaMb{wheSH zgWGFi-9zp!NK@LznY|_knhZ|z4=3lqZN`_)7iR2+^3QUtOwXQzmUZpB&&@1>R{c(s zKQ_BU@fUJ?lUPTgj``}8KJU3ON6B6E=u$rjJ>~~^T}gx)P-k!doH7`EIn87DN)MP- zxl?O#Yz(Bo+<&R_cn-{nw+r{z+yiHl#MDz1;;16F|S#54Vm!()#} zU5snXfs4xrTpz&Q0|!TGS&)y%LGIqYq6b<_Aji7jB|VRA@Obms;v?Im;gHadPw@w? z!hIS88WkS-!|8zzXS4^LgU{!!ZqnUW4lmbf9A$fYLV81dwe`W{@ZoCz2H6FiC?9ItU2I-Wm&F==fn6zS6vpl-Jv-hZv;IZ|;m}nF*DO4e3m*-%+dWZuFWd>M zj<%PahSDvy>Lcl}G7=h8LU z`E;fKs9Sqr*wDOBb>1f+)m1u)W0D8=%&!$a<+cwhYAcMJCUXYfTDoY(+f!vQB%n3n zYvNAWWDs^Th86>*J^R0J?2`*s(w@(yO9jD8WyL10Gn1i?)@1Q3%GcqeFoyS+OfPu- zX5)fY;3RB)JqBVs=xh_zaXYrIytod2rl2KEw0rF6cIhu|)NC z40KNXv}uxgE*vMTx?Sf?5R7g**Z3hR85+*YlD9Z;9o9G8*}E=rFO-l@AG_`INjQ3& z!5aAi`S9h(z{F@-9~j_h=iF~s0<=#M^}6L-4r2;#56BDifa6sp<(| z*6i?sk>o;IuUU!E>T0QS_`7l#-FeVG7NT*_H}vz!A+hk3cMB- zd){)1DX>9Gr0)=o3OMHgYkA-~Z}F@)MwdYcy@XW#V{s$^xS@^6w zS(Uyp{&_`Khg>`qowd~X0l5Gk6T10gMY=y!U*@6V&Nv6pKXGAPop>G2y_Kv#&U6n9 zd8bntd;J8oD2ttE^vh?hMqDR$TMB zu>cBvS+p?uXaI~bne%)CD+%tu#F<%DdmY}gWS3m3@PcvHgFG*v zeWN5O6uwt(ZhSlSJd7S7ZEPV`329;D4y#WHfPtk`U$wg=z{fLkLyFZ3;l!wW+lGhj zhqrf}Tr>1~5?p+E#u39I6)?}ZHEe9aUI-^nDmw0S3Yw9gR!Dr#hiRGOb+e4Z;5sw= zb^XqsheoHZK3S<(!r}JD_ca#ohaoq5!M&m8KyA`A${)l7pCk$xPmoIhB+}A zdMT30>N6`~{E>4DHyP}M^qc2Khs(sl@EdpVux2Zu8=`8Gg>6E)Tp%A{-*!}bz41#tSji1ZyPKN6bHEh|pt^y9e z&&=``@rFjyDGcM2anPj#W~}!rfZJ9HH5%lG!y`|$&M>1dK&6f2%A2h#q5tvVdnO@cx|w|z&U>@wseMBslo|R$T>MfHJT$C6LhN=jTz#?5yAf4(#Q17r`4w*{)=_i( zA{w{X$x77e<`uvLEeEF0*d77xYm7}IhNVNJLp%Eo_NauK(cVo9OM{@r-g$Dzwx5GH zK3|!#a#Rsqs%u)6p%Dykhue89Q%iyOsus)53`70@UZK^=(FfLgy%HDBJPpI2@5)yd zE`%-`QR_!eJqX21=)3gd;kNN#C!WzOgtP9N%deKoxR75F{IDmlc&BU#Ow*WX z|Ft>=jz}=my_ig_Y24aHkuc90D1#6J_m?9D2`OZty9u2)augtZMKJffn!68g^@WtnivpZ)Th7M|bXYIRr5pwA%lI>3_VZi{c z^6ZBpQ2Y6+$!b>q2>%6c4^M`D>6m!nyF;SyE})W-DPNUWMKxJsc4s2C2<2fjWWtSvGD+WXc8H% z?v)H3-+0BcmKVdww>p!~(hk56L$2g4El-8h6$2iA)vtu<%dhNFD)ooTZ5cHGO$ku% zxW~u!4n=U;?v}C3w;h4xEemFcU&w$9#3Mf6)Vcw4GtOAb-8=xNv`(7Iu1kjbFWyXY zIaLf3AFdg>w(0=9wrSM%`(xAK^qE^kJ>4tehUqb@^l5DPXpwQC>x%^FtHza@RZ|4r ztVkbb4~v8ckHyN!Prd|aT~BKIY<~mJF~|>Fw3Gt}jqY58} zZobq}JY8u&yi(e}b<~Bk=s6oLrns#Hn#M?ra;`_gB)>0d{ZcYvtp>gS{C!n$&CnyS zMn{FhL)(wsHQ12~cdjv9t9q*xUM|?fTxcB%2lx!k4vISubNb2*o>_APJ`J(0n~|~~ z<~&x7VN%b*V#!J|(>bMZLz{Ky=}kvrsNeB(Q|fsZT)ur!AbCm z?buvBr7}1u?`j<7At<6%<2b_eh4YYQ_0j0HCK}XJ%q9`v$Q+=(NoHszw5Q}aTwfm!chB&UToiB-&NOZip%vbQW44-J zK9U>+NBLE0al}$!GdyeP)ldPaZZ}`DZ_qKAWx_tXU^W-N+Y)N|-0(K6P>Yn6n-Br- zn7N;|Uyu&nPrjtj)Vl#K&Na>Zk{=E`T$8@!ZO(unwbzf-8Cna?PwgA9wKE94uPk-i zj`Xz7u%H{+uPfoE{R^9?Z#)JI#3g2ZuH?e^g^`JoeeXgs?xofP;SsR$zLI&-jEivX zq3zG=6RKd{j@N6)&x(LS5)vl0Mwj5LMaioEM{mO!HnlTsR|P|{9oNd8x1_?uQZ8D3 zr&qxRQ=IzeojwM`huKXN+I1Cf=yR>MS?)emY~@UwDs>RrPNm-&yCeh7-{@ucVtoxv zIH1}fl@bAOuDO<%Mb3ocTXbqG9PdH{#_1KylY-%=j0t7_3)0|>)O}+e6RII|x9M{2 zPsiYn>!yjX7vw-K>%@nXLLR~onIE*gd=A2OejZy?y)MD`B5UFuR^NgLm)R$kYaN7< zMr!7V4_$_P9rho5d+a`(;_;;-V`vEMShjq?zB%V%pBbAbiEOzEs}58rr>%;C>)_Jp zF)F!mZCG&1PEZfy&VzJU$wM&omEYLIjhS#x+L)LHXK%w&xfS%f{DUwmw{}#qXco)^ z)e1vL)xnB4%jZVy4uQ)Y!#7RXcmW=akzuZJuZ4Lp#V0JwiGll0RK?v;&4VfDhSe2y zK7#a_u4(QOhhX(+i3lH`E9m*S>|K%bJt%2bTcAcg1m_r;&FveX4R^HG$t*uv4^ISU zcuwy`?a$RP?)S+^U5B@bUn!pO(R^Rmy>m91NK6%mbW7h>pfy@|k}PUw6J`(pJIwyPf6Dpbd7d8^WS3vM5J zR^M|@9o#S0HvBoGf#{oWZ69XOVA>&*F7htXnN&NhQ!rz#p&nb7N@3a~mjsLKh@2#7 z2L~#}78%rVdOlkcKjAHO`z_>Y$nw@1|qpZYbMnivo*V5Mm%fei*cl(9X!m9ZnU57WGk{*mULukHb=%{dt`63C0kSI_Et`| zWKG-0qLEYUw2Es^c|JIgb6A|%pt z@Snm%xC8G6a4yV5sS%93aUZ2--tpSN;`(jqnzaM$0&Wa^1#TU8nA7jpYcOVS{d-)fn#6}o7n!X8&jRcnaiI?Id z@=|>IQ8tEmmklc>HbgP(e_1q0b-Y{tzlS4NW2uIBTwE~!(SQD;XvlUGjWK^-G=`z5 z4LSpPa$T@zCtpr_l8kS@G;Bmu?e0q>hLZ?%`2S?mnpeGpv@#RK$$vSNu%Z+wj(KCd zW=Az(sMFauSKI}k!n8LHA5ssl1-$5MsQUQRKN)@oB1OrP$w2Wtj%1bv(#?7jFTrOL ze*dq24W{GiHPA^Og8sb*H1%UwyxzS6>2`N7*xh{vmW`Y8UNh$vI1`VhS0LVYp^waw zSD^n*v5k_uUI8;_mSg6sSAcc*bjohoS77|!+a`Cb+QIdur?2UUw}XiVNyf2O?SP__ zeKJ9>9mwzWnZusk4$2R_zOAR&4)SNQA8e6p2bqP+tVbi-!RgVqVW)<*gV-U>t5*+g z2ToU_RC9;4gXpTAZSHc-vE zHotRt8_?M^Z1$dEZD34|l9lSvHV{m)?(5yZ4baboF%?I)fhNs+A{9z);BlE^Ovto0 zkgNJ}5*@UG3s=(mjGWm9)cSEGGNvFje=~orVjI{tit>t$>KiU^cJ6TBHn8Wx1|4V7 zHV|-W)b3@%Z6M8c=F#DO+JNhFclHIkeMF4N>R8)D1~!WJ(3ggA78(8>9da^h#8}Ev;8`q5Z2@9~-xB^0D*J%f}F2 zAgCk76+KmGR7)o;WJZQ;YhkBG1gfGCPX`BN7$DnHDOM;l(1^$`#d3I%s4;rT$+AaS z*fN|f9q1NYY^iGB{YJ5yC|D8?Z6jZS>sgp>5{gknH zL{$DSpYm^)7dUkk_i9bYVh+)B3yX~G!9@4{RmWS88@x7VgGsYPN_QCg1-yqWpiA?|$loDOMc*@p;%VeAjO9Jj@ow**x^O%Xd3{C+xmw?L&dBLmen? zOopv3-V@&jBAY-V5(!0{;zNv0ILe?w!sZ~to?IPm8jnzFXkDV3Py^o8K9aHAYe}(fG$3j^{+2~*QtGL75@j^ezp6l zb#^v0*VO# z+3W+%eA;bh0Xz!v{&6_<4S&sSjJa^W3t0Z!&myo<>VpO`3w-*3%>?wDh>7pA7YxF9 zenhp629_htmEC6q(479*v~9BtmBy^wLs%?2*^c5$w{x;1BO#?)(R-3N8x^!a&$!*~ z>OAEVS7l@IH{{r;qMdOM+CQrM*PyfN>V8u!*K_yV7-5`?jWGt_MYebEbhk*n_ca!l$v9H8eKq!{tN=v!!hr)?_P3!h6PYL=$^~;X)oR;-Nkd7xU17hf8>9$it;PG~%H#50~+9IS*Ixa3v2{ z@z8{at9iJFhiiGbj)&`cxPgbJJlx2`O+4JpLoyG4Ng3>N&Czsmk;5NsUDxgRCiY45 znj+6b{$xtQ$&~^VDfwAqmL)OsQebi=n%MW}Rha*c)3PZ*ccZ|(P^6R%!41aUZ%|@h zD3vX0!LURx;@BNGUqvqzz2n&^$2%=Tz_I@O})28^R{{`*f7#ES(&{HthJcD@48|L&<=V2-r-9K z$b8Ru@}RsC9F*S|Wm|a}Bo4kmNnA4o?A5n2TH$^HY%}gWQWMn#Ol4R5MUH6&b_?Ej z4s-Pcc7y9L%wK*0j413|N}nDK42In@y?nb7yn5_)RdT}8XdcVO$K9zm^BA1shxUwOhzDRydHP$k{2r0DvJAq7Z2ymKdzSsnzCjN43umJg?{^|1va(-mg(aAZL<4-vzs3{ z<`E87X_9ACMf`!N&+b-9Nr&Whw}DUiE0QYBLC| z%^YU?yaja3A9w2Duzf&&>1)TNXW@W(`D5G27k(fi>!kR@sg1yE{c62e0at-;@x;qE z3|}z!SX7?^!xWI_mB}ey-V7qH_sc4L)&eF57c8BdyBFBx>B-QQBEYMi?-J$5`vb6c z`bY7$mte5YD&44n9Pl+gaKxMyh*@)puV3hk} z*8W4yVCsdr3AE-GQ1;$BKHAg^$jp5^Ru;7utj>Y)uBhtAp1aH{Yo^C zUwy)aaXWN&15u~h0Wv<}An#UO>bS{)V20M*dxO1RfcVI=Ree4ef=M6mUA4O61Ge_5 z(0bx~8npQzRIQB>Ku7j|uCeqB z5MFSin6P~dtBtVwbjr#!v|jHFepY?EpQQj>({S$OOM)+s49 z_b6c?hs$73d2N>r_aVd7S{BtdLlzc`ZPr z*-84NOe;9Kzxc8C8+Wkw-Li;t$3p=pE{e*|3IU~Bl4rvDJ_lK=#uQe0mVr-D% z^8;SuHj7Rr9|wy+m2yXJYyrDuJQL%`w}N%LFSlJ{ZUgd^vB{sxIY2bEpOMu#4tT(A zFJ8Ot8JKi3e1K-ib+G!PWQFusKd|kXC^cLt28g>bmb_%O0JR5?HWW>51+%hM9M?;@ z0!F_>iyk~V00tD?o05AW6znfPa*y%kDcBR;F(mD71(>wD9h`gX59YjAwL0b$4Q81} zPPy*W0+`CHRmaR}1!E0YPqfW)0>iUK4R*mNW^usK$e7W zyNrV%Q!6QS@Rx7T^h{lUn6v^{Gt#cn^ydlazmQpjP@8J2b;?Q{!h$zR!0xVjaX zM+Tl)e0(doxnX|)sSkoc86v5GS0UEiEO=vm6TQxd$D9x**OM?3zUz`JQ@h@rH)w``!*7klm#C>Y5N4I zZSiwor(XkhUDi*Yc{d0goN(ILcu6>T=Bje7Dys#I^FQBzw_Ph3GG?;=OQtQ*WZZx` zy8}S`xKC0K=SKtg0S=zxm5+h{PBS^D)i*(hb?C69JHa5v(|A(Ko=}ilM{4Px-vUl8 zwpYn>Z3R~2E89u}=s+?+TV>A{e-N50;u?7B7>IZ@=UDyh$6#a3o@+~1-vVQ$9$lH; z83NRA%FkQ#?f`g7l5pKpg!+f}X6?Q`tstN}h~e331-1qXoiZT%f|4mqj-)Fc2loSp zb3GFtfrKan_Cmc{K$*Nvart}>xF*+UmU&bND3sTVju?E;vR4V8*RsO$`7W@+#dTj<$dzgVe>T>8-$7%lgE*of|-gvCx%s`*whW z6%A{-LTAx<u*j}3-@NucC;klQh%I`Ggv)3YAcbF1ajwqbV1KxM%$ z&jFfj(6PeUezR)}aH&x4oRx>Z=W?fJ#dD*zpxP+rmiRzd&~N1XbPcg&K&NEVFU+q4 zFP;6f#AeKzS=Qsfq}Y=p;$sNz=eq`%GvOSLs%XDD^z z%UCDy=3-olwMYt>vd2UW4FRJ&Qot>`dHw^}I-nQuq;mFhbxTY?ZXV7tA&Rq_=&;7L;xQO@}4!++J`VRIa%a_VhqKpiUOv*tzc( z81CLtvj)k-z}6kn0eyo(zRwY*sPJa63S0o=X`lZZ4nK;4otjx zQQ&H@f+|DWI>ZXJm#msTtNlC}UiPk8_{k$6>ojLXd}cMcdUPIJe<;#>%&t9& zBy&KY^()3~lWhi+mwtv4^{t>_Rg3Jz6Kg<;Q`ECDCoF)oOZ3WdkI#d6f0@tD7axQ2 zk_j3H!>d4Q``d~sH&a28lgGKGHDO@XTq-H(S`+wce=yAUQ7c$cbe5DkV;!)M)P5@I zN&#EnzEz#(bsiWg#@t>T_XJ47xmV{KSAwL;uJqij^Psdwk0B&+2;6(T#`~E|6SzC0 zcxb%bP1qiyW3mZ|-8?#WL6|A%SIE3NH!~GP9h_E~ zRq_;w%y8d8eP04b$O=vBFLVhsuY2K}aPt^Y(^pJk#WaHJ)t`6EqI#4?hfqsm%|OPQ zsk5n`>%ir5?-6P%Q-G^POG!uDGobf=Pm$=5LLeo%Y>KAIWpHciCOK)R6Cl`->!UKd z5pZn$yT7Aij-uf#y0PW zfw5-`Q&%)WEZVKhxAz6h;l4d&YZRD%U~?EFU2KkE@V!5q@6wZ@p^2gBQ={Lx^2ioU z2CHX>-cKJ4Sm=iY@;$gw$#j%wItto{#pkc4^1lH?)Zqr~?h@$=>sOG`Qn`+}UM zHG^TZWS@2e+W;?pK%cN`Syv?$=!ouP)qr(7U%aA7C47KCd$vF*8aIxO5)C+ z@05(D{VgCtIJ<94rbV@d+=ubCmC=uyJB0afe z*uK8<37F|elV7^&CTN>{sI>p<7hulv@XE63Mc|XBno^?hS&%d;Q8gcF*@RZo)zCjs zO4QMDdsa%O6Aacm0j0!%V6Z0tpp^VS^jFutt%5b|o8O*^>LTc{7|@9>XflZE8d z@LSsihlXgx+N7gD-A093BE;1$OrQ@@npw=9`Y7Rr{DuPV3hDPxuRbg0UhYVV@;q6{ zH7z~WeoCx>n|dbt%R;oD%$Ux|&*BAK>B)VbEV`J_4NkY*Vd`GQy*Fmzpo6A)Tw|fj z+>iG8+@@*sE&8Mva>YIcbgVj-!yRs171b0XHJ)<|?(@T(0dPW9?&& zdEAk%%Pd8t3b^Au=Pp_lm3;1#)z`~*&%MM={j|ySuzMa? zXM4WTQtdph(?hb(n++GZC&#`mO<$VF6(6tCSPXKxHS%r2v8E~9@!ZA?vS&yvH+%E@!+v*jx$84q1|8216W|Amj)x!masoYfyby25pJew1@Az>&N6mdB-Cr*pZ9ZS(v3dS`OI zf@LOE_Vt75`6j1FM&xo8+!rOU9+AO)V0vTQydy`U<0PNmMgFMw0d=!~JWe-dOI=<*r-jzmsi{%yoR;@9@Z7 z7vPIAWcJFvxm;RyPF9g*BDc?>9hz?rWWvhL1Ep67d{B2PB znesq9=xnet>W4VGZ_hcKSd7-i2eBXZgul&GL|`07+W7qc)n`PPCUgYr`fr-h4J=1! zLf!(J&@V(VwxiNm1S75!VPE92U{I~BdW@9q;T`9fMCCU>a{upDiEPr)3I7lz)+@$; zS!?<|RmXV8!{_D_UqCPR_Cv26^xg-*Iu3AbuAyjmrgbeXT%BeYkS`RlLFfA%5J zrOR|+UH?s&8He70f1=CSv|@X@*tB8ry}_2R!3@Q$I+zB7f4y&NuOidsO#J&;b^by6 z_$K_Tczl};ioY>uIEiuvahdgk{M7y;TtVb`1>462_u&fS6eXL@IwC?`L7W(JWa8(d zFWG{4xNiHnTFXz_f_Pqw883eCeYPMngLh^5tG&q<#2c@VlPx#4Weeijj4ZivrO&ek z@oR)d$OKMZwjioX+ATV%T#+q^B7+8MKfM$N1Tp8;wA)F+RX`A5S?UH%ReB5rarXS= z0}o4@fFO?U*O~ol<7*&@pSDO$RCMbAf~Z;eS>#j3XCR0TQY{m?fkJ34hVFPjt8RfUhM6Mzaj_!UPZ&%~3#=9#r#w_=pqZah#3gX@E5;e|8`fvqt#~IO<_huim1@V#a+2C`emTW;ZY8vuV zb7O6`AT|YyUEhB8JP<^7{CMY4YwiOG#=!*m-*XM8D%gTtSSs2`+!CD!~=R2zR47Ei7rS zAa=Cs=vG`B!tKT0e)ubL@b6V*4)-cDzZc2yXCDGx78y=AMJD?<6q$aQMTW4(^ypCH zt+(2Ciu+PlhSXI!)2eoov*}gnvdr?)w9{8%jE%1U6!s&To{(9+;$i)kKIu8)Yl zU7rU{-U_dBo>u^kROD;+yBES&e&bvM3JT$4U6ESV+eI+Z;rN7oXpOCZI`VGg9+xNh z$%X0?ok!h7=Vh1Z2!7}H4~=_Y``i-K%vSshwj?)29s zWaUEWKdqsp-L4Sk4y>9c|Ev(MmZ~tzRVsqo)t9c)WS*kOpbBUh7eQ(~ zR{Bu37*9I$302se^~GD_qk>w-t0fuuHO=tA((i4 zd!v^54PhDCE#Q;<-511rke7cE{MqvWC>RsN?uS-{w??b?{LLdQ|JQBt8b~5~jGyd{ zlkAO??2Vu7jU)ZD@wETmS6E(vBsNO*21x!Di~KLW!hZJ<=z0&C)J=0((4{#D21x#) znu%);-@+pQbD9G$0;N}2{;JjE&k74+UEq7~e>Lo=cfZ~+#ojQ*-Y`Y}IPmY)7*MDI!}|Dn z!LUAlUNEeWpBD`4tdE};4C~|P z1;hIIdBLzgeqJ!FkDnI|>*MDI!}|Dn!LUAlzBjC|H>~fkg!TQA-28hLhu(+4kGjC0 z+Va;GhvVH8hs*yq#eo<0_n%W7co7!8io^ep;vg7C$IlCf(ed+wVRZbwU>F@gFBnG0 z&kKgp@$-UVbo{(v7#%+^7)Hm>3x?70^MYY?{JdZo9X~G^M#s+!hSBl!f?;(0ykHm| zKQ9F@g-y25P8%Fn6!sz};ZvMTBL+?Z2PpJt%?)=vkhlkx1 zhmPM+9Pm&W-lWHW9oo=S+*EHs-G3*rk9Xt#wZJ+#^r5=0P#6U)_d9V@xUgvafo|E8 zu=EDk{da==eoI)UbrY7EzacDxC>9n>C$wJmU;J3!AH+-bei-JrzmoUA9>ew1Z{Tg{ zH`l1Wvof3PZkry^dNX^=g3^<0T21!PwhuY7=TWA7LALUO-Q(-bu4R)Bl*#pbP@Fww z#32ox11Z_=k|*|EIG>$8M0I+na%Eb!smKXq`)UrTnQL;pr6f2z>+ZXqQz9Zk>-++X z4+}EE#?VUDDy8#a&mf}{iX1N3*dgh*=YAo0Q=h-Sky8Nb)0WC-tu6y+oENqmov8rX z8_SnQzNi2*c8tF_$GZx=R4$mU)~^O656)RGs&W&oR<#F4v|4aRV!YI@{?tQ2A*wdXOv%gAyZvAST)v=xav=072ZpxTGvI z+7Sq1j^A0Uj(CS`LA(>PdXW7zzidHVm$Qpp6d0N${x0!aeMjkY(YGf^x2Xvy(e1`Bl5>YubJYTErRlGl25Yv^IS4VaRWDDZ-0l=U%#4o!Sd;8(PAqW3nMJB3Ok@>5B zzTZ`3!n-LlIlrOE4CgB{4orsmAJ}AQGH979n(S|ySorI1@K+JxFF(LvMTDRKZW#jp z$^<_C`!@(mmxkNWO)$Q835Kx1XPp0dmO?kd_yaZ9<|8_iKv&;eQ*BUK zA`ZWryH7~|(jU%TPp|v@Neuo|x>^wla+m#l0+u7}=YP7ZNBy*`$*iqC%qH!rw>kcn zn&I5b**_rhQPclg^%3!7-f6<2=lvv>P&HxaGvk=P)ze|k zwAor7defoS=V@vcNz>qr@(EW*jhP0`l;(B%253NInIkuKJEuZgU#Z!qTc$#hLQQFz z3+gZ-bL^CsFf|w^G~2|_O%?81yYd>?JOy4jg zxyF8ugJqzk`pdB$?BP&bTa9~V@G#h<fysFaH*~ z@#`ixfxjU)64;~Ej!Nly#OYx@1fJ4`zFQN4Ru99{Gy+J|d8o-lEgsI`;Y=RR;-NMV zXY){phq^qR!^62eDT*5;`9xmmf5f6=d zxQvI(dANdyD|xtzhbBB+&BHZ3T+73CJY3Ji4LmgE;YJ>A;^Aff=`go&dvs4U)X@)&4E;XI6W72fl!W3)jzjw7ew4fE9Q0xbu_*ykTZ_jX} zvU<>?+2Rgv6eg45!ueq#3wA#`i)@ETY(X%5K>hZ0ieGpY_*YBw{nf_WTtzqFkL{Gi z*6}0%bYZ?StV7T`WY|Jv*h*yBQe@a#WY}V4*lJ|ha%9+gWY~ga*otJ>l4RJLWZ0r) z*s5gMvSir0WP=8ygjOcQmL|j2Cc_pd!&WE59Ix2=WY_{_*a~IX5@q;Gv)CfD*!r^A z(z4j9ve<&M*jlpKGP2kTve@FW*t)UUlCjuovDiYf*c!3e^02BXsiCBf(o~c*P@0C) zFRx`*_D$T-IyI;Zuq6op8UJ%^>X8^23Y;KXMDd^UpWq1!uK1RSPTwXE`*R2V>Gs(Y zGxR>jOVNdf@(}x-b!-%d(I-ES!H^WW9LKulD{vlG$Krfl=NEiC^jL|b$C(W{&&@o8 z^9mut3RwAQ&2xAe&A^Ad_7c+iTz5}<2+ZY9p|Bj2F@$Yu*G@E zpyPaWyBLmUlB)Co75a z71jifR>OIZ&ufW#QXb>mqhnkNoUsb$NyoS0eDsO8`1U$eUjiFki25HMA?mjfSH$|$ ztnMJrbKf2&%DeN<KykA4t_ZzqkCC-%Fxfv7*0=qGscD85}GemqgW)SM_kxtu89_Y_gS zdJ>M&O9^=;W!}PhkM;z7yV-7a9JxC7MEMC1ah{}~Oz=_DaIEv(O7P$@QUB{yf*+(w z@DeP7zeDf}^=Sm3KOIMpOH7>iD5=BCD|{fz*B#d&cuxm{Z@5j=KY=)YUAQVy&thAg zCy}lb?K2_TAMLM<^Q4bd;`nVu|0vj=z_&x2F*w$FZX(JDB;e)EHXI?!jTwexbm>as z_=z0i`2B+M?c7ia92IgG68uD0f~W1lx5G;x@aocy)r0lz(> zakJ`A-*Idb&|?f4HnHbgcRkZ4nP9Mood8orFxV3iXS%J${LfPPdenFRA<7)4lnUxQ z*vg7!{}g2oI}gDV{3V61%ea3G6ZLN&FZ_Vz2#a%>fN}p9)@U4qmO#YTXcWiH5iCc` z9_*hsZRiL*W7T*1BwU@38{<4ep{lQxt_g}(!ZXbe=o?MFab(DIX zhc;(%zD{T)UOsvS7w63mJi_@pk7%O)3Bw4UmP*tgNR+R04kF4Ak|uc3Se)lJ+{epD zckaM>$P~kQkJ4bGyw)w8SGZ0i$_sz`fVCHHamRTDwQ`*INMDZgW`RvOuW;M~=gk7L zao)^N3+Fvv-obei&?k6>vp7$hI0ENM9r;9g8D*T0&WRxE_jpUR>r^7nM-LOmd5_Jz zc=cD{JbW9B^L3N&zQfuX-9*88?$S7%S7>As^}F67+O_-&&O_H?d^@B)Bl`797|xRnyl}p5;(nax)}JS? zTmMx!4}%Sf@};YA-os-r&ets-NR*cwit}b#as)qqEzT>rU&ps|KRi64Bl$%;W z@F^Ym_PQ(`9HHM_oL8t%#(58m8hpD#!UP;W)ChSr+kKMY(=QX-3rFE-Ry&d?&p3he z(Y8tWc9=Vcz_%lC9!e(RyqQ4?zMZs6gWy}n;=EZ(A;Amh;oGAJt-?`ZvMy17W;1d8 zxzF(JW*+-+tov$-^Capx9P60!`1#khl;J!)7lm*4xJ$>;tjQYZNjsE@{noxD%8k## z5vCR5+qq)l#C{(Z6W4!&DuLm1@axM}9fhNrc_zNSF8mCRq>&}WcB6D0p^hFdf26*1 zafCbU2>JD+;mCb|pODYnPjKWGC=>l~T^&dGct3uB)h&p|(X6P0;2-qI+e?yNjgKD+ z8ys-tj<3VFE3EuVVA#}7T+Sp{6KH;)IFBST{CEngik~qaPBO;Pthp86UPm8G+}Dv4 zzF@ou%M?dAff%nmp06N|>kq$TeBFL`96c;n;>S@4%0#fq26voL|u4jeOMjqo_Y(``7{X&?0HE}3X5y`GBss-jSXR_!l zN2*&)`$YD$JNz5IhkH!W(u?Kv&8H<_nvYfG zYuwr2+G|I*r`tK%eb=0RmQ+h83rD)SEjo4R5=Wf+Knn)lp5?@(*jrG^Oo}7oK#^F$ z#Y=^Q*y0R^1MQf#E4!NI&QDd9>}Zq zJ8oY1EibG+?BNzn28)Hdl0iY2*OA7gvS{&YeZZqSAgjuD;7tO2g_! z>A&S6<=C9eLILm|$Nrt3N92H~QX%y(peNFPnMX3_u%WZDL`%e!F^`45FO!vtHq}L7I!oHIDP9g%-M%0%;SeA z%U%mIie%nOJo%oT_y%pZs+%qfT`%rl55%sq%F%twePj?FVn1f0H!05V=U zp@XBwpdM6I4%$Cf&RF484EoM0zBH&G86228KE3>T0x(^d zB-f(Y0{l;_d31WTf`~xt5q+}$Biiju(A~}a$SaQxeXAlVr3PwaFV?ew?jRGq|xiF7S}?M<4a`| z)+8ejqdx68yK)2x+#j;8#(5NRelD|$L;5OWJbc?SW057ol91Oc)D(x@*{1dC0BawD zeAsW;bgdo<_B?0qx>6UR4Rkj8s+EkqMqWl_Eg3=X9RlaaE~Cg7Wh;|SrOrtGs}X8V zw4QV3^?Cye@kpQag{-r5eF&AiitkPTdL%VNbPhF-0m7KT#(Xz28A(2@UfeQo1er7w zja0ohioABTyr$>qjI>`o-Pv>45*aeQw5{ZFJks1hH^cSoE9CIS*YSq&^@wLyuGhUC z#z@azsZXPfW8sMNm#>T>Q61fU%Uhih z0nW6StYKCNp{iF>>s=her9&gsf!3qB=VL~5cRlj9iYI4#ycwd_^O1}9R0?v4BU-g_ zVi+x#RH3^xig1b-+s0^LMPxKy4$4Cb`OS8naR4^X0=X%#|3?edGkU-q*Mdq5PQ<#t%()lLv@RB9&aiV zyFW(uP46(myXjTLBb`yC$)_>DW3ekTx3)FV$?!Z9q_06|c_kX@9b(c~;pju$EgXsE zXB!Yvx;vIHxH%tkB1TY%g%I3%A?4h!>gaZ=e~i2_4oTx3dpPkX`(#pt=Fs*wS`BQ}P_r z6>@i&_iZXdT)19U`R*{{Yjooe?C&oWHwUtyIol#>-OIpXd1K6=$~Ox0wD9BeymADdR@&Vsh%c&SE{o$ZZsY4meb22_*wcoh1y-Ae*+? zwRBB%^$e{vYz(ao40O-v;J%%Hh{o3L%dOC3O+QmxjEVKd+ z^^I;?Lom`Hr!bws==8U;X?4Iz^AB`d-!P}ed?Rgg3KQ&&bkLNmkuLf>iwtjML@t%2 zCCe~>5Ymmz(E=k2^3QYh2k#PVcMj}Tji2@C52X&(m5i;;P`#!)+j1aNpYZK&$?o|U zVm(ZT;0fQ75P{i-s4)r4=zE9w=IN2cbcZzZw7fVi;X2(R#li=_=|?Lu&COEttl1e| zR0&0OD_slp6gaAM{Q1u1?HSOEWV!BbL61n5b6PxW-sKN*yaVa4qWxf>nAY(47Js;+ zWa$w0nFQA#C=AU#8v}06!c~b*^)MhtLfWcw0Z8wtEVj zc)9KB4U2%2^SSERm3qUIrU%PPMsI<-*tMc@t4P>9XVtS#yJ9#-&!-!@vjmO}9pBP> z{SJJ7IoD|@sR-O7}tw>PY`oG_x& zOoLC1Tw9Eyt6*+Avw8ebI;>^C(yJ6!2W7hDm-)vlA=5~atHUM}7;Cl%PArN8N5juS zcfSUKjjtNs876k;0-6zlHI%cBjMnX>Ee@f{9%VOPn+^} ze>izyXK0mtEWBsy${00lh9i4o)n1MH10!`pUF+u{DAQWvVQ&%z>qcF6SY$-w?VV#P zT)n!`8?4S-*0LO`g)JNGFTGe22`5D=xb~ik0%ps`PH4%3fagoo=CBmQoEu^eq0R(& zypSRA?i~W`4m~!$#GxE-S8K%C@!Q2E@T7t2-G`HdBz2dq2r94ZUSrIP0fpP4YxXvh z;DF7!myg(^V8h$DryH3GFfPs2F1#fG4tYG0&qgAFe`jP(NMAafR0-1DXV!wx2W%c9 z?e5BRPsKqQVcS`r*aS$(pOdiXO%3exSGsPoq83_65o~QhfMfUWFLYK31Q|)eIZu}n zV6sfKJ$^d@j)z`r*=F9V|aoJ`7iWAJgcDmWCDW&1;-+0BfJ%U;k!B~xzA8wdPZ zf$~CHanP7_rfqm`6v&&4nJ8wYg3Glc3xUm1(9X;|_Ih0)m;~yt^cM=m$6I4f-l_U) zK`=nimqD8m0DAWiSj%ch!F3kybmKP_kg;xCs?G&(oG)SHJI{&aDtW9N!r!h~eJ-vX zO6WyyhTjPVzSj2Yr_5;(u>Z~WzO-DR+v;_v>^2e37wuJCI!OdXQ9X9xbT|-gLi4Hb zBtnbXahqd9Klo4gMJLShp1S&yyizo{soK<@+B6%ks zWSqV-db%V4=jsD1>>7QckBgNv@x3?1c;0?!cO?W=OIaT3QTgKcf7J1z_wbr9pj+~4 zsC#)0yy3X;aDPAzY#cLfw^|}I%%IuliYpY$Tmi3BM^*JHeuihyS$i!kTP z7>E&f;GQoP1HG&=?Q84f;PTlF_P(-ec>69|r>!jl>K6zEN_A#~>H4PKt;ceJc;njP zN}meQIaXiGv>*$phXe_)#sw&#p`4)PTSf) z^=D+n$*a14r9eMy{Ip|T3?OtT2j|n0KqcX7dZbkVyh|4>r;R7VVYPMInsQNau(K>7 zbs!R-?-*ag#*4r$2W?HiJjmkrf2W;Km@UZD{QjIU%*3LeD+%N_56i2Upd=ZY1??G9CGLi8}Si)m;?s)F}p}S6bm~nx5wP2Dg(Wiqn*cHf6NPg(c>@Q2$$mcLpikVf_I|e8lR$!{i#qe zaowbJA7bI8mP%>yi4x#eagQ{)7z-U&wo)mqss$E?c|D`-#gOi&sHC$h6YrO=Vzjrt zE(ZW9twQl+mLFb!9t@<+Tb>K5k9LQSsOG|`NamqUl{s*-Ql}yCd?B#Djy`JlxB%jf z=iL)Ct_SL*d6ORmeQ~}e9>k5SCPkg<@mZ(8~P&9XHGn3Zc7y2PK-a&Prlo8*d`5^)9U1fGkPJT)#$nxsTL&!OsMQ4{Xw!m`z*P=j+|oaub}hvXi&4bLu3lv zG)-xCWhlqxhx<)5t)8fS<>I}e@1-yi=L^_8#pX5E&f+~)wGmq0U{UcxcQLUFjz;th zDG1lV7?B_lOh>*!miqPqW5=IRTvY(;7b> zO#p#?EfH>ZE%>~6{&q#h=*>`g5nDYxx~~j$cAv=WcE|-So>bD3)_Rb&EjTq$orI5T zTeXy=_Kn4GW~qc;#Bwiy)Q|c}Ji(B^#b}`9XcTPQIo6-q6b03%=tEum3c>Fh4An4I z!q6(6+K4O?4B3#P2KN)-LVw%Yf~-v3EB*B-@anh%Ps?IPMDp@U_i&;;|`k$ zbpGDu4Bod+w-)w$Zct4cZN$gJqMIfalQIE7_uvF;?Dik>1J)jsx6X>ht$_eROM6c_ z2f~Or&Evf)v9Rv(mBh1R!SHd=!g0%B2pl!;Jf<}9gWo@|wY>R!jW66|3|#zPEF3C! z+_&Xmjsnw3w*^Z?2=FrBsjYZbJkaFaWEe!frhoOzZW*{+2bbG~+Sx}7;bO&=^D>E% z_`H2?@qqjIrWo+P^>*iRhygCv(d35OI5;=F*ilik4j74ND-8uRfLA7tXU~E%IM5}t zaz|nnELC9!>7W=`m3(;onYkY>zl&4f_kN*L4-vb1mc3#vfO6$-#oa3sVBwanUSeGp z`16h@*(5Fuh=PS5gfkzDgu>N9yP$dnKalkfLxMNPg8D}_#`3LE;6S~lZ3oKFFuAv+ z#542Q{7`(HVR8wb%DdDg$p~B@S!6b@keBWQstSa!9$N8#nr}TcJx^mII&5rT5i_enXRMbw&n_M4=3MZOGtC7hvAq1nqzdRC-|oH* z<^Pro>Z?@DOaCOdhsLif7rf^Wn&GwT8)XA&VP;i0Qj^p-K#!tAFSh$2#sw7P^MOuJbWwP#6Sy=?}l02?&PFWK(nc zSDA43*~C^YpESJv*t(19xlb&@%tJFGU~l_is?q@>IB>@D>f8M3t*VLq>%kcT^nPZ=knBM@`>39U6 zOvS(sv8_)NAEv_!=2W(l>ruGg@{(;CdZMfp=O?9t-D%2RF?hY(PB&WJm<)w0fk~bZ zCy0RYD|S{k!#g8E@LHA_O_T!8vov14Vh(I7p1wSEMs z_VkaO@rZyE_9p#rJzJpewiMfefoKS8O4L+7M*@q)&Es1hM8lgE6M5FlpIJ2srdkv(*rU6+(t^-)97{rT;hH7S{ao?F}n=L*9A0- zN5aG7;G&A^a=k|(kf`Un{3L%NT-x8AzjlfQMgl%(Z{3dtOs^bB?+9E#h=bQP5@v?6 z0pN!;CuC4j)xP4xhYdcpY!~{HAy1A?nF0+*AmImj6Nha%| z)XGd;&&Kq)H;Oxok2vLkWKPYAOx+B8TwwC@YgXa@nEW)LW2+I@%_xNTn$kfJ_lJM~wM3Jqe$e zhYAi{bTSA7Z3&UhDhxy@zVvJ%>xD8H2>AHlRi$|Nty~ful}f)q_ku4Vw>PhCI~fYQ-)L#_yTpMMw@vBGmqf^X zEAv?IRX!B3nY>$O6o!w>t^Dq5o-@Tk2G>*ZNEF}gQ&6kvnCjqXRnV7PQ39A!~1<>egcP{jyGH^Ogg3#;|=vo%siDU0zviU z(J$EWkxxS2wp4Nyt@@f?p-@yky`<7xiSg&NAuwmN%6JX(gGNlIx@#=cMhC- z8-KF6H3zyO{xt1GEJ!EpD$mZU2kk_qfu*#u(DP~A;`3&)kZRBWk@SWDpR=ye?Up10 z@ih{Z*HsEOi3=h$#0mht6i<45!B6L<`(`<7p7YsoSU^FlIxG_OoG+A=b)bA?Ubvn| zUJf|!k=~H%90zaT51&~OM!@ZuWqa$pSWBv)Mqsy>iskJg(TfndPQ@cc$4 z?5?aBJn_B~PA=>>ZeNiE7nca7*ssrofj}4Wjee0pT2cXp!z36OnKThw=L@1)8=fX; z-NW_3nDUxkCk};xriX|_Ema&)ZP~8=-Z2io28BskhoN?hJ?2FDD)jkG+ru!qH^E-VG8pXm>%icHU_7#xuM(^>bZRN0% zc%1(#YEMvXJ>&Lv11b+%?7i-xRRWSin#}xD!C+rMN8`F932ZK|oh#T!f~9V3<27+a z$Xal8UTb6_ZZ|3x;Mk{sB@_lzw>_ZmFURLUW;d3BmrohnBB8|l-DC4OU*Ioac%5dw zAH2B4!su}|A5Jn`s_~- z9k`@6=YUJG-P5VL(ZFV?Hm`F}G%oj{jc2sMY7b@qcCy&jeQfq+j=90Rv9xDS~Y z5#d?IM>k8mLs|9H+_VB# zs0@7aHi&>;rIc-{>=AgnzsM~k@p>~zHnK~n@uh>P=uy8!qgoKyn`tY3troYJJUe=9 zoR{S;t`B1C>XDYF`?)j~pxc&lflY<2PcCXh+9=4uj5<8Y0lN`W`;?sTOqZa{1&*)B(@I@ftq~)Sf-u(cG96 z4Z6?cuh`o~gZxLaCinD8xF)(LkSD$jF6~nnToslG#)7XkiK1yBUS^uYSC9+W0v1@u zp?2wW!(QCBN6KN}rl1Wa5(HpyTl-*XdnDN0&=b+VQVRFAhNL-Sh<|#1*gl4}fPTL1 zr5M~Eg*~5!rw{npNmamGmm``^9Rd5L#rfip2-vEW@WK+chmLVZZ|+mL12^xwMkzM7 zz<`#rXSqcb(B7*VzaAR}aTP^9%jZSIrWbahOBAEQexZ2rR>NAjQ7XUSvv3+DI_2Mx z&n|;F3)%B4n}UI`!9g+HCk|Sj=ym&?vhaRh-_?=5?`9JuoGnPKH7|xyNV&#d{O+g?vdCHWinE`9#AL_OQ zRzuvb=0eVhp!iHD8n?4z@{n^t%Wj7G ziO^5=g>~+;K-|7}Ta}4$=~={|^v8oonAG)r3&1;iXQ}GCLR{Y1W5CmVLM|Go?=id| zp_Ez%k%jTCR(k{C<3a|MXF!29oo#%zN`l(6J`eU|4_zw7m-2ZUzL{%K2*D zvwLx{USwp-=oo7En_+*)?4p<+CU~fZxIQ`#BFlQ_@c0vOezKm+bL$FY0-SMZ7B+N8 z<%)YvC&!HA;8~&GfskFvU??snTp2~c?fgS)qP8TwBfftUw{90|)K%hkY5Hxo zt9BKH0A`QD_#Gza%9lKTUOg7|r{@)RwQrNWZ56EKov7nxu7T$GGj6ZeRO9k7EkpjD z19g$WJbHRmbmGT&e{;U3c&>6dI6qL}quo{wPr0O4c$fv_ax*rbYZM-?eLGeI3QLDd zQ|bNThV1o$esh0#-Fk#`?n)Ab9$9si?Mo;eA_N`Xu0_P#k39#&?a8lKJNSX6pzft5 zxqi4Fx{RyQB7r6Vy8YhM$Yw@D!Z{Y&rsZ+?`ZE5`y0VUng!AK7Qr7#a9qaz2M>Vcc zI>@-X5pqFi*GictT+e>b?if~GgtlvX|872`OY>{%0ML1y!kH;thqvS6&ZJzr_t7A^ zy>YwTh8SEA-=^z&_r6sbVD(`4!=ZZPNxCEnE-zzaP&|uXRkU2|-kKVGJ%|(d5+>`E z0hoPi?UYoLxnB%EZ;Z$4^*X8(Kw|TO%4Y&O&_A_)F3VmbbV-`+Ag`0yK5f{ISNx2; z!N8KTd(`Y}Il8~k9eDOeA{^Nr6G24zG-fZobw*?H$&V#)b|t${)`A**d}pw=ucjN0 zgX?xqlM;?4(6+a7)X^2)FPlC;fUWb`c*Eo`Odi}-eK)B%=104u$0p)wMsC9cmdc2E z6Oo{HYrB~#UmD2J7;*WmZv;un1qKaQ@Lc`q~MRPY+@tS-?FZTNT}xoZi2~`K_F?rkeDYEShj0)&UA6cYa|}$@?uJ8KQz4{p&y*#h5;odixVvF)9&Z1_ zo?jqK?}*qbAIRC>wvud@#@fAwvF(w%cM)te)iOFHQw-OZSj0}9OU3m>Y@Mli7J7fA zAQD7V+#^3OtAOamwrsR{s6J&}z@{LZ05)AGdzrg4@P29?mO3$F)(EuLDvKUH%Y|k7 zwYi@z<^fZ@9Y@^DVCZfYThxQD&wJ-C)$dMA!R_d3MYp63<7r{!yB?OyJRDMlRJn^PF zA1Y{S^whbH33e7;YO9F4A5Sn|2beI5hk4ySBo__zz2r9&JCgI z(8hFrr@*3GTrb0(bGRCd;0fv?D7}A}O5HpeHbgA7rFs+#u@4A+UvlC=+l=ku%P%pY zUiDP2*R2Q!dQTcW>52#H&}Zpq?d#yui7U3{m38>|!1&rO8tpeu$HIU+>6%TZXcEpJ zFuO|7u2{ZD{RHT3QWQ2X@Byz=-Ohe>nQ)oD|w^megi z0OwRzN>f$>(7hCUVuj9cY(0$S*Ry{9p$wD{y<+ryh014-xQ5H#1;TP3J9~pip|D)& zc~G-?By1@xdwsdA2G>t#+B5WN!C$#NGfhvgmml zWrioJgSB8zc+mF&-A8K7_D{H#7K!r{;uC{ruYFOtz7o%SliT4+I^0XSbA3 z0(eFfUJ@5(KypA)!u$es{!<%nh(YZLCcHF_23;}u{9VjuHDB{XG3GseuB^$z6v-qTohB9Q~Jp5^z3pNZk2N z3Jld1=&bUH0v#v2HzVkNLNot?htcT1SoJ)vfvj7>u-~o+8D0 zg)SC8Dp$JI6b9hybstw#@tTTQ_>k%FGzA@}2RF~%RC6pE#DXpFJF;ZKCRO2sF0EOx zK)YeC!o38TUzxCI>{%*YIG!eVCejO@UJzO8{-7LIUt5{ASu+8?oIu=mcUObMR_E91 z_5qM&Qz~Sj5)EcNRoqFf1(2t%)#r6H7v|}-6+b!^w`n5QbjK=k3>w`%gJ-7PA; zMduX}Tm)sDtmeexeCuYntHKLZf5GMn>2b;gmqsaMsFzs3yB`G;DMKr7X5_%DTZ4N} zyNAR0^37UphXR1d>|s2_{Wu>%T}|u1Lc9sqgg04AF_b~S^&WLo)J~)?d`Xx#1U;8y zL#;KqH3pvgY_aHi8V$7v7qta4L_ymk8`^Vo<6x;>($`P+sQ#8JE8-}X244&LzC1dU z3#+nftK<{%!SxkGQ^g5CSY%{&de6~RFb&)kq-384kH#f#FBp#dVT}5Z)5$A$v1E16B0L} zcH$9+OrGLT_4qo7>19s4dEbL-B3LeqnmDzi8cy-o>+h^W=X+zj%KT*+pyagnO-n~8 zP{(mawDpGL=dohd(}kYLhk`gwhqy;a9Yl+D*9ILx_g6F)NYHYWLBOr3@SRGTARQX@ zdZ;1;ESB6pY`Z1_?2hY_tn@O$uH*Hivb=cM;k`&K{c|t`OdT|+DGY^hvG|_A_%Mjt z0`JAjN#M0^YkL7|_u!+6Ey!VwfVxY!S;N{A0iIT^n|mS+*Sj!#mt>V$(Yw>dpcD(o zs-yF9J>>@o1hR@NZth!}1EF)!eZ8e9zoNCae?49TeK&XsO$J3^e1T=roX$Gn z;OGo>8Onr*vh^d+p$@98Jzkp*k)`HYraXhHRyP5gm}qhcYmYL0c%csx8D zDjs1}tOPNOr;qQyjl}u+T(_@tzi=dg>OSe*^ha?pC2|{NH=+9_XP7?jxEuvuRPyTY zZdO3<=SI$w$Y_9j&$sfeB7kdWsY^^{EdJb{eCV?NWL68tg?hHOdgs2Xc_eUY zy>jp=dVZ*|Qlz*dv$r>R8M_8g+*I?40YZ%Ou{hK&kLh_c_e0#aBxI!Q55?C7Y@QQ$9&UOZRts0>IGenW zi~{rbMmvmKqu}QFsQV~)ChSe}7v;>^)u3M4;heT5KLK(9_g4yk3h)g78Y!YPV-e{9RA}gXU5qEZDAtgy)vSE&ID3#D4S~#9WEQ6b)w#e#%_rWl=)k^h58n9z zOK{e`PfsdA`{uI1PUTpj=Dw6NeEi4$rjY%URlJiSAQxUBH?Xe&kQI-fjL-!DcOAdb zVgUjm4O+Ekre%=5Xhi=7x*ttFCcfXM80Bl_pTEQnCc@&_PYYPOYw-Q1f%95js;9z$ z*0!g__^v-b-u4nBFN%z(fl$?Ed#bbsC_8q)`P7vH(5E&Mh&q#vkGECT^V(FjDnMoX zAbr=$GT;K%RSUZ^L7!pm#)RYLuwEuU-}p8OjBhftEL_y6N-&NP3G5 z|EhA(2x{UzbgB*n1Y#!?Z=mNSE#1~E5HE+#3l~)LttLV2!tju1Rq2qhrM~9cPy?KE zBcw#ol)_>81}|x065LtJY78sFU~h^3J4vtGK(ou@?J0pM822%g_gLHjRt#Q6qsg@p ze8O*=xJC_ptkq%N`m_v=Fx>fcPdyDdM8$W!c1^(Tm!q3l2{wXpuvpkZ|K!d}xN+91 zVnaTvuTUY|T~Is5jcXqEThQ}!npb>}ZDcP6$=bJaLOglk(cfNv=Tk5!)ab5>lh1M9)thcDeihQX{xl8d&+i%Z8xpPs7=J zJzxKVBquks7rMmP){j^J8}?U*UL8JaKB`H-n_VD{*DfnTQ3bMX*7wW`T6BhAKz0YK>BMl&VW5pAa=$rrKzA2^b)pi6<%8lxnJ1^D_8>X_cQq1T8Uk#ebS$F zKw+3jg7D79CrgUfTz&e-lB=Y!8b zw{AZ2Bmw&7druY6q340Og&#NDN(ABRtpn|1g<#Q2LzLojMzp3GXkC9EbA3{QBh4!@TMOw0Cf%6L=*&Tj;og=i8W_jOl_)0s$ESSe(? z{$nP9Yx29*=zY!8Q>zHBNrU+hsvJ-7#Nl$qty_ot+1P90Nv_n#lN`Pvd{<{!(?QKI_<8 z2G5)|6|&It71%x_|1nzI!6mV%9AjZMv9Ai!IB75BM37+AO=6Sc^9Yb5DJ>44Du73! z^5;SqqvxCtMHHN%4~Ls2Z-SV4{Xx(6wq@tn4A8hax^<&W9r*Pe(~jDXp0{j06l|AP z3RkPs(m&kJ2OorvFu1oK9xge4LWm<02)f)C7a5U2Q7x=!6K57ME|B230l;<5_Er@l~#*azoh{j$}}jxDoYZ7-U6Oco=(ZAeC)AF zYIzGA`rgU3{DPfdvR{<7W9&>7Se)z5XFnPP`APi-l7~?_^(ytsc~s@Vamk!&WD&Za zy^oJ~=*|OYp=Pg-8xvsOjb@o!YDI9%%w^|xUIIX8X*xgp9>-U`u*P|ofv}e}xWOo? z4uZ!tJ2k>dkaziW&fBZ;kRwsfDIJ{(T~?et@wYO8>N2xT)t(eMooE}#MMC-V^m{L? zO&W+4D*+CR3-3DcH4lPb2Z&xr-)q6_>{9i~`ma}_=Xt0188AEI=hJ=Om7gj>f%_@v z3-tVmHc5?sQZ)vWY`IT7EDwXHEy`M_>j|LJQzoKb+n`+C@wb)D4h&LGy59Pyn1AdUpT(iY)kC5hM`% zrV2VX)ozMK$1f)TQ}=Ep=B0$;=TE|jPxD>`S3>mIPNt|162wGi-J#))hnBbN9;fr6 z=dlmS-#(;U0xwpS_jn~H!yXyu19SHfKydlztpYm;5I!mLIG~&eO`qBwE{H}z+MPG^ z?M(^r^-T#gCu)DoGuU}|)m;MAaWZ|1ku1aQTr>l3-I`JR2G&n=EE5}esAFNpd+N^H zbwpU#udp}8AGLop+$}jrod~TN8DEsBDxr&z*@P}sBc zfymjG23RR}OdxJ;DGWz3CMRt{_i?A?_5F_%x-Vc_(9^~AOpL2zM*XLtJHN?4?(pQXy33@pmo8OjpL z5Mg36QdJfS*nTKx2R!rcrg+Pdc)UJ=FwGW*!+B7Ww)}{UZ4NLUdvrF#I2vv=ZIaGZ z%f;o#5~?mt7;J@fy2$rvK0iI2r8+dzp+8OLz3Jj(P;`k84m4Q1YR67K`b@}HGq z`5467F&ixzhq4@tW3(8=^8b_YXKnkZ+Ay>JnQ81gzse_Gt-oKzp954k1~$YP(pI~%z)ASQ*dVe!C_PvJhzm)m^N*rs;f1391UXSvgDcejLpRG=eZe|&l z|0_tQAqNa%@tJk~PWoF#(?7q1KO4?$bVgM6Let%nltJJk=K@H_3ru7%NJ@K>~y*PMxm(a*&B zRhp9KS8>X=QpPcwnQfuO!Q!*w{GB}PS{VF0w3N6QJqBl%QKtWuIJw^0=w{2Gtqqj4 ze^pM##R4VWui|8U%JP|M%Cebp>{_!yO5B-sV)z&iW%`v(%%YZlu_c%7RP9RR`yqQ z&PM+;8cN)s;mp>KU*%EOiQ)aKZmjI@Lagq82V&R!J9S_*l<@D+{}nD)@6Yi5s+>$m z4ztmJFZo`E#b<-F(PDXId@TNV;mm9OjGhwjXE?vgpN*cJN2Vc%pV7`NqfC?IvxUE^ zbGB=d>!HlY>ZQ#4cjJF`4a#fI#Qk@#O-cW=x@XJB+BCBa%b!^lR?DAO~`XG{Mq56j0OWxZIOT!sZK?_Ysf z9b|~nQ|3|TQIF&R@l` zIw#Zxzps1q?-OrcdhPMx32v6)1T>%Y8NGkf9bpBMrMp?tv~IlA0H(D zbyxWwWLvV-VcS32RhLY>e2j9>eebGocTetsZ=ZO_Q?q^I-B}Cj9aJ`P-#YRi{}}#^LG=eO0YB}S{{}AsMniv?ZGZaU|16y|`fU_be&pZi9l4U< z{(8UB7#aNw`R6P7hmJgo|AFyu{ljbBH{LKy+rPVi_%CFmln?gTo%3JFI4K6sH-C6P z9XOFX!mgd-M*k?Q3PQydf@bEM|G7YB>2T~oIPAg4A8z~$3*V}NEl!hl`# zD(Ykr^)85|QE!3&0WX37(H5RW@A;b_!#{WhbRc&C*%=_&4{?L@F$IWXF?l9_73GT*kSnIpw z^rZnrLn}4;5_Js{ypEkV@L2|u+~qLU6g7;vtd+6;>^y+xXs8-#J(@X1>y(MO z_%WR>^&Cb*pY;bGb{{}I?NmbZo2!v4ac6=rY|27j8)Y06wjV||?)I=->oZWuz05-97;b;gqdknQyTHS=H(~%$nR~8#+^HHlFu1~WRbn>cPvlq>Dm#og z3s@2L5(bbr>O$vqm8+4>t8MdM+2waH*AVjdJ=@d%hXcqKfyhfL?p286lfB3s{sLrSG~0co za0q$mwI}0!KYIUpblU_~s*p2Bn&5#zA(CnRva>gM2)R*1d!g#X0MhYdXzmDK6>_lm zj!~*;5ps?uW|5oS5W?+tw5bKfSLx}pYlW^bs zLF9()*lvxgN~FQn)wx@(1gUK{@zfC;Laq(OpMJtVi1aF{TyP4kL{@*L8FI5NMbsQG z5l0pdAqTiviy!e0A`e{(=en9zB6iKzcdX>f5G{9}CZ6FzB=IF8l)GUNIjTz~$tz!p zc-D+<;OH(x)SD~D#OntUwwuOVL2M9-sXfd;wFxaBjct-KDn~eII}VzV29fnLyIkaV z3?j!~&3{$DtP)|KlanzxUXB!BIBS?6+|1(nFx-I9aH9nj|}EJQc(bO52sRah9lWe}OfP6f*d@66%Ai|y$PQ!7z z5;?O^KsPF^8d)=v%^wgrfV3StHPpXg5GgEjQBWzXL_Q0d@9BI|jm*i?7It$PKnm{8 zGqnFQfN&(&om@7*3K91nl&xA-gIKJT9-ePIfSgrl6kGOU06FOPSSZ%G3Q;&X(y&>! z1{pddC2x6l0J+mHtE5psfYhelO>pX}Ldrk!*iLEJAcrKzxvBIAFc*V36a5PpgKJSW zVb&GZX#MvRX>6Uqg%Tt9Uh;n{$X~BYGZ{A7;h+O*?E4@4o7te>T0zhkK=jEXtD(_q z@}tN74jPbC-~A3c{7t`un6cp3szLO}H7rvqjQ^}^aADK3Ezb=wD~i45tE{t;)2e~? z>O2K;$A$k{)!>J*fD`RcN}cl8o#P*DdfNHZmw&W#{8!p5O{z%#FEGYU>rFq5ao;-! zH@|)B8$DACZPYKNwx$W$mpoamnl``vcj=TytA)$|I0x&TSxu#dnU88X`^72A7S>VKQSh@vzoT3Pg&Ht>{&A_LsJtC z9n&*sG%YNB_em^LbR-)ftlj6@XzJ@~m}y#Qp0y+oEcbebxbJUqO- ze0*!x@bj--Dc{0~3axSE<*yX|(f!s>WR(ZCc(hDE6?eds8G*-dJs`kVteMz0Ozdr%dV!-!}+SdilP(-(3+%;8~i{I z!t*w#)ip7|wu(B(ur&KTZp6?jN3 z8=ld_`sTI^D>pJi`7(zO)&=yiW4o^W&=N*C{&g$0nJ+!OyDeRpIKlvHGnm>1E$CtX z&^>DIdd~wV#;A z$prLqEFH%VGC?X=$k;LgCP-5_pS4e!2?m+?EU848fZc&0eL#~58X8~1EQsHSRHb6|q%RSA)7zyzn-Nmp$=n7}`!b{NpKciGiX83ZPHBJPl7 zhT`*eSL7z;Gl3FwLWjaOCRqBhgO#a+2|NcT9UL|?LG-T0op$3)aA3!W8-c5s;I`=c zgE?Hx|LfD=rl+3xkLhV3_p7=2GXubP#%M&IJ3r@(MoHx2UEe0xf0hT$nDS)B`F|0I z`JoX6uGvxygAghHK|Vhe6YxM{p#j*}QlpB3q#y$i*=UW$IYnGt#zos=&tGXgay z>gzzB5!7}%-}f_Rgt}Gyy3DGK;L48(SYBgW&GAy1X|BU$JWU{ zMi{^Py1p`n5gyu0SH4F%)CXqn?xbQyaCbP~>w+;&Umg)aE-TyFW^fl^02lDrcOOK+H@VuK<(u@cX(6IvVPJ|G6%Hr3VBGt@TJHLwcb!FAZNMvFu!gMMTA;d>rIm%DiT<}{$^B|W`ro=qBT4e$`hG)3QfU0U z-2P)I3jJXy%KaFM)PH|fHAJb&tLkD(ozq6wP~X4`U5x*P+Y1wZK^^>2y|_NaN5 zIMf|aeSxr;vMHo(9Qyz1qc;1)qrU$0M{T4}F0(N*AOlLSgFRq29YafPQ)?3|4L!~O zZ^!5~c0B zst22Cz&a-tIIU0ss(DH!qN|@Sck1C!N-Zo+1zK60k(maR298yUWPZ4k%jwc1TAZAi z19TBg1*BvF)=qPMGz7lRckK~JE_Z;&WR}IJ<>coB&2X6UB`&IT&+i_-lFZ!Hc%TRJ z%X5Jh6i{Z}+Na(c^@n;SAVZGeQPz0i*lS)%Q6<<*KsB+?C7($8r)RwduJ^2+(jy8x zn+-^(WG0sY-IS4>40On+DPlnsvV^+a?vXo9bSiJ?~(wH^@xhSF1>t~$)Q)^>VI#3b_doa?{(^qFgr{+Sa$9& z8@t2Xyia8}`&k@Zq*lE9#{ulQZQJJ@XUyu*P`Y=8rZBrhaYFir#!ajaOiQ!Vla$#V z9BUKqZ4qE|IIZU8JzYHc z%L9P&?lwmirm{Qi7fW)}P-k=4v!ggEq7OJG*5!I({&!Y~RO82M0&CbEZmTD}4m--~ zkk4mxD=?Pb;k&8U#YvM{9gb}AR$raalmbM~)azVFlfbHt=ydECC*|xO?t5`~6w( zO>Gjn9VEn-@`$K?wlAI4Y~cKi$Dt&w*Z0@SclQ3LyyEsP;BzqOv6#4X!!!FM>${XM zc?vjWglH#C_;K5Qi@!g&VwRx80>9o_VLkipujI|#&$>nkcueR0XR>Lh_kZaV7Sa(E zaahyMUGp;g(f%h!8CUFQi#oh1U;A_FnRoj|?l|6f<1Ow`#ua$Snep3x6<+W6_f#Yu z#O{~vkKy{e|B1Wa>x1gj4zG=uwsbl%9hky5ZLVXOtONJUG`=D$wgV}Tb#8EOl6TNv z*0^q29@hb*C4Oefa*7VzO=V)w9`GF)?YR-rQ$rsxC$$JcG?(9_RIeP|zy?xYmI6?) TG_kZ8I8vWcN~rNvTB-*C?K8?M delta 13472 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 diff --git a/src/aca_model/agent/health.py b/src/aca_model/agent/health.py index 66f04fe..4246bcb 100644 --- a/src/aca_model/agent/health.py +++ b/src/aca_model/agent/health.py @@ -6,7 +6,16 @@ import jax.numpy as jnp from lcm import categorical -from lcm.typing import DiscreteState, FloatND, IntND, Period, ScalarInt +from lcm.typing import ( + DiscreteAction, + DiscreteState, + FloatND, + IntND, + Period, + ScalarInt, +) + +from aca_model.agent.labor_market import LaborSupply @categorical(ordered=True) @@ -60,3 +69,37 @@ def next_health_cross( regimes (Health) at the age-65 boundary. """ return health_trans_probs_cross[period, health] + + +def next_health_into_dimc( + health: DiscreteState, + period: Period, + health_trans_probs: FloatND, +) -> FloatND: + """Next health of a household that lands on disability Medicare: disabled. + + Pre-65 Medicare goes to households disabled next period, so landing in a + `dimc` regime reveals the next health state. + """ + probs = health_trans_probs[period, health] + return jnp.zeros_like(probs).at[HealthWithDisability.disabled].set(1.0) + + +def next_health_into_nomc( + health: DiscreteState, + period: Period, + labor_supply: DiscreteAction, + health_trans_probs: FloatND, +) -> FloatND: + """Next health of a pre-65 household that lands without Medicare. + + A household that does not work this period gets disability Medicare + whenever it is disabled next period, so landing without Medicare means it + is not disabled: its next health is the transition row with the disabled + state removed and renormalised. A worker keeps the unconditional row. + """ + probs = health_trans_probs[period, health] + off_disability = probs.at[HealthWithDisability.disabled].set(0.0) + mass = off_disability.sum() + conditional = off_disability / jnp.where(mass > 0.0, mass, 1.0) + return jnp.where(labor_supply == LaborSupply.do_not_work, conditional, probs) diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index 91f2503..b912f2d 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -12,6 +12,7 @@ - SSI benefit computation """ +import jax import jax.numpy as jnp import lcm from lcm import categorical @@ -183,47 +184,96 @@ def ssi_benefit( def premium( - age: Age, - good_health: IntND, - is_married: IntND, - labor_supply: DiscreteAction, buy_private: DiscreteAction, - premium_intercept: ScalarFloat, - premium_age: ScalarFloat, - premium_age_sq: ScalarFloat, - premium_age_cub: ScalarFloat, - premium_predicted_hcc: ScalarFloat, - premium_good_health: ScalarFloat, - premium_married: ScalarFloat, - premium_works: ScalarFloat, - premium_married_works: ScalarFloat, - premium_minimum: ScalarFloat, + is_married: IntND, predicted_hcc_insurer: FloatND, + private_premium_intercept: FloatND, + premium_predicted_hcc: ScalarFloat, + premium_markup: ScalarFloat, + premium_minimum: FloatND, ) -> FloatND: - """Compute health insurance premium for canwork regimes. + """Compute the private non-group premium. - Premium coefficients are regime-specific (different for each HIC type). - Return 0 when uninsured (`buy_private=no`). + `max(minimum[married], p0 + b (1 + markup) E[insurer cost])` when buying + private cover, 0 when uninsured (`buy_private=no`). The insurer cannot + price health or marital status beyond the predicted cost and the + married minimum. """ - works = labor_supply != LaborSupply.do_not_work raw = ( - premium_intercept - + premium_age * age - + premium_age_sq * age**2 - + premium_age_cub * age**3 - + premium_predicted_hcc * predicted_hcc_insurer - + premium_good_health * good_health - + premium_married * is_married - + premium_works * works - + premium_married_works * is_married * works + private_premium_intercept + + premium_predicted_hcc * (1.0 + premium_markup) * predicted_hcc_insurer ) return jnp.where( buy_private == BuyPrivate.yes, - jnp.maximum(premium_minimum, raw), + jnp.maximum(premium_minimum[is_married], raw), 0.0, ) +# Bracket and step count of the intercept bisection. The bracket matches the +# reference implementation; 100 halvings shrink it below float64 resolution. +_INTERCEPT_BRACKET = (-50_000.0, 10_000.0) +_N_INTERCEPT_BISECTIONS = 100 + + +def private_premium_intercept( + premium_predicted_hcc: ScalarFloat, + premium_markup: ScalarFloat, + premium_minimum: FloatND, + premium_sample_insurer_cost: FloatND, + premium_sample_is_married: IntND, +) -> FloatND: + """Solve the private premium intercept from the insurer's zero-profit condition. + + On the sample of private buyers under 65 in the data, the intercept `p0` + makes premium revenue equal expected payouts plus the markup: + + ``` + mean_i max(minimum[married_i], p0 + b (1 + markup) cost_i) + = (1 + markup) mean_i cost_i + ``` + + The left side is continuous and non-decreasing in `p0`; bisection on + `[-50000, 10000]` finds the root. Without a sign change on the bracket + the result is NaN, so an unpriceable slope `b` fails loudly downstream. + """ + slope = premium_predicted_hcc * (1.0 + premium_markup) + minimum = premium_minimum[premium_sample_is_married] + target = (1.0 + premium_markup) * jnp.mean(premium_sample_insurer_cost) + + def excess_revenue(intercept: FloatND) -> FloatND: + premiums = jnp.maximum(minimum, intercept + slope * premium_sample_insurer_cost) + return jnp.mean(premiums) - target + + def halve( + _: jax.Array, bracket: tuple[FloatND, FloatND] + ) -> tuple[FloatND, FloatND]: + low, high = bracket + mid = 0.5 * (low + high) + too_high = excess_revenue(mid) > 0.0 + return jnp.where(too_high, low, mid), jnp.where(too_high, mid, high) + + low0 = jnp.asarray(_INTERCEPT_BRACKET[0]) + high0 = jnp.asarray(_INTERCEPT_BRACKET[1]) + low, high = jax.lax.fori_loop(0, _N_INTERCEPT_BISECTIONS, halve, (low0, high0)) + bracketed = (excess_revenue(low0) <= 0.0) & (excess_revenue(high0) >= 0.0) + return jnp.where(bracketed, 0.5 * (low + high), jnp.nan) + + +def private_cover_paid_in_full( + buy_private: DiscreteAction, + premium_default: FloatND, +) -> BoolND: + """Private cover is a feasible choice only when its premium is paid in full. + + A household whose resources above the consumption floor fall short of the + premium cannot buy private cover: the government does not finance private + insurance through floor transfers, and an unpaid premium buys no private + cover. Not buying is always feasible. + """ + return (buy_private == BuyPrivate.no) | (premium_default <= 0.0) + + def premium_insured( age: Age, good_health: IntND, @@ -309,20 +359,44 @@ def oop_costs( return jnp.minimum(oop, oop_max) +def insured_oop( + total_health_costs: FloatND, + is_married: IntND, + deductible: ScalarFloat, + coinsurance_rate: ScalarFloat, + oop_max: ScalarFloat, +) -> FloatND: + """Compute OOP costs under the regime's plan. + + `deductible` and `oop_max` are the single household's; couples' + deductible and OOP maximum are twice as large. + """ + couple_scale = 1.0 + is_married + return oop_costs( + total_health_costs, + deductible * couple_scale, + coinsurance_rate, + oop_max * couple_scale, + ) + + def primary_oop( total_health_costs: FloatND, buy_private: DiscreteAction, + is_married: IntND, deductible: ScalarFloat, coinsurance_rate: ScalarFloat, oop_max: ScalarFloat, ) -> FloatND: - """Compute primary OOP costs. + """Compute primary OOP costs for regimes with the private-cover choice. - When uninsured (`buy_private=no`), OOP equals total health costs - (no coverage). + Private buyers pay `insured_oop`; when uninsured (`buy_private=no`), OOP + equals total health costs (no coverage). """ - insured_oop = oop_costs(total_health_costs, deductible, coinsurance_rate, oop_max) - return jnp.where(buy_private == BuyPrivate.yes, insured_oop, total_health_costs) + insured = insured_oop( + total_health_costs, is_married, deductible, coinsurance_rate, oop_max + ) + return jnp.where(buy_private == BuyPrivate.yes, insured, total_health_costs) def is_medicaid_eligible(is_ssi_eligible: BoolND) -> BoolND: @@ -394,17 +468,25 @@ def oop_with_medicaid( def hcc_insurer_predicted( + period: Period, + is_married: IntND, + good_health: IntND, hcc_persistent: ContinuousState, predicted_hcc_insurer_table: FloatND, hcc_persistent_grid: FloatND, ) -> FloatND: - """Interpolate pre-computed expected insurer cost for the current HCC state. + """Interpolate the private insurer's expected cost for the household. - The table contains E[total_costs - oop_costs | hcc_persistent] at each - persistent grid point. Linear interpolation handles off-grid values - during simulation (where draw_shock returns continuous AR1 values). + The table holds E[total_costs - oop_costs | age, married, good health, + persistent node] on `[period, is_married, good_health, node]`. Linear + interpolation handles off-grid values during simulation (where + draw_shock returns continuous AR1 values). """ - return jnp.interp(hcc_persistent, hcc_persistent_grid, predicted_hcc_insurer_table) + return jnp.interp( + hcc_persistent, + hcc_persistent_grid, + predicted_hcc_insurer_table[period, is_married, good_health], + ) def compute_hcc_insurer_table( diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6fb7e5b..e18ed10 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -498,6 +498,43 @@ def build_regime_probs(target: IntND, survival: FloatND) -> FloatND: return probs.at[target].add(survival) +def build_regime_probs_with_di_medicare( + *, + target_dimc: IntND, + target_nomc: IntND, + prob_dimc: FloatND, + survival: FloatND, +) -> FloatND: + """Build the regime probability vector with the disability-Medicare split. + + Survivors land in `target_dimc` with probability `prob_dimc` and in + `target_nomc` otherwise. From age 65 on both targets are the same `oamc` + regime, so the split is immaterial there. + """ + probs = jnp.zeros(19) + probs = probs.at[RegimeId.dead].set(1.0 - survival) + probs = probs.at[target_dimc].add(survival * prob_dimc) + return probs.at[target_nomc].add(survival * (1.0 - prob_dimc)) + + +def prob_di_medicare_next( + health: IntND, + period: IntND, + labor_supply: IntND, + health_trans_probs: FloatND, +) -> FloatND: + """Probability of holding disability Medicare next period. + + Before 65, Medicare covers households that are disabled and did not work + in the previous period: the household must not work this period and be + disabled next period. Before 65 `health_trans_probs` is the 3-state + `HealthWithDisability` matrix; from 65 the value does not matter (see + `build_regime_probs_with_di_medicare`). + """ + prob_disabled = health_trans_probs[period, health][HealthWithDisability.disabled] + return jnp.where(labor_supply == LaborSupply.do_not_work, prob_disabled, 0.0) + + def build_granular_regime_transition( *, transition_func: Callable[..., FloatND], @@ -752,7 +789,9 @@ def build_common_functions(spec: RegimeSpec) -> dict: ) has_buy_private = spec["his"] == "nongroup" and spec["mc"] == "nomc" functions["primary_oop"] = ( - health_insurance.primary_oop if has_buy_private else health_insurance.oop_costs + health_insurance.primary_oop + if has_buy_private + else health_insurance.insured_oop ) if can_work: @@ -1050,7 +1089,12 @@ def _build_per_target_regime_health( """Build per-target health transitions. Pre-65 regimes use HealthWithDisability (3-state), post-65 use Health (2-state). - Cross-grid transitions (3->2) happen at the age-65 boundary. + Cross-grid transitions (3->2) happen at the age-65 boundary. Between pre-65 + regimes the Medicare outcome reveals next-period disability for + non-workers, so each target carries the health law conditional on it: + + - `dimc` target: disabled (`next_health_into_dimc`); + - `nomc` target: not disabled if not working (`next_health_into_nomc`). """ target_regimes = precompute_target_regimes(spec) id_to_name = {int(getattr(RegimeId, name)): name for name in REGIME_SPECS} @@ -1065,13 +1109,15 @@ def _build_per_target_regime_health( target_name = id_to_name.get(target_id) if target_name is None: continue - target_spec = REGIME_SPECS[target_name] - target_is_post65 = target_spec["mc"] == "oamc" - - if spec["mc"] != "oamc" and target_is_post65: + target_mc = REGIME_SPECS[target_name]["mc"] + if spec["mc"] == "oamc": + result[target_name] = MarkovTransition(health.next_health) + elif target_mc == "oamc": result[target_name] = MarkovTransition(health.next_health_cross) + elif target_mc == "dimc": + result[target_name] = MarkovTransition(health.next_health_into_dimc) else: - result[target_name] = MarkovTransition(health.next_health) + result[target_name] = MarkovTransition(health.next_health_into_nomc) return result diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index f13e3c9..4a26139 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -8,9 +8,8 @@ from lcm import Regime from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, DiscreteAction, FloatND, Period +from lcm.typing import Age, DiscreteAction, DiscreteState, FloatND, Period -from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance from aca_model.baseline.regimes._common import ( REGIME_SPECS, @@ -23,35 +22,42 @@ build_nbegm_functions, build_pension_functions, build_regime_probs, + build_regime_probs_with_di_medicare, build_state_transitions, build_states, make_active_func, make_targets, + prob_di_medicare_next, select_ss_benefit, select_target_for_age, ) def _make_transition_canwork( - gets_medicare: bool, own: dict[str, int], ) -> Callable[..., FloatND]: """Create transition for canwork nongroup regimes. - Already nongroup — no SSI override needed. Gets Medicare if stops working - (when gets_medicare is True). + Already nongroup — no SSI override needed. Before 65, a household that + does not work and is disabled next period holds disability Medicare. """ def transition( age: Age, period: Period, + health: DiscreteState, labor_supply: DiscreteAction, survival_probs: FloatND, + health_trans_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] - mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) - target = select_target_for_age(age + 1, mc_next, own) - return build_regime_probs(target, sp) + return build_regime_probs_with_di_medicare( + target_dimc=select_target_for_age(age + 1, True, own), + target_nomc=select_target_for_age(age + 1, False, own), + prob_dimc=prob_di_medicare_next( + health, period, labor_supply, health_trans_probs + ), + survival=survival_probs[period], + ) return transition @@ -87,9 +93,11 @@ def _build_functions(spec: RegimeSpec) -> dict: # his and crossed_oamc_threshold are fixed params (constants per regime), # not DAG functions. pylcm resolves them from the params dict. - has_buy_private = spec["his"] == "nongroup" and spec["mc"] == "nomc" - if has_buy_private: + if _has_buy_private(spec): functions["hic_premium"] = health_insurance.premium + functions["private_premium_intercept"] = ( + health_insurance.private_premium_intercept + ) elif can_work: functions["hic_premium"] = health_insurance.premium_insured else: @@ -100,6 +108,23 @@ def _build_functions(spec: RegimeSpec) -> dict: return functions +def _has_buy_private(spec: RegimeSpec) -> bool: + return spec["his"] == "nongroup" and spec["mc"] == "nomc" + + +def build_constraints(spec: RegimeSpec) -> dict: + """Build the regime's choice constraints. + + Where private cover is a choice, it is feasible only if its premium is + paid in full (`private_cover_paid_in_full`). + """ + if _has_buy_private(spec): + return { + "private_cover_paid_in_full": health_insurance.private_cover_paid_in_full + } + return {} + + def build_regime( name: str, grids: Grids, @@ -113,7 +138,7 @@ def build_regime( own, _ng = make_targets(name) if spec["canwork"] == "canwork": - transition_func = _make_transition_canwork(gets_mc, own) + transition_func = _make_transition_canwork(own) else: transition_func = _make_transition_forcedout(gets_mc, own) @@ -126,7 +151,9 @@ def build_regime( else ("nbegm" if nbegm_solver is not None else "dcegm") ) functions = _build_functions(spec) - constraints: dict = {} + # The EGM solvers reject constraints that read the continuous state, which + # the private-cover constraint does through `premium_default`. + constraints = build_constraints(spec) if egm_solver is None else {} if nbegm_solver is not None: # NBEGM solves only this regime, so its solver-contract functions are # regime-level here rather than broadcast model-wide. The broadcast diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 309a71c..f959a2b 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -9,9 +9,16 @@ import jax.numpy as jnp from lcm import Regime from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, BoolND, DiscreteAction, FloatND, Period +from lcm.typing import ( + Age, + BoolND, + DiscreteAction, + DiscreteState, + FloatND, + IntND, + Period, +) -from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance from aca_model.baseline.regimes._common import ( REGIME_SPECS, @@ -24,41 +31,52 @@ build_nbegm_functions, build_pension_functions, build_regime_probs, + build_regime_probs_with_di_medicare, build_state_transitions, build_states, make_active_func, make_targets, + prob_di_medicare_next, select_ss_benefit, select_target_for_age, ) def _make_transition_canwork( - gets_medicare: bool, own: dict[str, int], ng: dict[str, int], ) -> Callable[..., FloatND]: """Create transition for canwork retiree regimes. - Retirees who stop working get Medicare (if gets_medicare). - Medicaid-eligible agents are overridden to nongroup targets. + Before 65, a household that does not work and is disabled next period + holds disability Medicare. Medicaid-eligible agents are overridden to + nongroup targets. """ def transition( age: Age, period: Period, + health: DiscreteState, labor_supply: DiscreteAction, is_medicaid_eligible: BoolND, survival_probs: FloatND, + health_trans_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] next_age = age + 1 - mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) - target = select_target_for_age(next_age, mc_next, own) - # Medicaid eligibility overrides to nongroup - ng_ssi = select_target_for_age(next_age, mc_next, ng) - target = jnp.where(is_medicaid_eligible, ng_ssi, target) - return build_regime_probs(target, sp) + + def target(mc_next: bool) -> IntND: + own_target = select_target_for_age(next_age, mc_next, own) + ng_target = select_target_for_age(next_age, mc_next, ng) + return jnp.where(is_medicaid_eligible, ng_target, own_target) + + return build_regime_probs_with_di_medicare( + target_dimc=target(True), + target_nomc=target(False), + prob_dimc=prob_di_medicare_next( + health, period, labor_supply, health_trans_probs + ), + survival=survival_probs[period], + ) return transition @@ -122,7 +140,7 @@ def build_regime( own, ng = make_targets(name) if spec["canwork"] == "canwork": - transition_func = _make_transition_canwork(gets_mc, own, ng) + transition_func = _make_transition_canwork(own, ng) else: transition_func = _make_transition_forcedout(gets_mc, own, ng) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index ff13664..e568dbe 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -10,7 +10,15 @@ import jax.numpy as jnp from lcm import Regime from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, BoolND, DiscreteAction, FloatND, Period +from lcm.typing import ( + Age, + BoolND, + DiscreteAction, + DiscreteState, + FloatND, + IntND, + Period, +) from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance @@ -24,46 +32,53 @@ build_granular_regime_transition, build_nbegm_functions, build_pension_functions, - build_regime_probs, + build_regime_probs_with_di_medicare, build_state_transitions, build_states, make_active_func, make_targets, + prob_di_medicare_next, select_ss_benefit, select_target_for_age, ) def _make_transition_canwork( - gets_medicare: bool, own: dict[str, int], ng: dict[str, int], ) -> Callable[..., FloatND]: """Create transition for canwork tied regimes. - Tied agents who stop working become nongroup (lose employer coverage). + Tied agents who stop working become nongroup (lose employer coverage); + before 65, those disabled next period hold disability Medicare there. Medicaid-eligible agents are also overridden to nongroup targets. """ def transition( age: Age, period: Period, + health: DiscreteState, labor_supply: DiscreteAction, is_medicaid_eligible: BoolND, survival_probs: FloatND, + health_trans_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] next_age = age + 1 - mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) - target = select_target_for_age(next_age, mc_next, own) - # Tied agents who stop working become nongroup - stopped = labor_supply == LaborSupply.do_not_work - ng_target = select_target_for_age(next_age, mc_next, ng) - target = jnp.where(stopped, ng_target, target) - # Medicaid eligibility overrides to nongroup - ng_ssi = select_target_for_age(next_age, mc_next, ng) - target = jnp.where(is_medicaid_eligible, ng_ssi, target) - return build_regime_probs(target, sp) + to_nongroup = (labor_supply == LaborSupply.do_not_work) | is_medicaid_eligible + + def target(mc_next: bool) -> IntND: + own_target = select_target_for_age(next_age, mc_next, own) + ng_target = select_target_for_age(next_age, mc_next, ng) + return jnp.where(to_nongroup, ng_target, own_target) + + return build_regime_probs_with_di_medicare( + target_dimc=target(True), + target_nomc=target(False), + prob_dimc=prob_di_medicare_next( + health, period, labor_supply, health_trans_probs + ), + survival=survival_probs[period], + ) return transition @@ -92,10 +107,9 @@ def build_regime( ) -> Regime: """Build a tied regime (all tied regimes are canwork).""" spec = REGIME_SPECS[name] - gets_mc = spec["mc"] != "nomc" own, ng = make_targets(name) - transition_func = _make_transition_canwork(gets_mc, own, ng) + transition_func = _make_transition_canwork(own, ng) states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver diff --git a/tests/test_private_premium.py b/tests/test_private_premium.py index 9c3e109..d1c3a9e 100644 --- a/tests/test_private_premium.py +++ b/tests/test_private_premium.py @@ -12,7 +12,7 @@ _MARKUP = jnp.asarray(0.1696) _MINIMUM = jnp.array([1000.0, 2000.0]) _SAMPLE_COST = jnp.array([1000.0, 3000.0, 8000.0]) -_SAMPLE_MARRIED = jnp.array([0, 1, 0]) +_SAMPLE_MARRIED = jnp.array([0, 1, 0], dtype=jnp.int32) @pytest.mark.parametrize( @@ -47,7 +47,7 @@ def test_private_premium_intercept_zero_profit_on_sample() -> None: """ rng = np.random.default_rng(seed=3) cost = jnp.asarray(np.exp(rng.normal(loc=7.5, scale=1.2, size=2000))) - married = jnp.asarray(rng.integers(0, 2, size=2000)) + married = jnp.asarray(rng.integers(0, 2, size=2000), dtype=jnp.int32) coeff = jnp.asarray(1.161245437600243) intercept = health_insurance.private_premium_intercept( premium_predicted_hcc=coeff, From 0c73bf130f86058731c518dbf89148df35da2064 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:41:48 +0200 Subject: [PATCH 048/101] FIX Carry the pension-corrected PIA across a claim-age adjustment in solve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next period's AIME encodes the phased `carried_pia`: in solve `pensions.total_to_pia`, the PIA* with PIA* + (1-τ) pbmax_{t+1}(PIA*) equal to PIA_adj + (1-τ) pbmax_{t+1}(PIA_unadj), so the re-imputed pension does not undo the claim adjustment; in simulate the adjusted PIA. The claim adjustment moves to the `pia_adjusted_next_period` node, and `full_benefit_next_period` feeds both the inversion and the imputed next-period pension wealth. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 27 +++--- src/aca_model/baseline/regimes/_common.py | 11 +++ src/aca_model/environment/pensions.py | 99 ++++++++++++-------- src/aca_model/environment/social_security.py | 90 +++++++----------- tests/helpers/social_security.py | 18 ++++ tests/test_model_components.py | 3 +- tests/test_model_creation.py | 16 ++-- tests/test_pension_integration.py | 36 +++++-- tests/test_pensions.py | 42 +++++---- tests/test_social_security.py | 17 ++-- 10 files changed, 215 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a11a970..41e7761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,21 +131,24 @@ ACA variants don't create new regimes — they swap functions on baseline regime *transition* still uses its own build-time `gets_medicare` constant (`mc != nomc`) — distinct from this. - **SS claim-age adjustment baked into AIME**: at the voluntary claim the - early-retirement reduction / delayed-retirement credit is applied to PIA and converted - back to AIME via `find_aime` (the exact inverse of `pia`), so the permanent adjustment - rides in the carried `aime` with no extra state and persists into the forced regimes. + early-retirement reduction / delayed-retirement credit is applied to PIA + (`pia_adjusted_next_period`) and converted back to AIME via the exact inverse of + `pia`, so the permanent adjustment rides in the carried `aime` with no extra state. The `pia_aime_grid` / `pia_table` carry a fifth bend point above the taxable max (`max_delayed_factor * max_pia`, round-tripped to AIME), so a top earner who delays keeps the delayed-retirement credit in the carried AIME instead of clamping at the - taxable max — matching the reference (struct-ret's - `convert_pia_to_aime(..., impose_upper_bound_on_aime=False)`). Post-claim labor - accrual runs on this adjusted AIME; `_accrue_aime` still caps the labor-earnings base - at the taxable max, and the solve-phase `pension_assets_adjustment` corrects the - pension imputation gap, with pension imputation reading an *unadjusted* PIA - (`pia_unadjusted_next_period`) so the claim-age credit never feeds the pension node — - faithful to the reference. The DI path (`ssdi_pia`) reads the un-baked AIME. - `inelig`/`forced` regimes use a plain `next_aime` with no claim inputs - (`_select_aime_law` routes on `spec["ss"]`). + taxable max. Which PIA the next AIME encodes is the phased `carried_pia` node: + - solve: `pensions.total_to_pia`, the PIA `PIA*` with + `PIA* + (1 − τ) pbmax_{t+1}(PIA*) = PIA_adj + (1 − τ) pbmax_{t+1}(PIA_unadj)`, + because solve re-imputes pension wealth from next period's PIA (French & Jones 2011, + app. D; struct-ret `convert_total_ben_to_pia`); + - simulate: the adjusted PIA itself, since simulate carries true pension wealth. + Pension imputation reads the unadjusted PIA (`pia_unadjusted_next_period`), and the + solve-phase `pension_assets_adjustment` corrects the accrual-evolved pension against + it. The earnings-test credit covers only benefits the earnings test withheld. In the + `forced` regimes (70+) AIME is frozen (`next_aime_forced`); `inelig` regimes use + plain accrual with no claim inputs (`_select_aime_law` routes on `spec["ss"]`). The + DI path reads the un-baked AIME after indexing and labor accrual. - **ACA subsidies/mandate respect Medicaid**: `premium_subsidy`, `cost_sharing`, and `mandate_penalty` take `is_medicaid_eligible` and return the neutral value when the household is Medicaid-eligible (Medicaid is minimum-essential coverage). diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index a738c85..5e0ce99 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -837,6 +837,10 @@ def build_pension_functions(spec: RegimeSpec) -> dict: `pension_assets_adjustment`, which reconciles the accrual-evolved pension with next period's AIME imputation; in simulate the adjustment is zero because the true wealth is carried directly. + - in `ss=choose` regimes, `carried_pia` is the PIA the next-period AIME + encodes: in solve the claim-adjusted PIA corrected so that SS plus + imputed pension benefits keep their adjusted total + (`pensions.total_to_pia`); in simulate the claim-adjusted PIA itself. """ can_work = spec["canwork"] == "canwork" @@ -859,9 +863,16 @@ def build_pension_functions(spec: RegimeSpec) -> dict: if spec["ss"] == "forced" else social_security.pia_unadjusted_next_period ) + functions["full_benefit_next_period"] = pensions.full_benefit_next_period functions["imputed_pension_wealth_next_period"] = ( pensions.imputed_pension_wealth_next_period ) + if spec["ss"] == "choose": + functions["pia_adjusted_next_period"] = social_security.pia_adjusted_next_period + functions["carried_pia"] = Phased( + solve=pensions.total_to_pia, + simulate=social_security.carried_pia_simulate, + ) functions["pension_assets_adjustment"] = Phased( solve=pensions.assets_adjustment, simulate=_zero_pension_assets_adjustment, diff --git a/src/aca_model/environment/pensions.py b/src/aca_model/environment/pensions.py index c38d335..1c4040f 100644 --- a/src/aca_model/environment/pensions.py +++ b/src/aca_model/environment/pensions.py @@ -61,37 +61,49 @@ def benefit( def total_to_pia( - pension_benefit: FloatND, - pia: FloatND, + pia_adjusted_next_period: FloatND, + pia_unadjusted_next_period: FloatND, + full_benefit_next_period: FloatND, + target_his: IntND, period: Period, - his: IntND, marginal_tax_rate: FloatND, - imp_intercept: FloatND, - imp_pia_coeff: FloatND, - imp_pia_kink_0_coeff: FloatND, - imp_pia_kink_1_coeff: FloatND, - imp_kink_0: FloatND, - imp_kink_1: FloatND, + imp_intercept_next_period: FloatND, + imp_pia_coeff_next_period: FloatND, + imp_pia_kink_0_coeff_next_period: FloatND, + imp_pia_kink_1_coeff_next_period: FloatND, + imp_kink_0_next_period: FloatND, + imp_kink_1_next_period: FloatND, ) -> FloatND: - """Invert pension imputation to recover PIA from total after-tax benefits. + """PIA the next-period AIME encodes in solve (French & Jones 2011, app. D). - Piecewise-linear inverse of the pension_benefit mapping, adjusted for - marginal tax rates. + Solve re-imputes pension wealth from next period's PIA, so a claim-age + adjustment or earnings-test credit baked into that PIA would also move the + imputed pension. The carried PIA `PIA*` instead solves + + ``` + PIA* + (1 - τ) pbmax_{t+1}(PIA*) = PIA_adj + (1 - τ) pbmax_{t+1}(PIA_unadj) + ``` + + which keeps the after-tax total of Social Security and imputed pension + benefits at its claim-adjusted value. `pbmax_{t+1}` uses next period's + coefficients for the target HIS and `τ` this period's marginal tax rate. + Without an adjustment (`PIA_adj == PIA_unadj`) the carried PIA is the + accrued PIA. """ after_tax = 1.0 - marginal_tax_rate - total_ben = after_tax * pension_benefit + pia + total_ben = after_tax * full_benefit_next_period + pia_adjusted_next_period - at_intercept = after_tax * imp_intercept[period, his] - at_pia = after_tax * imp_pia_coeff[period, his] - at_kink_0 = after_tax * imp_pia_kink_0_coeff[period, his] - at_kink_1 = after_tax * imp_pia_kink_1_coeff[period, his] + at_intercept = after_tax * imp_intercept_next_period[period, target_his] + at_pia = after_tax * imp_pia_coeff_next_period[period, target_his] + at_kink_0 = after_tax * imp_pia_kink_0_coeff_next_period[period, target_his] + at_kink_1 = after_tax * imp_pia_kink_1_coeff_next_period[period, target_his] - k0 = imp_kink_0[period] - k1 = imp_kink_1[period] + k0 = imp_kink_0_next_period[period] + k1 = imp_kink_1_next_period[period] kink_0_tb = at_intercept + k0 * (1.0 + at_pia) kink_1_tb = kink_0_tb + (k1 - k0) * (1.0 + at_pia + at_kink_0) - return jnp.where( + inverted = jnp.where( total_ben < at_intercept, 0.0, jnp.where( @@ -104,6 +116,11 @@ def total_to_pia( ), ), ) + return jnp.where( + pia_adjusted_next_period == pia_unadjusted_next_period, + pia_unadjusted_next_period, + inverted, + ) def accrual( @@ -198,7 +215,7 @@ def assets_adjustment( ) -def imputed_pension_wealth_next_period( +def full_benefit_next_period( pia_unadjusted_next_period: FloatND, target_his: IntND, period: Period, @@ -208,21 +225,17 @@ def imputed_pension_wealth_next_period( imp_pia_kink_1_coeff_next_period: FloatND, imp_kink_0_next_period: FloatND, imp_kink_1_next_period: FloatND, - epdv_constant_pension_next_period: FloatND, ) -> FloatND: - """Imputed pension wealth at next period using the target regime's HIS. - - `pw_{t+1} = Γ_{t+1} · pbmax_{t+1}` (French & Jones 2011, eq. D.3): the - *full* next-period benefit times the next-period annuity factor, with no - current-period fraction (the fraction lives inside `Γ`). Mirrors - `full_benefit` and `wealth` but indexes 1-period-shifted views so all - subscripts use bare-name parameters (`period`, `target_his`). Inlining is - required: pylcm's AST shape inference inspects the registered function's - body and does not trace through nested calls. - - The PIA input is `pia_unadjusted_next_period` — the next-period PIA from pure labor - accrual. French & Jones impute pension wealth from the unadjusted PIA, so the - claim-age reduction or credit baked into the carried AIME never enters here. + """Next period's full pension benefit `pbmax_{t+1}` at the target regime's HIS. + + Mirrors `full_benefit` but indexes 1-period-shifted views so all subscripts + use bare-name parameters (`period`, `target_his`). Inlining is required: + pylcm's AST shape inference inspects the registered function's body and + does not trace through nested calls. + + The PIA input is `pia_unadjusted_next_period` — the next-period PIA from pure + labor accrual. French & Jones impute pension wealth from the unadjusted PIA, + so the claim-age reduction or credit never enters here. """ intercept = imp_intercept_next_period[period, target_his] pia_pred = ( @@ -234,6 +247,18 @@ def imputed_pension_wealth_next_period( kink_1_adj = imp_pia_kink_1_coeff_next_period[period, target_his] * jnp.maximum( 0.0, pia_unadjusted_next_period - imp_kink_1_next_period[period] ) + return jnp.maximum(0.0, intercept + pia_pred + kink_0_adj + kink_1_adj) + + +def imputed_pension_wealth_next_period( + full_benefit_next_period: FloatND, + period: Period, + epdv_constant_pension_next_period: FloatND, +) -> FloatND: + """Imputed pension wealth at next period (French & Jones 2011, eq. D.3). - pbmax_next = jnp.maximum(0.0, intercept + pia_pred + kink_0_adj + kink_1_adj) - return pbmax_next * epdv_constant_pension_next_period[period] + `pw_{t+1} = Γ_{t+1} · pbmax_{t+1}`: the *full* next-period benefit times the + next-period annuity factor, with no current-period fraction (the fraction + lives inside `Γ`). + """ + return full_benefit_next_period * epdv_constant_pension_next_period[period] diff --git a/src/aca_model/environment/social_security.py b/src/aca_model/environment/social_security.py index bc5d96e..129c168 100644 --- a/src/aca_model/environment/social_security.py +++ b/src/aca_model/environment/social_security.py @@ -278,7 +278,7 @@ def _apply_benefit_rules( # --- AIME transition functions (state transitions, no aime_to_pia calls) --- -def next_aime( +def pia_adjusted_next_period( aime: ContinuousState, labor_income: FloatND, period: Period, @@ -297,16 +297,15 @@ def next_aime( aime_last_age_with_indexing: ScalarInt, aime_kink_2: ScalarFloat, ratio_lowest_earnings: FloatND, -) -> ContinuousState: - """Compute next period's AIME given labor earnings and the claim decision. +) -> FloatND: + """Next period's PIA after accrual, earnings-test credit and claim adjustment. Steps: 1. Accrue AIME from labor income (indexing, taxable cap, lowest-year drop). - 2. Credit back for earnings-test withholding (PIA round-trip). - 3. Bake the claim-age actuarial factor into AIME (`_apply_claim_adjustment`), + 2. Credit back for earnings-test withholding. + 3. Apply the per-year claim-age actuarial factor (`_apply_claim_adjustment`), so the carried AIME permanently encodes the early-retirement reduction or - the delayed-retirement credit. The flat-PIA benefit read off this AIME is - then correct at every later age, including the forced regimes. + the delayed-retirement credit. """ credited_pia = _accrue_and_credit_back_pia( aime=aime, @@ -324,8 +323,7 @@ def next_aime( aime_kink_2=aime_kink_2, ratio_lowest_earnings=ratio_lowest_earnings, ) - - adjusted_pia = _apply_claim_adjustment( + return _apply_claim_adjustment( pia=credited_pia, period=period, age=age, @@ -334,12 +332,30 @@ def next_aime( normal_retirement_age=normal_retirement_age, early_ret_adjustment=early_ret_adjustment, ) - # The extended `pia_table`/`pia_aime_grid` reach above the taxable max so a - # delayed-retirement credit on a top earner's PIA round-trips to an AIME - # above `aime_kink_2` rather than clamping there. `_accrue_aime` already - # capped the labor-earnings base at the taxable max; the actuarial credit is - # the only thing carried beyond it. - return jnp.interp(adjusted_pia, pia_table, pia_aime_grid) + + +def carried_pia_simulate(pia_adjusted_next_period: FloatND) -> FloatND: + """PIA the next-period AIME encodes in simulate: the adjusted PIA itself. + + Simulate carries the true pension wealth as a state, so no pension + imputation has to be offset. The solve-phase counterpart is + `pensions.total_to_pia`. + """ + return pia_adjusted_next_period + + +def next_aime( + carried_pia: FloatND, + pia_table: FloatND, + pia_aime_grid: FloatND, +) -> ContinuousState: + """AIME law of the post-65 `ss=choose` regimes: the AIME of the carried PIA. + + The extended `pia_table`/`pia_aime_grid` reach above the taxable max, so a + delayed-retirement credit on a top earner's PIA round-trips to an AIME + above `aime_kink_2` instead of clamping there. + """ + return jnp.interp(carried_pia, pia_table, pia_aime_grid) def next_aime_forced(aime: ContinuousState) -> ContinuousState: @@ -507,17 +523,11 @@ def _apply_claim_adjustment( def next_aime_disabled( aime: ContinuousState, + carried_pia: FloatND, labor_income: FloatND, period: Period, age: Age, health: DiscreteState, - claim_ss: DiscreteAction, - claimed_ss: DiscreteState, - normal_retirement_age: ScalarInt, - early_ret_adjustment: FloatND, - benefit_withheld_fraction: FloatND, - earnings_test_credited_back: FloatND, - earnings_test_repealed_age: ScalarInt, pia_table: FloatND, pia_aime_grid: FloatND, aime_accrual_factor: ScalarFloat, @@ -529,48 +539,20 @@ def next_aime_disabled( di_dropout_scale: FloatND, di_dropout_next_period_ratio: FloatND, ) -> ContinuousState: - """AIME transition for pre-65 regimes handling both disabled and non-disabled. + """AIME transition for pre-65 `ss=choose` regimes, disabled and non-disabled. - Non-disabled: standard AIME accrual from labor income, earnings-test - credit-back, and the claim-age actuarial bake (early reduction for claimants - below NRA — pre-65 never reaches the delayed-credit window). + Non-disabled: the AIME of the carried PIA (accrual, earnings-test credit and + claim-age adjustment; pre-65 never reaches the delayed-credit window). Disabled: maintain PIA continuity across ages by adjusting AIME so the DI dropout-year scale factor change doesn't alter the benefit. The disabled path reads the un-baked AIME — DI benefits are routed only when never claimed, so the claim adjustment never touches them. At Medicare transition, stores the dropout-adjusted AIME (switching to OA). """ - credited_pia = _accrue_and_credit_back_pia( - aime=aime, - labor_income=labor_income, - period=period, - age=age, - benefit_withheld_fraction=benefit_withheld_fraction, - earnings_test_credited_back=earnings_test_credited_back, - earnings_test_repealed_age=earnings_test_repealed_age, - pia_table=pia_table, - pia_aime_grid=pia_aime_grid, - aime_accrual_factor=aime_accrual_factor, - aggregate_wage_growth=aggregate_wage_growth, - aime_last_age_with_indexing=aime_last_age_with_indexing, - aime_kink_2=aime_kink_2, - ratio_lowest_earnings=ratio_lowest_earnings, - ) - - adjusted_pia = _apply_claim_adjustment( - pia=credited_pia, - period=period, - age=age, - claim_ss=claim_ss, - claimed_ss=claimed_ss, - normal_retirement_age=normal_retirement_age, - early_ret_adjustment=early_ret_adjustment, - ) regular = jnp.minimum( - jnp.interp(adjusted_pia, pia_table, pia_aime_grid), + jnp.interp(carried_pia, pia_table, pia_aime_grid), aime_kink_2, ) - accrued_aime = _accrue_aime( aime=aime, labor_income=labor_income, diff --git a/tests/helpers/social_security.py b/tests/helpers/social_security.py index 0c4ff2d..bbb911b 100644 --- a/tests/helpers/social_security.py +++ b/tests/helpers/social_security.py @@ -4,10 +4,14 @@ runtime dependency on the data-prep package. """ +from typing import Any + import numpy as np import pandas as pd from numpy.typing import NDArray +from aca_model.environment import social_security + def compute_pia_table( aime_kink_0: float, @@ -71,3 +75,17 @@ def compute_di_dropout_scale( result[period] = product * elapsed / (elapsed - years_to_drop) return result + + +def next_aime_from_state(**kwargs: Any) -> Any: + """Advance AIME one period through `pia_adjusted_next_period` and `next_aime`. + + Composes the two DAG steps the way simulate wires them (the carried PIA is + the claim-adjusted PIA). + """ + carried_pia = social_security.pia_adjusted_next_period(**kwargs) + return social_security.next_aime( + carried_pia=carried_pia, + pia_table=kwargs["pia_table"], + pia_aime_grid=kwargs["pia_aime_grid"], + ) diff --git a/tests/test_model_components.py b/tests/test_model_components.py index 5caa981..6f18dc5 100644 --- a/tests/test_model_components.py +++ b/tests/test_model_components.py @@ -1,6 +1,7 @@ """Tests for individual model component functions.""" import jax.numpy as jnp +from helpers.social_security import next_aime_from_state from aca_model.agent import preferences from aca_model.environment import social_security @@ -191,7 +192,7 @@ def test_aime_to_pia_three_brackets() -> None: def test_next_aime_accrual() -> None: ratio = jnp.zeros(100) ratio = ratio.at[55].set(0.5) - result = social_security.next_aime( + result = next_aime_from_state( aime=jnp.array(1000.0), labor_income=jnp.array(50000.0), period=jnp.int32(55), diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 8e4398f..b924bd8 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -203,13 +203,17 @@ def test_inelig_and_forced_regimes_aime_law_takes_no_claim_inputs(name: str) -> [n for n, s in REGIME_SPECS.items() if s["ss"] == "choose"], ) def test_choose_regimes_aime_law_takes_claim_inputs(name: str) -> None: - """`ss=choose` regimes bake the claim-age adjustment, so their AIME law - reads the `claim_ss` action and `claimed_ss` state.""" + """`ss=choose` regimes bake the claim-age adjustment into the carried PIA. + + The AIME law reads `carried_pia`, whose claim-adjusted input + `pia_adjusted_next_period` reads the `claim_ss` action and `claimed_ss` state. + """ regime = build_regime(name) - aime_law = regime.state_transitions["aime"] - params = set(inspect.signature(aime_law).parameters) - assert "claim_ss" in params - assert "claimed_ss" in params + aime_params = set(inspect.signature(regime.state_transitions["aime"]).parameters) + adjusted = regime.functions["pia_adjusted_next_period"] + adjusted_params = set(inspect.signature(adjusted).parameters) + assert "carried_pia" in aime_params + assert {"claim_ss", "claimed_ss"} <= adjusted_params def test_regime_specs_keys_match_regime_id() -> None: diff --git a/tests/test_pension_integration.py b/tests/test_pension_integration.py index 1219d9f..0d1c6df 100644 --- a/tests/test_pension_integration.py +++ b/tests/test_pension_integration.py @@ -80,21 +80,39 @@ def test_imputation_chain_full_benefit_to_wealth() -> None: assert jnp.isclose(result, 500.0, atol=ATOL) -def test_total_to_pia_inverts_imputed_benefit_via_dag() -> None: - """`pbmax → total_to_pia` round-trip via dags recovers original PIA.""" +def test_total_to_pia_keeps_adjusted_total_via_dag() -> None: + """The carried PIA keeps SS plus after-tax imputed pension at its adjusted total. + + Accrued PIA 8,000 is adjusted to 7,000; the carried PIA `p` satisfies + `p + (1 - τ) pbmax(p) = 7,000 + (1 - τ) pbmax(8,000)`. + """ + next_period_kwargs = {f"{k}_next_period": v for k, v in PBMAX_KWARGS.items()} functions = { - "pension_benefit": pensions.full_benefit, + "full_benefit_next_period": pensions.full_benefit_next_period, "total_to_pia": pensions.total_to_pia, } combined = concatenate_functions(functions, targets="total_to_pia") - recovered = combined( - pia=jnp.array(8000.0), + mtr = jnp.array(0.2) + carried = combined( + pia_adjusted_next_period=jnp.array(7000.0), + pia_unadjusted_next_period=jnp.array(8000.0), + target_his=jnp.int32(0), period=PERIOD, - his=jnp.int32(0), - marginal_tax_rate=jnp.array(0.2), - **PBMAX_KWARGS, + marginal_tax_rate=mtr, + **next_period_kwargs, ) - assert jnp.isclose(recovered, 8000.0, atol=ATOL) + + def pbmax(pia: jnp.ndarray) -> jnp.ndarray: + return pensions.full_benefit_next_period( + pia_unadjusted_next_period=pia, + target_his=jnp.int32(0), + period=PERIOD, + **next_period_kwargs, + ) + + total_carried = carried + (1.0 - mtr) * pbmax(carried) + target = 7000.0 + (1.0 - mtr) * pbmax(jnp.array(8000.0)) + assert jnp.isclose(total_carried, target, atol=ATOL) def test_next_assets_includes_pension_adjustment() -> None: diff --git a/tests/test_pensions.py b/tests/test_pensions.py index 1c4bfee..6cacdb5 100644 --- a/tests/test_pensions.py +++ b/tests/test_pensions.py @@ -197,12 +197,13 @@ def test_convert_total_ben_to_pia_below_kink_0() -> None: pia=pia_input, period=jnp.int32(29), his=jnp.int32(0), **_PBMAX_KWARGS ) recovered = pensions.total_to_pia( - pension_benefit=pbmax, - pia=pia_input, + pia_adjusted_next_period=pia_input, + pia_unadjusted_next_period=pia_input + 1.0, + full_benefit_next_period=pbmax, + target_his=jnp.int32(0), period=jnp.int32(29), - his=jnp.int32(0), marginal_tax_rate=mtr, - **_PBMAX_KWARGS, + **{f"{k}_next_period": v for k, v in _PBMAX_KWARGS.items()}, ) assert jnp.isclose(recovered, pia_input, atol=ATOL) @@ -216,12 +217,13 @@ def test_convert_total_ben_to_pia_between_kinks() -> None: pia=pia_input, period=jnp.int32(29), his=jnp.int32(0), **_PBMAX_KWARGS ) recovered = pensions.total_to_pia( - pension_benefit=pbmax, - pia=pia_input, + pia_adjusted_next_period=pia_input, + pia_unadjusted_next_period=pia_input + 1.0, + full_benefit_next_period=pbmax, + target_his=jnp.int32(0), period=jnp.int32(29), - his=jnp.int32(0), marginal_tax_rate=mtr, - **_PBMAX_KWARGS, + **{f"{k}_next_period": v for k, v in _PBMAX_KWARGS.items()}, ) assert jnp.isclose(recovered, pia_input, atol=ATOL) @@ -235,12 +237,13 @@ def test_convert_total_ben_to_pia_above_kink_1() -> None: pia=pia_input, period=jnp.int32(29), his=jnp.int32(0), **_PBMAX_KWARGS ) recovered = pensions.total_to_pia( - pension_benefit=pbmax, - pia=pia_input, + pia_adjusted_next_period=pia_input, + pia_unadjusted_next_period=pia_input + 1.0, + full_benefit_next_period=pbmax, + target_his=jnp.int32(0), period=jnp.int32(29), - his=jnp.int32(0), marginal_tax_rate=mtr, - **_PBMAX_KWARGS, + **{f"{k}_next_period": v for k, v in _PBMAX_KWARGS.items()}, ) assert jnp.isclose(recovered, pia_input, atol=ATOL) @@ -254,12 +257,13 @@ def test_convert_total_ben_to_pia_zero_mtr() -> None: pia=pia_input, period=jnp.int32(29), his=jnp.int32(0), **_PBMAX_KWARGS ) recovered = pensions.total_to_pia( - pension_benefit=pbmax, - pia=pia_input, + pia_adjusted_next_period=pia_input, + pia_unadjusted_next_period=pia_input + 1.0, + full_benefit_next_period=pbmax, + target_his=jnp.int32(0), period=jnp.int32(29), - his=jnp.int32(0), marginal_tax_rate=mtr, - **_PBMAX_KWARGS, + **{f"{k}_next_period": v for k, v in _PBMAX_KWARGS.items()}, ) assert jnp.isclose(recovered, pia_input, atol=ATOL) @@ -272,7 +276,7 @@ def test_imputed_pension_wealth_uses_unadjusted_pia() -> None: PIA yields the unreduced imputed wealth, independent of any claim-age reduction baked into the carried AIME. """ - result = pensions.imputed_pension_wealth_next_period( + full_benefit_next = pensions.full_benefit_next_period( pia_unadjusted_next_period=jnp.array(500.0), target_his=jnp.int32(0), period=jnp.int32(28), @@ -282,6 +286,10 @@ def test_imputed_pension_wealth_uses_unadjusted_pia() -> None: imp_pia_kink_1_coeff_next_period=jnp.zeros((30, 1)), imp_kink_0_next_period=jnp.full(30, 99999.0), imp_kink_1_next_period=jnp.full(30, 99999.0), + ) + result = pensions.imputed_pension_wealth_next_period( + full_benefit_next_period=full_benefit_next, + period=jnp.int32(28), epdv_constant_pension_next_period=EPDV, ) # pbmax = max(0, -50 + 500·0.2) = 50, pw = 50 · Γ(=10) = 500. diff --git a/tests/test_social_security.py b/tests/test_social_security.py index 331d2f2..68e193b 100644 --- a/tests/test_social_security.py +++ b/tests/test_social_security.py @@ -9,6 +9,7 @@ from helpers.social_security import ( compute_di_dropout_scale, compute_pia_table, + next_aime_from_state, ) from lcm.typing import ScalarInt @@ -154,7 +155,7 @@ def test_pia_to_aime_above_kink_1() -> None: def test_next_aime_indexing_high_income() -> None: - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, @@ -179,7 +180,7 @@ def test_next_aime_indexing_high_income() -> None: def test_next_aime_indexing_low_income() -> None: - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, @@ -203,7 +204,7 @@ def test_next_aime_indexing_low_income() -> None: def test_next_aime_no_indexing_high_income() -> None: - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, @@ -228,7 +229,7 @@ def test_next_aime_no_indexing_high_income() -> None: def test_next_aime_no_indexing_low_income() -> None: - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, @@ -253,7 +254,7 @@ def test_next_aime_no_indexing_low_income() -> None: def test_next_aime_high_aime_high_income_is_capped_at_taxable_max() -> None: """Labor accrual cannot lift an AIME at the taxable max above it.""" - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, @@ -277,7 +278,7 @@ def test_next_aime_high_aime_high_income_is_capped_at_taxable_max() -> None: def test_next_aime_cap_high_aime_low_income() -> None: - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, @@ -494,7 +495,7 @@ def _next_aime_claiming( labor_income: float = 0.0, ) -> jnp.ndarray: """Advance AIME one period under a given claim state, no labor accrual.""" - return social_security.next_aime( + return next_aime_from_state( aime=aime, labor_income=jnp.array(labor_income), period=jnp.int32(age), @@ -571,7 +572,7 @@ def test_delayed_claim_at_taxable_max_carries_credit_above_max() -> None: taxable max instead of clamping there, so the credit is carried forward. """ aime_at_max = AIME_KINK_2_SCALAR - result = social_security.next_aime( + result = next_aime_from_state( claim_ss=jnp.array(ClaimedSS.no), claimed_ss=jnp.array(ClaimedSS.no), normal_retirement_age=NORMAL_RETIREMENT_AGE, From d9e4fe19addde648ca02b1e44f91ec73a1a23dd9 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:46:46 +0200 Subject: [PATCH 049/101] CI Install pylcm at the main commit that aca-dev pins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 897c37f..2f31834 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@7e8e4c223a63a07725015ebdd5af84cd9092cee2" + git+https://github.com/OpenSourceEconomics/pylcm.git@f2b97fbb96dac55782adb940cc9f669a5b10d259" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 6252bc6b9f8fd0be709475166971fc81cdcff2fa Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:54:48 +0200 Subject: [PATCH 050/101] FIX Drop the withheld fraction from the forced regimes' benchmark params The forced regimes' AIME law no longer reads it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 72629 -> 72559 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 38cd92ba033fe564bc6d3ddb89c3f66a15128224..a17fcf75c8662b8afd4b4e977846058cab9814d8 100644 GIT binary patch delta 79 zcmV-V0I>hHw*>FD1O$Kul?Jf{iT$&i{TTt1RQnN^F987uv)cXz0h8_jA(ub_0Sc3v l{38RERU4Dh?HZGy<&@co|hIAVUBE delta 98 zcmaFAjb-b07M2FqsmvQ$I{)%yedl0caAyF6&E0>c87DVCmD;@NpCBWO)Z~Nzl{u%* ilZGgqJmF8;)L_QRtYF<+?=ng;G6R|0-!L*ZO9B8oCm&`2 From 562b8e1231355f23d2b29dc7bf26ce1f3588df39 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 21:58:11 +0200 Subject: [PATCH 051/101] Make mortality depend on health, keep people alive through 95, use a sinh asset grid - Regime transitions read survival_probs[period, health]; the per-regime param is indexed by the regime's own health grid. - Ages run 51-96: the forced-out regimes are active through 95 and only dead is active at 96 (end_age). - The asset grid is 10k * sinh(u) from the borrowing limit to 12M, with a node at zero and five nodes in [0, 30k]; 24 nodes as before. - Regenerate the benchmark snapshot from the processes worktrees. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 12 ++-- .../_benchmark_data/benchmark_params.pkl | Bin 69519 -> 70882 bytes src/aca_model/aca/model.py | 2 +- src/aca_model/baseline/model.py | 5 +- src/aca_model/baseline/regimes/_common.py | 63 +++++++++++++----- src/aca_model/baseline/regimes/_nongroup.py | 8 ++- src/aca_model/baseline/regimes/_retiree.py | 8 ++- src/aca_model/baseline/regimes/_tied.py | 5 +- src/aca_model/config.py | 1 + tests/test_benchmark.py | 5 +- tests/test_model_creation.py | 6 +- tests/test_regime_transitions.py | 15 ++++- 12 files changed, 92 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a11a970..aee6f76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,9 +75,10 @@ ACA variants don't create new regimes — they swap functions on baseline regime ### Key State Variables -- `assets`: Savings grid `[≈ −221k, 500k]`, 24 points (lower bound = minus one year of - maximum full-time earnings, so it shifts with the wage parameters; finer at low - levels) +- `assets`: Savings grid from minus one year of maximum full-time earnings (so the lower + bound shifts with the wage parameters) to 12M (the largest initial holding), 24 + points, sinh-spaced around zero (5 nodes below zero, a node at zero, 5 nodes in + `[0, 30k]`, geometric above; `build_assets_grid`) - `aime`: Average Indexed Monthly Earnings — piecewise grid at the PIA bend points (32 points total; `n_aime_gridpoints` is ignored on this path) - `health`: `HealthWithDisability` (disabled/bad/good) pre-65, `Health` (bad/good) @@ -85,7 +86,10 @@ ACA variants don't create new regimes — they swap functions on baseline regime - `log_ft_wage_res`: AR(1) wage residual shock (5-point Rouwenhorst) - `hcc_persistent` / `hcc_transitory`: Health cost shocks (`_ShockGrid` — integrated over, policy does not condition on them) -- Regime transitions determined by `select_target_for_age()` based on age and actions +- Regime transitions determined by `select_target_for_age()` based on age and actions; + death has probability `1 − survival_probs[age, health]`, a per-regime param on the + regime's own health grid. Ages run 51–96: 95 is the last age anyone is alive (survival + 1e-4 at 94, 0 at 95), and only `dead` is active at 96. ### Key Design Decisions diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 0447289b3312aa24583ec56331776a3e05e8a523..23ba63462ac2215ea2cd2b86e43c3365d2d3b12e 100644 GIT binary patch delta 15345 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 delta 15353 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 fgjR|P$b8|BM*&uF4`uXjSEO4l2GOwyX8+>+A z`cCFZ7Py>iv1@kP<^>ybA~*&r$9WLWAP z7MT6`{cqhW*x>Ps?%y6OVu4Ry{o_>8du;G|&DRq*jzILp*l)ToV}p9vjG@6nEU4-gEK+tEn~l7fdmyQGJUAJU8{8s(6hF+=^>@ZCS?xy|X7iIO4|y31N*7B41;HpoXt{ zOPrV>JZasT_Yvd!s`C78&P-stu$5ihj|oz$7B8<0W`h4LYB?B#kQ*ekW~_gm3H*lM zc79uSln#bP-`l@tB@=w`<~v(Y-=={rgf+9_5fiNS{Ui7OeWaRun>O~E%>uWsjF!)9 zvH;}T=!`2tY%soN{Nlol$K0mMiY%MI*kG2;I=59{KIAedbicK>#S(bO)|3Suy~iC} zwD7}Pduvc#qy)QG-{IDizsJN1Y=FD#dRrN%=O0|@%6B5;n{7eK*hMM#zq`RrJ(xUc z&suvBDSvh8m$l8@hAj*H|2xbPh+YrPc=zrZ?)KDF8$YreSj%6ua;&_bd!k@PJ+sCM z)E>Lg{7Uwr`ac&tJB4-QfSQFiWmj`A)?e-;I>B9q-b*Rnd~ey|8}*#CaX((?xq&Ix zBV}jfi97WHcD&!u2Y3M23sdUfvVK&5IWfFtUyv7QiC#aaaxiVL!oF&C+}Kxu&DCuC z;(@HaS(n0owA$tiLO0C*Y(s(d-agg*ewjXgz@~hPYxB?cdlg*{y=>}VM!M)$Gt7;> bpBe;{2HYwj&!T{+JIm`Zc&Q_cR2u$2QQLcV diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index 5cc6139..2336b9f 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -63,7 +63,7 @@ def create_model( """ ages = AgeGrid( start=MODEL_CONFIG.start_age, - stop=MODEL_CONFIG.end_age - 1, + stop=MODEL_CONFIG.end_age, step="Y", ) _fail_if_dcegm_without_consumption_points( diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 6040c45..52fd520 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -75,12 +75,13 @@ def create_model( Returns: A pylcm Model with 19 regimes (18 non-terminal + dead) spanning - ages 51-95. Regime names follow the `___` scheme. + ages 51-96; 95 is the last age anyone is alive. Regime names follow + the `___` scheme. """ ages = AgeGrid( start=MODEL_CONFIG.start_age, - stop=MODEL_CONFIG.end_age - 1, + stop=MODEL_CONFIG.end_age, step="Y", ) _fail_if_dcegm_without_consumption_points( diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 6fb7e5b..bb6a9fc 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -228,7 +228,7 @@ class RegimeSpec(TypedDict): @dataclass(frozen=True) class Grids: - assets: LinSpacedGrid + assets: IrregSpacedGrid aime: PiecewiseLinSpacedGrid pension_wealth: LinSpacedGrid consumption_dollars: IrregSpacedGrid @@ -257,6 +257,17 @@ class Grids: _PENSION_WEALTH_GRID = LinSpacedGrid(start=0.0, stop=2_000_000.0, n_points=2) +# Asset grid: `_ASSETS_SINH_SCALE * sinh(u)` with `u` evenly spaced on each side +# of zero. The spacing is about `_ASSETS_SINH_SCALE * du` near zero, where the +# consumption floor and the SSI asset tests bite, and geometric (ratio `e^du`) for +# large holdings. The top node is the largest initial asset holding in the data. +_ASSETS_TOP = 12_000_000.0 +_ASSETS_SINH_SCALE = 10_000.0 +# Share of nodes below zero; the negative branch only has to reach the borrowing +# limit. +_ASSETS_NEGATIVE_SHARE = 5 / 24 + + # AR(1) persistence of the Rouwenhorst shocks. Calibrated once; not # routed through fixed_params because they shape the grid topology # rather than feed any DAG function. The Rouwenhorst innovation std is @@ -276,15 +287,10 @@ def build_grids( """Build continuous-state/action grids from a `GridConfig`. The AIME grid is `PiecewiseLinSpacedGrid` breakpointed at the PIA - bends from `fixed_params["pia_aime_grid"]` (total 32 points). The - assets grid's lower bound is `-max_annual_labor_income` computed + bends from `fixed_params["pia_aime_grid"]`. The assets grid + (`build_assets_grid`) runs from `-max_annual_labor_income`, computed from `wage_params` (`log_ft_wage_mean`, `log_ft_wage_std`, - `adj_wage_hours_*`). - - `wage_params` is passed separately rather than via `fixed_params` - because `log_ft_wage_mean` is a per-iteration param at estimation - time (reconstructed from `wage_bias_coeffs_*`), not a fixed one; - the grid floor must still be known at build time. + `adj_wage_hours_*`), to the largest initial asset holding. `consumption_dollars_points` fixes the consumption action grid at construction (the DC-EGM kernel needs it then); `None` keeps the @@ -315,10 +321,8 @@ def build_grids( ) return Grids( - assets=LinSpacedGrid( - start=assets_start, - stop=500_000.0, - n_points=grid_config.n_assets_gridpoints, + assets=build_assets_grid( + floor=assets_start, n_points=grid_config.n_assets_gridpoints ), aime=_build_aime_grid(fixed_params=fixed_params), pension_wealth=_PENSION_WEALTH_GRID, @@ -335,6 +339,35 @@ def build_grids( ) +def build_assets_grid(*, floor: float, n_points: int) -> IrregSpacedGrid: + """Return the sinh-spaced asset grid from `floor` to `_ASSETS_TOP`. + + `round(n_points * _ASSETS_NEGATIVE_SHARE)` nodes (at least one) lie below + zero, starting at `floor`; the rest run from exactly zero to `_ASSETS_TOP`. + On each side the nodes are `_ASSETS_SINH_SCALE * sinh(u)` with `u` evenly + spaced, so the grid is fine near zero and geometric for large holdings. + + Args: + floor: Borrowing limit, the bottom node. Must be negative. + n_points: Total number of nodes. + + Returns: + The asset grid. + + """ + n_negative = max(1, round(n_points * _ASSETS_NEGATIVE_SHARE)) + scale = _ASSETS_SINH_SCALE + negative = scale * np.sinh( + np.linspace(np.arcsinh(floor / scale), 0.0, n_negative + 1)[:-1] + ) + positive = scale * np.sinh( + np.linspace(0.0, np.arcsinh(_ASSETS_TOP / scale), n_points - n_negative) + ) + negative[0] = floor + positive[-1] = _ASSETS_TOP + return IrregSpacedGrid(points=np.concatenate([negative, positive]).tolist()) + + def get_hcc_persistent_shock(*, grid_config: GridConfig) -> RouwenhorstAR1Process: """Return the persistent-HCC AR(1) shock grid for a given `grid_config`. @@ -430,7 +463,7 @@ def _compute_max_annual_labor_income( (age >= config.ss_forced_age) & (age < config.work_forced_out_age) ), ("oamc", "forced", "forcedout"): lambda age: ( - (age >= config.work_forced_out_age) & (age < config.end_age - 1) + (age >= config.work_forced_out_age) & (age < config.end_age) ), } @@ -937,7 +970,7 @@ def select_target_for_age( tgts["nomc_inelig"], ) return jnp.where( - next_age >= config.end_age - 1, + next_age >= config.end_age, RegimeId.dead, jnp.where( next_age >= config.work_forced_out_age, diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index f13e3c9..39cadd8 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -8,7 +8,7 @@ from lcm import Regime from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, DiscreteAction, FloatND, Period +from lcm.typing import Age, DiscreteAction, DiscreteState, FloatND, Period from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance @@ -45,10 +45,11 @@ def _make_transition_canwork( def transition( age: Age, period: Period, + health: DiscreteState, labor_supply: DiscreteAction, survival_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] + sp = survival_probs[period, health] mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) target = select_target_for_age(age + 1, mc_next, own) return build_regime_probs(target, sp) @@ -69,10 +70,11 @@ def _make_transition_forcedout( def transition( age: Age, period: Period, + health: DiscreteState, survival_probs: FloatND, ) -> FloatND: target = select_target_for_age(age + 1, gets_medicare, own) - return build_regime_probs(target, survival_probs[period]) + return build_regime_probs(target, survival_probs[period, health]) return transition diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 309a71c..d0f3a63 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -9,7 +9,7 @@ import jax.numpy as jnp from lcm import Regime from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, BoolND, DiscreteAction, FloatND, Period +from lcm.typing import Age, BoolND, DiscreteAction, DiscreteState, FloatND, Period from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance @@ -47,11 +47,12 @@ def _make_transition_canwork( def transition( age: Age, period: Period, + health: DiscreteState, labor_supply: DiscreteAction, is_medicaid_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] + sp = survival_probs[period, health] next_age = age + 1 mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) target = select_target_for_age(next_age, mc_next, own) @@ -76,10 +77,11 @@ def _make_transition_forcedout( def transition( age: Age, period: Period, + health: DiscreteState, is_medicaid_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] + sp = survival_probs[period, health] next_age = age + 1 target = select_target_for_age(next_age, gets_medicare, own) ng_ssi = select_target_for_age(next_age, gets_medicare, ng) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index ff13664..cd20e99 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -10,7 +10,7 @@ import jax.numpy as jnp from lcm import Regime from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, BoolND, DiscreteAction, FloatND, Period +from lcm.typing import Age, BoolND, DiscreteAction, DiscreteState, FloatND, Period from aca_model.agent.labor_market import LaborSupply from aca_model.baseline import health_insurance @@ -48,11 +48,12 @@ def _make_transition_canwork( def transition( age: Age, period: Period, + health: DiscreteState, labor_supply: DiscreteAction, is_medicaid_eligible: BoolND, survival_probs: FloatND, ) -> FloatND: - sp = survival_probs[period] + sp = survival_probs[period, health] next_age = age + 1 mc_next = gets_medicare & (labor_supply == LaborSupply.do_not_work) target = select_target_for_age(next_age, mc_next, own) diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 4d65551..4dd0e8c 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -13,6 +13,7 @@ class ModelConfig: start_age: int = 51 end_age: int = 96 + """First age at which everybody is dead; the model's last period.""" ss_early_age: int = 62 ss_forced_age: int = 70 work_forced_out_age: int = 72 diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index c3d54f6..6a60b0d 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -6,7 +6,6 @@ DiscreteGrid, GridBreakpoint, IrregSpacedGrid, - LinSpacedGrid, PiecewiseLinSpacedGrid, RouwenhorstAR1Process, ) @@ -32,12 +31,12 @@ def test_benchmark_model_builds_with_the_current_pylcm_grid_api() -> None: assert aime.n_points == 38 regime = model.user_regimes["retiree_nomc_inelig_canwork"] assert len(model.user_regimes) == 19 - assert model.n_periods == 45 + assert model.n_periods == 46 assets = regime.states["assets"] wage_res = regime.states["log_ft_wage_res"] pref_type = regime.states["pref_type"] consumption = regime.actions["consumption_dollars"] - assert isinstance(assets, LinSpacedGrid) + assert isinstance(assets, IrregSpacedGrid) assert isinstance(wage_res, RouwenhorstAR1Process) assert isinstance(pref_type, DiscreteGrid) assert isinstance(consumption, IrregSpacedGrid) diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 8e4398f..b85c0d1 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -53,13 +53,13 @@ def build_regime(name: str): def test_model_creates_successfully() -> None: model = make_baseline_model() assert len(model.user_regimes) == 19 - assert model.n_periods == 45 + assert model.n_periods == 46 def test_model_age_range() -> None: model = make_baseline_model() assert model.ages.values[0] == 51.0 - assert model.ages.values[-1] == 95.0 + assert model.ages.values[-1] == 96.0 def test_dead_regime_is_terminal() -> None: @@ -283,7 +283,7 @@ def test_hcc_persistent_and_transitory_are_shock_grids() -> None: def test_aca_model_creates_successfully() -> None: model = make_aca_model(policy=PolicyVariant.ACA) assert len(model.user_regimes) == 19 - assert model.n_periods == 45 + assert model.n_periods == 46 def test_aca_nongroup_inelig_has_real_functions() -> None: diff --git a/tests/test_regime_transitions.py b/tests/test_regime_transitions.py index 9040fdc..6064345 100644 --- a/tests/test_regime_transitions.py +++ b/tests/test_regime_transitions.py @@ -26,7 +26,7 @@ N_REGIMES = 19 N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age -SURVIVAL = jnp.ones(N_PERIODS) * 0.99 +SURVIVAL = jnp.ones((N_PERIODS, 3)) * 0.99 def _target_from_probs(probs: jnp.ndarray) -> int: @@ -47,6 +47,7 @@ def test_tied_stop_working_becomes_nongroup() -> None: probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.do_not_work), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -64,6 +65,7 @@ def test_tied_keeps_working_stays_tied() -> None: probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -83,6 +85,7 @@ def test_retiree_medicaid_override_to_nongroup() -> None: probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -99,6 +102,7 @@ def test_retiree_not_medicaid_stays_retiree() -> None: probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -115,6 +119,7 @@ def test_retiree_forcedout_medicaid_override() -> None: probs = transition( age=jnp.int32(80), period=jnp.int32(29), + health=jnp.int32(2), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, ) @@ -154,6 +159,7 @@ def test_retiree_age_bracket_transitions( probs = transition( age=jnp.asarray(age), period=period, + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -173,6 +179,7 @@ def test_nongroup_canwork_valid_probs() -> None: probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), survival_probs=SURVIVAL, ) @@ -188,6 +195,7 @@ def test_nongroup_forcedout_valid_probs() -> None: probs = transition( age=jnp.int32(80), period=jnp.int32(29), + health=jnp.int32(2), survival_probs=SURVIVAL, ) assert jnp.isclose(jnp.sum(probs), 1.0, atol=1e-6) @@ -205,6 +213,7 @@ def test_tied_medicaid_override_to_nongroup() -> None: probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -222,6 +231,7 @@ def test_tied_at_medicare_age_with_medicaid() -> None: probs = transition( age=jnp.int32(64), period=period, + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -236,10 +246,11 @@ def test_survival_prob_determines_death_weight() -> None: own, ng = make_targets("retiree_nomc_inelig_canwork") transition = retiree_canwork(gets_medicare=False, own=own, ng=ng) - survival = jnp.ones(N_PERIODS) * 0.85 + survival = jnp.ones((N_PERIODS, 3)) * 0.85 probs = transition( age=jnp.int32(55), period=jnp.int32(4), + health=jnp.int32(2), labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=survival, From 7b0ac9308c483827fe8f1942619f38d4d0b81cd6 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 22:00:43 +0200 Subject: [PATCH 052/101] TST Build ACA test models without the risk-rated premium parameters Under a variant with the reformed non-group market the non-group regimes before Medicare no longer read the snapshot's `premium_*` parameters, which pylcm refuses as unknown keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/helpers/model.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/helpers/model.py b/tests/helpers/model.py index 4818827..4f32d90 100644 --- a/tests/helpers/model.py +++ b/tests/helpers/model.py @@ -14,9 +14,16 @@ from aca_model.agent.preferences import BenchmarkPrefType from aca_model.baseline.health_insurance import HealthInsuranceState from aca_model.baseline.model import create_model as _create_baseline_model +from aca_model.baseline.regimes import REGIME_SPECS from aca_model.benchmark import get_benchmark_params from aca_model.config import BENCHMARK_GRID_CONFIG +_NONGROUP_BEFORE_MEDICARE = frozenset( + name + for name, spec in REGIME_SPECS.items() + if spec["his"] == "nongroup" and spec["mc"] == "nomc" +) + _DERIVED_CATEGORICALS = { "good_health": DiscreteGrid(GoodHealth), "is_married": DiscreteGrid(IsMarried), @@ -39,8 +46,23 @@ def make_baseline_model() -> Model: def make_aca_model(*, policy: PolicyVariant) -> Model: - """ACA model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params.""" + """ACA model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params. + + Under a variant with the reformed non-group market, the non-group regimes + before Medicare price cover at the community-rated ACA premium, so the + snapshot's risk-rated premium parameters (prefix `premium_`) are dropped + from those regimes, as aca-slurm does. + """ fixed_params, wage_params, _ = get_benchmark_params(model=None) + if policy != PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION: + fixed_params = { + name: ( + {k: v for k, v in value.items() if not k.startswith("premium_")} + if name in _NONGROUP_BEFORE_MEDICARE + else value + ) + for name, value in fixed_params.items() + } return _create_aca_model( policy=policy, fixed_params=fixed_params, From cbcf28df7197f8bb71c396a4a7a66e6681c5af87 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 22:02:26 +0200 Subject: [PATCH 053/101] TST Expect the ACA overlay to charge the ACA surtaxes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_aca_surtaxes_wiring.py | 105 ++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/test_aca_surtaxes_wiring.py diff --git a/tests/test_aca_surtaxes_wiring.py b/tests/test_aca_surtaxes_wiring.py new file mode 100644 index 0000000..7378ad2 --- /dev/null +++ b/tests/test_aca_surtaxes_wiring.py @@ -0,0 +1,105 @@ +"""The ACA overlay charges the ACA surtaxes in every regime's after-tax income. + +- Every ACA variant charges the 3.8% surtax on unearned income above 200,000. +- The full ACA also charges the 0.9% surtax on earnings above 200,000. + +The tests evaluate the real regime DAG after the ACA overlay, with income +before the surtaxes pinned to a constant, so they check the wiring rather +than the tax schedule. +""" + +import inspect + +import jax.numpy as jnp +import pytest +from dags import concatenate_functions + +from aca_model.aca.health_insurance import PolicyVariant +from aca_model.aca.regimes._overrides import apply_aca_overrides +from aca_model.baseline.regimes import REGIME_SPECS +from aca_model.baseline.regimes._common import build_model_functions +from aca_model.baseline.regimes._nongroup import _build_functions as nongroup_functions +from aca_model.baseline.regimes._retiree import _build_functions as retiree_functions +from aca_model.baseline.regimes._tied import _build_functions as tied_functions +from aca_model.environment import taxes + +INCOME_BEFORE_SURTAXES = 100_000.0 + + +def _after_tax_income( + *, + regime_name: str, + policy: PolicyVariant, + labor_income: float, + unearned_income: float, +) -> float: + functions = { + **build_model_functions(), + **_regime_functions(regime_name), + } + apply_aca_overrides(functions, REGIME_SPECS[regime_name], policy) + functions = { + name: (lambda: jnp.asarray(INCOME_BEFORE_SURTAXES)) + if func is taxes.after_tax_income + else func + for name, func in functions.items() + if callable(func) + } + functions["labor_income"] = lambda: jnp.asarray(labor_income) + functions["gross_income"] = lambda: jnp.asarray(labor_income + unearned_income) + combined = concatenate_functions( + functions, targets=["after_tax_income"], return_type="tuple" + ) + assert not inspect.signature(combined).parameters + return float(combined()[0]) + + +def _regime_functions(regime_name: str) -> dict: + """Regime-level functions of the baseline regime, as its builder sets them.""" + build = { + "retiree": retiree_functions, + "tied": tied_functions, + "nongroup": nongroup_functions, + }[REGIME_SPECS[regime_name]["his"]] + return build(REGIME_SPECS[regime_name]) + + +@pytest.mark.parametrize( + ("regime_name", "policy", "labor_income", "unearned_income", "expected"), + [ + # 3.8% of 50,000 unearned income above 200,000. + ("retiree_nomc_inelig_canwork", PolicyVariant.ACA, 0.0, 250_000.0, 98_100.0), + # 0.9% of 50,000 earnings above 200,000. + ("nongroup_nomc_inelig_canwork", PolicyVariant.ACA, 250_000.0, 0.0, 99_550.0), + # No earnings surtax outside the full ACA. + ( + "tied_nomc_inelig_canwork", + PolicyVariant.ACA_NO_MANDATE, + 250_000.0, + 0.0, + 100_000.0, + ), + ( + "nongroup_oamc_forced_forcedout", + PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION, + 0.0, + 250_000.0, + 98_100.0, + ), + ], +) +def test_aca_after_tax_income_charges_the_aca_surtaxes( + regime_name: str, + policy: PolicyVariant, + labor_income: float, + unearned_income: float, + expected: float, +) -> None: + """After-tax income under the ACA is income before the surtaxes less them.""" + result = _after_tax_income( + regime_name=regime_name, + policy=policy, + labor_income=labor_income, + unearned_income=unearned_income, + ) + assert result == pytest.approx(expected) From 69923ee4c55a7b700d831a8010463b57b7b6c6c9 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 22:03:07 +0200 Subject: [PATCH 054/101] Charge the ACA surtaxes under every ACA variant The overlay routes every regime's `after_tax_income` through the ACA surtaxes: the 3.8% surtax on unearned income above 200,000 in every variant, and the 0.9% surtax on earnings above 200,000 under the full ACA. `after_tax_income` becomes a regime-level node so the overlay can swap it; pylcm refuses a name defined at both the model and the regime level. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/aca/regimes/_overrides.py | 12 ++++++++++++ src/aca_model/baseline/regimes/_common.py | 10 ++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/aca_model/aca/regimes/_overrides.py b/src/aca_model/aca/regimes/_overrides.py index ed5cd53..104d699 100644 --- a/src/aca_model/aca/regimes/_overrides.py +++ b/src/aca_model/aca/regimes/_overrides.py @@ -9,6 +9,7 @@ from aca_model.aca import health_insurance as aca_hi from aca_model.aca.health_insurance import PolicyVariant from aca_model.baseline.regimes._common import RegimeSpec +from aca_model.environment import taxes def apply_aca_overrides( @@ -18,6 +19,10 @@ def apply_aca_overrides( ) -> None: """Override baseline functions with ACA versions in-place. + Every variant charges the ACA surtaxes in every regime: the 3.8% surtax + on unearned income above 200,000, plus, under the full ACA only, the + 0.9% surtax on earnings above 200,000 (struct-ret's composition). + Three orthogonal feature flags derived from the policy variant: - **Medicaid expansion**: two-track eligibility (categorical SSI plus the @@ -42,6 +47,13 @@ def apply_aca_overrides( PolicyVariant.ACA_NO_MEDICAID_EXPANSION, ) + functions["after_tax_income_before_aca_surtaxes"] = taxes.after_tax_income + functions["after_tax_income"] = ( + taxes.after_tax_income_with_aca_surtaxes + if policy == PolicyVariant.ACA + else taxes.after_tax_income_with_aca_investment_surtax + ) + if has_medicaid_expansion: functions["is_medicaid_eligible"] = aca_hi.is_medicaid_eligible diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 015c93f..6905b35 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -598,9 +598,11 @@ def build_model_functions(*, solver: SolverName = "brute_force") -> dict: Contains exactly the functions that are identical across all 18 living regimes AND are never swapped by the ACA policy overlay. Spec-dependent - selections (`good_health`, `leisure`, …) and overlay-swapped names - (`is_medicaid_eligible`, `cash_on_hand`, `primary_oop`) stay regime-level - in `build_common_functions`. The `dead` regime masks every entry the + selections (`good_health`, `leisure`, `oop_costs`, …) and overlay-swapped + names (`is_medicaid_eligible`, `cash_on_hand`, `primary_oop`, + `after_tax_income`) stay regime-level in `build_common_functions`: pylcm + refuses a name defined at both levels, so a regime can only swap a + regime-level entry. The `dead` regime masks every entry the bequest DAG does not read (see `build_dead_regime`). Under DC-EGM the solver-contract functions join the broadcast set. """ @@ -633,7 +635,6 @@ def build_model_functions(*, solver: SolverName = "brute_force") -> dict: # Taxes functions["taxable_ss_benefit"] = taxes.taxable_ss_benefit functions["gross_income"] = taxes.gross_income - functions["after_tax_income"] = taxes.after_tax_income # Every living regime carries pension wealth and the solve-phase pension # assets adjustment, both of which scale by the marginal income tax rate. functions["marginal_tax_rate"] = taxes.marginal_rate @@ -792,6 +793,7 @@ def build_common_functions(spec: RegimeSpec) -> dict: # Swapped per policy variant by the ACA overlay, hence regime-level functions["is_medicaid_eligible"] = health_insurance.is_medicaid_eligible + functions["after_tax_income"] = taxes.after_tax_income functions["premium_default"] = assets_and_income.premium_default functions["cash_on_hand"] = assets_and_income.cash_on_hand From c05e03e009d46ed8154f28089a8ae70f08b952bf Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 22:07:00 +0200 Subject: [PATCH 055/101] MAINT Regenerate the benchmark snapshot from the merged branches Built with scripts/regen_benchmark_params.py from the fix/draft-model worktrees of aca-data, aca-slurm and aca-model. Every value equals the hand-merged snapshot; the file is the regenerated pickle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 72552 -> 73593 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index cb88a62aeef958fdc888ea03dbe78dfabd45303d..05162d4f62567a6a0d89d454195f11b0d3de7de9 100644 GIT binary patch delta 12304 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 delta 12274 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 Date: Thu, 1 Oct 2026 22:30:34 +0200 Subject: [PATCH 056/101] FIX Read the disability probability from a dedicated (age, health) parameter The regime transitions index `prob_disabled_next[period, health]` in their own body, where pylcm lays out the `(age, health)` Series; the 3-D health transition matrix stays with the per-target health laws. Benchmark snapshot regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 97357 -> 113589 bytes src/aca_model/baseline/regimes/_common.py | 15 +++++----- src/aca_model/baseline/regimes/_nongroup.py | 4 +-- src/aca_model/baseline/regimes/_retiree.py | 4 +-- src/aca_model/baseline/regimes/_tied.py | 4 +-- tests/test_di_medicare_transitions.py | 8 ++++-- tests/test_regime_transitions.py | 27 +++++++++--------- 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index e87b64c8a63be48bd131905044240643d15b251b..2e0c12ac9b497b8dc5f6c0dca924c06fe655baf6 100644 GIT binary patch delta 8552 zcmeHMdrVVT80WjJz!i&hh~uHca7j@Hx}Xz$^gxgZE8tXA0zz$}&|ambj|&dLLd55A z25+~cOrva*hf~K^q>9UuZSG;R#fcb`Z7L283nM90H(BOm=K^BOKhv^*R@>yB+w;2L zIluEe-*>+6d_O2SdDlx&C3t(s?2iSKzJTXT^jd|iKy8pKHYf{Z8s$bKPSiml*UAKN zVLy6v*Lr7hS#?4XbGmYIpqc5VJUpc0t!7n*(Y(T}N{UP(H3?#$M>SmC-XQFs3A9Pc zZX+$^xqPE{;=p6*CsvpKl)cG@1S$6mJ^LFG9e1unG(m>mY>BD5S7k$kXC8LWe9(kY zt5-pIoC57jd3V;=H8wQy=&2jCziUSIdrEaXLzU>#>j^bix7kps?p$GCMhp7rlUSf4 zROmrT?BVR~Hq@^+ndX01-GcVCS?ew>SdaE~d#^yGL&yHidsh@K=uYzQmzJ&2p!IVO zZK~d4Lk(wdBq?q;qg!Vq+T&YFkX(|KB{12Lc~;@&1@oJc=;y-jKSg@fe!!>wz(yOA z#LvC|*W4yVhy9&(aE$@c(l76Nme`ONz0z3m#}QPK*_5NYr7@xzF};Vb6xh)IBALLj zv*5BW)K?f>GAI&*w(e#?!OLDq0FJyLF7~ z8kw|j9N5x5KDI1Ofe`TDMn1ymi1A|deA0tkm%$vjiE_m=O4@T1?3K+lKIgQu!9>|i zu~wvl2q-seT=-<0n&`lqZ0A{H-TfN)f`61-<2)-~QR-#~?qKJ+gBzvrQlOepXDRyX zq@mAJ)WGEO2CJCH*Q5|F((tS*<&!c#G0}Lr0$#xxso?FlanFt)zWR&<m6iu_~Pnph<~J;JelFHbS2+v z5tHo6Ew7sl5?rMM$)kv~#6IFI;Y6JhJg^eHK{6U|p5zd39ssT`yqh~5et|s60zP;u z84aF}jCGs>`9SKD9*3FTdyJyw$s;1xliA>Jf6~;F&y08}|7UPA!zbEPES}E|dvH$y z&QOq%t(~gTYKrt)wgi8k4Pp3x4&1amD7gq=sksh-l{ycYbzm(7ftQQml2-44i^X|d zNVM8IDG#Q&;BH%W*#V@1ae+h}m*M+5d?5z{us9EbU3AZ-O%~cKsc1guoRZ?XC0_?L zP+DKSK@Oo*1l_b#4iNS@YB{taGl+~4c!{1Ty_yw}NKxfNyr&GNdaotbj6rDiQ^Gt? zYORpdH3e{0x(W0;er$_K6>lH0Nd>ulpYSp$9^03Livj*weDhHQ|CSKvX&{(ed~pVK zT!=5IA&S~6v_4Qn4@K<~;)CU+z-&u zjF4JCqqwz_u1$nh=CR zFELXGEYMVPjr`nQ2BNTpdW$}Uf&xjUB+Cp7R4nLTF?D(|s)0Fo=6}vTzkAL-ftjEu zvpQXa?rMHwpKkwz@8hHse52F->B9IiKR3=jI1D zI)W7iuqQ;#P$nG^3ktemJ($!k@v_Nn_c+~ldr5^AduTF z9BGDdERtaZGT>)DBKghnti*mkfWf{u&wL59D3YlZT&Hlwgod)#u zgD*s#0aJZ&Z`G`os;rF*S!3eMxLQLUd|6qDlL@R4yxt9cMylp%G5?ygF_ts)No zAu%BU+py3NTXDb-iD(Fb33CHr#`k_mhz{{MUG@@txviqcWiR*0*%{qk?{X5)4`Ksz zYw4sOD+6F55$q!BWctB?Edj{=%K<<(vr)<}1z!BmkvcS`>Wh9YYkfI2Gf}jP)gHOj MA<|W@MHF}b1jbpXDF6Tf diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index e18ed10..101540a 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -518,20 +518,21 @@ def build_regime_probs_with_di_medicare( def prob_di_medicare_next( - health: IntND, - period: IntND, labor_supply: IntND, - health_trans_probs: FloatND, + prob_disabled: FloatND, ) -> FloatND: """Probability of holding disability Medicare next period. Before 65, Medicare covers households that are disabled and did not work in the previous period: the household must not work this period and be - disabled next period. Before 65 `health_trans_probs` is the 3-state - `HealthWithDisability` matrix; from 65 the value does not matter (see - `build_regime_probs_with_di_medicare`). + disabled next period, which happens with probability `prob_disabled` + (`prob_disabled_next[period, health]`, 0 from 65 on, where the split does + not matter; see `build_regime_probs_with_di_medicare`). + + Callers index `prob_disabled_next` in their own body: pylcm reads the + indexing expression from the consuming function's source to lay out the + `(age, health)` Series. """ - prob_disabled = health_trans_probs[period, health][HealthWithDisability.disabled] return jnp.where(labor_supply == LaborSupply.do_not_work, prob_disabled, 0.0) diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index 4a26139..45ddaa0 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -48,13 +48,13 @@ def transition( health: DiscreteState, labor_supply: DiscreteAction, survival_probs: FloatND, - health_trans_probs: FloatND, + prob_disabled_next: FloatND, ) -> FloatND: return build_regime_probs_with_di_medicare( target_dimc=select_target_for_age(age + 1, True, own), target_nomc=select_target_for_age(age + 1, False, own), prob_dimc=prob_di_medicare_next( - health, period, labor_supply, health_trans_probs + labor_supply, prob_disabled_next[period, health] ), survival=survival_probs[period], ) diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index f959a2b..98696e9 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -60,7 +60,7 @@ def transition( labor_supply: DiscreteAction, is_medicaid_eligible: BoolND, survival_probs: FloatND, - health_trans_probs: FloatND, + prob_disabled_next: FloatND, ) -> FloatND: next_age = age + 1 @@ -73,7 +73,7 @@ def target(mc_next: bool) -> IntND: target_dimc=target(True), target_nomc=target(False), prob_dimc=prob_di_medicare_next( - health, period, labor_supply, health_trans_probs + labor_supply, prob_disabled_next[period, health] ), survival=survival_probs[period], ) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index e568dbe..2b073ff 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -61,7 +61,7 @@ def transition( labor_supply: DiscreteAction, is_medicaid_eligible: BoolND, survival_probs: FloatND, - health_trans_probs: FloatND, + prob_disabled_next: FloatND, ) -> FloatND: next_age = age + 1 to_nongroup = (labor_supply == LaborSupply.do_not_work) | is_medicaid_eligible @@ -75,7 +75,7 @@ def target(mc_next: bool) -> IntND: target_dimc=target(True), target_nomc=target(False), prob_dimc=prob_di_medicare_next( - health, period, labor_supply, health_trans_probs + labor_supply, prob_disabled_next[period, health] ), survival=survival_probs[period], ) diff --git a/tests/test_di_medicare_transitions.py b/tests/test_di_medicare_transitions.py index 57861df..46486dd 100644 --- a/tests/test_di_medicare_transitions.py +++ b/tests/test_di_medicare_transitions.py @@ -33,6 +33,8 @@ # Rows: current health (disabled, bad, good); columns: next health. _HEALTH_ROWS = jnp.array([[0.98, 0.01, 0.01], [0.05, 0.75, 0.20], [0.002, 0.058, 0.94]]) HEALTH_TRANS = jnp.broadcast_to(_HEALTH_ROWS, (N_PERIODS, 3, 3)) +# P(disabled next period | health), the first column of the rows above. +PROB_DISABLED = HEALTH_TRANS[:, :, 0] def _nongroup_probs(regime: str, health_state: int, labor: int) -> jnp.ndarray: @@ -43,7 +45,7 @@ def _nongroup_probs(regime: str, health_state: int, labor: int) -> jnp.ndarray: health=jnp.int32(health_state), labor_supply=jnp.array(labor), survival_probs=SURVIVAL, - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, ) @@ -116,7 +118,7 @@ def test_retiree_transition_newly_disabled_non_worker_keeps_retiree_cover() -> N labor_supply=jnp.array(LaborSupply.do_not_work), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, ) np.testing.assert_allclose( probs[RegimeId.retiree_dimc_inelig_canwork], 0.99 * 0.002, atol=1e-12 @@ -133,7 +135,7 @@ def test_tied_transition_newly_disabled_non_worker_moves_to_nongroup_medicare() labor_supply=jnp.array(LaborSupply.do_not_work), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, ) np.testing.assert_allclose( probs[RegimeId.nongroup_dimc_inelig_canwork], 0.99 * 0.05, atol=1e-12 diff --git a/tests/test_regime_transitions.py b/tests/test_regime_transitions.py index 2259782..41e6801 100644 --- a/tests/test_regime_transitions.py +++ b/tests/test_regime_transitions.py @@ -27,11 +27,10 @@ N_REGIMES = 19 N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age SURVIVAL = jnp.ones(N_PERIODS) * 0.99 -# Pre-65 health rows (disabled, bad, good); a good-health household stays -# off disability with probability 0.998. -HEALTH_TRANS = jnp.broadcast_to( - jnp.array([[0.98, 0.01, 0.01], [0.05, 0.75, 0.20], [0.002, 0.058, 0.94]]), - (N_PERIODS, 3, 3), +# P(disabled next period | disabled, bad, good health). +PROB_DISABLED = jnp.broadcast_to( + jnp.array([0.98, 0.05, 0.002]), + (N_PERIODS, 3), ) @@ -54,7 +53,7 @@ def test_tied_stop_working_becomes_nongroup() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.do_not_work), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -73,7 +72,7 @@ def test_tied_keeps_working_stays_tied() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -94,7 +93,7 @@ def test_retiree_medicaid_override_to_nongroup() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -112,7 +111,7 @@ def test_retiree_not_medicaid_stays_retiree() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -169,7 +168,7 @@ def test_retiree_age_bracket_transitions( age=jnp.asarray(age), period=period, health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=SURVIVAL, @@ -190,7 +189,7 @@ def test_nongroup_canwork_valid_probs() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), survival_probs=SURVIVAL, ) @@ -224,7 +223,7 @@ def test_tied_medicaid_override_to_nongroup() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -243,7 +242,7 @@ def test_tied_at_medicare_age_with_medicaid() -> None: age=jnp.int32(64), period=period, health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(True), survival_probs=SURVIVAL, @@ -263,7 +262,7 @@ def test_survival_prob_determines_death_weight() -> None: age=jnp.int32(55), period=jnp.int32(4), health=jnp.int32(2), - health_trans_probs=HEALTH_TRANS, + prob_disabled_next=PROB_DISABLED, labor_supply=jnp.array(LaborSupply.h2000), is_medicaid_eligible=jnp.array(False), survival_probs=survival, From 2b410bdf0ff74ddf9159a32747bc23099b7f52f8 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 22:35:37 +0200 Subject: [PATCH 057/101] TST Share the ACA fixed-params helper with the NBEGM ACA tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/helpers/model.py | 33 +++++++++++++++++------------- tests/test_nbegm_model_creation.py | 6 +++--- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/tests/helpers/model.py b/tests/helpers/model.py index 4f32d90..28cbb83 100644 --- a/tests/helpers/model.py +++ b/tests/helpers/model.py @@ -45,27 +45,32 @@ def make_baseline_model() -> Model: ) -def make_aca_model(*, policy: PolicyVariant) -> Model: - """ACA model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params. +def aca_fixed_params(*, fixed_params: dict, policy: PolicyVariant) -> dict: + """Return baseline `fixed_params` fit for the ACA `policy` model. Under a variant with the reformed non-group market, the non-group regimes before Medicare price cover at the community-rated ACA premium, so the - snapshot's risk-rated premium parameters (prefix `premium_`) are dropped - from those regimes, as aca-slurm does. + risk-rated premium parameters (prefix `premium_`) are dropped from those + regimes, as aca-slurm does. """ + if policy == PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION: + return fixed_params + return { + name: ( + {k: v for k, v in value.items() if not k.startswith("premium_")} + if name in _NONGROUP_BEFORE_MEDICARE + else value + ) + for name, value in fixed_params.items() + } + + +def make_aca_model(*, policy: PolicyVariant) -> Model: + """ACA model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params.""" fixed_params, wage_params, _ = get_benchmark_params(model=None) - if policy != PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION: - fixed_params = { - name: ( - {k: v for k, v in value.items() if not k.startswith("premium_")} - if name in _NONGROUP_BEFORE_MEDICARE - else value - ) - for name, value in fixed_params.items() - } return _create_aca_model( policy=policy, - fixed_params=fixed_params, + fixed_params=aca_fixed_params(fixed_params=fixed_params, policy=policy), wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, grid_config=BENCHMARK_GRID_CONFIG, diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index f853fcc..99d5bc9 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -17,7 +17,7 @@ from typing import cast import pytest -from helpers.model import _DERIVED_CATEGORICALS +from helpers.model import _DERIVED_CATEGORICALS, aca_fixed_params from lcm import DiscreteGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime, LiquidMargin from lcm.exceptions import RegimeInitializationError @@ -300,7 +300,7 @@ def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: grid_config = _BRIDGED_GRID_CONFIG model = create_aca_model( policy=policy, - fixed_params=_FIXED_PARAMS, + fixed_params=aca_fixed_params(fixed_params=_FIXED_PARAMS, policy=policy), wage_params=_WAGE_PARAMS, derived_categoricals=_DERIVED_CATEGORICALS, grid_config=grid_config, @@ -333,7 +333,7 @@ def test_nbegm_aca_variants_leave_no_free_buy_private_params( grid_config = _BRIDGED_GRID_CONFIG model = create_aca_model( policy=policy, - fixed_params=_FIXED_PARAMS, + fixed_params=aca_fixed_params(fixed_params=_FIXED_PARAMS, policy=policy), wage_params=_WAGE_PARAMS, derived_categoricals=_DERIVED_CATEGORICALS, grid_config=grid_config, From 77c5c43972d90da0686a3039bc9a697e6f421d31 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:05:13 +0200 Subject: [PATCH 058/101] TEST Pension wealth stays undivided where nobody survives Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_pensions.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_pensions.py b/tests/test_pensions.py index 6cacdb5..28c59b4 100644 --- a/tests/test_pensions.py +++ b/tests/test_pensions.py @@ -188,6 +188,20 @@ def test_pension_wealth_next_with_benefit() -> None: assert jnp.isclose(result, expected, atol=ATOL) +def test_pension_wealth_next_is_undivided_where_nobody_survives() -> None: + """At an age with zero survival probability, next pension wealth is the + compounded wealth net of accrual and benefit, not divided by survival.""" + result = pensions.wealth_next_before_adjustment( + pension_wealth=jnp.array(3000.0), + pension_benefit=jnp.array(2000.0), + pension_accrual=jnp.array(500.0), + rate_of_return=jnp.asarray(0.03), + unconditional_survival_prob=jnp.array([0.99, 0.0]), + period=jnp.int32(1), + ) + assert jnp.isclose(result, 1.03 * 3000.0 + 500.0 - 2000.0, atol=ATOL) + + def test_convert_total_ben_to_pia_below_kink_0() -> None: """Round-trip: PIA below first kink recovers original PIA.""" pia_input = jnp.array(500.0) From 3155f6323658d5c26cf796f713f26d3a583c8ccf Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:05:41 +0200 Subject: [PATCH 059/101] FIX Leave pension wealth undivided where nobody survives With zero survival at the terminal age, the pension wealth update would divide by zero; skip the division there, as struct-ret does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/environment/pensions.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/aca_model/environment/pensions.py b/src/aca_model/environment/pensions.py index 1c4040f..e9a55d8 100644 --- a/src/aca_model/environment/pensions.py +++ b/src/aca_model/environment/pensions.py @@ -188,11 +188,18 @@ def wealth_next_before_adjustment( next = ((1 + r) * wealth + accrual - benefit) / survival_prob The (1 + r) factor compounds pension wealth at the market return rate. - The division by survival prob accounts for the annuity pricing. + The division by survival prob accounts for the annuity pricing. At an age + where nobody survives, the wealth is left undivided. """ - return ( + survival_prob = unconditional_survival_prob[period] + compounded = ( (1.0 + rate_of_return) * pension_wealth + pension_accrual - pension_benefit - ) / unconditional_survival_prob[period] + ) + return jnp.where( + survival_prob > 0.0, + compounded / jnp.where(survival_prob > 0.0, survival_prob, 1.0), + compounded, + ) def assets_adjustment( From 50bc566096421879e49c7f7ec4ce18f0d2e6c8d6 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:06:25 +0200 Subject: [PATCH 060/101] MAINT Regenerate the benchmark snapshot after the processes merge Built with scripts/regen_benchmark_params.py from the fix/draft-model worktrees, with assemble_params called on pref_params only. Against the processes snapshot it differs exactly in the prefs and budget keys; against the previous merged snapshot exactly in the processes keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 73593 -> 74474 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 05162d4f62567a6a0d89d454195f11b0d3de7de9..e24139de85310139486f3ef63f2e315dd18c4ee5 100644 GIT binary patch delta 10841 zcmeHN30PCd7G{#LDjI}90=NKzA}XNbQjr1CQl)MnP!&N?Y`7IywntUOr>$5woWQN9 zRm56r71a2OfTGa4w8cJm!5yn2OAa=k28PLF2HykdWXT}3b)qfm#t%dO{oK*OpOv#8W zqC-PT4QVly_#60xPx0*IBH}O$kE8Z8gU z2~fv8Yl)O}7EsaTt0_Aa8XrBf(aP(>mgjynK?~&Lt%Q7p1G!o^WDye1cl-0O884U@ ztBq|7{Z{z`Fhxffi;+0btObg9B)PI!Ezn}?4)SM76yW&@(6}xVw!4dP3bCf~sOkD> zd{Il!tYV=P2$LHvTG(7q97j)*EDepru3@=JPFarC6=(&L&ky76I{@Hz`nX38#Z z02wfWw@Y5XG(d}^s3UKe{;MJnOUNdW&B1k8Ux#n|r#8ja5J4?k(HVa&jwTpyaxgyA zg)uyBLKE(c)gjZO6@qaj(&n|T8BJC&TZ5+l=7A!zY+CT&@H7!xyTP+5Om%qZggI@a zHkhp$O<;b&fjLhH%=9ec%_p;7@@5%F%?1M<(N&U7^wed)q~{xsp1r#CsJxl>^p+ZYqppI1%;AZz(py@;$%;B+KvJ zdIOmp16y<$C@mO_4d@rP!9X#`z;PW0h8=o`byIeC+IXn>?$q%fN8f!kuBatO{#i5( z)X0mAUJ4(cQPU@CXw-5T$_tOa%jXR-Lb*XAx%m5P04AZz@|zBmuonN)h72}Jx_GQK zd8(`@<%s(!PjU&68=N?5;Mtv+>&o;uOH7?v3AwqM#G91_pzdWA*q%Z8P1b7nR#-J6 zJ5)yr(V%hLdC2sNH9FPb0no_#j02F$$DD~ZFo?l7`Ij=)#ovSS3l7R`jM7GuPWlR{ z4D!`9MWp3ESM2$0)nVGeJ0_MCL@!kxA^|A`UhW3x(UChVaTPH7ZYsViZ<1?RC8yqy zXI7N(Nx>0z$IAIY zs9+@SmA#X>{f#le)UiZg6yw(sQ)O6N#(2OMAlEAjc`iRGVL`!dl{icVH)r>JH>Xks zvvaM-XPi{RyL0W9eKSr4A6)pZ{>2>?d}?+rb@}&7IP*ul1gVb-?l2t?9erB`S0`I6 z?pRf%gcTks`YZaY;DitJZauxFf>}oKfxGjRaOBF)86%uj&}z!B6Ax6V;HIV4(f`U( zLJz}_HFsPKq#u4ed}#+2Tw&k$>W?>6@Z?dKrRKB}_6z>d zGEty{lJQ|8ciitmIS&-AP{QP>a^vw2o-5&y$VZOl*Hv(5Mf$G?;+61vA3MjcbxIh1 zV3zP0?*DP`PQka4N;p+wROfPE34hz@IkXP<2kd%can4@}50zyu9HLOd=hM&5mSO{T zXA|v<+?6oWaJNf%tr8w6N&3YG13j0&?YLWCB}`a!&9U7@r5PM>8D zr%0>e2C?Afl=71HvdFD)Z->MCxfQrGZ0?P|ey zrfwm-DzT^vcT&~lG6hpdg_6r2G@{B)&BwOFPJ^lo?zDjqZ47KEK0CQE+P`H62w2TT zE>0m$Z`r|_QV#-$|IDfraJnJ=0F7Q?KnZ+PHW# zE`w<}LSlpK8X7LH)w;gze$kQfi?~(d*yyMk;j`zUx%HNB6(`@m>>qs039?l={oRWx z6DMzIG1VR!YG<&S9~B!tKMD=JZ~a#Jf|hlY@ddvJukQ>0BWotZ!;FWHYJXU@_+Ibj zY4h5UVCYV`(neT}4f=I^%jt%;y(4}alYpqxjT2EXR$k7pQ>Po*=lYXR8xyV-IN`z( zqVWf5JLeSU9bN}CCB+Kee{O*G4i?CFKH`B+OvThyQ|{pE4~5lz#=OH4fubgVpI`rE zUAy$1_H4F<-&`I0eI2eRgI}z3U^3=~4LY=xM$~!v#a|Edk+7?`kve@}$W-E&@$#j; z;=ImEH2Hhu8^A}b1mj7=_S=MlB=5p=WV|MMw=qF|>aDxK`b`#7Z8C`}nvi1Gdh)c@ zm|pZcA@FJ_GQAoYBju+TvODgTtS&z_5-nVE%FO%fGV@f>v_Yl4lyJ|DTUW&eD(F1x zBL5Ge66(X#4@2ZC*lnHZd#CCY@B&&sx$GMiTx6vty3dVlcW9#z(1$2+T zI%a)>3eL%N+_cg~0aryHDIFS(tGatLy!!wJ%qTD}956`*&1dAstUgx@_g7yk9qFfn zgL`=18ojL+UfAn0%ng^bo2g~4zs*Xng`$8>CwAMaV12-f?GF8GVe-^{DHLA?YeY}` z4}V+(9ftK@Z(6B@efQ-@l;qYxX}64HeNHGLZ~vF?fv6hj9NP^9;rjMUz|7PB<~4BO z>`&+yNw`cMFn_@H{AzfUpZMvzF-rK%D9Y$|NHwgfTC;b$wGvJ)T=OU~xwZ;sS%sP( zys3aIbKGKf#aF?MFyEmEwh3`||IYRC!3y~FvD2u#AKrr%|CsW#lCOYU z+b5J(l~%%$#y{N$U)93PlfsNwJ5<6RMQiIn_Ns-_k(b7*sJqZ*qnU^3hZ?9vFX}?J z-GR0r`1VO0Sp)YO_<9Z){ljgz-~`$}dVe+4{1l+sPXgEg#N`892YZn~^6)o#RUnOH zC7M8LJn_)wjooy4BU1~HAo?}ow1BkO6CJNM)WQ+T_wtb6|O=Y(s_DtYFY~Y$lV<>iu(p36wI}=cEQH|vd zhlliR@OJrwW~-(+JkUn~uz#y^mRAG@UB(0dj|{v*&~<#!i-m6@ zQ*CsAJXbxvRqba*M9!HX9zA>doR}zC!$}=2)Yp*kCf7-ABxsgGmV`IE7BVDIUtlI0 zrmh#a$|@!zF?{+Q+Qk5P>r(~zB$4iJ3Y>V=yu9~K!7n_XibuN(f#Ylb=I?9Hex74O ze)A`>p@jI%9lxnEBVSG5RB8D!U?M%=0_;Yk`blUPbI^-^R|IzStHo-YyaZO@Ci}jm z@g;B>$MR#kizQ&*;~92p$0Xsjv=i86Mm5(&yNZAn9b^u6^lvWGVnl*?dY!9 zinWmR;@Oce&g|OJftDao*jzDd)7`3dHUl{}KVofmeYH(?wml@EC+#Z+GiiMZ7(^cy r0})*-1`_%VzVN6#dIG*dd=lpmi)7S#Y{Cydl*NnF9v@i{7uWYcQC8IF delta 10257 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 H%isGiQtt^9 From cf3fe2c28a52711048c833df0c7d8be895135ec9 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:13:54 +0200 Subject: [PATCH 061/101] TST Pass categorical Medicaid eligibility to the transitions in the mortality tests The retiree and tied transitions read `is_ssi_eligible`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_mortality.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_mortality.py b/tests/test_mortality.py index 03b2f73..73e1116 100644 --- a/tests/test_mortality.py +++ b/tests/test_mortality.py @@ -42,7 +42,7 @@ def _tied(health: int) -> jnp.ndarray: period=jnp.int32(4), health=jnp.int32(health), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) @@ -54,7 +54,7 @@ def _retiree(health: int) -> jnp.ndarray: period=jnp.int32(4), health=jnp.int32(health), labor_supply=jnp.array(LaborSupply.h2000), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) @@ -65,7 +65,7 @@ def _retiree_forcedout(health: int) -> jnp.ndarray: age=jnp.int32(80), period=jnp.int32(29), health=jnp.int32(health), - is_medicaid_eligible=jnp.array(False), + is_ssi_eligible=jnp.array(False), survival_probs=SURVIVAL, ) From e821fa85a980b1a0b8bc39df1a32c746cdcef4c7 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:16:47 +0200 Subject: [PATCH 062/101] MAINT Regenerate the benchmark snapshot after all merges Built with scripts/regen_benchmark_params.py from the fix/draft-model worktrees of aca-data, aca-slurm and aca-model, with every aca-data parameter pickle rebuilt from the merged code. Its values equal the previous snapshot; against the aca branch's snapshot only the bequest key differs (bequest_weight from prefs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 74474 -> 74474 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index e24139de85310139486f3ef63f2e315dd18c4ee5..c5ac0aaa5dd86c07fbbcc6ba301354f387bd959b 100644 GIT binary patch delta 145 zcmaELl;zb?mWC~ihoq;UlVw~vJx_+wdHQ@g#*@>lWEn%IAChKVGhJViQEGaP0wdq_ zjWUcUw`a&RnlerAS7MZ$K1Y#JQ<5(!wXig`xFo(jH8VYtq;DZqJZsG-aB;U6D~^x{DH{%Je=Zpme$tqxy73B}V1xKS2`rL0aZ2GD=Qg Ip~UC}0Q~ Date: Thu, 1 Oct 2026 23:24:26 +0200 Subject: [PATCH 063/101] TEST Pin couples' doubled deductible and OOP maximum under the ACA plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_private_premium.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_private_premium.py b/tests/test_private_premium.py index d1c3a9e..40e8891 100644 --- a/tests/test_private_premium.py +++ b/tests/test_private_premium.py @@ -4,6 +4,7 @@ import numpy as np import pytest +from aca_model.aca import health_insurance as aca_health_insurance from aca_model.baseline import health_insurance from aca_model.baseline.health_insurance import BuyPrivate from aca_model.baseline.regimes import _nongroup @@ -210,3 +211,26 @@ def test_nongroup_regimes_constrain_private_cover_to_paid_premiums( assert {name: func.__name__ for name, func in constraints.items()} == { name: name for name in expected } + + +@pytest.mark.parametrize( + ("is_married", "expected"), + [ + (0, 1109.843 + (10000.0 - 1109.843) * 0.1774133), + (1, 2219.686 + (10000.0 - 2219.686) * 0.1774133), + ], +) +def test_aca_primary_oop_doubles_couples_deductible_and_oop_max( + is_married: int, expected: float +) -> None: + """Under the ACA plan too, couples' deductible and OOP maximum double.""" + result = aca_health_insurance.primary_oop( + total_health_costs=jnp.asarray(10000.0), + cost_sharing_scale=jnp.asarray(1.0), + buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(is_married), + deductible=jnp.asarray(1109.843), + coinsurance_rate=jnp.asarray(0.1774133), + oop_max=jnp.asarray(6500.0), + ) + np.testing.assert_allclose(result, expected, atol=1e-6) From b87ac7c9e6884c5d420fbabdeb435d976fe2d542 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:26:43 +0200 Subject: [PATCH 064/101] FIX Double couples' deductible and OOP maximum under the ACA plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/aca/health_insurance.py | 9 ++++++--- tests/test_baseline_equivalence.py | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/aca_model/aca/health_insurance.py b/src/aca_model/aca/health_insurance.py index 299da48..c05e8ef 100644 --- a/src/aca_model/aca/health_insurance.py +++ b/src/aca_model/aca/health_insurance.py @@ -216,6 +216,7 @@ def primary_oop( total_health_costs: FloatND, cost_sharing_scale: FloatND, buy_private: DiscreteAction, + is_married: IntND, deductible: ScalarFloat, coinsurance_rate: ScalarFloat, oop_max: ScalarFloat, @@ -223,13 +224,15 @@ def primary_oop( """Compute primary OOP costs with ACA cost-sharing reductions. Scale deductible, coinsurance rate, and OOP max by the cost-sharing - factor before applying the standard OOP calculation. When uninsured + factor before applying the standard OOP calculation; couples' + deductible and OOP max are twice the single ones. When uninsured (`buy_private=no`), OOP equals total health costs (no coverage). """ + couple_scale = 1.0 + is_married insured_oop = oop_costs( total_health_costs, - deductible * cost_sharing_scale, + deductible * cost_sharing_scale * couple_scale, coinsurance_rate * cost_sharing_scale, - oop_max * cost_sharing_scale, + oop_max * cost_sharing_scale * couple_scale, ) return jnp.where(buy_private == BuyPrivate.yes, insured_oop, total_health_costs) diff --git a/tests/test_baseline_equivalence.py b/tests/test_baseline_equivalence.py index 61a9367..d1e30c9 100644 --- a/tests/test_baseline_equivalence.py +++ b/tests/test_baseline_equivalence.py @@ -116,6 +116,7 @@ def test_aca_primary_oop_scaled_reduces_costs() -> None: total_health_costs=costs, cost_sharing_scale=jnp.array(1.0), buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(0), deductible=deductible, coinsurance_rate=coinsurance, oop_max=oop_max_val, @@ -124,6 +125,7 @@ def test_aca_primary_oop_scaled_reduces_costs() -> None: total_health_costs=costs, cost_sharing_scale=jnp.array(0.3), buy_private=jnp.array(BuyPrivate.yes), + is_married=jnp.int32(0), deductible=deductible, coinsurance_rate=coinsurance, oop_max=oop_max_val, From 922b545b2dee8e4dd92accce13eaecf0d30559f2 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 1 Oct 2026 23:28:22 +0200 Subject: [PATCH 065/101] MAINT Carry the private-premium and disability inputs in the benchmark snapshot Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 73909 -> 117989 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 479d9b8a34b2b1ac056797d92c0be4d9b984a0d0..99b0f0a63cebb07bf819f428f845aec8f09ce29c 100644 GIT binary patch literal 117989 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 Gum1w_KK6J3 From ecca1c499895481c26b127d57e9d3a3780c51e14 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 00:05:18 +0200 Subject: [PATCH 066/101] MAINT Regenerate the benchmark snapshot after the premiums merge Built with scripts/regen_benchmark_params.py from the fix/draft-model worktrees, every aca-data input rebuilt from the merged code. Against the premiums snapshot only the bequest key and the private-premium sample differ: the sample comes from the merged estimation population. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 74474 -> 100740 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index c5ac0aaa5dd86c07fbbcc6ba301354f387bd959b..ed27509ea547e4c3f84a61892c6787cadd336766 100644 GIT binary patch delta 18603 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 From 5bda6432510a2af84afcd1dd9d5622b7b8764989 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 00:08:18 +0200 Subject: [PATCH 067/101] TEST Build the ACA execution-config model from ACA-fit params The ACA factory in test_execution_config gets the benchmark params via helpers.model.aca_fixed_params, which drops the risk-rated premium_* parameters the reformed non-group market does not read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_execution_config.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_execution_config.py b/tests/test_execution_config.py index 77bd52d..3e31683 100644 --- a/tests/test_execution_config.py +++ b/tests/test_execution_config.py @@ -5,6 +5,7 @@ import jax import pytest +from helpers.model import aca_fixed_params from lcm import DiscreteGrid, ExecutionConfig from lcm.exceptions import ExecutionPlanningError @@ -28,11 +29,12 @@ def _factory(kind): pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) fixed_params, wage_params, _ = get_benchmark_params(model=None) - factory = ( - partial(create_aca_model, policy=PolicyVariant.ACA) - if kind == "aca" - else create_model - ) + factory = create_model + if kind == "aca": + factory = partial(create_aca_model, policy=PolicyVariant.ACA) + fixed_params = aca_fixed_params( + fixed_params=fixed_params, policy=PolicyVariant.ACA + ) return partial( factory, fixed_params=fixed_params, From 82ac671f1b8d25a6c1670da7186eca5804ffcc9c Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 00:52:56 +0200 Subject: [PATCH 068/101] MAINT Regenerate the benchmark snapshot with the 950-row premium sample Only premium_sample_insurer_cost and premium_sample_is_married change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 100740 -> 106044 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index ed27509ea547e4c3f84a61892c6787cadd336766..f725e50bbe5a8ab951cb1f7b2da0152eb43eae21 100644 GIT binary patch delta 8511 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 wM{)vbnxsb{^*TQ3 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 TvXID0BboW9kr(nkjbiy9TwFU> From db17dcff6cce907d0325a7c759a60caaf40198e4 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 06:18:48 +0200 Subject: [PATCH 069/101] TST Read the scheduled disability-Medicare targets through the resolved age schedule Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_di_medicare_transitions.py | 65 ++++++++++++++++++--------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/tests/test_di_medicare_transitions.py b/tests/test_di_medicare_transitions.py index 2fc3a61..b43bd30 100644 --- a/tests/test_di_medicare_transitions.py +++ b/tests/test_di_medicare_transitions.py @@ -6,9 +6,12 @@ health distribution conditional on landing there. """ +from collections.abc import Callable + import jax.numpy as jnp import numpy as np import pytest +from lcm import AgeGrid, ByAge from aca_model.agent import health from aca_model.agent.health import HealthWithDisability @@ -27,7 +30,7 @@ _make_transition_canwork as retiree_canwork, ) from aca_model.baseline.regimes._tied import _make_transition_canwork as tied_canwork -from aca_model.config import MODEL_CONFIG +from aca_model.config import MODEL_AGES, MODEL_CONFIG N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age SURVIVAL = jnp.full((N_PERIODS, 3), 0.99) @@ -209,36 +212,54 @@ def test_per_target_health_law(source: str, target: str, law: str) -> None: assert laws[target].func is getattr(health, law) +def _nongroup_schedule(regime: str) -> ByAge: + own, _ = make_targets(regime) + return build_scheduled_regime_transition( + spec=REGIME_SPECS[regime], + transition_func=nongroup_canwork(own=own), + target_groups=(own,), + ) + + +def _retiree_schedule(regime: str) -> ByAge: + own, ng = make_targets(regime) + return build_scheduled_regime_transition( + spec=REGIME_SPECS[regime], + transition_func=retiree_canwork(own=own, ng=ng), + target_groups=(own, ng), + ) + + +def _tied_schedule(regime: str) -> ByAge: + own, ng = make_targets(regime) + return build_scheduled_regime_transition( + spec=REGIME_SPECS[regime], + transition_func=tied_canwork(own=own, ng=ng), + target_groups=(own, ng), + ) + + @pytest.mark.parametrize( - ("regime", "make_transition", "dimc_target"), + ("build_schedule", "regime", "dimc_target"), [ ( + _nongroup_schedule, "nongroup_nomc_inelig_canwork", - nongroup_canwork, "nongroup_dimc_inelig_canwork", ), - ("retiree_nomc_inelig_canwork", retiree_canwork, "retiree_dimc_inelig_canwork"), - ("tied_nomc_inelig_canwork", tied_canwork, "nongroup_dimc_inelig_canwork"), + ( + _retiree_schedule, + "retiree_nomc_inelig_canwork", + "retiree_dimc_inelig_canwork", + ), + (_tied_schedule, "tied_nomc_inelig_canwork", "nongroup_dimc_inelig_canwork"), ], ) def test_scheduled_targets_include_disability_medicare_entry( - regime: str, make_transition: object, dimc_target: str + build_schedule: Callable[[str], ByAge], regime: str, dimc_target: str ) -> None: """A pre-65 regime without Medicare declares its disability-Medicare target.""" - own, ng = make_targets(regime) - transition_func = ( - make_transition(own=own) - if make_transition is nongroup_canwork - else make_transition(own=own, ng=ng) - ) - schedule = build_scheduled_regime_transition( - spec=REGIME_SPECS[regime], - transition_func=transition_func, - target_groups=(own,) if make_transition is nongroup_canwork else (own, ng), - ) - targets_at_55 = next( - targets - for ages, targets in schedule._cases # noqa: SLF001 - if 55 in ages + targets_at_55 = ( + build_schedule(regime).resolve(AgeGrid(exact_values=MODEL_AGES)).at(55) ) - assert dimc_target in targets_at_55 + assert dimc_target in set(targets_at_55) # ty: ignore[invalid-argument-type] From aa1206112fa8e7b16a9c907ebcdb4bb422a73c77 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 06:20:55 +0200 Subject: [PATCH 070/101] Type the age stages and starting regimes for the checker Stages are plain integer [start, stop) pairs; the starting pairs carry pylcm's InitialRegimes type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/regimes/_common.py | 42 ++++++++++++++--------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 810fabb..71edfe7 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -35,7 +35,16 @@ post_decision_lower_bound, ) from lcm.solvers import OneMarginSolver -from lcm.typing import BoolND, FloatND, IntND, Period, RegimeName, ScalarInt, UserParams +from lcm.typing import ( + BoolND, + FloatND, + InitialRegimes, + IntND, + Period, + RegimeName, + ScalarInt, + UserParams, +) from aca_model.agent import ( assets_and_income, @@ -451,26 +460,26 @@ def _compute_max_annual_labor_income( # Structural stage data, shared by scheduled support and the numerical router. -# Intervals select existing grid points, not integer-year ranges. +# Each stage covers the half-open age interval `[start, stop)`. _AGE_STAGES = ( ( - AgeRange(start=config.start_age, stop=config.ss_early_age), + (config.start_age, config.ss_early_age), ("nomc_inelig", "dimc_inelig"), ), ( - AgeRange(start=config.ss_early_age, stop=config.medicare_age), + (config.ss_early_age, config.medicare_age), ("nomc_choose", "dimc_choose"), ), ( - AgeRange(start=config.medicare_age, stop=config.ss_forced_age), + (config.medicare_age, config.ss_forced_age), ("forced_choose",) * 2, ), ( - AgeRange(start=config.ss_forced_age, stop=config.work_forced_out_age), + (config.ss_forced_age, config.work_forced_out_age), ("forced_forced",) * 2, ), ( - AgeRange(start=config.work_forced_out_age, stop=MODEL_AGES[-1]), + (config.work_forced_out_age, MODEL_AGES[-1]), ("forcedout",) * 2, ), ) @@ -486,7 +495,7 @@ def _compute_max_annual_labor_income( # Admissible starting (age, regime) pairs, shared by every model variant: the # baseline estimation-sample ages. Later ages, and `dead` at any age, are reached only # through transitions. -INITIAL_REGIMES = MappingProxyType( +INITIAL_REGIMES: InitialRegimes = MappingProxyType( {AgeRange(start=config.start_age, stop=config.last_start_age + 1): ENTRY_REGIMES} ) @@ -506,11 +515,9 @@ def _compute_max_annual_labor_income( def transition_ages(spec: RegimeSpec) -> tuple[int, ...]: """Exact source coordinates at which the template declares its law.""" key = _STAGE_KEY.get((spec["mc"], spec["ss"], spec["canwork"])) - for interval, branches in _AGE_STAGES: + for (start, stop), branches in _AGE_STAGES: if key in branches: - return tuple( - age for age in MODEL_AGES if interval.start <= age < interval.stop - ) + return tuple(age for age in MODEL_AGES if start <= age < stop) raise ValueError(f"Unknown regime spec: {spec}") @@ -526,9 +533,10 @@ def next_model_age(period: Period) -> FloatND: def _target_pair(next_age: int, group: dict[str, int]) -> tuple[int, int]: if next_age == MODEL_AGES[-1]: - return (int(RegimeId.dead),) * 2 - for interval, branches in _AGE_STAGES: - if interval.start <= next_age < interval.stop: + dead = int(RegimeId.dead) + return dead, dead + for (start, stop), branches in _AGE_STAGES: + if start <= next_age < stop: return group[branches[0]], group[branches[1]] raise ValueError(f"Next age {next_age} is outside the declared stages") @@ -1132,9 +1140,9 @@ def select_target_for_age( ) -> IntND: """Select with the same stage table that supplies the schedule's support.""" result = jnp.asarray(RegimeId.dead) - for interval, branches in reversed(_AGE_STAGES): + for (start, stop), branches in reversed(_AGE_STAGES): target = jnp.where(jnp.array(mc_next), tgts[branches[1]], tgts[branches[0]]) - inside = (next_age >= interval.start) & (next_age < interval.stop) + inside = (next_age >= start) & (next_age < stop) result = jnp.where(inside, target, result) return result From aa70e9266556801223ee83b46b52f193d8134b1a Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 06:39:30 +0200 Subject: [PATCH 071/101] MAINT Regenerate the benchmark snapshot with the age-64 disability row Also expects pylcm's own default device-memory budget when a policy leaves it unset. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 106044 -> 107785 bytes tests/test_execution_config.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index f725e50bbe5a8ab951cb1f7b2da0152eb43eae21..86a3c69b5483c392eb85a7513e488408fef7d05b 100644 GIT binary patch delta 3006 zcmbuBd29?(9LM)N+S+cn-ED$y+i0Cpi`CR0?zFotjjmgYL}@M6G3u!8ibzVO;s}Dw z@Iw_%5RnRvV-VFU{*fRNBt$gsxKgF!i0e&VJ5ARxJIT)Gy_xs>e1F%xclK=9y31ju z5;_`R?lZJpls5`-VtJwIgcBZ4JovVfx}+gq{M_7Eth>`wbi5l9Rv&IKxb0L_iX@De zzYaz!#QJ1gsKht1>FN-4#VMLF3Io`xeq4RH*yr7FzeU}|hW94g9f~OPb!LgL=8wCX zJ+Wv^3Yz2>p{RsEw+#z{u$<9Er7ooWh3kfiArDWNN~9$IsF0wHehP47jWkj6&GdPT zB@cf}XjH_V5nd~QH6;>X%YNtAdW9x7G1li1nkm5voBZ5_EQemf1=@o1RDw%_5iBno zh8enpdr(_zrAq6$ptQ2!#)Hc-QNu{M^v3s@w&n*a&EJC3d|^Zo@wG>26>oa?2>;?u zQ%3(=ymD?9qJb||sDW-RO-QdveaQ!kzHK0j7aDcm#~3_?03 zkAovRgFO*OE4MMqE!#wJVNkFoQ>E?OewAeLnJvDV9u|XaZzUWKXQM@zj+pWq`x!iR9A-h*noS9 z{9!R`$4+0^o+3O`YCE5Cg`vvuq?yLK(Ekr3Ftb3#EY)J}S<3R-DMx7<{mgwTW{nmz zYdI2N*5%!O73q@}sktoph*}e({IfVPs-s)TDQQ!nVs&cD??b>4w$YiqM~ZwG1yw4QAP z7HRQMPfrV7-HEuwfL-?E#73P7)zLz0^9nSEqPOF~zmPLkZHWHs9oQ++T^1$XJ<>@7cq*=frI z3J=g~FmW?u53!Mg&^DTytg&RQ!4hojgGo)A)TD_Ih9^qXro~DuX{x>Bn(Yn&!QGeH zWM}r=d-r_jJKsI`+!MF#6BG6x4X)hEA>qk~>Z;*ZKW)h$uS3XoCJV(>a}4z-(yz1$BJ9g@^Bd9^PCd~zWYVd|z z9sWJ%%o;e;bNVt#b`&~pQ}gE!x0>5$L7ETDP`QinAB; z{|`HWk9g*kpu#g4lCH8DW92pWi}AYNtx1#oJN2>5X8Az95@NsUeg{IEEywVn!70hG zH6w;+UqyrUG>n-TUd)K$tOyUVpB^?qw49W;O+;rhBD!mz1*H@V3Q{-gONRCI6j^mS z>OJ)+hHXBN{%*i)R?|@2YhoBohar7?09B*l z+sS{jTH{e?JoXtgcn4ePj|SvebbpI|rrZprlN){b5Dr=Hh!Ss@nB7f_+4Nz!ta>zw zk4*&29O)G9h9>-#4UszfQ4T+Ci{M_-Zo^Zud%deyGJKN@?a3P3phG>?Qie{2P@nF>f-Pw#+&?n?rIcb_@RTpx>1paD*3r*hc;uSrU7yVwMYoH) zVAY0sl4|?0HHok!VD;KmJ_lC-)LV{o&0YJA5>5l`LlanRC9u*yRG{Gb_>7w%Zm$Hv z6IhINljpo|0xKw_^$hOi<~j_Z6K>$M5O2_+;| zva8%w`uT-5$giarjoa*3Y}Djx(y$WE)VAtonjkgcY-$fTH%FQx;kKyU*x4D>=<#P5 zbS@5pz8uE&)r2-}3T+{S(AG7zx5-NDd(lXHTjK}e$We*@J%_U;Y;kNHgT5cawe3qx zEY6&#C{_9$)H@1K#j}+s?-=&b#3*X%^D%fm14W^5hZ2^fjj|MJj)prme_cuB&!1ZJ x_Rv40*hIZ!*pS1v@zU Date: Fri, 2 Oct 2026 08:03:02 +0200 Subject: [PATCH 072/101] FIX Realise medical costs after the period's choices Total health costs read the transition's draws of this year's persistent and transitory shocks, so out-of-pocket spending enters end-of-period assets and the continuation integrates over it, as in the draft. The state a period carries is last year's shocks: the lagged persistent shock prices the private premium and predicts costs, and nothing reads the lagged transitory shock. An always-true constraint keeps both shock states in every regime, including dead, whose bequest integrates over the final period's bill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- AGENTS.md | 14 ++-- src/aca_model/agent/assets_and_income.py | 11 ++-- src/aca_model/baseline/health_insurance.py | 40 +++++++++--- src/aca_model/baseline/regimes/_common.py | 22 +++++-- tests/test_medical_cost_timing.py | 76 ++++++++++++++++++++++ 5 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 tests/test_medical_cost_timing.py diff --git a/AGENTS.md b/AGENTS.md index 474cc4e..0a1f0aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,8 +88,11 @@ ACA variants don't create new regimes — they swap functions on baseline regime - `health`: `HealthWithDisability` (disabled/bad/good) pre-65, `Health` (bad/good) post-65 - `log_ft_wage_res`: AR(1) wage residual shock (5-point Rouwenhorst) -- `hcc_persistent` / `hcc_transitory`: Health cost shocks (`_ShockGrid` — integrated - over, policy does not condition on them) +- `hcc_persistent` / `hcc_transitory`: last period's health-cost shocks (Rouwenhorst + AR(1) and Gauss-Hermite IID). Policy conditions on the lagged persistent shock, which + predicts this period's costs and prices the private premium; nothing reads the lagged + transitory shock. The always-true `medical_cost_shocks_carried` constraint keeps both + states in every regime, `dead` included. - Regime transitions determined by `select_target_for_age()` based on age and actions; death has probability `1 − survival_probs[age, health]`, a per-regime param on the regime's own health grid. Ages run 51–96: 95 is the last age anyone is alive (survival @@ -99,8 +102,11 @@ ACA variants don't create new regimes — they swap functions on baseline regime - **AgeGrid with int ages**: Model uses `AgeGrid` with integer start/stop — all `age` parameters are `int`, not `float` -- **OOP timing**: OOP health costs are in `next_assets` (post-consumption), not - `cash_on_hand`. Matches struct-ret: agent doesn't condition consumption on OOP. +- **OOP timing**: medical costs are realised after the period's choices, and the state + carries the lagged persistent shock ζ\_{t−1}. `total_health_costs` reads the + transition's draws `next_hcc_persistent` / `next_hcc_transitory`, so OOP enters + `next_assets` and the continuation integrates over it; `cash_on_hand`, transfers and + the consumption floor see no OOP. - **DAG key ≠ function name**: `functions["marginal_tax_rate"] = taxes.marginal_rate` — the dict key must match consuming functions' parameter names, not the definition name. Don't stutter module name in function name (`taxes.marginal_rate` not diff --git a/src/aca_model/agent/assets_and_income.py b/src/aca_model/agent/assets_and_income.py index ea11a1a..2dad637 100644 --- a/src/aca_model/agent/assets_and_income.py +++ b/src/aca_model/agent/assets_and_income.py @@ -63,9 +63,8 @@ def cash_on_hand( part (`premium_default`) is never paid, so the effective premium is `hic_premium - premium_default`. - OOP health costs are NOT deducted here — they are deducted from - next-period assets instead, matching the timing where HCC shocks are - integrated over (agent does not condition consumption on OOP). + Out-of-pocket health costs are not deducted here: they are realised after + the period's choices and come off end-of-period assets (`next_assets`). """ effective_premium = hic_premium - premium_default return assets + after_tax_income + ssi_benefit - effective_premium @@ -96,8 +95,10 @@ def next_assets( ) -> ContinuousState: """Compute beginning-of-next-period assets for non-terminal targets. - OOP health costs are deducted here (not from cash_on_hand) so that the - consumption choice does not condition on the HCC shock realization. + `oop_costs` is the bill for this period's medical-cost shocks, which are + drawn after the period's choices; the continuation integrates over them. + End-of-period assets can therefore fall below the borrowing limit that + binds consumption (medical debt). """ return ( cash_on_hand diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index 668aee9..b641a1a 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -514,8 +514,9 @@ def hcc_insurer_predicted( ) -> FloatND: """Interpolate the private insurer's expected cost for the household. - The table holds E[total_costs - oop_costs | age, married, good health, - persistent node] on `[period, is_married, good_health, node]`. Linear + `hcc_persistent` is last period's persistent shock, so the table holds + this period's E[total_costs - oop_costs | age, married, good health, + lagged persistent node] on `[period, is_married, good_health, node]`. Linear interpolation handles off-grid values during simulation (where draw_shock returns continuous AR1 values). """ @@ -540,8 +541,9 @@ def compute_hcc_insurer_table( ) -> FloatND: """Compute predicted insurer costs table for all persistent grid points. - For each source persistent state i, integrate over (target persistent j, - transitory k) weighted by transition probs and quadrature weights. + For each lagged persistent state i, integrate over this period's + (persistent j, transitory k) weighted by transition probs and quadrature + weights. """ std_trans = jnp.sqrt(1.0 - std_xsect_persistent**2) # total_costs[j, k] for all (persistent, transitory) combinations @@ -572,11 +574,17 @@ def total_costs( good_health: IntND, log_mean: FloatND, log_std: FloatND, - hcc_persistent: ContinuousState, - hcc_transitory: ContinuousState, + next_hcc_persistent: ContinuousState, + next_hcc_transitory: ContinuousState, std_xsect_persistent: ScalarFloat, ) -> FloatND: - """Compute total health care costs from log-normal model. + """Compute this period's total health care costs from the log-normal model. + + Costs are realised after the period's choices. The persistent and + transitory shocks that drive them are the draws the transition makes, + `next_hcc_persistent` and `next_hcc_transitory`: the next period carries + them as its lagged shocks. Age, marital status and health are this + period's. ``log_mean`` and ``log_std`` are ``pd.Series`` with ``(age, is_married, good_health)`` MultiIndex, resolved by pylcm via ``derived_categoricals``. @@ -585,5 +593,21 @@ def total_costs( return jnp.exp( log_mean[period, is_married, good_health] + log_std[period, is_married, good_health] - * (hcc_persistent * std_xsect_persistent + hcc_transitory * std_trans) + * (next_hcc_persistent * std_xsect_persistent + next_hcc_transitory * std_trans) ) + + +def medical_cost_shocks_carried( + hcc_persistent: ContinuousState, + hcc_transitory: ContinuousState, +) -> BoolND: + """Hold for every state: the lagged medical-cost shocks restrict no choice. + + A regime reads its lagged shocks only through the draws of this period's + shocks, which the transitions make. pylcm keeps a state in a regime only + when a root computation reads it, so this always-true constraint is that + read, in every regime, including `dead`, whose bequest integrates over the + shocks of the final period. + """ + del hcc_persistent, hcc_transitory + return jnp.array(True) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 71edfe7..7f4b3ae 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -731,8 +731,10 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: other broadcast function is masked with `None` so its unresolvable inputs (e.g. `pension_benefit`) don't surface as params in the dead template. - - constraints: every broadcast constraint is masked — `dead` has no - consumption action. + - constraints: every broadcast constraint but `medical_cost_shocks_carried` + is masked — `dead` has no consumption action. That one keeps the + medical-cost shock states, whose draws set the final period's + out-of-pocket bill and hence the bequest. - `pension_wealth` is masked explicitly: a carried state is rejected in terminal regimes before pruning could drop it. """ @@ -741,7 +743,11 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: for name in build_model_functions(solver=solver) if name not in _DEAD_KEEPS } - constraint_masks = dict.fromkeys(build_model_constraints(solver=solver)) + constraint_masks = dict.fromkeys( + name + for name in build_model_constraints(solver=solver) + if name != "medical_cost_shocks_carried" + ) return Regime( regime_transitions=None, functions={ @@ -863,7 +869,10 @@ def build_nbegm_functions() -> dict: def build_model_constraints(*, solver: SolverName) -> dict: """Build the model-level constraints broadcast into every regime. - `dead` masks every constraint — it has no consumption action. + `dead` masks every constraint except `medical_cost_shocks_carried` — it + has no consumption action, but its bequest integrates over the final + period's medical-cost shocks, so it keeps their states. + Grid search evaluates the action-level predicates directly: the borrowing constraint and the household's own consumption floor. An EGM-family solve proves the equivalent post-decision lower bound from its savings @@ -875,7 +884,10 @@ def build_model_constraints(*, solver: SolverName) -> dict: if solver == "brute_force" else post_decision_lower_bound(margin=ACA_LIQUID_MARGIN, lower=0.0) ) - constraints = {"borrowing_constraint": borrowing_constraint} + constraints = { + "borrowing_constraint": borrowing_constraint, + "medical_cost_shocks_carried": health_insurance.medical_cost_shocks_carried, + } if solver == "brute_force": constraints["consumption_floor_constraint"] = ( assets_and_income.consumption_above_floor diff --git a/tests/test_medical_cost_timing.py b/tests/test_medical_cost_timing.py new file mode 100644 index 0000000..0705808 --- /dev/null +++ b/tests/test_medical_cost_timing.py @@ -0,0 +1,76 @@ +"""Medical costs are realised after the period's choices. + +The state a household carries into period t holds last year's persistent +shock and last year's transitory shock. This year's shocks are drawn after +consumption, work, claiming and insurance are chosen; the resulting +out-of-pocket bill is taken off end-of-period assets. So the value and every +choice in period t depend on the lagged persistent shock, which predicts this +year's costs, and not at all on the lagged transitory shock. +""" + +import jax.numpy as jnp +import numpy as np +import pandas as pd +import pytest +from lcm import DiscreteGrid + +from aca_model.agent.preferences import BenchmarkPrefType +from aca_model.benchmark import ( + create_benchmark_model, + get_benchmark_initial_conditions, + get_benchmark_params, +) + +_SHOCKS = ("hcc_persistent", "hcc_transitory") + + +@pytest.fixture(scope="module") +def period_0_by_shock() -> dict[tuple[str, str], pd.Series]: + """Period-0 rows of twins that differ in one lagged shock, at its grid ends.""" + model = create_benchmark_model(pref_type_grid=DiscreteGrid(BenchmarkPrefType)) + _, _, params = get_benchmark_params(model=model) + base = get_benchmark_initial_conditions(model=model, n_subjects=1, seed=0) + grids = model.user_regimes["retiree_nomc_inelig_canwork"].states + initial_conditions = {name: jnp.tile(values, 4) for name, values in base.items()} + for row, shock in enumerate(_SHOCKS): + points = np.asarray(grids[shock].to_jax()) # ty: ignore[unresolved-attribute] + values = np.asarray(initial_conditions[shock]).copy() + values[2 * row : 2 * row + 2] = (points.min(), points.max()) + initial_conditions[shock] = jnp.asarray(values) + result = model.simulate( + params=params, initial_conditions=initial_conditions, log_level="off" + ) + df = result.to_dataframe(terminal_rows="all") + period_0 = df.loc[df["period"] == 0].sort_values("subject_id") + return { + (shock, end): period_0.iloc[2 * row + i] + for row, shock in enumerate(_SHOCKS) + for i, end in enumerate(("low", "high")) + } + + +def test_value_does_not_depend_on_lagged_transitory_shock( + period_0_by_shock: dict[tuple[str, str], pd.Series], +) -> None: + """Twins differing only in last year's transitory shock have equal value.""" + low = period_0_by_shock["hcc_transitory", "low"]["value"] + high = period_0_by_shock["hcc_transitory", "high"]["value"] + assert low == high + + +def test_consumption_does_not_depend_on_lagged_transitory_shock( + period_0_by_shock: dict[tuple[str, str], pd.Series], +) -> None: + """Twins differing only in last year's transitory shock consume the same.""" + low = period_0_by_shock["hcc_transitory", "low"]["consumption_dollars"] + high = period_0_by_shock["hcc_transitory", "high"]["consumption_dollars"] + assert low == high + + +def test_value_falls_with_lagged_persistent_shock( + period_0_by_shock: dict[tuple[str, str], pd.Series], +) -> None: + """A higher lagged persistent shock predicts higher costs and lowers value.""" + low = period_0_by_shock["hcc_persistent", "low"]["value"] + high = period_0_by_shock["hcc_persistent", "high"]["value"] + assert high < low From f3f0fb5e51e37c5427c74bc54fd12fabce867f36 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 08:33:36 +0200 Subject: [PATCH 073/101] TEST Follow the medical-cost shock states into dead and the EGM refusals dead now keeps the two shock states, and the all-constraint feasibility check supplies them. The NB-EGM and DC-EGM model builds refuse the model: NB-EGM compiles only liquid-state constraints and DC-EGM carries only deterministic discrete states into a terminal target. Those build tests are strict expected failures on the specific initialisation error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_budget_rules.py | 2 ++ tests/test_dcegm_model_creation.py | 9 +++++++++ tests/test_model_creation.py | 19 ++++++++++++++----- tests/test_nbegm_model_creation.py | 19 ++++++++++++++++++- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/tests/test_budget_rules.py b/tests/test_budget_rules.py index a4790ea..7797ce0 100644 --- a/tests/test_budget_rules.py +++ b/tests/test_budget_rules.py @@ -336,6 +336,8 @@ def test_married_household_cannot_consume_below_its_own_floor( consumption_dollars=jnp.asarray(consumption), cash_on_hand=jnp.asarray(500.0), consumption_dollars_floor=jnp.asarray(MARRIED_FLOOR), + hcc_persistent=jnp.asarray(0.0), + hcc_transitory=jnp.asarray(0.0), ) ) for constraint in constraints.values() diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index 6670eba..0df5beb 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -14,6 +14,7 @@ from helpers.model import _DERIVED_CATEGORICALS from lcm import DiscreteGrid, IrregSpacedGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime +from lcm.exceptions import ModelInitializationError from lcm.solvers import DCEGM from aca_model.agent import assets_and_income @@ -143,6 +144,14 @@ def test_benchmark_consumption_points_pin_both_floors() -> None: np.testing.assert_allclose(points[:2], [floor, floor * 2.0**exponent], rtol=1e-12) +@pytest.mark.xfail( + strict=True, + raises=ModelInitializationError, + reason=( + "DC-EGM carries only deterministically reached discrete states into a " + "terminal target, and `dead` keeps the medical-cost shock states" + ), +) def test_dcegm_benchmark_model_builds() -> None: """The benchmark model accepts `solver="dcegm"` end to end.""" model = create_model( diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index b0269a4..55435e1 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -372,14 +372,23 @@ def test_baseline_model_creates() -> None: def test_dead_regime_prunes_unused_broadcast_states() -> None: """States every living regime shares are declared once at the model level; - `dead` keeps only what the bequest DAG reads (`assets`, `pref_type`). + `dead` keeps what the bequest DAG reads (`assets`, `pref_type`) and the + medical-cost shock states, whose draws set the final period's bill. `pension_wealth` is masked (carried states are illegal in terminal regimes); the other unused broadcast states are pruned by reachability.""" model = make_baseline_model() - assert model.pruned_variables["dead"] == frozenset( - {"aime", "spousal_income", "hcc_persistent", "hcc_transitory"} - ) - assert set(model.user_regimes["dead"].states) == {"assets", "pref_type"} + assert model.pruned_variables["dead"] == frozenset({"aime", "spousal_income"}) + + +def test_dead_regime_keeps_the_medical_cost_shock_states() -> None: + """`dead` carries the bequest states and the medical-cost shock states.""" + model = make_baseline_model() + assert set(model.user_regimes["dead"].states) == { + "assets", + "pref_type", + "hcc_persistent", + "hcc_transitory", + } def test_living_regimes_keep_every_broadcast_state() -> None: diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 99d5bc9..2784b26 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -20,7 +20,7 @@ from helpers.model import _DERIVED_CATEGORICALS, aca_fixed_params from lcm import DiscreteGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime, LiquidMargin -from lcm.exceptions import RegimeInitializationError +from lcm.exceptions import ModelInitializationError, RegimeInitializationError from lcm.solvers import NBEGM from aca_model.aca import PolicyVariant @@ -53,6 +53,16 @@ BENCHMARK_GRID_CONFIG, nbegm_jump_read="bridged" ) +# NBEGM compiles only constraints with a boundary on the liquid state, so it refuses +# the always-true `medical_cost_shocks_carried` constraint that keeps the lagged +# medical-cost shock states. A model that realises medical costs after the period's +# choices therefore does not build under NBEGM. +_REFUSES_POST_CHOICE_MEDICAL_COSTS = pytest.mark.xfail( + strict=True, + raises=ModelInitializationError, + reason="NBEGM cannot compile the constraint keeping the medical-cost shock states", +) + def _build_regimes(solver: SolverName) -> dict[str, Regime]: return build_all_regimes( @@ -169,6 +179,7 @@ def test_nbegm_m1_regime_declares_both_discrete_actions() -> None: assert discrete == {"buy_private", "labor_supply"} +@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_model_builds_a_regime_declaring_several_discrete_actions() -> None: """The M1 regime builds under NBEGM with both of its discrete actions live. @@ -201,6 +212,7 @@ def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: assert law is expected, target_name +@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_gives_every_living_regime_the_savings_form_budget() -> None: """Under NBEGM every living regime carries `resources` and `savings`. @@ -217,6 +229,7 @@ def test_nbegm_gives_every_living_regime_the_savings_form_budget() -> None: assert missing == [] +@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: """NBEGM inverts the Euler equation internally, so the M1 regime never carries the DC-EGM `inverse_marginal_utility` function (whose @@ -226,6 +239,7 @@ def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: assert "inverse_marginal_utility" not in model.user_regimes[_M1_REGIME].functions +@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_m1_regime_keeps_the_borrowing_constraint() -> None: """The M1 regime declares the borrowing constraint like every brute regime. @@ -278,6 +292,7 @@ def test_ssi_benefit_declares_the_income_test_kink() -> None: assert income_test.indexed_by == "spousal_income" +@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: """The M1 regime builds under NBEGM only with `nbegm_jump_read="bridged"`. @@ -292,6 +307,7 @@ def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: assert isinstance(_build_model_with("nbegm", _BRIDGED_GRID_CONFIG), Model) +@_REFUSES_POST_CHOICE_MEDICAL_COSTS @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: """Every ACA policy variant builds a model under NBEGM with the M1 regime on @@ -320,6 +336,7 @@ def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: assert "labor_supply" in regimes[_M1_REGIME].actions +@_REFUSES_POST_CHOICE_MEDICAL_COSTS @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_aca_variants_leave_no_free_buy_private_params( policy: PolicyVariant, From d8973c402f559895404149a857d1299fb32084f2 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 11:51:59 +0200 Subject: [PATCH 074/101] Port ACA models to explicit graphs and retain medical shock draws --- .github/workflows/main.yml | 2 +- AGENTS.md | 4 +- docs/dag-migration.md | 21 ++-- src/aca_model/aca/model.py | 4 +- src/aca_model/baseline/health_insurance.py | 16 --- src/aca_model/baseline/model.py | 4 +- src/aca_model/baseline/regimes/__init__.py | 10 +- src/aca_model/baseline/regimes/_common.py | 112 ++++++++++---------- src/aca_model/baseline/regimes/_nongroup.py | 4 +- src/aca_model/baseline/regimes/_retiree.py | 4 +- src/aca_model/baseline/regimes/_tied.py | 4 +- tests/test_dag_coverage.py | 49 ++++++--- tests/test_dcegm_model_creation.py | 9 -- tests/test_di_medicare_transitions.py | 49 ++------- tests/test_model_creation.py | 13 ++- tests/test_nbegm_model_creation.py | 19 +--- 16 files changed, 134 insertions(+), 190 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2f31834..7afb3e2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@f2b97fbb96dac55782adb940cc9f669a5b10d259" + git+https://github.com/OpenSourceEconomics/pylcm.git@93d96bf11eb754d0e66472a7f076fee9b3bf9feb" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest diff --git a/AGENTS.md b/AGENTS.md index 0a1f0aa..c17aa67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,8 +91,8 @@ ACA variants don't create new regimes — they swap functions on baseline regime - `hcc_persistent` / `hcc_transitory`: last period's health-cost shocks (Rouwenhorst AR(1) and Gauss-Hermite IID). Policy conditions on the lagged persistent shock, which predicts this period's costs and prices the private premium; nothing reads the lagged - transitory shock. The always-true `medical_cost_shocks_carried` constraint keeps both - states in every regime, `dead` included. + transitory shock. The draw dependencies retain both states in living regimes; `dead` + carries only assets and preference type. - Regime transitions determined by `select_target_for_age()` based on age and actions; death has probability `1 − survival_probs[age, health]`, a per-regime param on the regime's own health grid. Ages run 51–96: 95 is the last age anyone is alive (survival diff --git a/docs/dag-migration.md b/docs/dag-migration.md index 189f1e8..b6892b6 100644 --- a/docs/dag-migration.md +++ b/docs/dag-migration.md @@ -1,9 +1,9 @@ # ACA: one structural stage table, existing numerical programs -Both factories declare the same admissible starts, `INITIAL_REGIMES`: ages 51–60 -(`AgeRange(start=start_age, stop=last_start_age + 1)`) in the five `*_inelig_canwork` +Both factories declare the same admissible starts, `INITIAL_NODES`: ages 51–60 +as explicit `(age, regime)` pairs in the five `*_inelig_canwork` regimes, 50 pairs. Every later age, and `dead` at any age, is reached only through -transitions; the engine derives the solved domain from these roots, **181** nodes. +transitions; the engine derives the solved domain from these roots, **184** nodes. `(51, "dead")` is not solved. Starts at age ≥ 61 or in `dead` are rejected at admission. @@ -11,18 +11,21 @@ admission. admissible start age and raises on any kept row outside `model.initial_nodes`. `_AGE_STAGES` and the host-side clock supply both numerical next-age routing and -source-age support schedules. Each source's existing numerical transition body and -per-target probability cell are shared across age cases. There is no closure -specialization per boundary. Code order/global regime IDs and existing parameter +`Model.edges` source-age selectors, grouped by destination. Each source has one +mapping of `StochasticTransition` probability cells; the model graph selects the +cells allowed at each source age. Code order/global regime IDs and parameter paths remain; different target schemas can still need different continuation programs, so no backend compile-time improvement is claimed. -The dead regime remains `regime_transitions=None` at every age. Age 94 retains its -living consumption/saving problem and handoff into the separate age-95 bequest. The +The dead regime remains `regime_transitions=None` at every age. Age 95 retains its +living consumption/saving problem and handoff into the separate age-96 bequest. The three-to-two health grid, claim/lagged-work entry and exit, carried pension state and phase semantics are not simplified away. -Use `model.reachability.nodes` and `model.initial_nodes`; keep the existing phase +Use `model.graph.nodes`, `model.graph.initial_nodes`, and `model.graph.edges`; +`model.graph.solution` and `model.graph.simulation` expose the effective phase +graphs. The declared edge selectors are identical across policy variants and +phases; carried pension wealth retains its distinct solve/simulate laws. Keep the phase target queries and period-keyed solutions. GridSearch is the first numerical acceptance target. Annual ACA calibration is not automatically valid on a differently spaced clock. diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index a8af7a6..5b17c97 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -14,7 +14,7 @@ from aca_model.aca.regimes import build_all_regimes from aca_model.baseline.model import _fail_if_dcegm_without_consumption_points from aca_model.baseline.regimes import ( - INITIAL_REGIMES, + INITIAL_NODES, RegimeId, SolverName, build_model_slots, @@ -91,7 +91,7 @@ def create_model( return Model( regimes=regimes, - initial_regimes=INITIAL_REGIMES, + initial_nodes=INITIAL_NODES, ages=ages, regime_id_class=RegimeId, description=f"Structural retirement model ({policy.name})", diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index b641a1a..190e7d2 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -595,19 +595,3 @@ def total_costs( + log_std[period, is_married, good_health] * (next_hcc_persistent * std_xsect_persistent + next_hcc_transitory * std_trans) ) - - -def medical_cost_shocks_carried( - hcc_persistent: ContinuousState, - hcc_transitory: ContinuousState, -) -> BoolND: - """Hold for every state: the lagged medical-cost shocks restrict no choice. - - A regime reads its lagged shocks only through the draws of this period's - shocks, which the transitions make. pylcm keeps a state in a regime only - when a root computation reads it, so this always-true constraint is that - read, in every regime, including `dead`, whose bequest integrates over the - shocks of the final period. - """ - del hcc_persistent, hcc_transitory - return jnp.array(True) diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 07469ed..3522e30 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -17,7 +17,7 @@ from lcm.typing import UserParams from aca_model.baseline.regimes import ( - INITIAL_REGIMES, + INITIAL_NODES, RegimeId, SolverName, build_all_regimes, @@ -102,7 +102,7 @@ def create_model( return Model( regimes=regimes, - initial_regimes=INITIAL_REGIMES, + initial_nodes=INITIAL_NODES, ages=ages, regime_id_class=RegimeId, description="Baseline structural retirement model (pre-ACA)", diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index f11e95f..9acf2c1 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -22,7 +22,7 @@ from aca_model.baseline.regimes import _tied as tied from aca_model.baseline.regimes._common import ( ENTRY_REGIMES, - INITIAL_REGIMES, + INITIAL_NODES, REGIME_SPECS, Grids, RegimeId, @@ -30,6 +30,7 @@ build_dead_regime, build_grids, build_model_constraints, + build_model_edges, build_model_functions, build_model_state_transitions, build_model_states, @@ -40,7 +41,7 @@ __all__ = [ "ENTRY_REGIMES", - "INITIAL_REGIMES", + "INITIAL_NODES", "REGIME_SPECS", "RegimeId", "build_all_regimes", @@ -128,9 +129,9 @@ def build_model_slots( pref_type_grid: DiscreteGrid, solver: SolverName = "brute_force", ) -> dict[str, Any]: - """Build the model-level regime slots broadcast into every regime. + """Build the shared model topology and broadcast regime slots. - Returns keyword arguments for `lcm.Model(...)`: the functions, + Returns keyword arguments for `lcm.Model(...)`: the edges, functions, constraint, states, and laws of motion shared by all living regimes. Both the baseline and the ACA `create_model` consume this — the ACA overlay swaps only regime-level functions, so the broadcast slots are @@ -144,6 +145,7 @@ def build_model_slots( pref_type_grid=pref_type_grid, ) return { + "edges": build_model_edges(), "functions": build_model_functions(solver=solver), "constraints": build_model_constraints(solver=solver), "states": build_model_states(grids), diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 7f4b3ae..e505db3 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -14,18 +14,16 @@ import jax.numpy as jnp import numpy as np from lcm import ( - AgeRange, - ByAge, DiscreteGrid, GridBreakpoint, IrregSpacedGrid, LinSpacedGrid, - MarkovTransition, NormalIIDProcess, Phased, PiecewiseLinSpacedGrid, Regime, RouwenhorstAR1Process, + StochasticTransition, categorical, fixed_transition, ) @@ -38,7 +36,7 @@ from lcm.typing import ( BoolND, FloatND, - InitialRegimes, + InitialNodes, IntND, Period, RegimeName, @@ -495,8 +493,11 @@ def _compute_max_annual_labor_income( # Admissible starting (age, regime) pairs, shared by every model variant: the # baseline estimation-sample ages. Later ages, and `dead` at any age, are reached only # through transitions. -INITIAL_REGIMES: InitialRegimes = MappingProxyType( - {AgeRange(start=config.start_age, stop=config.last_start_age + 1): ENTRY_REGIMES} +INITIAL_NODES: InitialNodes = tuple( + (age, regime) + for age in MODEL_AGES + if config.start_age <= age <= config.last_start_age + for regime in ENTRY_REGIMES ) _STAGE_KEY = { @@ -541,42 +542,48 @@ def _target_pair(next_age: int, group: dict[str, int]) -> tuple[int, int]: raise ValueError(f"Next age {next_age} is outside the declared stages") -def build_scheduled_regime_transition( +def build_regime_edges( *, spec: RegimeSpec, - transition_func: Callable[..., FloatND], target_groups: tuple[dict[str, int], ...], -) -> ByAge: - """Schedule support while sharing the original numerical law and cells. - - Unlike the first proposal, no boundary-specific numeric closure is made. - Parameter paths and global regime-code order stay unchanged. Only declared - target subsets vary; upstream may still need distinct continuation kernels - when destination schemas differ, so this is not a compile-time claim. - """ +) -> dict[RegimeName, tuple[int, ...]]: + """Map each destination to the exact source ages that can reach it.""" # Both Medicare branches are reachable from every living regime: before 65 a # non-worker who becomes disabled moves onto disability Medicare. - grouped: dict[tuple[int, ...], list[int]] = {} + id_to_name = { + int(getattr(RegimeId, name)): name for name in (*REGIME_SPECS, "dead") + } + grouped: dict[RegimeName, list[int]] = {} next_age_by_age = dict(itertools.pairwise(MODEL_AGES)) for age in transition_ages(spec): next_age = next_age_by_age[age] pairs = tuple(_target_pair(next_age, group) for group in target_groups) supported_ids = {target for pair in pairs for target in pair} target_ids = tuple(sorted(supported_ids | {int(RegimeId.dead)})) - grouped.setdefault(target_ids, []).append(age) - all_ids = {i for target_ids in grouped for i in target_ids} - # One probability-cell wrapper per target, reused at every applicable age. - cells = build_granular_regime_transition( - transition_func=transition_func, target_ids=all_ids - ) - id_to_name = { - int(getattr(RegimeId, name)): name for name in (*REGIME_SPECS, "dead") + for target_id in target_ids: + grouped.setdefault(id_to_name[target_id], []).append(age) + return {target: tuple(source_ages) for target, source_ages in grouped.items()} + + +def build_model_edges() -> dict[RegimeName, dict[RegimeName, tuple[int, ...]]]: + """Declare the policy-invariant topology of the living ACA regimes.""" + return { + name: build_regime_edges(spec=spec, target_groups=make_targets(name)) + for name, spec in REGIME_SPECS.items() } - return ByAge( - cases={ - tuple(source_ages): {id_to_name[i]: cells[id_to_name[i]] for i in ids} - for ids, source_ages in grouped.items() - } + + +def build_regime_transition( + *, + spec: RegimeSpec, + transition_func: Callable[..., FloatND], + target_groups: tuple[dict[str, int], ...], +) -> dict[RegimeName, StochasticTransition]: + """Reuse one scalar probability cell per destination across all source ages.""" + edges = build_regime_edges(spec=spec, target_groups=target_groups) + return build_granular_regime_transition( + transition_func=transition_func, + target_ids=(int(getattr(RegimeId, target)) for target in edges), ) @@ -675,19 +682,19 @@ def build_granular_regime_transition( *, transition_func: Callable[..., FloatND], target_ids: Iterable[int], -) -> dict[RegimeName, MarkovTransition]: - """Declare the regime's reachable targets via per-target probability cells. +) -> dict[RegimeName, StochasticTransition]: + """Build the regime's per-target probability cells. Each cell evaluates the regime's probability vector and selects its - target's entry — identical arithmetic to the coarse vector form, with the - key set making every other regime structurally unreachable. + target's entry. The model graph supplies the source-age support for these + cells. """ id_to_name = { int(getattr(RegimeId, name)): name for name in (*REGIME_SPECS, "dead") } declared = sorted({*(int(i) for i in target_ids), int(RegimeId.dead)}) return { - id_to_name[target_id]: MarkovTransition( + id_to_name[target_id]: StochasticTransition( func=_prob_of_target(transition_func=transition_func, target_id=target_id) ) for target_id in declared @@ -731,10 +738,8 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: other broadcast function is masked with `None` so its unresolvable inputs (e.g. `pension_benefit`) don't surface as params in the dead template. - - constraints: every broadcast constraint but `medical_cost_shocks_carried` - is masked — `dead` has no consumption action. That one keeps the - medical-cost shock states, whose draws set the final period's - out-of-pocket bill and hence the bequest. + - constraints: every broadcast constraint is masked because `dead` has no + consumption action. - `pension_wealth` is masked explicitly: a carried state is rejected in terminal regimes before pruning could drop it. """ @@ -743,11 +748,7 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: for name in build_model_functions(solver=solver) if name not in _DEAD_KEEPS } - constraint_masks = dict.fromkeys( - name - for name in build_model_constraints(solver=solver) - if name != "medical_cost_shocks_carried" - ) + constraint_masks = dict.fromkeys(build_model_constraints(solver=solver)) return Regime( regime_transitions=None, functions={ @@ -869,9 +870,7 @@ def build_nbegm_functions() -> dict: def build_model_constraints(*, solver: SolverName) -> dict: """Build the model-level constraints broadcast into every regime. - `dead` masks every constraint except `medical_cost_shocks_carried` — it - has no consumption action, but its bequest integrates over the final - period's medical-cost shocks, so it keeps their states. + `dead` masks every constraint because it has no consumption action. Grid search evaluates the action-level predicates directly: the borrowing constraint and the household's own consumption floor. An EGM-family @@ -886,7 +885,6 @@ def build_model_constraints(*, solver: SolverName) -> dict: ) constraints = { "borrowing_constraint": borrowing_constraint, - "medical_cost_shocks_carried": health_insurance.medical_cost_shocks_carried, } if solver == "brute_force": constraints["consumption_floor_constraint"] = ( @@ -929,7 +927,7 @@ def build_model_state_transitions() -> dict: """ return { "pref_type": fixed_transition("pref_type"), - "spousal_income": MarkovTransition(func=labor_market.next_spousal_income), + "spousal_income": StochasticTransition(func=labor_market.next_spousal_income), # Carried state: evolved only in simulate (in solve, `pension_wealth` # is re-imputed from AIME each period and has no transition). "pension_wealth": pensions.wealth_next_before_adjustment, @@ -1249,7 +1247,7 @@ def _build_per_target_regime_assets( def _build_per_target_regime_health( spec: RegimeSpec, -) -> dict[RegimeName, MarkovTransition]: +) -> dict[RegimeName, StochasticTransition]: """Build per-target health transitions. Pre-65 regimes use HealthWithDisability (3-state), post-65 use Health (2-state). @@ -1263,7 +1261,7 @@ def _build_per_target_regime_health( target_regimes = precompute_target_regimes(spec) id_to_name = {int(getattr(RegimeId, name)): name for name in REGIME_SPECS} - result: dict[RegimeName, MarkovTransition] = {} + result: dict[RegimeName, StochasticTransition] = {} seen_ids: set[int] = set() for target_id in target_regimes.values(): @@ -1275,13 +1273,17 @@ def _build_per_target_regime_health( continue target_mc = REGIME_SPECS[target_name]["mc"] if spec["mc"] == "oamc": - result[target_name] = MarkovTransition(func=health.next_health) + result[target_name] = StochasticTransition(func=health.next_health) elif target_mc == "oamc": - result[target_name] = MarkovTransition(func=health.next_health_cross) + result[target_name] = StochasticTransition(func=health.next_health_cross) elif target_mc == "dimc": - result[target_name] = MarkovTransition(func=health.next_health_into_dimc) + result[target_name] = StochasticTransition( + func=health.next_health_into_dimc + ) else: - result[target_name] = MarkovTransition(func=health.next_health_into_nomc) + result[target_name] = StochasticTransition( + func=health.next_health_into_nomc + ) return result diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index 989d907..391728f 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -22,7 +22,7 @@ build_pension_functions, build_regime_probs, build_regime_probs_with_di_medicare, - build_scheduled_regime_transition, + build_regime_transition, build_state_transitions, build_states, make_targets, @@ -170,7 +170,7 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - regime_transitions=build_scheduled_regime_transition( + regime_transitions=build_regime_transition( spec=spec, transition_func=transition_func, target_groups=(own,) ), states=states, diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index 2212292..c0d28e5 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -32,7 +32,7 @@ build_pension_functions, build_regime_probs, build_regime_probs_with_di_medicare, - build_scheduled_regime_transition, + build_regime_transition, build_state_transitions, build_states, make_targets, @@ -166,7 +166,7 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - regime_transitions=build_scheduled_regime_transition( + regime_transitions=build_regime_transition( spec=spec, transition_func=transition_func, target_groups=(own, ng) ), states=states, diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index a1a2e00..416a73e 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -33,7 +33,7 @@ build_nbegm_functions, build_pension_functions, build_regime_probs_with_di_medicare, - build_scheduled_regime_transition, + build_regime_transition, build_state_transitions, build_states, make_targets, @@ -129,7 +129,7 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - regime_transitions=build_scheduled_regime_transition( + regime_transitions=build_regime_transition( spec=spec, transition_func=transition_func, target_groups=(own, ng) ), states=states, diff --git a/tests/test_dag_coverage.py b/tests/test_dag_coverage.py index 81841ff..fc98f19 100644 --- a/tests/test_dag_coverage.py +++ b/tests/test_dag_coverage.py @@ -23,22 +23,22 @@ def test_initial_nodes_are_the_age_51_to_60_entry_regimes(model): for age in range(MODEL_CONFIG.start_age, MODEL_CONFIG.last_start_age + 1) for regime in ENTRY_REGIMES } - assert {(float(a), r) for a, r in model.initial_nodes} == { + assert {(float(a), r) for a, r in model.graph.initial_nodes} == { (float(a), r) for a, r in expected } def test_solved_domain_has_184_nodes(model): - assert len(model.reachability.nodes) == 184 + assert len(model.graph.nodes) == 184 def test_dead_at_the_first_age_is_not_solved(model): - assert (51, "dead") not in model.reachability.nodes + assert (51, "dead") not in model.graph.nodes def test_later_living_nodes_are_solved_but_not_admissible(model): node = (64, "retiree_nomc_choose_canwork") - assert (node in model.reachability.nodes, node in model.initial_nodes) == ( + assert (node in model.graph.nodes, node in model.graph.initial_nodes) == ( True, False, ) @@ -70,40 +70,45 @@ def test_simulate_rejects_starts_outside_the_entry_contract(model, age, regime): model.simulate(params=params, initial_conditions=ic, log_level="off") -def test_boundary_targets_change_but_numeric_cells_are_shared(model): - schedule = model.user_regimes[ - "retiree_nomc_inelig_canwork" - ].regime_transitions.resolve(model.ages) - assert set(schedule.at(60)) == { +def test_boundary_targets_change_with_source_age(model): + assert set( + model.graph.solution.targets(period=9, source="retiree_nomc_inelig_canwork") + ) == { "retiree_nomc_inelig_canwork", "retiree_dimc_inelig_canwork", "nongroup_nomc_inelig_canwork", "nongroup_dimc_inelig_canwork", "dead", } - assert set(schedule.at(61)) == { + assert set( + model.graph.solution.targets(period=10, source="retiree_nomc_inelig_canwork") + ) == { "retiree_nomc_choose_canwork", "retiree_dimc_choose_canwork", "nongroup_nomc_choose_canwork", "nongroup_dimc_choose_canwork", "dead", } - assert schedule.at(60)["dead"] is schedule.at(61)["dead"] + edges = model.graph.edges.solve["retiree_nomc_inelig_canwork"] + assert ( + 60 in edges["retiree_nomc_inelig_canwork"], + 61 in edges["retiree_nomc_inelig_canwork"], + ) == (True, False) def test_bequest_continuation_is_not_removed(model): regime = model.user_regimes["retiree_oamc_forced_forcedout"] - assert set(regime.regime_transitions.resolve(model.ages).at(95)) == {"dead"} + assert model.graph.solution.targets( + period=44, source="retiree_oamc_forced_forcedout" + ) == ("dead",) assert "consumption_dollars" in regime.actions - assert (95, "retiree_oamc_forced_forcedout") in model.reachability.nodes - assert (96, "dead") in model.reachability.nodes + assert (95, "retiree_oamc_forced_forcedout") in model.graph.nodes + assert (96, "dead") in model.graph.nodes def test_terminal_regime_keeps_the_original_none_declaration(model): assert model.user_regimes["dead"].regime_transitions is None - assert { - (age, "dead") for age in model.ages.exact_values[1:] - } <= model.reachability.nodes + assert {(age, "dead") for age in model.ages.exact_values[1:]} <= model.graph.nodes def test_select_admissible_starts_drops_starts_at_61_and_above(model): @@ -117,3 +122,13 @@ def test_select_admissible_starts_drops_starts_at_61_and_above(model): ) kept = select_admissible_starts(model=model, initial_conditions=ic) assert list(kept.index) == [1] + + +def test_medical_cost_draws_keep_living_states_without_choice_constraints(model): + """Living regimes carry medical shocks; bequests depend only on assets and type.""" + for name, regime in model.user_regimes.items(): + assert "medical_cost_shocks_carried" not in regime.constraints + if name == "dead": + assert set(regime.states) == {"assets", "pref_type"} + else: + assert {"hcc_persistent", "hcc_transitory"} <= set(regime.states) diff --git a/tests/test_dcegm_model_creation.py b/tests/test_dcegm_model_creation.py index 0df5beb..6670eba 100644 --- a/tests/test_dcegm_model_creation.py +++ b/tests/test_dcegm_model_creation.py @@ -14,7 +14,6 @@ from helpers.model import _DERIVED_CATEGORICALS from lcm import DiscreteGrid, IrregSpacedGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime -from lcm.exceptions import ModelInitializationError from lcm.solvers import DCEGM from aca_model.agent import assets_and_income @@ -144,14 +143,6 @@ def test_benchmark_consumption_points_pin_both_floors() -> None: np.testing.assert_allclose(points[:2], [floor, floor * 2.0**exponent], rtol=1e-12) -@pytest.mark.xfail( - strict=True, - raises=ModelInitializationError, - reason=( - "DC-EGM carries only deterministically reached discrete states into a " - "terminal target, and `dead` keeps the medical-cost shock states" - ), -) def test_dcegm_benchmark_model_builds() -> None: """The benchmark model accepts `solver="dcegm"` end to end.""" model = create_model( diff --git a/tests/test_di_medicare_transitions.py b/tests/test_di_medicare_transitions.py index b43bd30..7c88074 100644 --- a/tests/test_di_medicare_transitions.py +++ b/tests/test_di_medicare_transitions.py @@ -6,12 +6,9 @@ health distribution conditional on landing there. """ -from collections.abc import Callable - import jax.numpy as jnp import numpy as np import pytest -from lcm import AgeGrid, ByAge from aca_model.agent import health from aca_model.agent.health import HealthWithDisability @@ -20,7 +17,7 @@ REGIME_SPECS, RegimeId, _build_per_target_regime_health, - build_scheduled_regime_transition, + build_model_edges, make_targets, ) from aca_model.baseline.regimes._nongroup import ( @@ -30,7 +27,7 @@ _make_transition_canwork as retiree_canwork, ) from aca_model.baseline.regimes._tied import _make_transition_canwork as tied_canwork -from aca_model.config import MODEL_AGES, MODEL_CONFIG +from aca_model.config import MODEL_CONFIG N_PERIODS = MODEL_CONFIG.end_age - MODEL_CONFIG.start_age SURVIVAL = jnp.full((N_PERIODS, 3), 0.99) @@ -212,54 +209,22 @@ def test_per_target_health_law(source: str, target: str, law: str) -> None: assert laws[target].func is getattr(health, law) -def _nongroup_schedule(regime: str) -> ByAge: - own, _ = make_targets(regime) - return build_scheduled_regime_transition( - spec=REGIME_SPECS[regime], - transition_func=nongroup_canwork(own=own), - target_groups=(own,), - ) - - -def _retiree_schedule(regime: str) -> ByAge: - own, ng = make_targets(regime) - return build_scheduled_regime_transition( - spec=REGIME_SPECS[regime], - transition_func=retiree_canwork(own=own, ng=ng), - target_groups=(own, ng), - ) - - -def _tied_schedule(regime: str) -> ByAge: - own, ng = make_targets(regime) - return build_scheduled_regime_transition( - spec=REGIME_SPECS[regime], - transition_func=tied_canwork(own=own, ng=ng), - target_groups=(own, ng), - ) - - @pytest.mark.parametrize( - ("build_schedule", "regime", "dimc_target"), + ("regime", "dimc_target"), [ ( - _nongroup_schedule, "nongroup_nomc_inelig_canwork", "nongroup_dimc_inelig_canwork", ), ( - _retiree_schedule, "retiree_nomc_inelig_canwork", "retiree_dimc_inelig_canwork", ), - (_tied_schedule, "tied_nomc_inelig_canwork", "nongroup_dimc_inelig_canwork"), + ("tied_nomc_inelig_canwork", "nongroup_dimc_inelig_canwork"), ], ) -def test_scheduled_targets_include_disability_medicare_entry( - build_schedule: Callable[[str], ByAge], regime: str, dimc_target: str +def test_graph_targets_include_disability_medicare_entry( + regime: str, dimc_target: str ) -> None: """A pre-65 regime without Medicare declares its disability-Medicare target.""" - targets_at_55 = ( - build_schedule(regime).resolve(AgeGrid(exact_values=MODEL_AGES)).at(55) - ) - assert dimc_target in set(targets_at_55) # ty: ignore[invalid-argument-type] + assert 55 in build_model_edges()[regime][dimc_target] diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 55435e1..683adf5 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -372,22 +372,21 @@ def test_baseline_model_creates() -> None: def test_dead_regime_prunes_unused_broadcast_states() -> None: """States every living regime shares are declared once at the model level; - `dead` keeps what the bequest DAG reads (`assets`, `pref_type`) and the - medical-cost shock states, whose draws set the final period's bill. + `dead` keeps what the bequest DAG reads (`assets`, `pref_type`). `pension_wealth` is masked (carried states are illegal in terminal regimes); the other unused broadcast states are pruned by reachability.""" model = make_baseline_model() - assert model.pruned_variables["dead"] == frozenset({"aime", "spousal_income"}) + assert model.pruned_variables["dead"] == frozenset( + {"aime", "spousal_income", "hcc_persistent", "hcc_transitory"} + ) -def test_dead_regime_keeps_the_medical_cost_shock_states() -> None: - """`dead` carries the bequest states and the medical-cost shock states.""" +def test_dead_regime_keeps_only_the_bequest_states() -> None: + """`dead` carries only assets and preference type for valuing bequests.""" model = make_baseline_model() assert set(model.user_regimes["dead"].states) == { "assets", "pref_type", - "hcc_persistent", - "hcc_transitory", } diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 2784b26..99d5bc9 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -20,7 +20,7 @@ from helpers.model import _DERIVED_CATEGORICALS, aca_fixed_params from lcm import DiscreteGrid, Model, Regime from lcm.consumption_savings_regime import ConsumptionSavingsRegime, LiquidMargin -from lcm.exceptions import ModelInitializationError, RegimeInitializationError +from lcm.exceptions import RegimeInitializationError from lcm.solvers import NBEGM from aca_model.aca import PolicyVariant @@ -53,16 +53,6 @@ BENCHMARK_GRID_CONFIG, nbegm_jump_read="bridged" ) -# NBEGM compiles only constraints with a boundary on the liquid state, so it refuses -# the always-true `medical_cost_shocks_carried` constraint that keeps the lagged -# medical-cost shock states. A model that realises medical costs after the period's -# choices therefore does not build under NBEGM. -_REFUSES_POST_CHOICE_MEDICAL_COSTS = pytest.mark.xfail( - strict=True, - raises=ModelInitializationError, - reason="NBEGM cannot compile the constraint keeping the medical-cost shock states", -) - def _build_regimes(solver: SolverName) -> dict[str, Regime]: return build_all_regimes( @@ -179,7 +169,6 @@ def test_nbegm_m1_regime_declares_both_discrete_actions() -> None: assert discrete == {"buy_private", "labor_supply"} -@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_model_builds_a_regime_declaring_several_discrete_actions() -> None: """The M1 regime builds under NBEGM with both of its discrete actions live. @@ -212,7 +201,6 @@ def test_nbegm_m1_regime_takes_the_savings_form_assets_laws() -> None: assert law is expected, target_name -@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_gives_every_living_regime_the_savings_form_budget() -> None: """Under NBEGM every living regime carries `resources` and `savings`. @@ -229,7 +217,6 @@ def test_nbegm_gives_every_living_regime_the_savings_form_budget() -> None: assert missing == [] -@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: """NBEGM inverts the Euler equation internally, so the M1 regime never carries the DC-EGM `inverse_marginal_utility` function (whose @@ -239,7 +226,6 @@ def test_nbegm_m1_regime_does_not_carry_inverse_marginal_utility() -> None: assert "inverse_marginal_utility" not in model.user_regimes[_M1_REGIME].functions -@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_m1_regime_keeps_the_borrowing_constraint() -> None: """The M1 regime declares the borrowing constraint like every brute regime. @@ -292,7 +278,6 @@ def test_ssi_benefit_declares_the_income_test_kink() -> None: assert income_test.indexed_by == "spousal_income" -@_REFUSES_POST_CHOICE_MEDICAL_COSTS def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: """The M1 regime builds under NBEGM only with `nbegm_jump_read="bridged"`. @@ -307,7 +292,6 @@ def test_nbegm_labor_supply_requires_the_bridged_cliff_read() -> None: assert isinstance(_build_model_with("nbegm", _BRIDGED_GRID_CONFIG), Model) -@_REFUSES_POST_CHOICE_MEDICAL_COSTS @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: """Every ACA policy variant builds a model under NBEGM with the M1 regime on @@ -336,7 +320,6 @@ def test_nbegm_builds_every_aca_policy_variant(policy: PolicyVariant) -> None: assert "labor_supply" in regimes[_M1_REGIME].actions -@_REFUSES_POST_CHOICE_MEDICAL_COSTS @pytest.mark.parametrize("policy", list(PolicyVariant)) def test_nbegm_aca_variants_leave_no_free_buy_private_params( policy: PolicyVariant, From a2a16ee9c20f23751eede351e628498e20208dbf Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 12:38:19 +0200 Subject: [PATCH 075/101] Pin standalone CI to the pylcm draw-state test follow-up --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7afb3e2..b359cd2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@93d96bf11eb754d0e66472a7f076fee9b3bf9feb" + git+https://github.com/OpenSourceEconomics/pylcm.git@00f87c66baadb296287a2908cc44d5e60fa52ee5" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 196a56ad036bb63bb43898db334b701f12a9255d Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 2 Oct 2026 13:08:25 +0200 Subject: [PATCH 076/101] Pin standalone CI to latest pylcm draw-state branch --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b359cd2..a00345a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@00f87c66baadb296287a2908cc44d5e60fa52ee5" + git+https://github.com/OpenSourceEconomics/pylcm.git@a37267e7220db9763fc51f149836b079780b3713" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 938c7339527cd65746501df09394181f1b6ba98b Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 7 Oct 2026 00:55:54 +0200 Subject: [PATCH 077/101] Declare regime transitions on Model.edges 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- docs/dag-migration.md | 2 +- src/aca_model/baseline/regimes/__init__.py | 23 +++++++++++-- src/aca_model/baseline/regimes/_common.py | 1 - src/aca_model/baseline/regimes/_nongroup.py | 36 +++++++++++++-------- src/aca_model/baseline/regimes/_retiree.py | 28 +++++++++------- src/aca_model/baseline/regimes/_tied.py | 20 +++++++----- src/aca_model/consumption_dollars_grid.py | 7 ++-- tests/test_dag_coverage.py | 4 +-- tests/test_model_creation.py | 4 +-- 10 files changed, 83 insertions(+), 44 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a00345a..83b5139 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@a37267e7220db9763fc51f149836b079780b3713" + git+https://github.com/OpenSourceEconomics/pylcm.git@9a07f40a2d2f20a9777c0954942502f380515e03" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest diff --git a/docs/dag-migration.md b/docs/dag-migration.md index b6892b6..5e4650f 100644 --- a/docs/dag-migration.md +++ b/docs/dag-migration.md @@ -17,7 +17,7 @@ cells allowed at each source age. Code order/global regime IDs and parameter paths remain; different target schemas can still need different continuation programs, so no backend compile-time improvement is claimed. -The dead regime remains `regime_transitions=None` at every age. Age 95 retains its +The dead regime has no outgoing edges in `Model.edges` and is terminal at every age. Age 95 retains its living consumption/saving problem and handoff into the separate age-96 bequest. The three-to-two health grid, claim/lagged-work entry and exit, carried pension state and phase semantics are not simplified away. diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index 9acf2c1..6368063 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from typing import Any -from lcm import DiscreteGrid, Regime +from lcm import DiscreteGrid, Regime, Transition from lcm.solvers import DCEGM, NBEGM from lcm.typing import UserParams @@ -58,6 +58,12 @@ "nongroup": nongroup.build_regime, } +_HIS_LAWS = { + "retiree": retiree.build_law, + "tied": tied.build_law, + "nongroup": nongroup.build_law, +} + def build_regime( name: str, @@ -145,9 +151,22 @@ def build_model_slots( pref_type_grid=pref_type_grid, ) return { - "edges": build_model_edges(), + "edges": build_edges(), "functions": build_model_functions(solver=solver), "constraints": build_model_constraints(solver=solver), "states": build_model_states(grids), "state_transitions": build_model_state_transitions(), } + + +def build_edges() -> dict[str, Transition]: + """Declare each living regime's outgoing edges together with their law. + + `dead` has no outgoing edges and is therefore terminal. + """ + return { + name: Transition( + targets=targets, law=_HIS_LAWS[REGIME_SPECS[name]["his"]](name) + ) + for name, targets in build_model_edges().items() + } diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index e505db3..9bb3684 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -750,7 +750,6 @@ def build_dead_regime(*, solver: SolverName = "brute_force") -> Regime: } constraint_masks = dict.fromkeys(build_model_constraints(solver=solver)) return Regime( - regime_transitions=None, functions={ "utility": preferences.bequest, "scaled_bequest_weight": preferences.scaled_bequest_weight, diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index 391728f..f61a8ee 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -6,9 +6,16 @@ from collections.abc import Callable -from lcm import Regime +from lcm import Regime, StochasticTransition from lcm.solvers import DCEGM, NBEGM -from lcm.typing import Age, DiscreteAction, DiscreteState, FloatND, Period +from lcm.typing import ( + Age, + DiscreteAction, + DiscreteState, + FloatND, + Period, + RegimeName, +) from aca_model.baseline import health_insurance from aca_model.baseline.regimes._common import ( @@ -131,6 +138,20 @@ def build_constraints(spec: RegimeSpec) -> dict: return {} +def build_law(name: str) -> dict[RegimeName, StochasticTransition]: + """Build the nongroup regime's per-target transition probabilities.""" + spec = REGIME_SPECS[name] + gets_mc = spec["mc"] != "nomc" + own, _ng = make_targets(name) + if spec["canwork"] == "canwork": + transition_func = _make_transition_canwork(own) + else: + transition_func = _make_transition_forcedout(gets_mc, own) + return build_regime_transition( + spec=spec, transition_func=transition_func, target_groups=(own,) + ) + + def build_regime( name: str, grids: Grids, @@ -140,14 +161,6 @@ def build_regime( ) -> Regime: """Build a nongroup regime.""" spec = REGIME_SPECS[name] - gets_mc = spec["mc"] != "nomc" - own, _ng = make_targets(name) - - if spec["canwork"] == "canwork": - transition_func = _make_transition_canwork(own) - else: - transition_func = _make_transition_forcedout(gets_mc, own) - states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver @@ -170,9 +183,6 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - regime_transitions=build_regime_transition( - spec=spec, transition_func=transition_func, target_groups=(own,) - ), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index c0d28e5..bf46009 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -8,7 +8,7 @@ from collections.abc import Callable import jax.numpy as jnp -from lcm import Regime +from lcm import Regime, StochasticTransition from lcm.solvers import DCEGM, NBEGM from lcm.typing import ( Age, @@ -18,6 +18,7 @@ FloatND, IntND, Period, + RegimeName, ) from aca_model.baseline import health_insurance @@ -133,6 +134,20 @@ def _build_functions(spec: RegimeSpec) -> dict: return functions +def build_law(name: str) -> dict[RegimeName, StochasticTransition]: + """Build the retiree regime's per-target transition probabilities.""" + spec = REGIME_SPECS[name] + gets_mc = spec["mc"] != "nomc" + own, ng = make_targets(name) + if spec["canwork"] == "canwork": + transition_func = _make_transition_canwork(own, ng) + else: + transition_func = _make_transition_forcedout(gets_mc, own, ng) + return build_regime_transition( + spec=spec, transition_func=transition_func, target_groups=(own, ng) + ) + + def build_regime( name: str, grids: Grids, @@ -142,14 +157,6 @@ def build_regime( ) -> Regime: """Build a retiree regime.""" spec = REGIME_SPECS[name] - gets_mc = spec["mc"] != "nomc" - own, ng = make_targets(name) - - if spec["canwork"] == "canwork": - transition_func = _make_transition_canwork(own, ng) - else: - transition_func = _make_transition_forcedout(gets_mc, own, ng) - states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver @@ -166,9 +173,6 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - regime_transitions=build_regime_transition( - spec=spec, transition_func=transition_func, target_groups=(own, ng) - ), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index 416a73e..dc88d28 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -9,7 +9,7 @@ from collections.abc import Callable import jax.numpy as jnp -from lcm import Regime +from lcm import Regime, StochasticTransition from lcm.solvers import DCEGM, NBEGM from lcm.typing import ( Age, @@ -19,6 +19,7 @@ FloatND, IntND, Period, + RegimeName, ) from aca_model.agent.labor_market import LaborSupply @@ -101,6 +102,16 @@ def _build_functions(spec: RegimeSpec) -> dict: return functions +def build_law(name: str) -> dict[RegimeName, StochasticTransition]: + """Build the tied regime's per-target transition probabilities.""" + spec = REGIME_SPECS[name] + own, ng = make_targets(name) + transition_func = _make_transition_canwork(own, ng) + return build_regime_transition( + spec=spec, transition_func=transition_func, target_groups=(own, ng) + ) + + def build_regime( name: str, grids: Grids, @@ -110,10 +121,6 @@ def build_regime( ) -> Regime: """Build a tied regime (all tied regimes are canwork).""" spec = REGIME_SPECS[name] - own, ng = make_targets(name) - - transition_func = _make_transition_canwork(own, ng) - states = build_states(spec, grids) egm_solver = dcegm_solver if dcegm_solver is not None else nbegm_solver state_solver = ( @@ -129,9 +136,6 @@ def build_regime( functions = {**functions, **build_nbegm_functions()} return build_alive_regime( egm_solver=egm_solver, - regime_transitions=build_regime_transition( - spec=spec, transition_func=transition_func, target_groups=(own, ng) - ), states=states, state_transitions=build_state_transitions(spec, solver=state_solver), actions=build_actions(spec, grids), diff --git a/src/aca_model/consumption_dollars_grid.py b/src/aca_model/consumption_dollars_grid.py index 3cb6179..abb2dfb 100644 --- a/src/aca_model/consumption_dollars_grid.py +++ b/src/aca_model/consumption_dollars_grid.py @@ -68,7 +68,7 @@ def inject_consumption_dollars_points( max_consumption_dollars_arr = jnp.asarray(max_consumption_dollars) out: dict[str, Any] = dict(params) for regime_name, regime in model.user_regimes.items(): - if regime.terminal: + if model.graph.laws[regime_name].terminal: continue grid = regime.actions.get("consumption_dollars") if grid is None: @@ -131,7 +131,10 @@ def inject_consumption_floor_schedule( ) out: dict[str, Any] = dict(params) for regime_name, regime in model.user_regimes.items(): - if regime.terminal or "resources" not in regime.functions: + if ( + model.graph.laws[regime_name].terminal + or "resources" not in regime.functions + ): continue regime_entry = dict(out.get(regime_name, {})) resources_entry = dict(regime_entry.get("resources", {})) diff --git a/tests/test_dag_coverage.py b/tests/test_dag_coverage.py index fc98f19..09e2861 100644 --- a/tests/test_dag_coverage.py +++ b/tests/test_dag_coverage.py @@ -106,8 +106,8 @@ def test_bequest_continuation_is_not_removed(model): assert (96, "dead") in model.graph.nodes -def test_terminal_regime_keeps_the_original_none_declaration(model): - assert model.user_regimes["dead"].regime_transitions is None +def test_dead_regime_is_terminal_at_every_age(model): + assert model.graph.laws["dead"].terminal assert {(age, "dead") for age in model.ages.exact_values[1:]} <= model.graph.nodes diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 683adf5..3432fc0 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -64,13 +64,13 @@ def test_model_age_range() -> None: def test_dead_regime_is_terminal() -> None: model = make_baseline_model() - assert model.user_regimes["dead"].terminal + assert model.graph.laws["dead"].terminal def test_non_terminal_regimes_not_terminal() -> None: model = make_baseline_model() for name in REGIME_SPECS: - assert not model.user_regimes[name].terminal + assert not model.graph.laws[name].terminal def test_regime_id_dead_is_last() -> None: From 0a5ea105e4026f9ba632d2ac03464bdb98ceea7b Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 7 Oct 2026 13:53:32 +0200 Subject: [PATCH 078/101] TEST Expect the benchmark law arguments under params["edges"] 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_benchmark.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index b881d0d..74b3af8 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -11,6 +11,7 @@ ) from aca_model.agent.preferences import BenchmarkPrefType +from aca_model.baseline.regimes import REGIME_SPECS from aca_model.benchmark import ( create_benchmark_model, get_benchmark_initial_conditions, @@ -183,3 +184,38 @@ def test_initial_conditions_choose_population_size_after_model_construction() -> np.testing.assert_array_equal(values, repeated[name]) np.testing.assert_array_equal(small["age"], [51.0, 51.0]) np.testing.assert_array_equal(small["claimed_ss"], [0, 0]) + + +_LAW_ARGS = ("survival_probs", "prob_disabled_next") + + +def test_benchmark_law_args_live_under_their_source_edges() -> None: + """Each source's law arguments sit at `fixed_params["edges"][source]`.""" + fixed_params, _, _ = get_benchmark_params(model=None) + sources_with_both = { + source + for source, args in fixed_params["edges"].items() + if set(args) == set(_LAW_ARGS) + } + sources_with_survival_only = { + source + for source, args in fixed_params["edges"].items() + if set(args) == {"survival_probs"} + } + forcedout = {name for name in REGIME_SPECS if name.endswith("_forcedout")} + assert (sources_with_both, sources_with_survival_only) == ( + set(REGIME_SPECS) - forcedout, + forcedout, + ) + + +def test_benchmark_regimes_hold_no_law_args() -> None: + """No law argument stays under a regime, where it would not reach the law.""" + fixed_params, _, _ = get_benchmark_params(model=None) + leftovers = [ + (name, arg) + for name in REGIME_SPECS + for arg in _LAW_ARGS + if arg in fixed_params[name] + ] + assert leftovers == [] From 721d7742aa29bd739d2be2aa1fbf7e730d66e85b Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 7 Oct 2026 13:53:42 +0200 Subject: [PATCH 079/101] ENH Route the benchmark law arguments to params["edges"] `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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/benchmark.py | 39 +++++++++++++++++++++++++++++- tests/test_nbegm_model_creation.py | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index b392a87..2c3c678 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -48,6 +48,11 @@ Path(__file__).resolve().parent / "_benchmark_data" / "benchmark_params.pkl" ) +# Arguments of the regime-transition laws declared in `Model(edges=...)`. The +# snapshot stores them under each source regime; pylcm reads an edge-declared +# callable's parameters under `params["edges"][source]`. +_EDGE_LAW_PARAMS = frozenset({"survival_probs", "prob_disabled_next"}) + _N_BENCHMARK_PREF_TYPES = len(DiscreteGrid(BenchmarkPrefType).categories) _DERIVED_CATEGORICALS = { @@ -105,10 +110,14 @@ def get_benchmark_params( an `IrregSpacedGrid` with runtime-supplied points. Pass `model=None` to skip injection (e.g. when constructing the model with `fixed_params`). + + The regime-transition law arguments (`survival_probs`, + `prob_disabled_next`) are returned under `fixed_params["edges"][source]`, + where pylcm looks for the parameters of edge-declared callables. """ with _PARAMS_FILE.open("rb") as fh: data = cloudpickle.load(fh) - fixed_params = data["fixed_params"] + fixed_params = _move_law_params_to_edges(data["fixed_params"]) wage_params = data["wage_params"] params = data["params"] max_consumption_dollars = float(fixed_params.pop("max_consumption_dollars")) @@ -122,6 +131,34 @@ def get_benchmark_params( return fixed_params, wage_params, params +def _move_law_params_to_edges(fixed_params: dict[str, Any]) -> dict[str, Any]: + """Move each source regime's law arguments to `edges[source]`. + + Every other entry, including the regime's own function parameters, keeps + its path. + """ + regimes = { + name: value + for name, value in fixed_params.items() + if isinstance(value, dict) and not _EDGE_LAW_PARAMS.isdisjoint(value) + } + edges = { + name: {k: v for k, v in value.items() if k in _EDGE_LAW_PARAMS} + for name, value in regimes.items() + } + return { + **{ + name: ( + {k: v for k, v in value.items() if k not in _EDGE_LAW_PARAMS} + if name in regimes + else value + ) + for name, value in fixed_params.items() + }, + "edges": edges, + } + + def get_benchmark_consumption_dollars_points(*, n_points: int) -> tuple[float, ...]: """Compute construction-time consumption_dollars points from the snapshot. diff --git a/tests/test_nbegm_model_creation.py b/tests/test_nbegm_model_creation.py index 99d5bc9..23e423d 100644 --- a/tests/test_nbegm_model_creation.py +++ b/tests/test_nbegm_model_creation.py @@ -344,7 +344,7 @@ def test_nbegm_aca_variants_leave_no_free_buy_private_params( offenders = [ (regime_name, function_name) for regime_name, functions in template.items() - if isinstance(functions, dict) + if isinstance(functions, dict) and regime_name != "edges" for function_name, params in functions.items() if isinstance(params, dict) and "buy_private" in params ] From 280e1e77c45b0d0a3a5072f8a29b96bce5c0fd8a Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 7 Oct 2026 13:53:48 +0200 Subject: [PATCH 080/101] MAINT Pin CI pylcm to the edges-params API Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 83b5139..89715da 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@9a07f40a2d2f20a9777c0954942502f380515e03" + git+https://github.com/OpenSourceEconomics/pylcm.git@db04e088ceede5e3c31c709669e2eb4e00845e0f" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From b9915a288f62a11badfbfa225eab28efa647c013 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 7 Oct 2026 14:50:44 +0200 Subject: [PATCH 081/101] MAINT Read the benchmark law arguments from the snapshot's edges layout 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 Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .../_benchmark_data/benchmark_params.pkl | Bin 107785 -> 107957 bytes src/aca_model/benchmark.py | 35 +----------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/aca_model/_benchmark_data/benchmark_params.pkl b/src/aca_model/_benchmark_data/benchmark_params.pkl index 86a3c69b5483c392eb85a7513e488408fef7d05b..c5844b5ec3726a2e8f12d7aa04a98ea587bc6850 100644 GIT binary patch delta 3171 zcmbuBe@t6d6vulWl&+7`7K&NRkAY$)bfTLHCd9dEFl$I7#3f6X;H-3g>Eo^~bM14P z4q-;NAuNo&vO8J!%Pu3Eu#~XoZIf-m#F>ovha^T5qb{0-uz&VT(m64D->X7z1H3jg z>AijDoO`~Xd+zPMuj^J$Y$8Y7q3!P}9ntz8=?ZwR*%#u){D#`m;Nw1>xH4`RkNa3A zPmhg3u#WnkTmf;7pB1O$MySEn47?(ZYzsXNI%)(GZV27Y=A1+ zKV1jKVE#m}(V*!Sq`50MO;T+=RDw?Ev4&bW4{of7GCb4(cMP7SK>U^gEO<*jSXO$8 z*|G8KSn%$s_YC@8&as1-@VEi&g~%rl3dxNiH*K;9Jn$mE_YUZcw6=6^E0lmVwGB*= z;u@^l4q0OK&*#PKKbA!j*g! z-6n++pjMjL1G!+FpF=;1l6JMzQi`#(6ReP}{QTzA1qKBUlR^3O=yIgcQ;N$wp%AY< z3R6tKUaG((Xe}Lt3RqIA#A3)pw4BGw1!YcjivNT*OexfS^=opI zmveKv6bCY+*sDSzP!vbfrRd6xVoZf1Mo~XI(Km?Cy!6r3+2)CIOBX;$| zP1B~l{tL{Ca^lXGw^tBZQZ3TfV<2UlE>Qwk@TwE= zE&lEdM8$UlHXJ<(c6|PGIM*I0acCc-(R2xJz6?DkkHY@&=5RB)sV|VN%nza8sZ{^S zPb2&#r^d-R;Q$F0nuTB}?B*{c75*N}_SQg9@b3(@v<6(exfU;v|2_c&g=hf^eDfq+ zeQ5zlD&Etkgd?>H;lzrtTeV6_e<-i*ZP1KTqjABNwz V_Wf}qh!Qsv8XypkIjbPN@GsGd6*m9? delta 3065 zcmb_eYiv_h9KZit2DIF+*TuMY#XUfvG}|1oWEf!&sjb=A(i!>Wqh;A#Ij|AvDhaVN zOhj3jkMz!e_<(HM4J4bcsc_X~Jm%xWh;cE|#4pNxC7?Ve`#@Z9o^!jvJ&Ut<#hcu7 z?>*<9|L^yIpVJo?A0Atr)tS{}77|&zZmO?80RyJkW7BTD^b}N}w-#FQ{9drgSCY4Nhzr^kdLXohZCXoe z&9UdE?GV5tmEcIeIsF;rRk2-nq`u0&5}}Xkgt~}YSPxbZYt2~mBG_?!2NYwt0RrNv zSuNZJrvN_2npeP~|D%L1;)EYIvu9x=l*tKB{x@a}Ho_)x(wxNGJ3)Gsu98h`7&7CY zCb&4Cuvx^Vc)*_y5iEL*@-3R3imo#rpOPEwF%nfm=jeUmwJ@2a6Ah6jGLS0a|?=t+PgHsm-O}1m`n!H@Qp$lU!lL zLfv`U0KuO)f-{B*QoFgpx?oCftl6F>!fY#-=_9Nfi<-b$kS4%XwG5Vl^EQk1%ni2F z0J~<6-9ba_aC{v+lv_nj?W_9YboffLwU2zuJi3KW#iBFr9O7ZF-pYo0Hf*zCXE|)V z*9aIipin=j(5NAW)b91LJOlS%P0vUEjpJjIv;vdX|L<=A+r1`?SSn$sAMEp0$yCW* zBk9A_V~pR4FU!G%wpy@Y@MRcNrTsJu#ND%aVTmv54IPHgnT{aEaQ9K2o**K$b41SU8Fe;HHX?%``-rs#5hw%+R% z*;*W?wf?%t{~zniPjm1c#^ABR3x7*`mDL6xCA9i(6V)%$VC==B7$9iEf7HoH5@Hn_}Pah0y_?iAk4vK}+}UwRjY<+*~f@;w+?KX>+D;H);i&sJecJbl;1x8H%pePph5hM9|9 z$6>$-u#W={7z3{Fht=rl2Pb|Q2bUweV@F#{pryTe_rA84_725Y{lU<$b*=-W_+1=4 s7>a{;3E4~=V^oO^PdZjq{i(cQe{1SonDo+a5mm?`O2T(%HWDfQ2W6u5=Kufz diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index 2c3c678..093e65e 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -48,11 +48,6 @@ Path(__file__).resolve().parent / "_benchmark_data" / "benchmark_params.pkl" ) -# Arguments of the regime-transition laws declared in `Model(edges=...)`. The -# snapshot stores them under each source regime; pylcm reads an edge-declared -# callable's parameters under `params["edges"][source]`. -_EDGE_LAW_PARAMS = frozenset({"survival_probs", "prob_disabled_next"}) - _N_BENCHMARK_PREF_TYPES = len(DiscreteGrid(BenchmarkPrefType).categories) _DERIVED_CATEGORICALS = { @@ -117,7 +112,7 @@ def get_benchmark_params( """ with _PARAMS_FILE.open("rb") as fh: data = cloudpickle.load(fh) - fixed_params = _move_law_params_to_edges(data["fixed_params"]) + fixed_params = data["fixed_params"] wage_params = data["wage_params"] params = data["params"] max_consumption_dollars = float(fixed_params.pop("max_consumption_dollars")) @@ -131,34 +126,6 @@ def get_benchmark_params( return fixed_params, wage_params, params -def _move_law_params_to_edges(fixed_params: dict[str, Any]) -> dict[str, Any]: - """Move each source regime's law arguments to `edges[source]`. - - Every other entry, including the regime's own function parameters, keeps - its path. - """ - regimes = { - name: value - for name, value in fixed_params.items() - if isinstance(value, dict) and not _EDGE_LAW_PARAMS.isdisjoint(value) - } - edges = { - name: {k: v for k, v in value.items() if k in _EDGE_LAW_PARAMS} - for name, value in regimes.items() - } - return { - **{ - name: ( - {k: v for k, v in value.items() if k not in _EDGE_LAW_PARAMS} - if name in regimes - else value - ) - for name, value in fixed_params.items() - }, - "edges": edges, - } - - def get_benchmark_consumption_dollars_points(*, n_points: int) -> tuple[float, ...]: """Compute construction-time consumption_dollars points from the snapshot. From 2400c0a2e594fb2558a5694f2c3e771a85c62c09 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Wed, 7 Oct 2026 15:18:50 +0200 Subject: [PATCH 082/101] MAINT Pin CI pylcm to the fixed edges-params head Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 89715da..630f01e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@db04e088ceede5e3c31c709669e2eb4e00845e0f" + git+https://github.com/OpenSourceEconomics/pylcm.git@1730869f2f67efdaf23d4393201a45b619252689" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 591da5c42ea52d4c8c80f192647f971944fada92 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 8 Oct 2026 07:47:29 +0200 Subject: [PATCH 083/101] REF Schedule each regime's transition cells by source age 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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/regimes/__init__.py | 12 +++++------ src/aca_model/baseline/regimes/_common.py | 24 ++++++++++++++++----- src/aca_model/baseline/regimes/_nongroup.py | 7 +++--- src/aca_model/baseline/regimes/_retiree.py | 7 +++--- src/aca_model/baseline/regimes/_tied.py | 7 +++--- tests/test_dag_coverage.py | 11 +++++++++- 6 files changed, 43 insertions(+), 25 deletions(-) diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index 6368063..bd83aed 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -30,7 +30,6 @@ build_dead_regime, build_grids, build_model_constraints, - build_model_edges, build_model_functions, build_model_state_transitions, build_model_states, @@ -160,13 +159,12 @@ def build_model_slots( def build_edges() -> dict[str, Transition]: - """Declare each living regime's outgoing edges together with their law. + """Declare each living regime's outgoing edges by their law. - `dead` has no outgoing edges and is therefore terminal. + The law's age schedule names each destination at the source ages that reach + it. `dead` has no outgoing edges and is therefore terminal. """ return { - name: Transition( - targets=targets, law=_HIS_LAWS[REGIME_SPECS[name]["his"]](name) - ) - for name, targets in build_model_edges().items() + name: Transition(law=_HIS_LAWS[spec["his"]](name)) + for name, spec in REGIME_SPECS.items() } diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 9bb3684..747064a 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -14,6 +14,7 @@ import jax.numpy as jnp import numpy as np from lcm import ( + ByAge, DiscreteGrid, GridBreakpoint, IrregSpacedGrid, @@ -578,13 +579,27 @@ def build_regime_transition( spec: RegimeSpec, transition_func: Callable[..., FloatND], target_groups: tuple[dict[str, int], ...], -) -> dict[RegimeName, StochasticTransition]: - """Reuse one scalar probability cell per destination across all source ages.""" +) -> ByAge: + """Name each destination's probability cell at the source ages that reach it. + + One scalar probability cell per destination is shared by every source age; + the ages with the same destinations form one case. + """ edges = build_regime_edges(spec=spec, target_groups=target_groups) - return build_granular_regime_transition( + cells = build_granular_regime_transition( transition_func=transition_func, target_ids=(int(getattr(RegimeId, target)) for target in edges), ) + ages_by_targets: dict[tuple[RegimeName, ...], list[int]] = {} + for age in transition_ages(spec): + targets = tuple(target for target, ages in edges.items() if age in ages) + ages_by_targets.setdefault(targets, []).append(age) + return ByAge( + cases={ + tuple(ages): {target: cells[target] for target in targets} + for targets, ages in ages_by_targets.items() + } + ) def build_states(spec: RegimeSpec, grids: Grids) -> dict: @@ -686,8 +701,7 @@ def build_granular_regime_transition( """Build the regime's per-target probability cells. Each cell evaluates the regime's probability vector and selects its - target's entry. The model graph supplies the source-age support for these - cells. + target's entry. `build_regime_transition` schedules the cells by source age. """ id_to_name = { int(getattr(RegimeId, name)): name for name in (*REGIME_SPECS, "dead") diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index f61a8ee..b6a2eb6 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -6,7 +6,7 @@ from collections.abc import Callable -from lcm import Regime, StochasticTransition +from lcm import ByAge, Regime from lcm.solvers import DCEGM, NBEGM from lcm.typing import ( Age, @@ -14,7 +14,6 @@ DiscreteState, FloatND, Period, - RegimeName, ) from aca_model.baseline import health_insurance @@ -138,8 +137,8 @@ def build_constraints(spec: RegimeSpec) -> dict: return {} -def build_law(name: str) -> dict[RegimeName, StochasticTransition]: - """Build the nongroup regime's per-target transition probabilities.""" +def build_law(name: str) -> ByAge: + """Build the nongroup regime's per-target transition probabilities by source age.""" spec = REGIME_SPECS[name] gets_mc = spec["mc"] != "nomc" own, _ng = make_targets(name) diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index bf46009..f4010e0 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -8,7 +8,7 @@ from collections.abc import Callable import jax.numpy as jnp -from lcm import Regime, StochasticTransition +from lcm import ByAge, Regime from lcm.solvers import DCEGM, NBEGM from lcm.typing import ( Age, @@ -18,7 +18,6 @@ FloatND, IntND, Period, - RegimeName, ) from aca_model.baseline import health_insurance @@ -134,8 +133,8 @@ def _build_functions(spec: RegimeSpec) -> dict: return functions -def build_law(name: str) -> dict[RegimeName, StochasticTransition]: - """Build the retiree regime's per-target transition probabilities.""" +def build_law(name: str) -> ByAge: + """Build the retiree regime's per-target transition probabilities by source age.""" spec = REGIME_SPECS[name] gets_mc = spec["mc"] != "nomc" own, ng = make_targets(name) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index dc88d28..96bcd15 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -9,7 +9,7 @@ from collections.abc import Callable import jax.numpy as jnp -from lcm import Regime, StochasticTransition +from lcm import ByAge, Regime from lcm.solvers import DCEGM, NBEGM from lcm.typing import ( Age, @@ -19,7 +19,6 @@ FloatND, IntND, Period, - RegimeName, ) from aca_model.agent.labor_market import LaborSupply @@ -102,8 +101,8 @@ def _build_functions(spec: RegimeSpec) -> dict: return functions -def build_law(name: str) -> dict[RegimeName, StochasticTransition]: - """Build the tied regime's per-target transition probabilities.""" +def build_law(name: str) -> ByAge: + """Build the tied regime's per-target transition probabilities by source age.""" spec = REGIME_SPECS[name] own, ng = make_targets(name) transition_func = _make_transition_canwork(own, ng) diff --git a/tests/test_dag_coverage.py b/tests/test_dag_coverage.py index 09e2861..16b6472 100644 --- a/tests/test_dag_coverage.py +++ b/tests/test_dag_coverage.py @@ -6,7 +6,8 @@ from helpers.model import make_baseline_model from lcm.exceptions import InvalidInitialConditionsError -from aca_model.baseline.regimes import ENTRY_REGIMES +from aca_model.baseline.regimes import ENTRY_REGIMES, REGIME_SPECS +from aca_model.baseline.regimes._common import build_model_edges from aca_model.benchmark import get_benchmark_initial_conditions, get_benchmark_params from aca_model.config import MODEL_CONFIG from aca_model.simulation import select_admissible_starts @@ -96,6 +97,14 @@ def test_boundary_targets_change_with_source_age(model): ) == (True, False) +@pytest.mark.parametrize("regime", list(REGIME_SPECS)) +def test_each_regime_law_reaches_exactly_its_declared_targets(model, regime): + """The law's age schedule names each target at the ages that can reach it.""" + assert dict(model.graph.edges.solve[regime]) == { + target: frozenset(ages) for target, ages in build_model_edges()[regime].items() + } + + def test_bequest_continuation_is_not_removed(model): regime = model.user_regimes["retiree_oamc_forced_forcedout"] assert model.graph.solution.targets( From 5761c527f67146d91d45526b73285f91eb33b81d Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 8 Oct 2026 07:53:31 +0200 Subject: [PATCH 084/101] MAINT Pin CI pylcm to the strict transition-targets head Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 630f01e..49db6eb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; move it together with the aca-dev pylcm submodule pointer. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@1730869f2f67efdaf23d4393201a45b619252689" + git+https://github.com/OpenSourceEconomics/pylcm.git@ccf35fc6a2ddc1cec7ea8320819c4980476c166d" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From 621c1500a9f4b5dc9170163dac959d14fa63c20c Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 8 Oct 2026 23:00:31 +0200 Subject: [PATCH 085/101] FIX Drop the risk-rated premium intercept where the ACA plan is community-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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/aca/regimes/_overrides.py | 3 ++ tests/test_aca_plan.py | 49 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/aca_model/aca/regimes/_overrides.py b/src/aca_model/aca/regimes/_overrides.py index 104d699..cdc904d 100644 --- a/src/aca_model/aca/regimes/_overrides.py +++ b/src/aca_model/aca/regimes/_overrides.py @@ -63,6 +63,9 @@ def apply_aca_overrides( # No mandate: mandate_penalty is a fixed param (0.0) in the params # dict, not a DAG function — no entry needed here. functions["plan_premium"] = aca_hi.community_rated_premium + # The community-rated premium replaces the risk-rated one, so the + # risk-rated premium's zero-profit intercept has no consumer left. + functions.pop("private_premium_intercept", None) functions["hic_premium_subsidy"] = aca_hi.premium_subsidy functions["cost_sharing_scale"] = aca_hi.cost_sharing functions["premium_default"] = aca_hi.premium_default diff --git a/tests/test_aca_plan.py b/tests/test_aca_plan.py index 9847aa1..c8b0e3c 100644 --- a/tests/test_aca_plan.py +++ b/tests/test_aca_plan.py @@ -81,3 +81,52 @@ def test_aca_non_group_premium_is_community_rated( The variant without the non-group reform keeps the risk-rated premium. """ assert _aca_premium(policy, spousal_income) == pytest.approx(expected) + + +RISK_RATED_PREMIUM_PARAMS = frozenset( + { + "premium_markup", + "premium_minimum", + "premium_predicted_hcc", + "premium_sample_insurer_cost", + "premium_sample_is_married", + } +) + + +def _free_arguments(policy: PolicyVariant, regime: str) -> frozenset[str]: + """Arguments of a non-group regime's DAG that no function of it produces.""" + spec = REGIME_SPECS[regime] + functions = {**build_model_functions(), **_build_functions(spec)} + functions = {name: func for name, func in functions.items() if callable(func)} + apply_aca_overrides(functions, spec, policy) + arguments = { + name + for func in functions.values() + for name in inspect.signature(func).parameters + } + return frozenset(arguments - functions.keys()) + + +@pytest.mark.parametrize( + "regime", ["nongroup_nomc_inelig_canwork", "nongroup_nomc_choose_canwork"] +) +@pytest.mark.parametrize( + ("policy", "expected"), + [ + (PolicyVariant.ACA, frozenset()), + (PolicyVariant.ACA_NO_MANDATE, frozenset()), + (PolicyVariant.ACA_NO_MEDICAID_EXPANSION, frozenset()), + (PolicyVariant.ACA_NO_MEDICAID_EXPANSION_NO_MANDATE, frozenset()), + (PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION, RISK_RATED_PREMIUM_PARAMS), + ], +) +def test_aca_non_group_regime_reads_risk_rated_premium_params_only_without_reform( + policy: PolicyVariant, regime: str, expected: frozenset[str] +) -> None: + """A community-rated non-group market reads none of the risk-rated inputs. + + The variant without the non-group reform still prices private cover by + risk and reads all of them. + """ + assert _free_arguments(policy, regime) & RISK_RATED_PREMIUM_PARAMS == expected From e2b17fb23df8d6e7622485e9130b89e2fbb9e66a Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 9 Oct 2026 09:16:25 +0200 Subject: [PATCH 086/101] FIX Declare the admissible starts as InitialNodes and read their pairs 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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/regimes/_common.py | 13 +++++++------ src/aca_model/simulation.py | 9 +++++---- tests/test_simulation.py | 8 +++++++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 747064a..81ce991 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -17,6 +17,7 @@ ByAge, DiscreteGrid, GridBreakpoint, + InitialNodes, IrregSpacedGrid, LinSpacedGrid, NormalIIDProcess, @@ -37,7 +38,6 @@ from lcm.typing import ( BoolND, FloatND, - InitialNodes, IntND, Period, RegimeName, @@ -494,11 +494,12 @@ def _compute_max_annual_labor_income( # Admissible starting (age, regime) pairs, shared by every model variant: the # baseline estimation-sample ages. Later ages, and `dead` at any age, are reached only # through transitions. -INITIAL_NODES: InitialNodes = tuple( - (age, regime) - for age in MODEL_AGES - if config.start_age <= age <= config.last_start_age - for regime in ENTRY_REGIMES +INITIAL_NODES = InitialNodes( + by_age={ + age: ENTRY_REGIMES + for age in MODEL_AGES + if config.start_age <= age <= config.last_start_age + } ) _STAGE_KEY = { diff --git a/src/aca_model/simulation.py b/src/aca_model/simulation.py index a71bec7..743394a 100644 --- a/src/aca_model/simulation.py +++ b/src/aca_model/simulation.py @@ -54,22 +54,23 @@ def select_admissible_starts( ) -> pd.DataFrame: """Keep the rows that start at or before the model's last admissible start age. - Rows older than every age in `model.initial_nodes` are outside the sample + Rows older than every age in `model.graph.initial_nodes` are outside the sample the model is meant to simulate and are dropped. Every kept row must start at an admissible `(age, regime_name)` pair; any other kept row raises. Args: - model: A pylcm `Model` (anything exposing `initial_nodes`). + model: A pylcm `Model` (anything exposing `graph.initial_nodes`, the + admissible `(age, regime_name)` pairs). initial_conditions: Seed DataFrame with `age` and `regime_name` columns. Returns: The rows of `initial_conditions` inside the model's entry ages. Raises: - ValueError: If a kept row starts at a pair outside `model.initial_nodes`. + ValueError: If a kept row starts at a pair outside `model.graph.initial_nodes`. """ - admitted = {(float(age), regime) for age, regime in model.initial_nodes} + admitted = {(float(age), regime) for age, regime in model.graph.initial_nodes} last_entry_age = max(age for age, _ in admitted) kept = initial_conditions.loc[initial_conditions["age"] <= last_entry_age] pairs = set(zip(kept["age"].astype(float), kept["regime_name"], strict=True)) diff --git a/tests/test_simulation.py b/tests/test_simulation.py index f74458d..5abf531 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -1,8 +1,11 @@ """Tests for the pylcm simulate-interaction helpers.""" +from types import SimpleNamespace + import numpy as np import pandas as pd import pytest +from lcm import InitialNodes from aca_model.simulation import restore_subject_ids, simulate_with_dense_index @@ -12,7 +15,10 @@ class _StubModel: def __init__(self) -> None: self.seen_index: list[int] = [] - self.initial_nodes = frozenset({(51, "a"), (51, "b"), (51, "c"), (52, "a")}) + self.initial_nodes = InitialNodes(by_age={51: ("a", "b", "c"), 52: "a"}) + self.graph = SimpleNamespace( + initial_nodes=frozenset({(51, "a"), (51, "b"), (51, "c"), (52, "a")}) + ) def simulate(self, *, initial_conditions: pd.DataFrame, **_kwargs: object) -> str: self.seen_index = list(initial_conditions.index) From 98ebb477b7b418f47bba7480ec762ff1a1cafeae Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sat, 10 Oct 2026 19:39:49 +0200 Subject: [PATCH 087/101] Test ACA against the precise pylcm API --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 49db6eb..1624b74 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,12 +30,12 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install pylcm at the revision aca-dev pins + - name: Install pylcm at the supported API revision # Pinned by commit so a deleted or rewritten branch cannot break the - # install; move it together with the aca-dev pylcm submodule pointer. + # install; keep it compatible with the model's graph declarations. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@ccf35fc6a2ddc1cec7ea8320819c4980476c166d" + git+https://github.com/OpenSourceEconomics/pylcm.git@43a4bf2b0ad003b7308dd3478cd008458b528102" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest From a5e695080ef528652e5c73521aeee0b99c5c5eed Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 9 Oct 2026 15:00:05 +0200 Subject: [PATCH 088/101] ENH Add a grid flag that folds the transitory medical-cost shock `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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/regimes/_common.py | 1 + src/aca_model/config.py | 4 ++ tests/helpers/model.py | 8 +-- tests/test_transition_only_medical_shock.py | 62 +++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/test_transition_only_medical_shock.py diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 81ce991..d5909f0 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -325,6 +325,7 @@ def build_grids( gauss_hermite=True, mu=0.0, sigma=1.0, + fold=grid_config.fold_hcc_transitory, ) assets_start = -_compute_max_annual_labor_income( diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 8b8f6fd..b33e8e2 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -34,6 +34,10 @@ class GridConfig: n_wage_res_gridpoints: int = 5 n_hcc_persistent_gridpoints: int = 3 n_hcc_transitory_gridpoints: int = 5 + # Fold the transitory medical-cost shock out of the state space. Nothing a + # period computes reads last year's transitory shock, so with the fold it is + # drawn on the transition only and no value carries its axis. + fold_hcc_transitory: bool = False # The post-decision savings grid is cubically clustered toward the borrowing # constraint. Its node count controls resolution under DC-EGM and NB-EGM. n_savings_gridpoints: int = 200 diff --git a/tests/helpers/model.py b/tests/helpers/model.py index 28cbb83..df958e8 100644 --- a/tests/helpers/model.py +++ b/tests/helpers/model.py @@ -16,7 +16,7 @@ from aca_model.baseline.model import create_model as _create_baseline_model from aca_model.baseline.regimes import REGIME_SPECS from aca_model.benchmark import get_benchmark_params -from aca_model.config import BENCHMARK_GRID_CONFIG +from aca_model.config import BENCHMARK_GRID_CONFIG, GridConfig _NONGROUP_BEFORE_MEDICARE = frozenset( name @@ -33,14 +33,14 @@ } -def make_baseline_model() -> Model: - """Baseline model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params.""" +def make_baseline_model(*, grid_config: GridConfig = BENCHMARK_GRID_CONFIG) -> Model: + """Baseline model on `grid_config` with the benchmark snapshot params.""" fixed_params, wage_params, _ = get_benchmark_params(model=None) return _create_baseline_model( fixed_params=fixed_params, wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, - grid_config=BENCHMARK_GRID_CONFIG, + grid_config=grid_config, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) diff --git a/tests/test_transition_only_medical_shock.py b/tests/test_transition_only_medical_shock.py new file mode 100644 index 0000000..4cb07f1 --- /dev/null +++ b/tests/test_transition_only_medical_shock.py @@ -0,0 +1,62 @@ +"""Folding the transitory medical-cost shock takes it out of every state space. + +Nothing a period computes reads last year's transitory shock: this year's draw +enters the out-of-pocket bill inside the end-of-period asset law, and the draw is +IID. With `fold_hcc_transitory`, the shock is therefore drawn on the transition +only. Living regimes keep the lagged persistent shock, which prices the private +premium, and lose the transitory axis; values agree with the unfolded model on +every transitory slice. +""" + +from dataclasses import replace + +import numpy as np +import pytest +from helpers.model import make_baseline_model + +from aca_model.benchmark import get_benchmark_params +from aca_model.config import BENCHMARK_GRID_CONFIG + +_FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) +_REGIME = "retiree_nomc_inelig_canwork" + + +@pytest.fixture(scope="module") +def folded_model(): + return make_baseline_model(grid_config=_FOLDED) + + +def test_living_regimes_carry_no_lagged_transitory_shock(folded_model): + assert not any( + "hcc_transitory" in folded_model.state_names(regime_name=name) + for name in folded_model.user_regimes + ) + + +def test_living_regimes_keep_the_lagged_persistent_shock(folded_model): + assert all( + "hcc_persistent" in folded_model.state_names(regime_name=name) + for name in folded_model.user_regimes + if name != "dead" + ) + + +@pytest.mark.long_running +def test_folded_value_equals_every_transitory_slice_of_the_unfolded_value( + folded_model, +): + """The two models' sums differ only in order, so they agree to rounding.""" + unfolded_model = make_baseline_model() + _, _, params = get_benchmark_params(model=unfolded_model) + unfolded = np.asarray( + unfolded_model.solve(params=params, log_level="off").values[0][_REGIME] + ) + folded = np.asarray( + folded_model.solve(params=params, log_level="off").values[0][_REGIME] + ) + axis = unfolded_model.state_names(regime_name=_REGIME).index("hcc_transitory") + by_slice = np.moveaxis(unfolded, axis, 0) + + np.testing.assert_allclose( + by_slice, np.broadcast_to(folded, by_slice.shape), rtol=1e-6, atol=0 + ) From 649bed97cf05e662a79115f342437023872b50fd Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 9 Oct 2026 15:54:43 +0200 Subject: [PATCH 089/101] ENH Declare the asset law as resources minus the medical bill `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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/aca/health_insurance.py | 33 ++++++++++++ src/aca_model/aca/regimes/_overrides.py | 5 +- src/aca_model/agent/assets_and_income.py | 25 +++++++++ src/aca_model/baseline/regimes/_common.py | 53 +++++++++++++++++-- src/aca_model/baseline/regimes/_nongroup.py | 14 ++++-- src/aca_model/baseline/regimes/_retiree.py | 14 ++++-- src/aca_model/baseline/regimes/_tied.py | 14 ++++-- src/aca_model/config.py | 5 ++ tests/test_aca_policies.py | 31 ++++++++++++ tests/test_subtracted_medical_bill.py | 56 +++++++++++++++++++++ 10 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 tests/test_subtracted_medical_bill.py diff --git a/src/aca_model/aca/health_insurance.py b/src/aca_model/aca/health_insurance.py index c05e8ef..7e0b5bb 100644 --- a/src/aca_model/aca/health_insurance.py +++ b/src/aca_model/aca/health_insurance.py @@ -129,6 +129,39 @@ def cost_sharing( return jnp.where(is_insured & ~is_medicaid_eligible, scale, 1.0) +def cost_sharing_bracket( + gross_income: FloatND, + spousal_income: DiscreteState, + buy_private: DiscreteAction, + is_medicaid_eligible: BoolND, + cost_sharing_schedule: MappingLeaf, +) -> IntND: + """Return the household's cost-sharing bracket, `n_kinks + 1` when neutral. + + The bracket is the FPL bracket of `cost_sharing`; a household without + private cover, or Medicaid-eligible, falls in the neutral bracket one past + the last, whose scale is 1.0. The bracket takes finitely many values, so + the out-of-pocket bill can be averaged per bracket. + """ + sched = cast("Mapping[str, Any]", cost_sharing_schedule.data) + kinks = sched["kinks"] # [n_kinks, 3] + bracket = jnp.searchsorted(kinks[:, spousal_income], gross_income, side="right") + is_insured = buy_private == BuyPrivate.yes + return jnp.where( + is_insured & ~is_medicaid_eligible, bracket, kinks.shape[0] + 1 + ).astype(jnp.int32) + + +def cost_sharing_scale_of_bracket( + cost_sharing_bracket: IntND, + cost_sharing_schedule: MappingLeaf, +) -> FloatND: + """Return the cost-sharing scale of a bracket from `cost_sharing_bracket`.""" + sched = cast("Mapping[str, Any]", cost_sharing_schedule.data) + factors = sched["factors"] # [n_kinks + 1] + return jnp.append(factors, jnp.ones((), dtype=factors.dtype))[cost_sharing_bracket] + + def is_medicaid_eligible( is_ssi_eligible: BoolND, aca_magi: FloatND, diff --git a/src/aca_model/aca/regimes/_overrides.py b/src/aca_model/aca/regimes/_overrides.py index cdc904d..eb92ffe 100644 --- a/src/aca_model/aca/regimes/_overrides.py +++ b/src/aca_model/aca/regimes/_overrides.py @@ -67,7 +67,10 @@ def apply_aca_overrides( # risk-rated premium's zero-profit intercept has no consumer left. functions.pop("private_premium_intercept", None) functions["hic_premium_subsidy"] = aca_hi.premium_subsidy - functions["cost_sharing_scale"] = aca_hi.cost_sharing + # Split at the bracket, which takes finitely many values, so the + # out-of-pocket bill can be averaged per bracket. + functions["cost_sharing_bracket"] = aca_hi.cost_sharing_bracket + functions["cost_sharing_scale"] = aca_hi.cost_sharing_scale_of_bracket functions["premium_default"] = aca_hi.premium_default functions["cash_on_hand"] = aca_hi.cash_on_hand functions["primary_oop"] = aca_hi.primary_oop diff --git a/src/aca_model/agent/assets_and_income.py b/src/aca_model/agent/assets_and_income.py index 2dad637..251c7f8 100644 --- a/src/aca_model/agent/assets_and_income.py +++ b/src/aca_model/agent/assets_and_income.py @@ -109,6 +109,31 @@ def next_assets( ) +def assets_before_bill( + cash_on_hand: FloatND, + transfers: FloatND, + pension_assets_adjustment: FloatND, + consumption_dollars: ContinuousAction, +) -> FloatND: + """Compute next-period assets for non-terminal targets before the medical bill. + + `next_assets` is this minus `oop_costs`. + """ + return cash_on_hand + transfers + pension_assets_adjustment - consumption_dollars + + +def assets_before_bill_when_dead( + cash_on_hand: FloatND, + transfers: FloatND, + consumption_dollars: ContinuousAction, +) -> FloatND: + """Compute assets for the dead/terminal target before the medical bill. + + `next_assets_when_dead` is this minus `oop_costs`. + """ + return cash_on_hand + transfers - consumption_dollars + + def next_assets_when_dead( cash_on_hand: FloatND, transfers: FloatND, diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index d5909f0..4f63eab 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -26,6 +26,7 @@ Regime, RouwenhorstAR1Process, StochasticTransition, + SubtractedBill, categorical, fixed_transition, ) @@ -949,7 +950,7 @@ def build_model_state_transitions() -> dict: } -def build_common_functions(spec: RegimeSpec) -> dict: +def build_common_functions(spec: RegimeSpec, *, subtract_bill: bool = False) -> dict: """Build the regime-level functions dict for a non-dead regime. Contains the spec-dependent selections and the overlay-swapped names; @@ -1004,6 +1005,11 @@ def build_common_functions(spec: RegimeSpec) -> dict: # Swapped per policy variant by the ACA overlay, hence regime-level functions["is_medicaid_eligible"] = health_insurance.is_medicaid_eligible functions["after_tax_income"] = taxes.after_tax_income + if subtract_bill: + functions["assets_before_bill"] = assets_and_income.assets_before_bill + functions["assets_before_bill_when_dead"] = ( + assets_and_income.assets_before_bill_when_dead + ) functions["premium_default"] = assets_and_income.premium_default functions["cash_on_hand"] = assets_and_income.cash_on_hand @@ -1173,7 +1179,7 @@ def select_target_for_age( def build_state_transitions( - spec: RegimeSpec, *, solver: SolverName = "brute_force" + spec: RegimeSpec, *, solver: SolverName = "brute_force", subtract_bill: bool = False ) -> dict: """Build the regime-level state transitions dict for a non-dead regime. @@ -1184,7 +1190,9 @@ def build_state_transitions( post-decision (savings) form. """ transitions: dict = {} - transitions["assets"] = _build_per_target_regime_assets(spec, solver=solver) + transitions["assets"] = _build_per_target_regime_assets( + spec, solver=solver, subtract_bill=subtract_bill + ) transitions["health"] = _build_per_target_regime_health(spec) claimed_ss_transition = _build_per_target_regime_claimed_ss(spec) if claimed_ss_transition: @@ -1222,8 +1230,24 @@ def _select_aime_law(spec: RegimeSpec) -> Callable[..., FloatND]: ) +# Every input of the out-of-pocket bill that varies across a source's states and +# actions, with its support. The bill reads nothing else besides next period's +# medical-cost draws, the period and parameters. +_BILL_CONDITIONERS: Mapping[str, tuple[int | bool, ...]] = MappingProxyType( + { + "is_married": (0, 1), + "good_health": (0, 1), + "is_medicaid_eligible": (False, True), + "buy_private": (BuyPrivate.no, BuyPrivate.yes), + # Under the ACA policy: the four FPL kinks of the cost-sharing schedule + # give brackets 0-4, and 5 is the neutral bracket. + "cost_sharing_bracket": tuple(range(6)), + } +) + + def _build_per_target_regime_assets( - spec: RegimeSpec, *, solver: SolverName = "brute_force" + spec: RegimeSpec, *, solver: SolverName = "brute_force", subtract_bill: bool = False ) -> dict[RegimeName, Callable[..., FloatND]]: """Build per-target assets transitions. @@ -1232,11 +1256,30 @@ def _build_per_target_regime_assets( pull in the `next_aime`-dependent imputation chain — `dead` has no `aime` state and pylcm cannot resolve `next_aime` there. Non-dead targets use the full `next_assets` with the pension correction. - Under DC-EGM both laws take their post-decision (savings) form. + Under DC-EGM both laws take their post-decision (savings) form. With + `subtract_bill`, each law is declared as its resources before the bill + (`assets_before_bill`, `assets_before_bill_when_dead`) minus `oop_costs`, + conditioned on every source-side input of the bill. """ + if subtract_bill and solver != "brute_force": + msg = f"subtract_hcc_bill needs the brute-force asset law, not {solver!r}." + raise ValueError(msg) + living_law: Callable[..., FloatND] + dead_law: Callable[..., FloatND] if solver in ("dcegm", "nbegm"): living_law = assets_and_income.next_assets_from_savings dead_law = assets_and_income.next_assets_when_dead_from_savings + elif subtract_bill: + living_law = SubtractedBill( + resources="assets_before_bill", + bill="oop_costs", + conditioners=_BILL_CONDITIONERS, + ) + dead_law = SubtractedBill( + resources="assets_before_bill_when_dead", + bill="oop_costs", + conditioners=_BILL_CONDITIONERS, + ) else: living_law = assets_and_income.next_assets dead_law = assets_and_income.next_assets_when_dead diff --git a/src/aca_model/baseline/regimes/_nongroup.py b/src/aca_model/baseline/regimes/_nongroup.py index b6a2eb6..a346e0c 100644 --- a/src/aca_model/baseline/regimes/_nongroup.py +++ b/src/aca_model/baseline/regimes/_nongroup.py @@ -92,10 +92,10 @@ def transition( return transition -def _build_functions(spec: RegimeSpec) -> dict: +def _build_functions(spec: RegimeSpec, *, subtract_bill: bool = False) -> dict: """Build functions dict for a nongroup regime.""" can_work = spec["canwork"] == "canwork" - functions = build_common_functions(spec) + functions = build_common_functions(spec, subtract_bill=subtract_bill) functions["ss_benefit"] = select_ss_benefit(spec) @@ -168,7 +168,9 @@ def build_regime( if egm_solver is None else ("nbegm" if nbegm_solver is not None else "dcegm") ) - functions = _build_functions(spec) + functions = _build_functions( + spec, subtract_bill=grids.grid_config.subtract_hcc_bill + ) # The EGM solvers reject constraints that read the continuous state, which # the private-cover constraint does through `premium_default`. constraints = build_constraints(spec) if egm_solver is None else {} @@ -183,7 +185,11 @@ def build_regime( return build_alive_regime( egm_solver=egm_solver, states=states, - state_transitions=build_state_transitions(spec, solver=state_solver), + state_transitions=build_state_transitions( + spec, + solver=state_solver, + subtract_bill=grids.grid_config.subtract_hcc_bill, + ), actions=build_actions(spec, grids), functions=functions, constraints=constraints, diff --git a/src/aca_model/baseline/regimes/_retiree.py b/src/aca_model/baseline/regimes/_retiree.py index f4010e0..a0f304b 100644 --- a/src/aca_model/baseline/regimes/_retiree.py +++ b/src/aca_model/baseline/regimes/_retiree.py @@ -113,10 +113,10 @@ def transition( return transition -def _build_functions(spec: RegimeSpec) -> dict: +def _build_functions(spec: RegimeSpec, *, subtract_bill: bool = False) -> dict: """Build functions dict for a retiree regime.""" can_work = spec["canwork"] == "canwork" - functions = build_common_functions(spec) + functions = build_common_functions(spec, subtract_bill=subtract_bill) functions["ss_benefit"] = select_ss_benefit(spec) @@ -164,7 +164,9 @@ def build_regime( if egm_solver is None else ("nbegm" if nbegm_solver is not None else "dcegm") ) - functions = _build_functions(spec) + functions = _build_functions( + spec, subtract_bill=grids.grid_config.subtract_hcc_bill + ) if nbegm_solver is not None: # NBEGM's solver contract is stated per regime: it reads the budget in # savings form off `resources` and the post-decision node off @@ -173,7 +175,11 @@ def build_regime( return build_alive_regime( egm_solver=egm_solver, states=states, - state_transitions=build_state_transitions(spec, solver=state_solver), + state_transitions=build_state_transitions( + spec, + solver=state_solver, + subtract_bill=grids.grid_config.subtract_hcc_bill, + ), actions=build_actions(spec, grids), functions=functions, ) diff --git a/src/aca_model/baseline/regimes/_tied.py b/src/aca_model/baseline/regimes/_tied.py index 96bcd15..9a9c4b3 100644 --- a/src/aca_model/baseline/regimes/_tied.py +++ b/src/aca_model/baseline/regimes/_tied.py @@ -86,9 +86,9 @@ def target(mc_next: bool) -> IntND: return transition -def _build_functions(spec: RegimeSpec) -> dict: +def _build_functions(spec: RegimeSpec, *, subtract_bill: bool = False) -> dict: """Build functions dict for a tied regime.""" - functions = build_common_functions(spec) + functions = build_common_functions(spec, subtract_bill=subtract_bill) functions["ss_benefit"] = select_ss_benefit(spec) @@ -127,7 +127,9 @@ def build_regime( if egm_solver is None else ("nbegm" if nbegm_solver is not None else "dcegm") ) - functions = _build_functions(spec) + functions = _build_functions( + spec, subtract_bill=grids.grid_config.subtract_hcc_bill + ) if nbegm_solver is not None: # NBEGM's solver contract is stated per regime: it reads the budget in # savings form off `resources` and the post-decision node off @@ -136,7 +138,11 @@ def build_regime( return build_alive_regime( egm_solver=egm_solver, states=states, - state_transitions=build_state_transitions(spec, solver=state_solver), + state_transitions=build_state_transitions( + spec, + solver=state_solver, + subtract_bill=grids.grid_config.subtract_hcc_bill, + ), actions=build_actions(spec, grids), functions=functions, ) diff --git a/src/aca_model/config.py b/src/aca_model/config.py index b33e8e2..2031952 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -38,6 +38,11 @@ class GridConfig: # period computes reads last year's transitory shock, so with the fold it is # drawn on the transition only and no value carries its axis. fold_hcc_transitory: bool = False + # Declare the asset law toward living targets as resources minus the + # out-of-pocket bill, so the solve averages each target's value over the + # transitory shock once per period instead of at every source point. Needs + # `fold_hcc_transitory` and the brute-force asset law. + subtract_hcc_bill: bool = False # The post-decision savings grid is cubically clustered toward the borrowing # constraint. Its node count controls resolution under DC-EGM and NB-EGM. n_savings_gridpoints: int = 200 diff --git a/tests/test_aca_policies.py b/tests/test_aca_policies.py index 1c327d6..f2c9dee 100644 --- a/tests/test_aca_policies.py +++ b/tests/test_aca_policies.py @@ -372,3 +372,34 @@ def test_premium_subsidy_just_below_400_fpl() -> None: premium_credit_schedule=PREMIUM_CREDIT_SCHEDULE, ) assert result > 0.0 + + +@pytest.mark.parametrize( + ("income_fpl_frac", "buy_private", "is_medicaid_eligible"), + [ + (0.5, BuyPrivate.yes, False), + (1.2, BuyPrivate.yes, False), + (1.7, BuyPrivate.yes, False), + (2.3, BuyPrivate.yes, False), + (3.0, BuyPrivate.yes, False), + (1.2, BuyPrivate.no, False), + (1.2, BuyPrivate.yes, True), + ], +) +def test_cost_sharing_scale_of_its_bracket_equals_the_cost_sharing_scale( + income_fpl_frac: float, buy_private: int, is_medicaid_eligible: bool +) -> None: + """The scale read off the bracket is the scale the bracket lookup gives.""" + kwargs = { + "gross_income": jnp.array(income_fpl_frac * FPL_SINGLE), + "spousal_income": jnp.int32(0), + "buy_private": jnp.array(buy_private), + "is_medicaid_eligible": jnp.array(is_medicaid_eligible), + "cost_sharing_schedule": COST_SHARING_SCHEDULE, + } + bracket = aca_hi.cost_sharing_bracket(**kwargs) + scale = aca_hi.cost_sharing_scale_of_bracket( + cost_sharing_bracket=bracket, cost_sharing_schedule=COST_SHARING_SCHEDULE + ) + + assert scale == aca_hi.cost_sharing(**kwargs) diff --git a/tests/test_subtracted_medical_bill.py b/tests/test_subtracted_medical_bill.py new file mode 100644 index 0000000..9a97705 --- /dev/null +++ b/tests/test_subtracted_medical_bill.py @@ -0,0 +1,56 @@ +"""Declaring the asset law as resources minus the medical bill is exact. + +With `subtract_hcc_bill`, the asset law toward every target is +`lcm.SubtractedBill`: end-of-period resources before the out-of-pocket bill, +minus the bill. pylcm then averages each target's value over the transitory +medical-cost shock once per period, on the merged points `{a_j + bill_k}`, +rather than interpolating it at every shock node of every source point. With +linear interpolation in assets that average is exact, so the values agree with +the plain asset law to rounding. +""" + +from dataclasses import replace + +import lcm +import numpy as np +import pytest +from helpers.model import make_baseline_model + +from aca_model.benchmark import get_benchmark_params +from aca_model.config import BENCHMARK_GRID_CONFIG + +_FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) +_SUBTRACTED = replace(_FOLDED, subtract_hcc_bill=True) +_REGIME = "retiree_nomc_inelig_canwork" + + +@pytest.fixture(scope="module") +def subtracted_model(): + return make_baseline_model(grid_config=_SUBTRACTED) + + +def test_every_target_takes_the_subtracted_bill(subtracted_model): + laws = [ + law + for name, regime in subtracted_model.user_regimes.items() + if name != "dead" + for law in regime.state_transitions["assets"].values() + ] + + assert laws + assert all(isinstance(law, lcm.SubtractedBill) for law in laws) + + +@pytest.mark.long_running +def test_subtracted_bill_value_equals_the_plain_law_value(subtracted_model): + """The two routes sum the same terms in a different order.""" + folded_model = make_baseline_model(grid_config=_FOLDED) + _, _, params = get_benchmark_params(model=folded_model) + folded = np.asarray( + folded_model.solve(params=params, log_level="off").values[0][_REGIME] + ) + subtracted = np.asarray( + subtracted_model.solve(params=params, log_level="off").values[0][_REGIME] + ) + + np.testing.assert_allclose(subtracted, folded, rtol=1e-9, atol=0) From 622ba0038639d1c82b83af21ed9f78dfbf23f51a Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sat, 10 Oct 2026 16:19:28 +0200 Subject: [PATCH 090/101] ENH Declare the asset law as lcm.AdditiveShockTransition 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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/baseline/health_insurance.py | 5 ++ src/aca_model/baseline/regimes/_common.py | 19 ++++--- tests/test_additive_shock_medical_bill.py | 65 ++++++++++++++++++++++ tests/test_subtracted_medical_bill.py | 56 ------------------- 4 files changed, 80 insertions(+), 65 deletions(-) create mode 100644 tests/test_additive_shock_medical_bill.py delete mode 100644 tests/test_subtracted_medical_bill.py diff --git a/src/aca_model/baseline/health_insurance.py b/src/aca_model/baseline/health_insurance.py index 190e7d2..d073287 100644 --- a/src/aca_model/baseline/health_insurance.py +++ b/src/aca_model/baseline/health_insurance.py @@ -357,6 +357,11 @@ def oop_costs( return jnp.minimum(oop, oop_max) +def negative_oop_costs(oop_costs: FloatND) -> FloatND: + """Return the out-of-pocket bill as the shock added to assets.""" + return -oop_costs + + def insured_oop( total_health_costs: FloatND, is_married: IntND, diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 4f63eab..7a45538 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -14,6 +14,7 @@ import jax.numpy as jnp import numpy as np from lcm import ( + AdditiveShockTransition, ByAge, DiscreteGrid, GridBreakpoint, @@ -26,7 +27,6 @@ Regime, RouwenhorstAR1Process, StochasticTransition, - SubtractedBill, categorical, fixed_transition, ) @@ -1007,6 +1007,7 @@ def build_common_functions(spec: RegimeSpec, *, subtract_bill: bool = False) -> functions["after_tax_income"] = taxes.after_tax_income if subtract_bill: functions["assets_before_bill"] = assets_and_income.assets_before_bill + functions["negative_oop_costs"] = health_insurance.negative_oop_costs functions["assets_before_bill_when_dead"] = ( assets_and_income.assets_before_bill_when_dead ) @@ -1258,8 +1259,8 @@ def _build_per_target_regime_assets( targets use the full `next_assets` with the pension correction. Under DC-EGM both laws take their post-decision (savings) form. With `subtract_bill`, each law is declared as its resources before the bill - (`assets_before_bill`, `assets_before_bill_when_dead`) minus `oop_costs`, - conditioned on every source-side input of the bill. + (`assets_before_bill`, `assets_before_bill_when_dead`) plus the shock + `negative_oop_costs`, conditioned on every source-side input of the bill. """ if subtract_bill and solver != "brute_force": msg = f"subtract_hcc_bill needs the brute-force asset law, not {solver!r}." @@ -1270,14 +1271,14 @@ def _build_per_target_regime_assets( living_law = assets_and_income.next_assets_from_savings dead_law = assets_and_income.next_assets_when_dead_from_savings elif subtract_bill: - living_law = SubtractedBill( - resources="assets_before_bill", - bill="oop_costs", + living_law = AdditiveShockTransition( + base="assets_before_bill", + shock="negative_oop_costs", conditioners=_BILL_CONDITIONERS, ) - dead_law = SubtractedBill( - resources="assets_before_bill_when_dead", - bill="oop_costs", + dead_law = AdditiveShockTransition( + base="assets_before_bill_when_dead", + shock="negative_oop_costs", conditioners=_BILL_CONDITIONERS, ) else: diff --git a/tests/test_additive_shock_medical_bill.py b/tests/test_additive_shock_medical_bill.py new file mode 100644 index 0000000..c7932af --- /dev/null +++ b/tests/test_additive_shock_medical_bill.py @@ -0,0 +1,65 @@ +"""Declaring the asset law as resources plus the negated medical bill is exact. + +With `subtract_hcc_bill`, the asset law toward every target is +`lcm.AdditiveShockTransition`: end-of-period resources before the out-of-pocket +bill, plus the shock `negative_oop_costs`. pylcm then averages each target's value +over the transitory medical-cost shock once per period, on the merged points +`{a_j - shock_k}`, rather than interpolating it at every shock node of every source +point. With linear interpolation in assets that average is exact, so the values +agree with the plain asset law to rounding. +""" + +from dataclasses import replace + +import jax.numpy as jnp +import lcm +import numpy as np +import pytest +from helpers.model import make_baseline_model + +from aca_model.baseline.health_insurance import negative_oop_costs +from aca_model.benchmark import get_benchmark_params +from aca_model.config import BENCHMARK_GRID_CONFIG + +_FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) +_ADDITIVE = replace(_FOLDED, subtract_hcc_bill=True) +_REGIME = "retiree_nomc_inelig_canwork" + + +@pytest.fixture(scope="module") +def additive_model(): + return make_baseline_model(grid_config=_ADDITIVE) + + +def test_negative_oop_costs_is_the_negated_bill(): + np.testing.assert_array_equal( + negative_oop_costs(oop_costs=jnp.array([0.0, 1.5, 250.0])), + np.array([0.0, -1.5, -250.0]), + ) + + +def test_every_target_adds_the_negated_bill(additive_model): + laws = [ + (type(law), law.shock) + for name, regime in additive_model.user_regimes.items() + if name != "dead" + for law in regime.state_transitions["assets"].values() + ] + + assert laws + assert set(laws) == {(lcm.AdditiveShockTransition, "negative_oop_costs")} + + +@pytest.mark.long_running +def test_additive_shock_value_equals_the_plain_law_value(additive_model): + """The two routes sum the same terms in a different order.""" + folded_model = make_baseline_model(grid_config=_FOLDED) + _, _, params = get_benchmark_params(model=folded_model) + folded = np.asarray( + folded_model.solve(params=params, log_level="off").values[0][_REGIME] + ) + additive = np.asarray( + additive_model.solve(params=params, log_level="off").values[0][_REGIME] + ) + + np.testing.assert_allclose(additive, folded, rtol=1e-9, atol=0) diff --git a/tests/test_subtracted_medical_bill.py b/tests/test_subtracted_medical_bill.py deleted file mode 100644 index 9a97705..0000000 --- a/tests/test_subtracted_medical_bill.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Declaring the asset law as resources minus the medical bill is exact. - -With `subtract_hcc_bill`, the asset law toward every target is -`lcm.SubtractedBill`: end-of-period resources before the out-of-pocket bill, -minus the bill. pylcm then averages each target's value over the transitory -medical-cost shock once per period, on the merged points `{a_j + bill_k}`, -rather than interpolating it at every shock node of every source point. With -linear interpolation in assets that average is exact, so the values agree with -the plain asset law to rounding. -""" - -from dataclasses import replace - -import lcm -import numpy as np -import pytest -from helpers.model import make_baseline_model - -from aca_model.benchmark import get_benchmark_params -from aca_model.config import BENCHMARK_GRID_CONFIG - -_FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) -_SUBTRACTED = replace(_FOLDED, subtract_hcc_bill=True) -_REGIME = "retiree_nomc_inelig_canwork" - - -@pytest.fixture(scope="module") -def subtracted_model(): - return make_baseline_model(grid_config=_SUBTRACTED) - - -def test_every_target_takes_the_subtracted_bill(subtracted_model): - laws = [ - law - for name, regime in subtracted_model.user_regimes.items() - if name != "dead" - for law in regime.state_transitions["assets"].values() - ] - - assert laws - assert all(isinstance(law, lcm.SubtractedBill) for law in laws) - - -@pytest.mark.long_running -def test_subtracted_bill_value_equals_the_plain_law_value(subtracted_model): - """The two routes sum the same terms in a different order.""" - folded_model = make_baseline_model(grid_config=_FOLDED) - _, _, params = get_benchmark_params(model=folded_model) - folded = np.asarray( - folded_model.solve(params=params, log_level="off").values[0][_REGIME] - ) - subtracted = np.asarray( - subtracted_model.solve(params=params, log_level="off").values[0][_REGIME] - ) - - np.testing.assert_allclose(subtracted, folded, rtol=1e-9, atol=0) From 5b75bee408cf5dca40da5ba5b087057f9e15524e Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 11 Oct 2026 06:53:08 +0200 Subject: [PATCH 091/101] Enable medical-shock folding in the ACA benchmark --- .github/workflows/main.yml | 2 +- README.md | 8 +++ src/aca_model/baseline/regimes/_common.py | 2 +- src/aca_model/benchmark.py | 25 ++++++-- src/aca_model/config.py | 2 + tests/test_additive_shock_medical_bill.py | 58 ++++++++++++++----- tests/test_benchmark_folding.py | 64 +++++++++++++++++++++ tests/test_transition_only_medical_shock.py | 17 ++++-- 8 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 tests/test_benchmark_folding.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1624b74..3d2f21d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; keep it compatible with the model's graph declarations. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@43a4bf2b0ad003b7308dd3478cd008458b528102" + git+https://github.com/OpenSourceEconomics/pylcm.git@eea73a8f0e301e88f02278df3595c797fdba24df" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest diff --git a/README.md b/README.md index 9edf892..f22ff8f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Core lifecycle model for the ACA structural retirement project. +The benchmark folds the IID transitory medical-cost shock and declares its asset +transition as resources plus the negated medical bill. Production grid defaults remain +unfolded. To measure the unfolded benchmark control, pass +`grid_config=replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=False, subtract_hcc_bill=False)` +to `create_benchmark_model`. Pass the same grid configuration to +`get_benchmark_initial_conditions`; both routes retain identical seeded draws for their +shared initial states. + Model factories accept `execution_config=ExecutionConfig(...)`. An omitted policy uses the smallest JAX allocator `bytes_limit` among the selected accelerators as an explicit per-device budget. This enables budget-aware width selection; CPU construction remains diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 7a45538..2032ce1 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -1239,7 +1239,7 @@ def _select_aime_law(spec: RegimeSpec) -> Callable[..., FloatND]: "is_married": (0, 1), "good_health": (0, 1), "is_medicaid_eligible": (False, True), - "buy_private": (BuyPrivate.no, BuyPrivate.yes), + "buy_private": (int(BuyPrivate.no), int(BuyPrivate.yes)), # Under the ACA policy: the four FPL kinks of the cost-sharing schedule # give brackets 0-4, and 5 is the neutral bracket. "cost_sharing_bracket": tuple(range(6)), diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index 093e65e..3eed460 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -37,7 +37,7 @@ from aca_model.baseline.health_insurance import HealthInsuranceState from aca_model.baseline.model import create_model from aca_model.baseline.regimes import ENTRY_REGIMES -from aca_model.config import BENCHMARK_GRID_CONFIG +from aca_model.config import BENCHMARK_GRID_CONFIG, GridConfig from aca_model.consumption_dollars_grid import ( compute_consumption_dollars_points, inject_consumption_dollars_points, @@ -66,6 +66,7 @@ def create_benchmark_model( *, pref_type_grid: DiscreteGrid, + grid_config: GridConfig = BENCHMARK_GRID_CONFIG, execution_config: ExecutionConfig | None = None, ) -> Model: """Create the aca baseline with `BENCHMARK_GRID_CONFIG` and frozen fixed_params. @@ -75,13 +76,14 @@ def create_benchmark_model( Args: pref_type_grid: Pref-type grid; pass `DiscreteGrid(BenchmarkPrefType)`. + grid_config: Economic resolution and medical-shock folding declarations. execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. """ fixed_params, wage_params, _ = get_benchmark_params(model=None) return create_model( - grid_config=BENCHMARK_GRID_CONFIG, + grid_config=grid_config, fixed_params=fixed_params, wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, @@ -151,7 +153,11 @@ def get_benchmark_consumption_dollars_points(*, n_points: int) -> tuple[float, . def get_benchmark_initial_conditions( - *, model: Model, n_subjects: int, seed: int + *, + model: Model, + n_subjects: int, + seed: int, + grid_config: GridConfig = BENCHMARK_GRID_CONFIG, ) -> dict[str, Array]: """Draw random feasible initial conditions across five age-51 regimes. @@ -177,7 +183,13 @@ def get_benchmark_initial_conditions( assets_lo, assets_hi = float(assets_pts.min()), float(assets_pts.max()) aime_lo, aime_hi = float(aime_pts.min()), float(aime_pts.max()) hcc_p_pts = np.asarray(grids["hcc_persistent"].to_jax()) # ty: ignore[unresolved-attribute] - hcc_t_pts = np.asarray(grids["hcc_transitory"].to_jax()) # ty: ignore[unresolved-attribute] + hcc_t_grid = grids.get("hcc_transitory") + # Preserve the random stream for shared states when the folded state is absent. + hcc_t_pts = ( + np.asarray(hcc_t_grid.to_jax()) # ty: ignore[unresolved-attribute] + if hcc_t_grid is not None + else np.arange(grid_config.n_hcc_transitory_gridpoints) + ) wage_res_pts = np.asarray(grids["log_ft_wage_res"].to_jax()) # ty: ignore[unresolved-attribute] # For lagged_labor_supply: 0 for tied rows (regime lacks the state), @@ -187,7 +199,7 @@ def get_benchmark_initial_conditions( np.int32 ) - return { + initial = { "regime_id": jnp.asarray(regime), "age": jnp.full(n_subjects, 51.0), "assets": jnp.asarray(rng.uniform(assets_lo, assets_hi, n_subjects)), @@ -208,3 +220,6 @@ def get_benchmark_initial_conditions( # everyone starts as not-yet-claimed. "claimed_ss": jnp.zeros(n_subjects, dtype=jnp.int32), } + if "hcc_transitory" not in model.state_names(regime_name=ENTRY_REGIMES[0]): + initial.pop("hcc_transitory") + return initial diff --git a/src/aca_model/config.py b/src/aca_model/config.py index 2031952..d95569b 100644 --- a/src/aca_model/config.py +++ b/src/aca_model/config.py @@ -67,6 +67,8 @@ class GridConfig: GRID_CONFIG = GridConfig() BENCHMARK_GRID_CONFIG = GridConfig( + fold_hcc_transitory=True, + subtract_hcc_bill=True, n_assets_gridpoints=3, n_aime_gridpoints=3, n_consumption_dollars_gridpoints=5, diff --git a/tests/test_additive_shock_medical_bill.py b/tests/test_additive_shock_medical_bill.py index c7932af..9017edb 100644 --- a/tests/test_additive_shock_medical_bill.py +++ b/tests/test_additive_shock_medical_bill.py @@ -9,6 +9,7 @@ agree with the plain asset law to rounding. """ +from collections.abc import Mapping from dataclasses import replace import jax.numpy as jnp @@ -17,41 +18,46 @@ import pytest from helpers.model import make_baseline_model +from aca_model.agent import assets_and_income from aca_model.baseline.health_insurance import negative_oop_costs from aca_model.benchmark import get_benchmark_params from aca_model.config import BENCHMARK_GRID_CONFIG -_FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) +_FOLDED = replace( + BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True, subtract_hcc_bill=False +) _ADDITIVE = replace(_FOLDED, subtract_hcc_bill=True) _REGIME = "retiree_nomc_inelig_canwork" @pytest.fixture(scope="module") -def additive_model(): +def additive_model() -> lcm.Model: return make_baseline_model(grid_config=_ADDITIVE) -def test_negative_oop_costs_is_the_negated_bill(): +def test_negative_oop_costs_is_the_negated_bill() -> None: np.testing.assert_array_equal( negative_oop_costs(oop_costs=jnp.array([0.0, 1.5, 250.0])), np.array([0.0, -1.5, -250.0]), ) -def test_every_target_adds_the_negated_bill(additive_model): - laws = [ - (type(law), law.shock) - for name, regime in additive_model.user_regimes.items() - if name != "dead" - for law in regime.state_transitions["assets"].values() - ] - - assert laws - assert set(laws) == {(lcm.AdditiveShockTransition, "negative_oop_costs")} +def test_every_target_adds_the_negated_bill(additive_model: lcm.Model) -> None: + for name, regime in additive_model.user_regimes.items(): + if name == "dead": + continue + laws = regime.state_transitions["assets"] + assert isinstance(laws, Mapping) + assert laws + for law in laws.values(): + assert isinstance(law, lcm.AdditiveShockTransition) + assert law.shock == "negative_oop_costs" @pytest.mark.long_running -def test_additive_shock_value_equals_the_plain_law_value(additive_model): +def test_additive_shock_value_equals_the_plain_law_value( + additive_model: lcm.Model, +) -> None: """The two routes sum the same terms in a different order.""" folded_model = make_baseline_model(grid_config=_FOLDED) _, _, params = get_benchmark_params(model=folded_model) @@ -63,3 +69,27 @@ def test_additive_shock_value_equals_the_plain_law_value(additive_model): ) np.testing.assert_allclose(additive, folded, rtol=1e-9, atol=0) + + +@pytest.mark.parametrize( + ("bill", "expected_living", "expected_dead"), + [(0.0, 85.0, 75.0), (20.0, 65.0, 55.0), (250.0, -165.0, -175.0)], +) +def test_asset_law_preserves_resources_minus_medical_costs( + bill: float, expected_living: float, expected_dead: float +) -> None: + common = { + "cash_on_hand": jnp.array(100.0), + "transfers": jnp.array(5.0), + "consumption_dollars": jnp.array(30.0), + } + shock = negative_oop_costs(oop_costs=jnp.array(bill)) + living = ( + assets_and_income.assets_before_bill( + **common, pension_assets_adjustment=jnp.array(10.0) + ) + + shock + ) + dead = assets_and_income.assets_before_bill_when_dead(**common) + shock + np.testing.assert_allclose(living, expected_living, rtol=0, atol=0) + np.testing.assert_allclose(dead, expected_dead, rtol=0, atol=0) diff --git a/tests/test_benchmark_folding.py b/tests/test_benchmark_folding.py new file mode 100644 index 0000000..71c096f --- /dev/null +++ b/tests/test_benchmark_folding.py @@ -0,0 +1,64 @@ +"""The ACA benchmark activates transitory-shock folding explicitly.""" + +from collections.abc import Mapping +from dataclasses import replace + +import lcm +import numpy as np +import pytest +from lcm import DiscreteGrid + +from aca_model.agent.preferences import BenchmarkPrefType +from aca_model.benchmark import create_benchmark_model, get_benchmark_initial_conditions +from aca_model.config import BENCHMARK_GRID_CONFIG + + +@pytest.fixture(scope="module") +def benchmark_model() -> lcm.Model: + return create_benchmark_model(pref_type_grid=DiscreteGrid(BenchmarkPrefType)) + + +def test_benchmark_folds_the_transitory_medical_bill( + benchmark_model: lcm.Model, +) -> None: + model = benchmark_model + living = { + name: regime for name, regime in model.user_regimes.items() if name != "dead" + } + assert living + assert all( + "hcc_transitory" not in model.state_names(regime_name=name) for name in living + ) + for regime in living.values(): + laws = regime.state_transitions["assets"] + assert isinstance(laws, Mapping) + assert all( + isinstance(law, lcm.AdditiveShockTransition) for law in laws.values() + ) + initial = get_benchmark_initial_conditions(model=model, n_subjects=8, seed=42) + assert "hcc_transitory" not in initial + + +def test_unfolded_control_has_identical_shared_initial_conditions( + benchmark_model: lcm.Model, +) -> None: + control_grid = replace( + BENCHMARK_GRID_CONFIG, fold_hcc_transitory=False, subtract_hcc_bill=False + ) + control = create_benchmark_model( + pref_type_grid=DiscreteGrid(BenchmarkPrefType), grid_config=control_grid + ) + assert all( + "hcc_transitory" in control.state_names(regime_name=name) + for name in control.user_regimes + if name != "dead" + ) + folded_initial = get_benchmark_initial_conditions( + model=benchmark_model, n_subjects=8, seed=42 + ) + control_initial = get_benchmark_initial_conditions( + model=control, n_subjects=8, seed=42, grid_config=control_grid + ) + assert set(control_initial) == set(folded_initial) | {"hcc_transitory"} + for name, values in folded_initial.items(): + np.testing.assert_array_equal(values, control_initial[name]) diff --git a/tests/test_transition_only_medical_shock.py b/tests/test_transition_only_medical_shock.py index 4cb07f1..b2a1f42 100644 --- a/tests/test_transition_only_medical_shock.py +++ b/tests/test_transition_only_medical_shock.py @@ -13,6 +13,7 @@ import numpy as np import pytest from helpers.model import make_baseline_model +from lcm import Model from aca_model.benchmark import get_benchmark_params from aca_model.config import BENCHMARK_GRID_CONFIG @@ -22,18 +23,18 @@ @pytest.fixture(scope="module") -def folded_model(): +def folded_model() -> Model: return make_baseline_model(grid_config=_FOLDED) -def test_living_regimes_carry_no_lagged_transitory_shock(folded_model): +def test_living_regimes_carry_no_lagged_transitory_shock(folded_model: Model) -> None: assert not any( "hcc_transitory" in folded_model.state_names(regime_name=name) for name in folded_model.user_regimes ) -def test_living_regimes_keep_the_lagged_persistent_shock(folded_model): +def test_living_regimes_keep_the_lagged_persistent_shock(folded_model: Model) -> None: assert all( "hcc_persistent" in folded_model.state_names(regime_name=name) for name in folded_model.user_regimes @@ -43,10 +44,14 @@ def test_living_regimes_keep_the_lagged_persistent_shock(folded_model): @pytest.mark.long_running def test_folded_value_equals_every_transitory_slice_of_the_unfolded_value( - folded_model, -): + folded_model: Model, +) -> None: """The two models' sums differ only in order, so they agree to rounding.""" - unfolded_model = make_baseline_model() + unfolded_model = make_baseline_model( + grid_config=replace( + BENCHMARK_GRID_CONFIG, fold_hcc_transitory=False, subtract_hcc_bill=False + ) + ) _, _, params = get_benchmark_params(model=unfolded_model) unfolded = np.asarray( unfolded_model.solve(params=params, log_level="off").values[0][_REGIME] From ee48d9b5ed6739d054291a9a2e8734c4a1f9a2fd Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 11 Oct 2026 06:54:29 +0200 Subject: [PATCH 092/101] Preserve unfolded controls in legacy model test fixtures --- tests/helpers/model.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/helpers/model.py b/tests/helpers/model.py index df958e8..3645ab7 100644 --- a/tests/helpers/model.py +++ b/tests/helpers/model.py @@ -5,6 +5,8 @@ Production callers (aca-slurm, scripts) assemble these explicitly. """ +from dataclasses import replace + from lcm import DiscreteGrid, Model from aca_model.aca.health_insurance import PolicyVariant @@ -32,8 +34,12 @@ "pref_type": DiscreteGrid(BenchmarkPrefType), } +_UNFOLDED_GRID_CONFIG = replace( + BENCHMARK_GRID_CONFIG, fold_hcc_transitory=False, subtract_hcc_bill=False +) + -def make_baseline_model(*, grid_config: GridConfig = BENCHMARK_GRID_CONFIG) -> Model: +def make_baseline_model(*, grid_config: GridConfig = _UNFOLDED_GRID_CONFIG) -> Model: """Baseline model on `grid_config` with the benchmark snapshot params.""" fixed_params, wage_params, _ = get_benchmark_params(model=None) return _create_baseline_model( @@ -65,14 +71,16 @@ def aca_fixed_params(*, fixed_params: dict, policy: PolicyVariant) -> dict: } -def make_aca_model(*, policy: PolicyVariant) -> Model: - """ACA model on `BENCHMARK_GRID_CONFIG` with the benchmark snapshot params.""" +def make_aca_model( + *, policy: PolicyVariant, grid_config: GridConfig = _UNFOLDED_GRID_CONFIG +) -> Model: + """ACA model on `grid_config` with the benchmark snapshot params.""" fixed_params, wage_params, _ = get_benchmark_params(model=None) return _create_aca_model( policy=policy, fixed_params=aca_fixed_params(fixed_params=fixed_params, policy=policy), wage_params=wage_params, derived_categoricals=_DERIVED_CATEGORICALS, - grid_config=BENCHMARK_GRID_CONFIG, + grid_config=grid_config, pref_type_grid=DiscreteGrid(BenchmarkPrefType), ) From b962ebd27f01f03e7ec6fe5888fdca4feae0dd50 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 11 Oct 2026 06:56:47 +0200 Subject: [PATCH 093/101] Check folded values across every solved model cell --- tests/test_additive_shock_medical_bill.py | 26 ++++++++++----- tests/test_transition_only_medical_shock.py | 37 +++++++++++++-------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/tests/test_additive_shock_medical_bill.py b/tests/test_additive_shock_medical_bill.py index 9017edb..9fff9d0 100644 --- a/tests/test_additive_shock_medical_bill.py +++ b/tests/test_additive_shock_medical_bill.py @@ -27,7 +27,6 @@ BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True, subtract_hcc_bill=False ) _ADDITIVE = replace(_FOLDED, subtract_hcc_bill=True) -_REGIME = "retiree_nomc_inelig_canwork" @pytest.fixture(scope="module") @@ -61,14 +60,23 @@ def test_additive_shock_value_equals_the_plain_law_value( """The two routes sum the same terms in a different order.""" folded_model = make_baseline_model(grid_config=_FOLDED) _, _, params = get_benchmark_params(model=folded_model) - folded = np.asarray( - folded_model.solve(params=params, log_level="off").values[0][_REGIME] - ) - additive = np.asarray( - additive_model.solve(params=params, log_level="off").values[0][_REGIME] - ) - - np.testing.assert_allclose(additive, folded, rtol=1e-9, atol=0) + folded_values = folded_model.solve(params=params, log_level="off").values + additive_values = additive_model.solve(params=params, log_level="off").values + assert set(additive_values) == set(folded_values) + for period, regimes in folded_values.items(): + assert set(additive_values[period]) == set(regimes) + for name, values in regimes.items(): + np.testing.assert_array_equal( + np.isfinite(additive_values[period][name]), np.isfinite(values) + ) + np.testing.assert_allclose( + additive_values[period][name], + values, + rtol=1e-9, + atol=0, + equal_nan=False, + err_msg=f"period={period}, regime={name}", + ) @pytest.mark.parametrize( diff --git a/tests/test_transition_only_medical_shock.py b/tests/test_transition_only_medical_shock.py index b2a1f42..a112d16 100644 --- a/tests/test_transition_only_medical_shock.py +++ b/tests/test_transition_only_medical_shock.py @@ -19,7 +19,6 @@ from aca_model.config import BENCHMARK_GRID_CONFIG _FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) -_REGIME = "retiree_nomc_inelig_canwork" @pytest.fixture(scope="module") @@ -53,15 +52,27 @@ def test_folded_value_equals_every_transitory_slice_of_the_unfolded_value( ) ) _, _, params = get_benchmark_params(model=unfolded_model) - unfolded = np.asarray( - unfolded_model.solve(params=params, log_level="off").values[0][_REGIME] - ) - folded = np.asarray( - folded_model.solve(params=params, log_level="off").values[0][_REGIME] - ) - axis = unfolded_model.state_names(regime_name=_REGIME).index("hcc_transitory") - by_slice = np.moveaxis(unfolded, axis, 0) - - np.testing.assert_allclose( - by_slice, np.broadcast_to(folded, by_slice.shape), rtol=1e-6, atol=0 - ) + unfolded_values = unfolded_model.solve(params=params, log_level="off").values + folded_values = folded_model.solve(params=params, log_level="off").values + assert set(folded_values) == set(unfolded_values) + for period, regimes in unfolded_values.items(): + assert set(folded_values[period]) == set(regimes) + for name, values in regimes.items(): + unfolded = np.asarray(values) + folded = np.asarray(folded_values[period][name]) + states = unfolded_model.state_names(regime_name=name) + by_slice = ( + np.moveaxis(unfolded, states.index("hcc_transitory"), 0) + if "hcc_transitory" in states + else unfolded + ) + expected = np.broadcast_to(folded, by_slice.shape) + np.testing.assert_array_equal(np.isfinite(by_slice), np.isfinite(expected)) + np.testing.assert_allclose( + by_slice, + expected, + rtol=1e-6, + atol=0, + equal_nan=False, + err_msg=f"period={period}, regime={name}", + ) From 89d26dda693c92cfda02e2352fa914c174175fec Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 11 Oct 2026 06:58:42 +0200 Subject: [PATCH 094/101] State exact ACA cost-sharing parity with a numeric assertion --- tests/test_aca_policies.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_aca_policies.py b/tests/test_aca_policies.py index f2c9dee..b16fda4 100644 --- a/tests/test_aca_policies.py +++ b/tests/test_aca_policies.py @@ -3,6 +3,7 @@ import inspect import jax.numpy as jnp +import numpy as np import pytest from lcm.params import MappingLeaf @@ -402,4 +403,4 @@ def test_cost_sharing_scale_of_its_bracket_equals_the_cost_sharing_scale( cost_sharing_bracket=bracket, cost_sharing_schedule=COST_SHARING_SCHEDULE ) - assert scale == aca_hi.cost_sharing(**kwargs) + np.testing.assert_allclose(scale, aca_hi.cost_sharing(**kwargs), rtol=0, atol=0) From 54397e8dc6df4c3de53c4470e92f4754ce9595dc Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 11 Oct 2026 07:06:38 +0200 Subject: [PATCH 095/101] Use the unfolded control for lagged medical-shock twins --- tests/test_medical_cost_timing.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_medical_cost_timing.py b/tests/test_medical_cost_timing.py index 0705808..2ebbc8d 100644 --- a/tests/test_medical_cost_timing.py +++ b/tests/test_medical_cost_timing.py @@ -8,6 +8,8 @@ year's costs, and not at all on the lagged transitory shock. """ +from dataclasses import replace + import jax.numpy as jnp import numpy as np import pandas as pd @@ -20,6 +22,7 @@ get_benchmark_initial_conditions, get_benchmark_params, ) +from aca_model.config import BENCHMARK_GRID_CONFIG _SHOCKS = ("hcc_persistent", "hcc_transitory") @@ -27,9 +30,16 @@ @pytest.fixture(scope="module") def period_0_by_shock() -> dict[tuple[str, str], pd.Series]: """Period-0 rows of twins that differ in one lagged shock, at its grid ends.""" - model = create_benchmark_model(pref_type_grid=DiscreteGrid(BenchmarkPrefType)) + grid_config = replace( + BENCHMARK_GRID_CONFIG, fold_hcc_transitory=False, subtract_hcc_bill=False + ) + model = create_benchmark_model( + pref_type_grid=DiscreteGrid(BenchmarkPrefType), grid_config=grid_config + ) _, _, params = get_benchmark_params(model=model) - base = get_benchmark_initial_conditions(model=model, n_subjects=1, seed=0) + base = get_benchmark_initial_conditions( + model=model, n_subjects=1, seed=0, grid_config=grid_config + ) grids = model.user_regimes["retiree_nomc_inelig_canwork"].states initial_conditions = {name: jnp.tile(values, 4) for name, values in base.items()} for row, shock in enumerate(_SHOCKS): From 5e69b66e20a76bd715151697b6c1e07b34c461d3 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Sun, 11 Oct 2026 07:06:54 +0200 Subject: [PATCH 096/101] Keep the transition-only fold witness separate from additive folding --- tests/test_transition_only_medical_shock.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_transition_only_medical_shock.py b/tests/test_transition_only_medical_shock.py index a112d16..a4a148f 100644 --- a/tests/test_transition_only_medical_shock.py +++ b/tests/test_transition_only_medical_shock.py @@ -18,7 +18,9 @@ from aca_model.benchmark import get_benchmark_params from aca_model.config import BENCHMARK_GRID_CONFIG -_FOLDED = replace(BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True) +_FOLDED = replace( + BENCHMARK_GRID_CONFIG, fold_hcc_transitory=True, subtract_hcc_bill=False +) @pytest.fixture(scope="module") From 03a8143055fd3f67356f585a9201c4462b5b3ed5 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Tue, 20 Oct 2026 17:27:34 +0200 Subject: [PATCH 097/101] Pass grid points to IrregSpacedGrid as arrays 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) Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S --- src/aca_model/baseline/regimes/_common.py | 4 ++-- src/aca_model/baseline/regimes/_dcegm.py | 5 ++++- src/aca_model/baseline/regimes/_nbegm.py | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 2032ce1..11bcc7d 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -342,7 +342,7 @@ def build_grids( consumption_dollars=( IrregSpacedGrid(n_points=grid_config.n_consumption_dollars_gridpoints) if consumption_dollars_points is None - else IrregSpacedGrid(points=consumption_dollars_points) + else IrregSpacedGrid(points=np.asarray(consumption_dollars_points)) ), wage_res=wage_res, hcc_persistent=hcc_persistent, @@ -378,7 +378,7 @@ def build_assets_grid(*, floor: float, n_points: int) -> IrregSpacedGrid: ) negative[0] = floor positive[-1] = _ASSETS_TOP - return IrregSpacedGrid(points=np.concatenate([negative, positive]).tolist()) + return IrregSpacedGrid(points=np.concatenate([negative, positive])) def get_hcc_persistent_shock(*, grid_config: GridConfig) -> RouwenhorstAR1Process: diff --git a/src/aca_model/baseline/regimes/_dcegm.py b/src/aca_model/baseline/regimes/_dcegm.py index 54e3ab2..588cbae 100644 --- a/src/aca_model/baseline/regimes/_dcegm.py +++ b/src/aca_model/baseline/regimes/_dcegm.py @@ -11,6 +11,7 @@ the one piece that needs `lcm.solvers`. """ +import numpy as np from lcm import IrregSpacedGrid from lcm.solvers import DCEGM @@ -31,7 +32,9 @@ def build_dcegm_solver(grids: Grids) -> DCEGM: assets_points = grids.assets.to_jax() savings_stop = float(assets_points[-1]) - float(assets_points[0]) savings_grid = IrregSpacedGrid( - points=tuple(savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)), + points=np.array( + [savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)] + ), ) return DCEGM(savings_grid=savings_grid) diff --git a/src/aca_model/baseline/regimes/_nbegm.py b/src/aca_model/baseline/regimes/_nbegm.py index 23fb96a..1007ed0 100644 --- a/src/aca_model/baseline/regimes/_nbegm.py +++ b/src/aca_model/baseline/regimes/_nbegm.py @@ -4,6 +4,7 @@ Device placement and compiled program widths belong to the model execution policy. """ +import numpy as np from lcm import IrregSpacedGrid from lcm.solvers import NBEGM @@ -17,7 +18,9 @@ def build_nbegm_solver(grids: Grids) -> NBEGM: assets_points = grids.assets.to_jax() savings_stop = float(assets_points[-1]) - float(assets_points[0]) savings_grid = IrregSpacedGrid( - points=tuple(savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)), + points=np.array( + [savings_stop * (i / (n_points - 1)) ** 3 for i in range(n_points)] + ), ) return NBEGM( savings_grid=savings_grid, From 3c237f9faff4745c53403c6e90dceede5f02b187 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 22 Oct 2026 21:04:26 +0200 Subject: [PATCH 098/101] Let simulations restart mid-life from any living regime `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) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- src/aca_model/aca/model.py | 8 +++- src/aca_model/baseline/model.py | 8 +++- src/aca_model/baseline/regimes/__init__.py | 2 + src/aca_model/baseline/regimes/_common.py | 18 +++++++ src/aca_model/benchmark.py | 8 +++- tests/test_restart_initial_nodes.py | 56 ++++++++++++++++++++++ 6 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 tests/test_restart_initial_nodes.py diff --git a/src/aca_model/aca/model.py b/src/aca_model/aca/model.py index 5b17c97..971b8b0 100644 --- a/src/aca_model/aca/model.py +++ b/src/aca_model/aca/model.py @@ -7,7 +7,7 @@ from collections.abc import Mapping from typing import Any -from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model +from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, InitialNodes, Model from lcm.typing import UserParams from aca_model.aca import PolicyVariant @@ -34,6 +34,7 @@ def create_model( solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, execution_config: ExecutionConfig | None = None, + initial_nodes: InitialNodes = INITIAL_NODES, ) -> Model: """Create an ACA policy variant model. @@ -61,6 +62,9 @@ def create_model( execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. + initial_nodes: Admissible starting `(age, regime)` pairs. The default + admits the estimation sample's entry ages and regimes; pass + `build_restart_initial_nodes` to restart simulations mid-life. Returns: pylcm Model. @@ -91,7 +95,7 @@ def create_model( return Model( regimes=regimes, - initial_nodes=INITIAL_NODES, + initial_nodes=initial_nodes, ages=ages, regime_id_class=RegimeId, description=f"Structural retirement model ({policy.name})", diff --git a/src/aca_model/baseline/model.py b/src/aca_model/baseline/model.py index 3522e30..1b1efe3 100644 --- a/src/aca_model/baseline/model.py +++ b/src/aca_model/baseline/model.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from typing import Any -from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, Model +from lcm import AgeGrid, DiscreteGrid, ExecutionConfig, InitialNodes, Model from lcm.typing import UserParams from aca_model.baseline.regimes import ( @@ -37,6 +37,7 @@ def create_model( solver: SolverName = "brute_force", consumption_dollars_points: tuple[float, ...] | None = None, execution_config: ExecutionConfig | None = None, + initial_nodes: InitialNodes = INITIAL_NODES, ) -> Model: """Create the baseline structural retirement model. @@ -73,6 +74,9 @@ def create_model( execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. + initial_nodes: Admissible starting `(age, regime)` pairs. The default + admits the estimation sample's entry ages and regimes; pass + `build_restart_initial_nodes` to restart simulations mid-life. Returns: A pylcm Model with 19 regimes (18 non-terminal + dead) spanning @@ -102,7 +106,7 @@ def create_model( return Model( regimes=regimes, - initial_nodes=INITIAL_NODES, + initial_nodes=initial_nodes, ages=ages, regime_id_class=RegimeId, description="Baseline structural retirement model (pre-ACA)", diff --git a/src/aca_model/baseline/regimes/__init__.py b/src/aca_model/baseline/regimes/__init__.py index bd83aed..65b1f19 100644 --- a/src/aca_model/baseline/regimes/__init__.py +++ b/src/aca_model/baseline/regimes/__init__.py @@ -33,6 +33,7 @@ build_model_functions, build_model_state_transitions, build_model_states, + build_restart_initial_nodes, ) from aca_model.baseline.regimes._dcegm import build_dcegm_solver from aca_model.baseline.regimes._nbegm import build_nbegm_solver @@ -46,6 +47,7 @@ "build_all_regimes", "build_model_slots", "build_regime", + "build_restart_initial_nodes", "nongroup", "retiree", "tied", diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 2032ce1..5dd4180 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -526,6 +526,24 @@ def transition_ages(spec: RegimeSpec) -> tuple[int, ...]: raise ValueError(f"Unknown regime spec: {spec}") +def build_restart_initial_nodes(*, ages: Iterable[int]) -> InitialNodes: + """Admit every living regime active at each of `ages` as a start. + + A simulation restarted from the states another simulation reached at some + age starts in whichever living regime that simulation had arrived at. + """ + return InitialNodes( + by_age={ + age: tuple( + name + for name, spec in REGIME_SPECS.items() + if age in transition_ages(spec) + ) + for age in ages + } + ) + + def next_model_age(period: Period) -> FloatND: """Advance by one grid position, never by an assumed number of years. diff --git a/src/aca_model/benchmark.py b/src/aca_model/benchmark.py index 3eed460..c3ca289 100644 --- a/src/aca_model/benchmark.py +++ b/src/aca_model/benchmark.py @@ -29,14 +29,14 @@ import jax.numpy as jnp import numpy as np from jax import Array -from lcm import DiscreteGrid, ExecutionConfig, Model +from lcm import DiscreteGrid, ExecutionConfig, InitialNodes, Model from aca_model.agent.health import GoodHealth from aca_model.agent.labor_market import IsMarried from aca_model.agent.preferences import BenchmarkPrefType from aca_model.baseline.health_insurance import HealthInsuranceState from aca_model.baseline.model import create_model -from aca_model.baseline.regimes import ENTRY_REGIMES +from aca_model.baseline.regimes import ENTRY_REGIMES, INITIAL_NODES from aca_model.config import BENCHMARK_GRID_CONFIG, GridConfig from aca_model.consumption_dollars_grid import ( compute_consumption_dollars_points, @@ -68,6 +68,7 @@ def create_benchmark_model( pref_type_grid: DiscreteGrid, grid_config: GridConfig = BENCHMARK_GRID_CONFIG, execution_config: ExecutionConfig | None = None, + initial_nodes: InitialNodes = INITIAL_NODES, ) -> Model: """Create the aca baseline with `BENCHMARK_GRID_CONFIG` and frozen fixed_params. @@ -80,6 +81,8 @@ def create_benchmark_model( execution_config: Explicit hardware-local policy forwarded unchanged. None uses the smallest selected accelerator allocator limit as the device-memory budget; CPU construction remains unbudgeted. + initial_nodes: Admissible starting `(age, regime)` pairs, forwarded + to `create_model`. """ fixed_params, wage_params, _ = get_benchmark_params(model=None) return create_model( @@ -89,6 +92,7 @@ def create_benchmark_model( derived_categoricals=_DERIVED_CATEGORICALS, pref_type_grid=pref_type_grid, execution_config=execution_config, + initial_nodes=initial_nodes, ) diff --git a/tests/test_restart_initial_nodes.py b/tests/test_restart_initial_nodes.py new file mode 100644 index 0000000..0fa69ea --- /dev/null +++ b/tests/test_restart_initial_nodes.py @@ -0,0 +1,56 @@ +"""Starts mid-life, at the regimes a simulation can have reached by that age.""" + +import pandas as pd +import pytest +from lcm import DiscreteGrid + +from aca_model.agent.preferences import BenchmarkPrefType +from aca_model.baseline.regimes import build_restart_initial_nodes +from aca_model.benchmark import create_benchmark_model +from aca_model.simulation import select_admissible_starts + + +@pytest.mark.parametrize( + ("age", "expected"), + [ + ( + 61, + ( + "retiree_nomc_inelig_canwork", + "tied_nomc_inelig_canwork", + "nongroup_nomc_inelig_canwork", + "retiree_dimc_inelig_canwork", + "nongroup_dimc_inelig_canwork", + ), + ), + ( + 66, + ( + "retiree_oamc_choose_canwork", + "tied_oamc_choose_canwork", + "nongroup_oamc_choose_canwork", + ), + ), + ( + 73, + ("retiree_oamc_forced_forcedout", "nongroup_oamc_forced_forcedout"), + ), + ], +) +def test_build_restart_initial_nodes_admits_every_living_regime_of_the_age( + age: int, expected: tuple[str, ...] +) -> None: + """At each age, the starts are exactly the living regimes active then.""" + nodes = build_restart_initial_nodes(ages=(age,)) + assert set(nodes.by_age[age]) == set(expected) + + +def test_model_with_restart_nodes_admits_a_start_on_old_age_medicare() -> None: + """A model built with restart nodes accepts a start at 66 on Medicare.""" + model = create_benchmark_model( + pref_type_grid=DiscreteGrid(BenchmarkPrefType), + initial_nodes=build_restart_initial_nodes(ages=range(51, 70)), + ) + starts = pd.DataFrame({"age": [66.0], "regime_name": ["tied_oamc_choose_canwork"]}) + admitted = select_admissible_starts(model=model, initial_conditions=starts) + assert admitted["regime_name"].tolist() == ["tied_oamc_choose_canwork"] From bfcbbc3fc2be736a7ff2e976f12e14a70e60001d Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Thu, 22 Oct 2026 23:20:12 +0200 Subject: [PATCH 099/101] Register policy and solver helpers only where consumed --- src/aca_model/aca/regimes/_overrides.py | 5 +++- src/aca_model/baseline/regimes/_common.py | 10 ++----- tests/test_aca_plan.py | 15 ++++++++++ tests/test_model_creation.py | 34 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/aca_model/aca/regimes/_overrides.py b/src/aca_model/aca/regimes/_overrides.py index eb92ffe..23cbd77 100644 --- a/src/aca_model/aca/regimes/_overrides.py +++ b/src/aca_model/aca/regimes/_overrides.py @@ -8,6 +8,7 @@ from aca_model.aca import health_insurance as aca_hi from aca_model.aca.health_insurance import PolicyVariant +from aca_model.baseline import health_insurance as baseline_hi from aca_model.baseline.regimes._common import RegimeSpec from aca_model.environment import taxes @@ -55,6 +56,7 @@ def apply_aca_overrides( ) if has_medicaid_expansion: + functions["aca_magi"] = baseline_hi.aca_magi functions["is_medicaid_eligible"] = aca_hi.is_medicaid_eligible if has_subsidies and spec["his"] == "nongroup" and spec["mc"] == "nomc": @@ -64,8 +66,9 @@ def apply_aca_overrides( # dict, not a DAG function — no entry needed here. functions["plan_premium"] = aca_hi.community_rated_premium # The community-rated premium replaces the risk-rated one, so the - # risk-rated premium's zero-profit intercept has no consumer left. + # risk-rated inputs have no consumer left. functions.pop("private_premium_intercept", None) + functions.pop("predicted_hcc_insurer", None) functions["hic_premium_subsidy"] = aca_hi.premium_subsidy # Split at the bracket, which takes finitely many values, so the # out-of-pocket bill can be averaged per bracket. diff --git a/src/aca_model/baseline/regimes/_common.py b/src/aca_model/baseline/regimes/_common.py index 11bcc7d..2816f12 100644 --- a/src/aca_model/baseline/regimes/_common.py +++ b/src/aca_model/baseline/regimes/_common.py @@ -845,12 +845,10 @@ def build_model_functions(*, solver: SolverName = "brute_force") -> dict: # assets adjustment, both of which scale by the marginal income tax rate. functions["marginal_tax_rate"] = taxes.marginal_rate - # HIC premium - functions["predicted_hcc_insurer"] = health_insurance.hcc_insurer_predicted - # Transfers functions["consumption_dollars_floor"] = assets_and_income.consumption_dollars_floor - functions["transfers"] = assets_and_income.transfers + if solver == "brute_force": + functions["transfers"] = assets_and_income.transfers functions["consumption_equiv"] = preferences.consumption_equiv return functions @@ -999,10 +997,8 @@ def build_common_functions(spec: RegimeSpec, *, subtract_bill: bool = False) -> if spec["mc"] == "oamc" else health_insurance.is_disabled_from_health ) - # MAGI for the ACA Medicaid expansion track; pruned in the baseline DAG. - functions["aca_magi"] = health_insurance.aca_magi - # Swapped per policy variant by the ACA overlay, hence regime-level + functions["predicted_hcc_insurer"] = health_insurance.hcc_insurer_predicted functions["is_medicaid_eligible"] = health_insurance.is_medicaid_eligible functions["after_tax_income"] = taxes.after_tax_income if subtract_bill: diff --git a/tests/test_aca_plan.py b/tests/test_aca_plan.py index c8b0e3c..8686faa 100644 --- a/tests/test_aca_plan.py +++ b/tests/test_aca_plan.py @@ -83,6 +83,21 @@ def test_aca_non_group_premium_is_community_rated( assert _aca_premium(policy, spousal_income) == pytest.approx(expected) +@pytest.mark.parametrize("policy", list(PolicyVariant)) +def test_aca_non_group_risk_prediction_is_registered_only_without_reform( + policy: PolicyVariant, +) -> None: + """The community-rated premium does not register a risk prediction node.""" + functions = {**build_model_functions(), **_build_functions(REGIME_SPECS[REGIME])} + apply_aca_overrides(functions, REGIME_SPECS[REGIME], policy) + expected = ( + health_insurance.hcc_insurer_predicted + if policy == PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION + else None + ) + assert functions.get("predicted_hcc_insurer") is expected + + RISK_RATED_PREMIUM_PARAMS = frozenset( { "premium_markup", diff --git a/tests/test_model_creation.py b/tests/test_model_creation.py index 9940ffd..790a98f 100644 --- a/tests/test_model_creation.py +++ b/tests/test_model_creation.py @@ -13,11 +13,15 @@ from aca_model.aca import health_insurance as aca_hi from aca_model.aca.health_insurance import PolicyVariant from aca_model.aca.regimes import build_all_regimes as _build_aca_regimes +from aca_model.agent import assets_and_income from aca_model.agent.preferences import BenchmarkPrefType +from aca_model.baseline import health_insurance as baseline_hi from aca_model.baseline.regimes import REGIME_SPECS, RegimeId from aca_model.baseline.regimes import build_regime as _build_regime from aca_model.baseline.regimes._common import ( + SolverName, build_grids, + build_model_functions, build_model_state_transitions, build_model_states, ) @@ -290,6 +294,36 @@ def test_aca_model_creates_successfully() -> None: assert model.n_periods == 46 +@pytest.mark.parametrize("name", REGIME_SPECS) +def test_baseline_regime_omits_expansion_magi(name: str) -> None: + """Baseline eligibility does not register the ACA expansion income node.""" + assert "aca_magi" not in build_regime(name).functions + + +@pytest.mark.parametrize("policy", list(PolicyVariant)) +def test_aca_regime_registers_magi_only_for_expansion(policy: PolicyVariant) -> None: + """The expansion eligibility consumer owns its MAGI income node.""" + has_expansion = policy in ( + PolicyVariant.ACA, + PolicyVariant.ACA_NO_MANDATE, + PolicyVariant.ACA_ONLY_MEDICAID_EXPANSION, + ) + expected = baseline_hi.aca_magi if has_expansion else None + regimes = build_aca_regimes(policy) + assert { + name: regimes[name].functions.get("aca_magi") for name in REGIME_SPECS + } == dict.fromkeys(REGIME_SPECS, expected) + + +@pytest.mark.parametrize("solver", ["brute_force", "dcegm", "nbegm"]) +def test_model_registers_transfers_only_for_brute_asset_laws( + solver: SolverName, +) -> None: + """Savings-form asset laws already include transfers through resources.""" + expected = assets_and_income.transfers if solver == "brute_force" else None + assert build_model_functions(solver=solver).get("transfers") is expected + + def test_aca_nongroup_inelig_has_real_functions() -> None: """Nongroup+nomc regimes get real ACA functions under ACA policy.""" regimes = build_aca_regimes(PolicyVariant.ACA) From ffb2f37cd9c5ba619ef75c1cb415140448cb8d35 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 23 Oct 2026 06:21:10 +0200 Subject: [PATCH 100/101] Test against pylcm #604 and offer uncompensated-care targets Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- .github/workflows/main.yml | 2 +- tests/test_benchmark.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48163c1..71f5307 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: # install; keep it compatible with the model's graph declarations. run: >- pip install "pylcm @ - git+https://github.com/OpenSourceEconomics/pylcm.git@eea73a8f0e301e88f02278df3595c797fdba24df" + git+https://github.com/OpenSourceEconomics/pylcm.git@7e99a19badafe20e29a517f69d9ec155bba8e0a0" - name: Install aca-model with test deps run: pip install -e . pytest pdbp - name: Run pytest diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 74b3af8..69b5b64 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -2,6 +2,7 @@ import numpy as np import pytest +from _lcm.simulation.additional_targets import _collect_all_available_targets from lcm import ( DiscreteGrid, GridBreakpoint, @@ -112,6 +113,17 @@ def test_benchmark_panel_exposes_hic_premium_and_wage_targets() -> None: assert (defined_wage > 0).all() +def test_benchmark_model_offers_uncompensated_care_targets() -> None: + """Transfers, the floor and total health costs can be added to the panel. + + Uncompensated care is the part of `transfers` above + `consumption_dollars_floor`, relative to `total_health_costs`. + """ + model = create_benchmark_model(pref_type_grid=DiscreteGrid(BenchmarkPrefType)) + available = _collect_all_available_targets(model._regimes) # noqa: SLF001 + assert {"transfers", "consumption_dollars_floor", "total_health_costs"} <= available + + @pytest.mark.long_running def test_benchmark_simulate_obeys_borrowing_constraint() -> None: """`consumption_dollars <= max(cash_on_hand, floor)` holds for every alive row. From d0d2ea620e1e766775b25e9d9dd12081919f3550 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Fri, 23 Oct 2026 08:15:32 +0200 Subject: [PATCH 101/101] Test the health-cost covariates instead of total health costs as targets Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hs8mfXPYyEa24KaAp6BwCW --- tests/test_benchmark.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 69b5b64..6e3a970 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -114,14 +114,21 @@ def test_benchmark_panel_exposes_hic_premium_and_wage_targets() -> None: def test_benchmark_model_offers_uncompensated_care_targets() -> None: - """Transfers, the floor and total health costs can be added to the panel. + """Transfers, the floor and the health-cost covariates can be added to the panel. Uncompensated care is the part of `transfers` above - `consumption_dollars_floor`, relative to `total_health_costs`. + `consumption_dollars_floor`. Total health costs depend on the next period's + shocks, so they are rebuilt from `is_married`, `good_health` and the shock + states rather than offered as a target. """ model = create_benchmark_model(pref_type_grid=DiscreteGrid(BenchmarkPrefType)) available = _collect_all_available_targets(model._regimes) # noqa: SLF001 - assert {"transfers", "consumption_dollars_floor", "total_health_costs"} <= available + assert { + "transfers", + "consumption_dollars_floor", + "is_married", + "good_health", + } <= available @pytest.mark.long_running