Skip to content

Commit 1bff8f0

Browse files
hmgaudeckerclaude
andcommitted
Fix the ty failures the state-conditioned field introduced
CI has been failing `ty` since 3dd6ef9 and I never saw it, because I skip the hook locally (this worktree has no type-checking env). It is reachable with `uvx ty@0.0.56 check --python <shared pylcm tree>/.pixi/envs/type-checking`. 36 diagnostics -> 0. Two real annotation bugs of mine: - `_get_conditioned_weights_func` read `sc.on` / `sc.by` off a `StateConditioned | None` without narrowing, and the call site passed `Grid | None` into a `DiscreteGrid | None` parameter. Both go away by passing the regime's grid mapping plus the already-narrowed `StateConditioned`, and resolving the conditioning grid inside — the same shape `next_state.py` already uses, so the two seams now read alike. - `_process_family` returned `str` where `conditioned_row` wants the `Family` literal. The rest are fallout in four test files that splat a kwargs table into a process constructor with `**`. Those tables were annotated `dict[str, bool]` / inferred as `dict[str, float]`, and only ever type-checked by luck: every constructor parameter happened to accept a number (`bool` is an `int`, and `mu`/`sigma` take `int | float | None`). `state_conditioned: StateConditioned | None` is the first parameter that does not, so the checker started reporting a possible landing. `Any` is the honest value type for a splat table; the alternative was a dozen ignore comments. Also adds the missing `log_level` to seven `Model.solve` calls in the state-conditioned tests, which its overloads require. 66 targeted tests pass; the full suite runs on CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DW9D1WhLN8JhhiEPCScbaw
1 parent 4c40e2a commit 1bff8f0

8 files changed

Lines changed: 48 additions & 41 deletions

File tree

src/_lcm/processes/state_conditioned.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from lcm.typing import Float1D, ScalarFloat, ScalarInt
2828

