Runtime-supplied points on action grids; tighten grid + regime validators#338
Runtime-supplied points on action grids; tighten grid + regime validators#338hmgaudecker merged 23 commits intomainfrom
Conversation
Extends the existing runtime-points mechanism (previously state-only)
to continuous action grids. With this change, an action declared as
`IrregSpacedGrid(n_points=N)` adds an `{action_name: {"points":
"Float1D"}}` entry to the regime params template, and `state_action_space()`
substitutes the runtime-supplied points into `continuous_actions` at
solve / simulate time.
Motivation: aca-dev's structural retirement model has a `consumption`
action grid whose lower bound is the per-iteration `consumption_floor`
parameter. Without this change the c-grid bounds would have to be
fixed at build time, which forces either an over-wide grid (wasted
density) or model rebuilds per estimation iteration (recompilation).
Mirrors the existing state-grid treatment:
- `regime_template.py`: walks `regime.actions` alongside `regime.states`,
factoring the shared shadowing check into helpers.
- `interfaces.InternalRegime.state_action_space()`: builds both
state and continuous-action replacements in a single sweep over
`self.grids`, then calls `_base_state_action_space.replace(...)`
with whichever side actually had substitutions.
- `pandas_utils._is_runtime_grid_param`: also recognises action grids
so column extraction in `to_dataframe()` keeps working.
Tests (TDD): four new tests in `tests/test_runtime_params.py`,
mirroring the state-grid counterparts — params-template entry,
solve, runtime-vs-fixed equivalence, and a sanity check that
varying runtime points actually changes V.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Benchmark comparison (main → HEAD)Comparing
|
aca-model now declares `consumption` as `IrregSpacedGrid(n_points=N)` with runtime-supplied points. The bench builder now passes `model=model` to `get_benchmark_params` so consumption gridpoints are injected into params before solving. aca-model rev: adc8a19 → 4123fe9 (feature/runtime-consumption-points) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`IrregSpacedGrid(n_points=N)` declares a continuous grid whose values are supplied at runtime via `params[regime][grid_name]['points']`. Substitution happens inside `InternalRegime.state_action_space(regime_params=...)` at solve / simulate time. Any code path that calls `to_jax()` on the base grid before substitution silently got `jnp.full(N, jnp.nan)` and went on to compute against the placeholder. That is exactly what fired in `validate_initial_conditions` for `task_simulate_aca`: the validator built the action grid by calling `internal_regime.grids[name].to_jax()` (placeholder NaNs), then asked `borrowing_constraint(consumption=NaN, wealth=W)` whether each gridpoint was feasible. NaN comparisons are False, so every action was reported infeasible for every subject in every initial regime. Make the invariant explicit: `IrregSpacedGrid.to_jax()` raises `GridInitializationError` for runtime-supplied grids, with a message pointing the caller at `state_action_space(regime_params=...)` for real values or `.n_points` for shape. Confine the legitimate "placeholder needed for AOT tracing" caller (the base state-action space) to a private helper in `state_action_space.py` that uses NaN explicitly. Reroute `_check_regime_feasibility` through the substituted state-action space. Add regression tests covering both runtime action and runtime state grids round-tripping `simulate(check_initial_conditions=True)`, and unit tests pinning down the new raise + the existing NaN-source mechanics in `map_coordinates` / `get_irreg_coordinate`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Move late `DiscreteGrid`, `map_coordinates`, and `get_irreg_coordinate` imports to the module top level (PLC0415), drop the unnecessary `val` assignment before return (RET504), and mark the unused `wealth` arg in the local `borrow` constraint as `# noqa: ARG001`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A regime function whose output is then re-indexed by a discrete state inside another consumer (function, constraint, or transition) is a silent footgun: pylcm broadcasts function outputs to per-cell scalars before consumption, so the indexing silently produces NaN at runtime instead of the intended scalar. The aca-baseline benchmark hit this via `bequest(... utility_scale_factor[pref_type])` where `utility_scale_factor` is registered as a regime function — the dead regime's V came back all-NaN with no actionable error. Adds an AST-walking validator in `validate_logical_consistency` that inspects every consumer (functions, constraints, transition) for a `Subscript(Name=X, slice=Name=Y)` pattern where `X` is in `regime.functions` and `Y` is a `DiscreteGrid` state. If any clash is found, raises `RegimeInitializationError` listing each clash and pointing the user at the safe pattern (function takes the state, returns a scalar — see `discount_factor`). Three TDD tests in `tests/test_function_output_state_indexing.py`: - the clash raises (functions case) - the safe pattern (function takes the state, scalar return) builds - the check applies to constraints too Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aca-model `feature/runtime-consumption-points` 4123fe9 → 1342861 (refactors `utility_scale_factor` to take `pref_type` and return a scalar, eliminating the regime-function-output / state-indexed-input clash that produced NaN in the dead regime's V). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion space `create_regime_state_action_space` (used during forward simulation) was calling `create_state_action_space` directly, which leaves `pass_points_at_runtime=True` IrregSpacedGrid action grids as their NaN placeholder. The placeholder fed straight into `argmax_and_max_Q_over_a` and `_lookup_values_from_indices`, so optimal actions came back NaN, the source regime's `next_state` propagated NaN into every target regime's namespaced state, and `validate_V` raised on the first downstream regime whose utility depended on those states (the dead regime in aca-model: assets/pref_type both NaN). Route through `internal_regime.state_action_space(regime_params=...)` (the same path solve uses) and overlay the per-subject states. Add a TDD regression test in tests/test_runtime_params.py covering the simulate path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…grid values `LogSpacedGrid` previously inherited only the generic continuous-grid checks (start < stop, n_points > 0). With `start <= 0`, `to_jax()` silently returned NaN/-inf, and the bug would only surface deep inside an interpolation kernel. Now refuses at construction. While here, tighten two adjacent silent-failure modes: - `_validate_continuous_grid` rejects non-finite `start`/`stop`. `start >= stop` is False for NaN, so a NaN bound previously slipped through every check. - `_validate_irreg_spaced_grid` rejects non-finite points. The ascending-order test uses `>=`, which is False for NaN, so a NaN point previously passed the order check silently. Both matter for runtime-supplied grids: e.g. `geomspace(consumption_floor, MAX, N)` with a bad `consumption_floor` produces all-NaN points, and we want that caught at the grid layer rather than as a downstream V_arr NaN diagnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code reviewFound 6 issues (reporting all, no score filter applied per request).
pylcm/tests/test_single_feasible_action.py Lines 246 to 251 in db6214f pylcm/tests/test_single_feasible_action.py Lines 397 to 408 in db6214f pylcm/tests/test_single_feasible_action.py Lines 448 to 457 in db6214f
pylcm/tests/test_runtime_params.py Lines 153 to 175 in db6214f pylcm/tests/test_runtime_params.py Lines 243 to 286 in db6214f pylcm/tests/test_single_feasible_action.py Lines 264 to 294 in db6214f
pylcm/src/lcm/simulation/transitions.py Lines 52 to 61 in db6214f vs the dropped validation in: pylcm/src/lcm/state_action_space.py Lines 55 to 62 in db6214f
pylcm/src/lcm/params/regime_template.py Lines 107 to 121 in db6214f
pylcm/src/lcm/params/regime_template.py Lines 31 to 43 in db6214f
Lines 266 to 296 in db6214f 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
… banners
- tests/test_single_feasible_action.py: drop three decorative section
banners (AGENTS.md prohibits `# ---...---` separators); fold the
banner prose into the docstrings of the tests/helpers below.
- tests/test_single_feasible_action.py: type-annotate `_crra_bequest`
and `_alive_utility`'s pref_type / consumption_weight /
coefficient_rra arguments (DiscreteState / FloatND).
- tests/test_runtime_params.py: type-annotate `_make_action_grid_model`
and `_make_action_grid_model_with_stateful_dead`.
- src/lcm/simulation/transitions.py: re-run `_validate_all_states_present`
in the new `create_regime_state_action_space` (the substitution
switch from `create_state_action_space(states=...)` to
`base.replace(states=...)` had silently dropped this check).
- src/lcm/params/regime_template.py: docstring on
`_fail_if_runtime_grid_shadows_function`; fix stale phrasing in
`create_regime_params_template` ("matching the state name" →
"matching the state or action name").
- src/lcm/interfaces.py: comment why the `_ShockGrid` substitution
branch is gated on `in_states` only (state-only by design,
AGENTS.md forbids ShockGrids as actions; gate is the explicit
enforcement of that invariant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The validator's error message already explains why; the class docstring only needs the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rid path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Derived categoricals (`regime.derived_categoricals`, function outputs that pylcm treats as categoricals — see https://pylcm.readthedocs.io/en/latest/pandas-interop/#derived-categoricals) suffer the same per-cell broadcast clash as discrete states. Extend `discrete_state_names` in `_validate_function_output_state_indexing` to include them; add a TDD test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…module) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pylcm is a general library; references to a particular companion application become stale fast and force readers to know unrelated projects to follow the test rationale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The variable previously named `discrete_state_names` accumulated state
DiscreteGrids, derived categoricals, and now discrete actions — all
three suffer the same per-cell broadcast clash when a consumer does
`func_output[X]`. Renamed the variable, the two helpers
(`_validate_function_output_grid_indexing`,
`_find_function_output_grid_indexing`), the test module
(`test_function_output_grid_indexing.py`), and the error-message
wording ("discrete state" → "discrete grid"). Added a TDD test for
the discrete-action case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion The previous docstring claimed the indexing 'silently produces NaN', but a disabled-validator probe shows otherwise: - When the producer takes the discrete grid as input, its output is a per-cell scalar; `func_output[grid]` raises `IndexError: Too many indices` at trace time. This is the real footgun the validator should catch. - When the producer does NOT take the discrete grid as input, its output stays array-shaped and `func_output[grid]` is correct code that solves to sensible V values. The previous validator flagged both shapes — including the safe one — as a clash. Tighten: only fire when the producing function also takes the discrete grid as input. Update the description to match observed behaviour (IndexError, not NaN). Add a regression test that exercises the array-valued-producer + state-indexed-consumer shape and asserts it builds without raising. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Note: aca-baseline performance regression is because the model switched to using an |
mj023
left a comment
There was a problem hiding this comment.
Everything else looks correct.
| raise RegimeInitializationError(msg) | ||
|
|
||
|
|
||
| def _validate_function_output_grid_indexing(regime: Regime) -> list[str]: |
There was a problem hiding this comment.
I would prefer to not do this check. I think it's quite intuitive that indexing like this is wrong and checking all user functions for mistakes like this is too much. I understand that it can be hard to debug just from the error message, but I think the examples are enough to show how to correctly use additional functions.
There was a problem hiding this comment.
It's always a fine line between trying to error early and expecting a certain level of understanding from the user. In this case, it actually happened to me when I got a little lost during refactoring and forgot an instance.
It would be great if you could spell out what precisely you are worried about? E.g., runtime, fragility of the approach, ...?
There was a problem hiding this comment.
I think checking for this is quite complicated and there are many ways to hide the pattern from the check and still get essentially the same error. Users are also supposed to mostly treat their functions like they were using scalars, so it should be clear that this won't work. I just want to avoid that we start checking the user supplied functions for all sorts of coding errors, as the same justification can be made for many other cases, like for example a user having a function utility( consumption, ..) and thinking they can index into the consumption variable consumption[0] because they provided consumption as a grid.
If you both want this check then I'm fine with it, but we should really try not to add too many of these, as they add complexity and are not very reliable.
There was a problem hiding this comment.
I agree on the general sentiment. At the same time, I don't think this will hurt much and do at least some good (it certainly would have saved me a couple of hours had it been around last week).
So let's keep it in but remove it should it ever cause trouble!
…entries - AGENTS.md: include @.ai-instructions/modules/dags.md (pylcm is dags-built; the convention/idioms are project-relevant). - .pre-commit-config.yaml: narrow GFM mdformat `files:` to root-level (AGENTS|CLAUDE|README); the `modules/.*|profiles/.*` patterns were intended for the .ai-instructions repo and never matched here. Bump check-jsonschema 0.37.1 → 0.37.2 and ruff v0.15.11 → v0.15.12 to current. - .gitignore: drop unused `# pytask` section (pylcm doesn't use pytask). - .github/workflows/main.yml: pixi-version v0.66.0 → v0.67.2 to match the locally installed pixi. All other pinned hooks/actions verified at-or-newer than the boilerplate template baseline.
Summary
Three threads bundled into one PR:
Feature.
IrregSpacedGridruntime-supplied points (previously state-only) extended to continuous action grids. Action grids declared asIrregSpacedGrid(n_points=N)now contribute{action_name: {"points": "Float1D"}}to the regime params template;InternalRegime.state_action_space(regime_params=...)substitutes runtime points intocontinuous_actionsat solve and simulate time. Motivation: lets a model tie itsconsumption(or other continuous-action) grid bounds to a per-iteration parameter without rebuilding the model each estimation step.Two silent-failure fixes that surfaced while completing the feature.
create_regime_state_action_space(forward simulation) bypassed the substitution that solve uses, sostate_action_space.continuous_actionscarried the compile-time NaN placeholder. That fed intoargmax_and_max_Q_over_aand_lookup_values_from_indices, optimal actions came back NaN, the source regime'snext_statepropagated NaN to every target regime's namespaced state, andvalidate_Vraised on the first downstream regime whose utility depended on those states. Now routes through the same substitution path solve uses; restores_validate_all_states_presenton the per-subject states overlay.IrregSpacedGrid.to_jax()silently returned a placeholder. Previously runtime grids returnedjnp.full(N, NaN)fromto_jax(), so any caller forgetting to callstate_action_space(regime_params=...)got an undetectably broken grid. Now raises with a message pointing the caller at the substituted-grid path.Adjacent validator hardening (also surfaced while completing the feature).
fthat takes a discrete gridg(state, action, or derived categorical) as input has a per-cell scalar output, and a consumer that doesf[g]raisesIndexError: Too many indices: array is 0-dimensional, but 1 were indexedat trace time — well after the user could see what they wrote. New AST validator catches the pattern atRegime.__post_init__. The clashing function in the original spec produced this footgun and survived all existing validators.func_output[grid]is correct code when the producer doesn't takegrid; the validator now correctly exempts that shape).LogSpacedGrid(start=0, …)(or negative) silently produced NaN/-inf viato_jax()because the base validator only checkedstart < stop. Now refused at construction. Tightened two related silent-failure modes for free:_validate_continuous_gridrejects non-finitestart/stop(thestart >= stopcheck is False for NaN, so a NaN bound previously slipped through), and_validate_irreg_spaced_gridrejects non-finite points (the ascending-order test uses>=, also False for NaN, so an all-NaN runtime-supplied points array previously passed the order check silently).Changes
src/lcm/grids/continuous.py—LogSpacedGrid.__post_init__enforcesstart > 0;_validate_continuous_gridrejects non-finite bounds viajnp.isfinite;_validate_irreg_spaced_gridrejects non-finite points;IrregSpacedGrid.to_jax()raises with a message pointing atstate_action_space(regime_params=...).states[name]/.continuous_actions[name].src/lcm/params/regime_template.py— walkregime.actionsalongsideregime.statesfor runtime-grid params; shared_fail_if_runtime_grid_shadows_functionhelper.src/lcm/interfaces.py—InternalRegime.state_action_space(regime_params=...)builds both state and continuous-action replacements in one pass and returns via_base_state_action_space.replace(...).src/lcm/state_action_space.py—_grid_to_jax_or_placeholderprivate helper centralises the NaN-placeholder convention; deep-module ordering (public function on top, helpers below).src/lcm/regime_building/validation.py— new_validate_function_output_grid_indexingAST validator covering states, actions, and derived categoricals; only fires when the producing function takes the discrete grid as input.src/lcm/simulation/transitions.py+src/lcm/simulation/simulate.py—create_regime_state_action_spaceroutes throughinternal_regime.state_action_space(regime_params=...)and overlays per-subject states with_validate_all_states_present.src/lcm/pandas_utils.py—_is_runtime_grid_paramalso recognises action grids.tests/test_runtime_params.py— TDD tests for runtime action grids: template entry, solve works, runtime ≡ fixed equivalence, varying runtime points changes V, simulate path returns finite V.tests/test_function_output_grid_indexing.py— TDD tests for the validator across all three discrete-grid sources, plus a regression test that the array-valued-producer + state-indexed-consumer shape (correct code) is not flagged.tests/test_grids.py— TDD tests for the new log-grid / non-finite guards.tests/test_single_feasible_action.py— regression tests pinning downmap_coordinatesNaN-on-non-finite-coordinate,get_irreg_coordinatedivide-by-zero on duplicate points, runtime-state-gridvalidate_initial_conditionsplaceholder feasibility, and CRRA-bequest-with-discrete-state-indexing underjnp.where.Test plan
pixi run -e tests-cpu tests(880 passed, 5 skipped)pixi run typrek run --all-filestest_benchmark_model_simulates_end_to_end) — full 18-regime baseline solve+simulate with runtime-supplied consumption points🤖 Generated with Claude Code