Repository navigation
Solve and simulate on reachable discrete states with a composite state (#522) - #574
Conversation
Brings the CI repairs of PRs 568 and 569 down the chain. Owned fixes for the failures red on 564's own CI: - The discrete-law check leaves joint transitions' drawn outputs (swept over their target grid) and their lotteries and support providers out of the composed laws, so a joint draw no longer meets a deterministic law's annotations. - It evaluates the laws first on the grid of the variables they read; only when a code leaves its grid there is the feasibility evaluated on the full state-action product, so a valid model (Mahler-Yum) never materializes that product. - read_at_codes accepts integer arrays and Python-int positions, filling with the dtype's default (NaN for floats). - The regime-transition certificate follows the feasible-row evaluator through _evaluate_on_feasible_rows; its memory mutation is re-anchored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. No conflicts; certificate pins recomputed from the merged tree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
The chunk profile now records the stages clip_next_states dispatches: a realized off-grid count per checked continuous target state (runtime checks or a raise rule) and the clip_state clipping, so a complete profile covers every stage the simulation runs. The cross-mesh admission report solves its budget probes with runtime_checks=False, as the other solve-core observation tests do, and takes its by-name refusal one byte below the reading core's position: the transition preflight now refuses a budget below the model's own inputs before any core is planned. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up (realized off-grid counts in chunk profiles, cross-mesh admission probes). Slice-4 fingerprint pins regenerated from the merged tree: ds_app2_housing, multi_regime_normal and nbegm_stochastic_node_toy change at both precisions under this branch's process-law changes. Certificate seals recomputed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up and the 567 fingerprint pins. Certificate seals and pins recomputed from the merged tree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up and the 567 fingerprint pins down the chain. Certificate seals and pins recomputed from the merged tree; slice-4 fingerprint pins regenerated from it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up and the 567 fingerprint pins down the chain. Certificate seals and pins recomputed from the merged tree; slice-4 fingerprint pins regenerated from it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up and the 567 fingerprint pins down the chain. Certificate seals and pins recomputed from the merged tree; slice-4 fingerprint pins regenerated from it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up and the 567 fingerprint pins down the chain. Certificate seals and pins recomputed from the merged tree; slice-4 fingerprint pins regenerated from it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Brings the CI repairs of PRs 568 and 569 down the chain. Brings the 569 follow-up and the 567 fingerprint pins down the chain. Certificate seals and pins recomputed from the merged tree; slice-4 fingerprint pins regenerated from it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Stored solve value and lookup value agree at grid rows only when both phases share the current utility and the Koopmans aggregator.
Main holds PR 546 as a squash commit whose tree equals c0114d9, which this branch already contains, so the merge keeps this branch's tree unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
With x64 on, JAX narrows a weakly typed int64, such as jnp.asarray(2**32 - 1) or a Python int passed into jit, to int32 before comparing it with an int32 column, so a wide value could match a stored characteristic that equals its low 32 bits. category_with now gives every signed-integer array its own dtype explicitly, which removes weak typing, so the comparison widens the column instead. The int32 minimum is the missing-category code and no characteristic may take it, so a miss can never look like a match. Tests cover weak and strong integers, Python ints passed into jit, every signed width and bool, eager, jit, vmap and jit of vmap, the int64 neighbourhood and 32-bit aliases of every stored value, and broadcasting against bool flags.
from_profile gathers a NumPy profile with NumPy, so an int64 or float64 table reaches model validation with its exact values when x64 is disabled. New tests pin each age to its own row for any key order, keep float and Fraction age coordinates exact, and compare the gathered table bit for bit with the hand-built one.
Two more false acceptances in the usage check. A target whose state is a grid state only in simulation counted as keeping it for a solve-phase hand-over, and a source's ordinary law counted toward a target-state cell that a joint transition of the source produces instead. The usage check now intersects a target's kept names with that phase's grid states, and drops the outputs of the source's joint transitions on each edge. Broadcast pruning keeps its behavior.
- Skip the simulate evaluation of a state law in periods where no simulated subject occupies its regime, matching the regime-law and joint checks. - Label a phase-invariant law's simulate-only evaluation with the simulate phase, so its diagnostics name the phase that failed. - Run `Model.validate_transitions` on a call-local warning logger that leaves the shared `lcm` logger level alone, so its own "not numerically validated" warning and later `lcm` warnings are emitted. - Re-pin the moved certificate corridors and reseal the edited sources. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013B2NrGDCdPVNsCbHsUfW4E
Main's tree equals the PR 547 head this branch already contains, so the merge keeps this branch's tree unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Two transition-check tests assert the diagnostic of a phase-invariant law that fails only in its simulate evaluation; that message now names the phase. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013B2NrGDCdPVNsCbHsUfW4E
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Puts this branch on top of its predecessor so the pylcm PR stack forms one chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
- Dispatch the state clip with subject outputs, matching its chunk profile, so a budgeted simulation compiles it once. - Omit `off_grid` on the continuous grids and `off_grid`/`clip_points` on the V coordinate finders from fingerprints at their defaults; the seven pinned fingerprints return to their base values. - Keep an unbudgeted multi-block off-grid count compiled in the model-owned operations, so repeat checked solves compile nothing new. - Word the solve-kernel, preflight and realized off-grid refusals with one helper that names the phase and the count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
00203d3 widened the kernel's codes to ValueND, which also admitted unsigned and float codes: an unsigned code past the int32 range wraps when compared without x64, and a fractional float code is never flagged. The kernel now takes signed integer codes of any width, so int64 storage is counted exactly and unsigned or float codes are refused at the boundary. Found by the post-closure review of 00203d3.
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json # tests/data/fingerprints_slice4_base.json
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
# Conflicts: # src/_lcm/transition_checks.py # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
Pins of conditional actions copy their readers' annotations, building readers made per age or per period at a representative period. Both sides of a Phased reader were built at the regime's first solved period, but a simulation factory need only exist where subjects can be in the regime: a regime the solve values from period 1 that subjects reach at period 2 made the model fail while building its simulation utility at period 1. The solve side of a phased reader is now built at the first solved period and the simulation side at the first visited period. Model passes the schedule's visited periods through broadcast pruning to the binder. Model.__init__ is re-anchored in the candidate certificate (repin_corridors.py --changed-source src/lcm/model.py, verify.py and its self-test pass). The test is the reviewer's example: working is worth 101 and 102 at its two solved periods.
# Conflicts: # tests/candidate_certificate/direct_flow.py # tests/candidate_certificate/sources.json
|
Thank you. The reachable-set construction and the lowering to one composite axis are Two defects on the input boundary of the composite state. Each one stops a valid
Also, the tests do not detect some defects in the stated behaviour. Please add the cases Please also update the PR description:
|
… named - The enumeration hands laws and constraints the numeric age of each period, so a sub-annual or irregular AgeGrid builds; exact ages stay for label lookups. - The generated encoder accepts the numeric and boolean inputs its member laws read, such as a floating helper or the age; its output stays an int32 code. - New tests: a quarterly clock with and without a member law reading age; a floating helper read by a member law, against dense V and the dense panel; V finite exactly at the codes an independent oracle reaches at every period; a branch reached only in simulation, and the phases swapped; two targets of one phase with different member laws, by literal set and by V; nonzero-label starts with decoded rows; a constraint on a free parameter skipped with the support unrestricted; the exact missing code for in-grid and off-grid tuples.
A member law reading an int64 helper under x64 builds and solves like the dense model.
…narrowing A member law returning int64 codes under x64 solves like the dense model, and a wide code outside the table reads MISSING_CAT_CODE instead of wrapping into it.
…eration An int64 law output off the member grid no longer wraps into a valid code and enters the reachable set.
hmgaudecker
left a comment
There was a problem hiding this comment.
Thank you for the fast fixes. The fractional age labels and the int32/int64 encoder
inputs are now correct. One case is still refused, and two tests do not detect the
defects they are about.
The composite encoder refuses a helper that returns a Python float. pylcm allows a
user function to return a Python scalar (UserFunctionResult in
src/lcm/typing.py:175-184). Without a composite state, such a model solves. With a
composite state, the encoder rejects the value before the member laws run:
BeartypeCallHintParamViolation: Method _CompositeEncoder.__call__() parameter kwargs=0.5
violates type hint FloatND | _SignedInts | BoolND
The fix is one annotation. Please see the inline comment.
Two tests do not detect the defect class they target:
tests/test_composite_state_boundaries.py:401-406: the helper test floors its own
value (floor(a + 0.5)). An encoder that floors floating inputs passes it.tests/test_composite_state_boundaries.py:395-398: the age law reads only age 0. An encoder that replaces the age by 0 or by the
period passes it.
The collapsed block at the end holds a patch with two new tests and the fix. I
applied it to 91148ce8e and ran it:
- New tests without the fix, fp64: 2 failed, 10 passed. The 2 failures are the
Python-helper composite cases, with the error above. - New tests with the fix: 12 passed, fp64 and fp32.
- The three composite test files with the fix: 73 passed at fp64; 67 passed and 6
skipped at fp32. prekon the two changed files: passed (ruff, format, ty).
Patch: two tests and the encoder fix
diff --git a/src/_lcm/regime_building/composite_states.py b/src/_lcm/regime_building/composite_states.py
index 341f2f8c4..93b7dced9 100644
--- a/src/_lcm/regime_building/composite_states.py
+++ b/src/_lcm/regime_building/composite_states.py
@@ -41,6 +41,7 @@ from _lcm.regime_building.reachable_states import (
)
from _lcm.typing import (
DataclassInstance,
+ EconFunctionArg,
FlatRegimeParams,
FunctionName,
ReferenceName,
@@ -58,7 +59,6 @@ from lcm.transition import fixed_transition
from lcm.typing import (
BoolND,
DiscreteState,
- FloatND,
IntND,
ScalarInt,
UserInitialConditions,
@@ -454,9 +454,9 @@ class _CompositeEncoder:
laws: Callable[..., tuple[_SignedInts, ...]]
"""The member laws, returning next member codes in member order.
- They read whatever the member laws read: codes, but also floating or boolean
- helpers, signed integer helpers of any width and the age, so the keyword inputs
- are not restricted to int32 codes.
+ Inputs use the same value-tree contract as other function forwarders. Helper
+ values, including Python scalars, pass through unchanged. The returned member
+ codes retain their signed-integer contract and are checked before indexing.
"""
law_args: tuple[ReferenceName, ...]
@@ -471,7 +471,7 @@ class _CompositeEncoder:
shape: tuple[int, ...]
"""Number of codes of every member."""
- def __call__(self, **kwargs: FloatND | _SignedInts | BoolND) -> IntND:
+ def __call__(self, **kwargs: EconFunctionArg) -> IntND:
values = kwargs | dict(self.bound)
nexts = self.laws(**{arg: values[arg] for arg in self.law_args})
return _read_flat(table=self.encoding, shape=self.shape, index=nexts)
diff --git a/tests/test_composite_state_boundaries.py b/tests/test_composite_state_boundaries.py
index f965957de..e46ce0eca 100644
--- a/tests/test_composite_state_boundaries.py
+++ b/tests/test_composite_state_boundaries.py
@@ -9,6 +9,7 @@
"""
from collections.abc import Mapping
+from fractions import Fraction
import jax
import jax.numpy as jnp
@@ -49,6 +50,7 @@ from lcm.typing import (
ScalarInt,
StateName,
UserFunction,
+ UserInitialConditions,
)
from tests.conftest import DECIMAL_PRECISION, assert_agrees_to_ulp
@@ -710,3 +712,179 @@ def test_wide_code_outside_the_table_never_wraps_into_it(code: int) -> None:
table = jnp.arange(4, dtype=jnp.int32)
got = _read_flat(table=table, shape=(2, 2), index=(jnp.int64(code), jnp.int64(0)))
assert int(got) == MISSING_CAT_CODE
+
+
+def _python_half(*, a: DiscreteState) -> float: # noqa: ARG001 - DAG dependency
+ """Return a Python scalar with a declared categorical dependency."""
+ return 0.5
+
+
+def _array_half(*, a: DiscreteState) -> FloatND:
+ """Return the same fractional value as an array in the working precision."""
+ return jnp.zeros_like(a, dtype=float) + 0.5
+
+
+def _next_a_above_quarter(*, half: FloatND) -> DiscreteState:
+ return jnp.asarray(half > 0.25, dtype=jnp.int32)
+
+
+def _next_b_below_zero(*, half: FloatND) -> DiscreteState:
+ return jnp.asarray(half < 0.0, dtype=jnp.int32)
+
+
+def _next_a_above_quarter_py(*, half: float) -> DiscreteState:
+ return jnp.asarray(half > 0.25, dtype=jnp.int32)
+
+
+def _next_b_below_zero_py(*, half: float) -> DiscreteState:
+ return jnp.asarray(half < 0.0, dtype=jnp.int32)
+
+
+def _fractional_helper_model(
+ *, half: UserFunction, composite: bool, enable_jit: bool
+) -> Model:
+ """Build two live periods with both member laws reading the same helper."""
+ return Model(
+ n_periods=3,
+ regimes={
+ "live": Regime(
+ states={"a": DiscreteGrid(_Bit), "b": DiscreteGrid(_Bit)},
+ state_transitions=(
+ {"a": _next_a_above_quarter_py, "b": _next_b_below_zero_py}
+ if half is _python_half
+ else {"a": _next_a_above_quarter, "b": _next_b_below_zero}
+ ),
+ functions={"utility": _identity_utility, "half": half},
+ ),
+ "dead": Regime(functions={"utility": _zero}),
+ },
+ edges={
+ "live": {
+ "live": Periods(values=(0,)),
+ "dead": Periods(values=(1,)),
+ }
+ },
+ regime_id_class=_RegimeId,
+ initial_nodes=InitialNodes(by_period={0: "live"}),
+ execution_config=ExecutionConfig(device_memory_bytes=None),
+ enable_jit=enable_jit,
+ composite_states=(
+ {
+ "history": CompositeState(
+ members=("a", "b"), start_codes={"a": (0,), "b": (0,)}
+ )
+ }
+ if composite
+ else {}
+ ),
+ )
+
+
+def _assert_fractional_transition(
+ *,
+ model: Model,
+ composite: bool,
+ initial_conditions: UserInitialConditions,
+) -> pd.DataFrame:
+ """Check literal support, value and decoded states for the path 00 to 10."""
+ if composite:
+ by_node = model._composite_states["history"].reachable.by_node
+ assert {node: set(tuples) for node, tuples in by_node.items()} == {
+ (0, "live"): {(0, 0)},
+ (1, "live"): {(1, 0)},
+ }
+ model.validate_transitions(params=_PARAMS)
+ solution = model.solve(params=_PARAMS, log_level="off")
+ values = np.asarray(solution.values[0]["live"])
+ # The initial tuple 00 is first in either representation. Its utilities are
+ # 1 and 2, with discount factor 9/10, so its value is 1 + 9/10 * 2 = 14/5.
+ np.testing.assert_array_almost_equal(
+ values.ravel()[0], 14 / 5, decimal=DECIMAL_PRECISION
+ )
+ panel = model.simulate(
+ params=_PARAMS,
+ solution=solution,
+ initial_conditions=initial_conditions,
+ seed=7,
+ log_level="off",
+ ).to_dataframe(
+ use_labels=False, additional_targets=["a", "b"] if composite else None
+ )
+ live = panel.loc[panel["regime_name"] == "live"].sort_values("period")
+ np.testing.assert_array_equal(live["period"].to_numpy(), [0, 1])
+ np.testing.assert_array_equal(live[["a", "b"]].to_numpy(), [[0, 0], [1, 0]])
+ return live
+
+
+@pytest.mark.parametrize("composite", [False, True], ids=["dense", "composite"])
+@pytest.mark.parametrize("enable_jit", [False, True], ids=["eager", "jit"])
+@pytest.mark.parametrize("half", [_python_half, _array_half], ids=["python", "array"])
+def test_fractional_helper_preserves_value_and_state_path(
+ *, half: UserFunction, composite: bool, enable_jit: bool
+) -> None:
+ """Preserve Python and array helper values through public solve and simulate."""
+ model = _fractional_helper_model(
+ half=half, composite=composite, enable_jit=enable_jit
+ )
+ _assert_fractional_transition(
+ model=model,
+ composite=composite,
+ initial_conditions={
+ "regime_id": jnp.full(1, _RegimeId.live, dtype=jnp.int32),
+ "period": jnp.zeros(1, dtype=jnp.int32),
+ "a": jnp.zeros(1, dtype=jnp.int32),
+ "b": jnp.zeros(1, dtype=jnp.int32),
+ },
+ )
+
+
+def _next_a_from_fractional_age(*, age: FloatND) -> DiscreteState:
+ return jnp.asarray(age > 0.25, dtype=jnp.int32)
+
+
+@pytest.mark.parametrize("composite", [False, True], ids=["dense", "composite"])
+@pytest.mark.parametrize("enable_jit", [False, True], ids=["eager", "jit"])
+def test_nonzero_fractional_age_preserves_value_and_state_path(
+ *, composite: bool, enable_jit: bool
+) -> None:
+ """Use the source age 1/2 at period zero to reach the literal next state 10."""
+ start_age = Fraction(1, 2)
+ model = Model(
+ regimes={
+ "live": Regime(
+ states={"a": DiscreteGrid(_Bit), "b": DiscreteGrid(_Bit)},
+ state_transitions={
+ "a": _next_a_from_fractional_age,
+ "b": fixed_transition("b"),
+ },
+ functions={"utility": _identity_utility},
+ ),
+ "dead": Regime(functions={"utility": _zero}),
+ },
+ edges={"live": {"live": start_age, "dead": Fraction(1)}},
+ ages=AgeGrid(start=start_age, inclusive_stop=Fraction(3, 2), step="2Q"),
+ regime_id_class=_RegimeId,
+ initial_nodes=InitialNodes(by_age={start_age: "live"}),
+ execution_config=ExecutionConfig(device_memory_bytes=None),
+ enable_jit=enable_jit,
+ composite_states=(
+ {
+ "history": CompositeState(
+ members=("a", "b"), start_codes={"a": (0,), "b": (0,)}
+ )
+ }
+ if composite
+ else {}
+ ),
+ )
+ live = _assert_fractional_transition(
+ model=model,
+ composite=composite,
+ initial_conditions={
+ "regime_id": jnp.full(1, _RegimeId.live, dtype=jnp.int32),
+ "age": jnp.asarray([0.5]),
+ "a": jnp.zeros(1, dtype=jnp.int32),
+ "b": jnp.zeros(1, dtype=jnp.int32),
+ },
+ )
+ np.testing.assert_array_equal(live["age"].to_numpy(), [0.5, 1.0])…ntract Apply Hans-Martin's review patch: the composite encoder accepts any EconFunctionArg, including a Python float, and new tests check a fractional helper and a nonzero fractional age through reachability, V and simulated member states.
Closes #522 as one vertical slice: model description to solve and simulate on reachable discrete states. Interface names are placeholders.
What changes
Model(composite_states={"history": CompositeState(members=(...), start_codes={...})})replaces the axes of deterministic discrete member states by one generated discrete state whose codes index the member tuples reachable from the start codes.src/_lcm/regime_building/reachable_states.pyenumerates the tuples reachable at each(period, regime)from the engine's own laws, constraint pools and fixed params. It tries every code of the other discrete variables those read, and skips (and reports) constraints that read continuous variables or free params, which can only enlarge the sets. It follows every target of both phases' static regime graphs. Stochastic member laws, laws reading non-enumerable inputs, and non-discrete members are refused by name.src/_lcm/regime_building/composite_states.pylowers the declaration at the top ofModel.__init__. A dense build (no jit, no seal) supplies the engine laws. Each carrying regime then holds:<name>_is_reachable, which makes each code infeasible at the periods it cannot be reached in, so V reads-infthere and no reachable cell reads it.Fixed params of member laws are bound into the encoder and marked consumed. Codes off the set encode to
MISSING_CAT_CODE, which reads NaN.Refused by name: a phase-specific (
Phased) member law or grid, a member held on different grids in different regimes, a member declared by its values (DiscreteGrid(values=...)), a member law that reads a variable declared by its values, and a declared constraint or function that collides with a generated name.The enumeration evaluates laws and constraints with each period's numeric age, so sub-annual and irregular
AgeGrids build. The generated encoder accepts every numeric or boolean input its member laws read, such as a floating helper, an int64 helper under x64, or the age. Member laws may return int64 codes; the encoder and the enumeration check them against the grid in their own width before narrowing to int32.Initial conditions name the members, as code arrays or as a labelled frame. Only subjects whose starting regime carries the members are encoded, and they need every member column; a supplied column is still checked for whole int32 codes and its length. An unreachable start tuple or an unknown label raises
InvalidInitialConditionsError. Members reachto_dataframethroughadditional_targets.Certified: only
src/lcm/model.py.Model.__init__,Model.simulateand the module surface are re-pinned (check_seals.py --fix,repin_corridors.py --changed-source src/lcm/model.py;check_seals,verify.pyandverify.py --self-testexit 0). The slot-read ledger, public API index, model reference and changelog are updated.Evidence
tests/test_composite_states.pycovers a three-counter toy with a stochastic offer, a cap read from fixed params, and a continuous budget:assert_agrees_to_ulp, 4 ULP);-infat codes unreachable at their period;MISSING_CAT_CODEfor an in-grid tuple outside the set and for an off-grid tuple;tests/test_composite_state_boundaries.py:AgeGridwith and without a member law reading age, and a member law reading a floating helper, each against dense V (and the dense panel for the helper);tests/regime_building/test_reachable_states.py: the per-node sets equal an itertools oracle, the union is their sorted union, a constraint reading a free parameter is skipped with the support unrestricted, and refusals.Focused files at 91148ce (
tests/test_composite_states.py,tests/test_composite_state_boundaries.py,tests/regime_building/test_reachable_states.py,tests/test_regime_slot_read_ledger.py,tests/test_docs_public_api_coverage.py,tests/ci/test_ci_workloads_manifest.py):pixi run -e tests-cpu pytest <files> -v --precision=64and--precision=32on macOS arm64, junit 520 tests, 0 failures at each precision (six int64 tests skip without x64).arcidiacono2025 on one A40 (Marvin
mlgpu_short, fp32), 24 decision periods, pylcme5e882ef0(jobs 28225824, 28231639, 28231640). That build had both a closed and a masked variant; the closed variant is removed here and the masked numbers below are this PR's design.r6 holds 31,813 reachable tuples. The r6 job reached its 8-hour limit after the composite run, because the paper's own input preparation took 7.9 hours. At 13 periods the prototype showed dense r6 does not fit an A40 (planner: 34 GB resident vs a 30.4 GB budget), while composite V is 1.07 GB.
Limits and follow-ups
runtime_checks=Truethe composite solve spends about 5 s per solve invalidate_discrete_state_laws_all_periods. The encoder returnsMISSING_CAT_CODEat infeasible actions, so the check takes its second, feasibility-joint pass every period. The result is correct, but a composite-aware fast path intransition_checks.py(certified) would remove the cost.lookup_policy/value_framein member terms;