2929
__all__ = [
30+
"Family",
3031
"StateConditioned",
3132
"conditioned_row",
3233
"gather_sigma",

src/_lcm/regime_building/processing.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
from _lcm.processes.ar1 import TauchenAR1Process
4747
from _lcm.processes.iid import NormalIIDProcess
4848
from _lcm.processes.state_conditioned import (
49+
Family,
50+
StateConditioned,
4951
conditioned_row,
5052
gather_sigma,
5153
sigma_array_by_code,
@@ -1235,13 +1237,7 @@ def _process_regime_core(
12351237
)
12361238
processed_functions |= {
12371239
f"weight_{user_regime}__next_{process}": _get_weights_func_for_process(
1238-
name=process,
1239-
grid=grid,
1240-
conditioning_grid=(
1241-
all_grids[user_regime].get(grid.state_conditioned.on)
1242-
if grid.state_conditioned is not None
1243-
else None
1244-
),
1240+
name=process, grid=grid, grids=all_grids[user_regime]
12451241
)
12461242
for (user_regime, process), grid in target_process_grids.items()
12471243
} | {
@@ -1570,7 +1566,7 @@ def next_func(**kwargs: Any) -> Int1D: # noqa: ARG001, ANN401
15701566
return next_func
15711567

15721568

1573-
def _process_family(grid: _ContinuousStochasticProcess) -> str:
1569+
def _process_family(grid: _ContinuousStochasticProcess) -> Family:
15741570
"""Map a supported process to its state-conditioned family (audit F2).
15751571
15761572
Only families whose transition CDF carries ``sigma`` can express a fixed-node,
@@ -1672,15 +1668,16 @@ def _get_conditioned_weights_func(
16721668
*,
16731669
name: str,
16741670
grid: _ContinuousStochasticProcess,
1675-
conditioning_grid: DiscreteGrid | None,
1671+
sc: StateConditioned,
1672+
grids: Mapping[StateOrActionName, Grid],
16761673
) -> UserFunction:
16771674
"""Weights function for a state-conditioned process (direct-CDF, audit F1/F5/F6).
16781675
16791676
The transition row is computed DIRECTLY at the from-value on the FIXED common nodes
16801677
(from the scalar ``sigma``), with the per-regime ``sigma`` gathered by the
16811678
conditioning state's integer code. No precomputed-row interpolation (F1).
16821679
"""
1683-
sc = grid.state_conditioned
1680+
conditioning_grid = grids.get(sc.on)
16841681
if not isinstance(conditioning_grid, DiscreteGrid):
16851682
msg = (
16861683
f"state_conditioned.on='{sc.on}' must name a DiscreteGrid state in the "
@@ -1716,22 +1713,22 @@ def _get_weights_func_for_process(
17161713
*,
17171714
name: str,
17181715
grid: _ContinuousStochasticProcess,
1719-
conditioning_grid: DiscreteGrid | None = None,
1716+
grids: Mapping[StateOrActionName, Grid] = MappingProxyType({}),
17201717
) -> UserFunction:
17211718
"""Get function that uses linear interpolation to calculate the process weights.
17221719
17231720
For processes whose params are supplied at runtime, the grid points and
17241721
transition probabilities are computed inside JIT from those runtime params. For a
17251722
state-conditioned process the row is instead computed directly at the from-value
1726-
(``_get_conditioned_weights_func``).
1723+
(``_get_conditioned_weights_func``), and `grids` is that regime's grid mapping, from
1724+
which the conditioning state is resolved.
17271725
17281726
"""
1729-
if grid.state_conditioned is not None:
1730-
# `conditioning_grid` may be None here (unknown `on`); the callee raises a
1731-
# clear message naming the offending state.
1732-
return _get_conditioned_weights_func(
1733-
name=name, grid=grid, conditioning_grid=conditioning_grid
1734-
)
1727+
sc = grid.state_conditioned
1728+
if sc is not None:
1729+
# `sc.on` may be absent from `grids`; the callee raises a clear message naming
1730+
# the offending state.
1731+
return _get_conditioned_weights_func(name=name, grid=grid, sc=sc, grids=grids)
17351732

17361733
if grid.params_to_pass_at_runtime:
17371734
n_points = grid.n_points

tests/test_models/processes.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import functools
2-
from typing import Literal
2+
from typing import Any, Literal
33

44
from jax import numpy as jnp
55

@@ -32,7 +32,11 @@
3232
"rouwenhorst": RouwenhorstAR1Process,
3333
}
3434

35-
_SHOCK_GRID_KWARGS: dict[str, dict[str, bool]] = {
35+
# Heterogeneous per-class constructor kwargs, splatted with `**`, so the value type
36+
# is genuinely `Any`: a checker must assume any key of the target class could receive
37+
# one. (It typed as `bool` only by luck — every param used to accept a bool, since
38+
# `bool` is an `int`.)
39+
_SHOCK_GRID_KWARGS: dict[str, dict[str, Any]] = {
3640
"uniform": {},
3741
"normal": {"gauss_hermite": True},
3842
"lognormal": {"gauss_hermite": True},

tests/test_processes.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
import pandas as pd
24
import pytest
35
from jax import numpy as jnp
@@ -203,7 +205,7 @@ def test_process_grid_fully_specified_with_all_params(grid_cls, kwargs):
203205
def test_ar1_grid_centers_on_unconditional_mean(grid_cls):
204206
"""Midpoint of AR(1) gridpoints is approximately mu / (1 - rho)."""
205207
mu, rho = 2.0, 0.8
206-
kwargs = {"rho": rho, "sigma": 0.5, "mu": mu}
208+
kwargs: dict[str, Any] = {"rho": rho, "sigma": 0.5, "mu": mu}
207209
if grid_cls is TauchenAR1Process:
208210
kwargs["gauss_hermite"] = True
209211
grid = grid_cls(n_points=11, **kwargs)
@@ -216,7 +218,7 @@ def test_ar1_grid_centers_on_unconditional_mean(grid_cls):
216218
@pytest.mark.parametrize("grid_cls", _AR1_GRID_CLASSES)
217219
def test_ar1_transition_probs_rows_sum_to_one(grid_cls):
218220
"""Each row of the transition matrix sums to 1."""
219-
kwargs = {"rho": 0.9, "sigma": 0.5, "mu": 1.0}
221+
kwargs: dict[str, Any] = {"rho": 0.9, "sigma": 0.5, "mu": 1.0}
220222
if grid_cls is TauchenAR1Process:
221223
kwargs["gauss_hermite"] = True
222224
grid = grid_cls(n_points=7, **kwargs)
@@ -398,7 +400,7 @@ def test_lognormal_gauss_hermite_weights_sum_to_one():
398400
aaae(P[0].sum(), 1.0, decimal=DECIMAL_PRECISION)
399401

400402

401-
_NORMAL_MIXTURE_KWARGS = {
403+
_NORMAL_MIXTURE_KWARGS: dict[str, Any] = {
402404
"n_std": 3.0,
403405
"p1": 0.9,
404406
"mu1": 0.0,
@@ -438,7 +440,7 @@ def test_iid_normal_mixture_stationary_moments():
438440
aaae(got_std, float(jnp.sqrt(expected_var)), decimal=1)
439441

440442

441-
_TAUCHEN_NORMAL_MIXTURE_KWARGS = {
443+
_TAUCHEN_NORMAL_MIXTURE_KWARGS: dict[str, Any] = {
442444
"rho": 0.8,
443445
"mu": 1.0,
444446
"n_std": 3.0,

tests/test_runtime_process_params.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for runtime-supplied stochastic-process parameters."""
22

3+
from typing import Any
4+
35
import jax.numpy as jnp
46
import pytest
57

@@ -41,7 +43,7 @@ def _constraint(consumption: ContinuousAction, wealth: ContinuousState) -> Float
4143
return consumption <= wealth
4244

4345

44-
_TAUCHEN_PARAMS = {"rho": 0.9, "sigma": 1.0, "mu": 0.0, "n_std": 2}
46+
_TAUCHEN_PARAMS: dict[str, Any] = {"rho": 0.9, "sigma": 1.0, "mu": 0.0, "n_std": 2}
4547

4648

4749
def _make_model(*, fixed_params=None):

tests/test_shock_draw.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for draw_shock sampling correctness across all shock grid types."""
22

33
from types import MappingProxyType
4+
from typing import Any
45

56
import jax
67
import pytest
@@ -19,7 +20,7 @@
1920

2021
_N_DRAWS = 10_000
2122

22-
_NORMAL_MIXTURE_KWARGS = {
23+
_NORMAL_MIXTURE_KWARGS: dict[str, Any] = {
2324
"n_std": 3.0,
2425
"p1": 0.9,
2526
"mu1": 0.0,
@@ -28,7 +29,7 @@
2829
"sigma2": 0.3,
2930
}
3031

31-
_TAUCHEN_NORMAL_MIXTURE_KWARGS = {
32+
_TAUCHEN_NORMAL_MIXTURE_KWARGS: dict[str, Any] = {
3233
"rho": 0.8,
3334
"mu": 1.0,
3435
"n_std": 3.0,
@@ -54,7 +55,7 @@ def _draw_many(grid, params, key_seed=0, current_value=None):
5455
@pytest.mark.parametrize("params_at_init", [True, False])
5556
def test_draw_shock_uniform(params_at_init):
5657
"""Uniform.draw_shock uses start/stop params."""
57-
kwargs = {"start": 2.0, "stop": 4.0}
58+
kwargs: dict[str, Any] = {"start": 2.0, "stop": 4.0}
5859
if params_at_init:
5960
grid = UniformIIDProcess(n_points=5, batch_size=0, distributed=False, **kwargs)
6061
params = grid.params
@@ -70,7 +71,7 @@ def test_draw_shock_uniform(params_at_init):
7071
@pytest.mark.parametrize("params_at_init", [True, False])
7172
def test_draw_shock_normal(params_at_init):
7273
"""Normal.draw_shock uses mu/sigma params."""
73-
kwargs = {"mu": 5.0, "sigma": 0.1}
74+
kwargs: dict[str, Any] = {"mu": 5.0, "sigma": 0.1}
7475
if params_at_init:
7576
grid = NormalIIDProcess(
7677
n_points=5, batch_size=0, distributed=False, gauss_hermite=True, **kwargs
@@ -89,7 +90,7 @@ def test_draw_shock_normal(params_at_init):
8990
@pytest.mark.parametrize("params_at_init", [True, False])
9091
def test_draw_shock_lognormal(params_at_init):
9192
"""LogNormal.draw_shock produces positive samples with correct log-moments."""
92-
kwargs = {"mu": 1.0, "sigma": 0.1}
93+
kwargs: dict[str, Any] = {"mu": 1.0, "sigma": 0.1}
9394
if params_at_init:
9495
grid = LogNormalIIDProcess(
9596
n_points=5, batch_size=0, distributed=False, gauss_hermite=True, **kwargs
@@ -107,7 +108,7 @@ def test_draw_shock_lognormal(params_at_init):
107108
@pytest.mark.parametrize("params_at_init", [True, False])
108109
def test_draw_shock_tauchen(params_at_init):
109110
"""Tauchen.draw_shock uses mu/sigma/rho params."""
110-
kwargs = {"rho": 0.5, "sigma": 0.1, "mu": 2.0}
111+
kwargs: dict[str, Any] = {"rho": 0.5, "sigma": 0.1, "mu": 2.0}
111112
if params_at_init:
112113
grid = TauchenAR1Process(
113114
n_points=5, batch_size=0, distributed=False, gauss_hermite=True, **kwargs
@@ -124,7 +125,7 @@ def test_draw_shock_tauchen(params_at_init):
124125
@pytest.mark.parametrize("params_at_init", [True, False])
125126
def test_draw_shock_rouwenhorst(params_at_init):
126127
"""Rouwenhorst.draw_shock uses mu/sigma/rho params."""
127-
kwargs = {"rho": 0.5, "sigma": 0.1, "mu": 2.0}
128+
kwargs: dict[str, Any] = {"rho": 0.5, "sigma": 0.1, "mu": 2.0}
128129
if params_at_init:
129130
grid = RouwenhorstAR1Process(
130131
n_points=5, batch_size=0, distributed=False, **kwargs
@@ -186,7 +187,7 @@ def test_draw_shock_tauchen_normal_mixture(params_at_init):
186187
def test_ar1_draw_shock_unconditional_moments(grid_cls):
187188
"""Long-run simulated moments match AR(1) unconditional moments."""
188189
mu, rho, sigma = 0.5, 0.7, 0.3
189-
kwargs = {"rho": rho, "sigma": sigma, "mu": mu}
190+
kwargs: dict[str, Any] = {"rho": rho, "sigma": sigma, "mu": mu}
190191
if grid_cls is TauchenAR1Process:
191192
kwargs["gauss_hermite"] = True
192193
grid = grid_cls(n_points=11, **kwargs)

tests/test_state_conditioned_model.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ def test_gauss_hermite_state_conditioned_rejected():
269269
),
270270
)
271271
with pytest.raises(ModelInitializationError, match="Gauss-Hermite"):
272-
_model_with_income(income).solve(params=_params())
272+
_model_with_income(income).solve(params=_params(), log_level="warning")
273273

274274

275275
def test_rouwenhorst_state_conditioned_rejected():
@@ -284,7 +284,7 @@ def test_rouwenhorst_state_conditioned_rejected():
284284
),
285285
)
286286
with pytest.raises(ModelInitializationError, match="only supported for CDF-binned"):
287-
_model_with_income(income).solve(params=_params())
287+
_model_with_income(income).solve(params=_params(), log_level="warning")
288288

289289

290290
def test_unknown_conditioning_state_rejected():
@@ -298,7 +298,7 @@ def test_unknown_conditioning_state_rejected():
298298
state_conditioned=StateConditioned(on="nope", by={"low": 0.1, "high": 0.3}),
299299
)
300300
with pytest.raises(ModelInitializationError, match="must name a DiscreteGrid"):
301-
_model_with_income(income).solve(params=_params())
301+
_model_with_income(income).solve(params=_params(), log_level="warning")
302302

303303

304304
@categorical(ordered=True)
@@ -355,7 +355,7 @@ def test_gauss_hermite_tauchen_state_conditioned_rejected():
355355
),
356356
)
357357
with pytest.raises(ModelInitializationError, match="Gauss-Hermite"):
358-
_model_with_income(income).solve(params=_params())
358+
_model_with_income(income).solve(params=_params(), log_level="warning")
359359

360360

361361
def test_runtime_grid_param_rejected():
@@ -375,7 +375,7 @@ def test_runtime_grid_param_rejected():
375375
),
376376
)
377377
with pytest.raises(ModelInitializationError, match="every grid parameter fixed"):
378-
_model_with_income(income).solve(params=_params())
378+
_model_with_income(income).solve(params=_params(), log_level="warning")
379379

380380

381381
@pytest.mark.parametrize("bad", [float("nan"), float("inf")])
@@ -392,7 +392,7 @@ def test_nonfinite_sigma_rejected(bad):
392392
),
393393
)
394394
with pytest.raises(ModelInitializationError, match="finite positive sigmas"):
395-
_model_with_income(income).solve(params=_params())
395+
_model_with_income(income).solve(params=_params(), log_level="warning")
396396

397397

398398
def test_nonpositive_sigma_rejected():
@@ -408,4 +408,4 @@ def test_nonpositive_sigma_rejected():
408408
),
409409
)
410410
with pytest.raises(ModelInitializationError, match="finite positive sigmas"):
411-
_model_with_income(income).solve(params=_params())
411+
_model_with_income(income).solve(params=_params(), log_level="warning")

tests/test_state_conditioned_shocks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def test_state_conditioned_dataclass_is_frozen():
166166
sc = StateConditioned(on="uncertainty", by={"low": 0.2, "high": 1.0})
167167
assert sc.on == "uncertainty"
168168
with pytest.raises((AttributeError, TypeError)):
169-
sc.on = "other" # type: ignore[misc]
169+
sc.on = "other" # ty: ignore[invalid-assignment]
170170

171171

172172
def test_conditioned_row_dispatch_sums_to_one():

0 commit comments

Comments
 (0)