Skip to content

Commit d644992

Browse files
hmgaudeckerclaude
andcommitted
Make the degenerate-row interpolation contract hold under autodiff
The singleton/empty-row overrides selected the right primal values but ran after the bracket arithmetic had touched the NaN padding, and `jnp.where` propagates cotangents through both of its branches — so `jax.grad` of the read returned NaN (0 · NaN) where the constant clamp's derivative is zero, poisoning the asset-row Euler marginal whenever a child carry row has one finite node. The gathered bracket endpoints are now sanitized to finite dummies on degenerate rows before the arithmetic; the overrides still publish the contract values. A `lax.cond` dispatch would not fix this: the production readers vmap the row read, and `cond` lowers to `select` under `vmap`, re-evaluating both branches. Also re-pin NaN queries to NaN (the singleton constant would otherwise mask an upstream failure with a finite value) and add a `Phased` cost-free-base regression for the composed NEGM resources. Regressions: query gradient and node-value gradient of a singleton row, the vmap+grad composition, and the production stacked read differentiated with a singleton winning candidate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123hk3uzBgBeZown4eBBQ2C
1 parent 0ff1c91 commit d644992

4 files changed

Lines changed: 188 additions & 12 deletions

File tree

src/_lcm/egm/interp.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,23 +151,39 @@ def interp_on_prepared_grid(
151151
jnp.maximum(valid_length - 1, 1),
152152
).astype(jnp.int32)
153153
lower = upper - 1
154+
# Degenerate valid prefixes (fewer than two nodes) gather NaN padding into
155+
# the bracket. `jnp.where` propagates cotangents through BOTH of its
156+
# branches, so NaN partials in the discarded bracket arithmetic would
157+
# poison the selected constant's derivative (`0 · NaN = NaN`) — and the
158+
# readers are differentiated (asset-row mode grads the continuation read).
159+
# Feed the arithmetic a finite dummy bracket on those rows; the overrides
160+
# below still publish the contract values.
161+
degenerate = valid_length < 2 # noqa: PLR2004
162+
163+
def _sanitized(gathered: FloatND, dummy: float) -> FloatND:
164+
return jnp.where(degenerate, dummy, gathered)
165+
154166
result = _interp_between_nodes(
155167
x_query=x_query,
156-
xp_lower=xp[lower],
157-
xp_upper=xp[upper],
158-
fp_lower=fp[lower],
159-
fp_upper=fp[upper],
160-
slope_lower=None if fp_slopes is None else fp_slopes[lower],
161-
slope_upper=None if fp_slopes is None else fp_slopes[upper],
168+
xp_lower=_sanitized(xp[lower], 0.0),
169+
xp_upper=_sanitized(xp[upper], 1.0),
170+
fp_lower=_sanitized(fp[lower], 0.0),
171+
fp_upper=_sanitized(fp[upper], 0.0),
172+
slope_lower=None if fp_slopes is None else _sanitized(fp_slopes[lower], 0.0),
173+
slope_upper=None if fp_slopes is None else _sanitized(fp_slopes[upper], 0.0),
162174
)
163-
# Degenerate valid prefixes bypass the bracket arithmetic (whose gathered
164-
# upper node is NaN there, which the zero-weight guards would silently turn
165-
# into 0.0):
166-
# - one valid node ⇒ the edge clamp on both sides is that node's value,
175+
# Degenerate-row contract:
176+
# - one valid node ⇒ the edge clamp on both sides is that node's value
177+
# (constant in the query, identity in the node's value — also under
178+
# autodiff),
167179
# - an empty prefix (only from an already-poisoned carry) ⇒ NaN, so the
168180
# runtime NaN diagnostics surface the poison instead of a finite constant.
169181
result = jnp.where(valid_length == 1, fp[0], result)
170-
return jnp.where(valid_length == 0, jnp.nan, result)
182+
result = jnp.where(valid_length == 0, jnp.nan, result)
183+
# A NaN query marks an upstream failure. Regular rows propagate it through
184+
# the bracket arithmetic; the degenerate-row constants above would mask it,
185+
# so re-pin it explicitly — fail-loud on every row shape.
186+
return jnp.where(jnp.isnan(x_query), jnp.nan, result)
171187

172188

173189
def interp_right_germ_on_padded_grid(

tests/solution/test_continuation_stacked_read.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,50 @@ def test_stacked_read_propagates_a_poisoned_candidate_row():
177177
assert bool(jnp.isnan(smoothed_value))
178178

179179

180+
def test_stacked_read_with_a_singleton_candidate_is_differentiable():
181+
"""A singleton candidate keeps the read differentiable in the query.
182+
183+
Asset-row mode publishes the Euler marginal by differentiating the
184+
continuation read, so a one-node candidate carry — a constant clamp in the
185+
query — must contribute a zero tangent, not a NaN one leaked from its
186+
padded bracket. The singleton's clamp value wins here, so the read's query
187+
derivative is exactly zero.
188+
"""
189+
singleton = jnp.array([2.0, jnp.nan, jnp.nan])
190+
live = jnp.array([0.0, 5.0, 10.0])
191+
carry = EGMCarry(
192+
endog_grid=jnp.stack([singleton, live])[None, :, :],
193+
value=jnp.stack(
194+
[jnp.array([10.0, jnp.nan, jnp.nan]), jnp.array([1.0, 2.0, 3.0])]
195+
)[None, :, :],
196+
marginal_utility=jnp.stack(
197+
[jnp.array([0.0, jnp.nan, jnp.nan]), jnp.array([0.5, 0.5, 0.5])]
198+
)[None, :, :],
199+
taste_shock_scale=jnp.asarray(0.0),
200+
)
201+
prepared_search_grid, prepared_valid_length = _prepare(carry)
202+
203+
def read_value(query):
204+
smoothed_value, _ = _aggregate_child_choices(
205+
carry=carry,
206+
prepared_search_grid=prepared_search_grid,
207+
prepared_valid_length=prepared_valid_length,
208+
has_taste_shocks=False,
209+
child_index=(),
210+
child_passive_values=(jnp.asarray(0.0),),
211+
child_passive_grids=(jnp.asarray([0.0]),),
212+
row_queries=query[None],
213+
row_gradients=jnp.asarray([1.0]),
214+
n_outer_candidates=2,
215+
)
216+
return smoothed_value
217+
218+
value, derivative = jax.value_and_grad(read_value)(jnp.asarray(4.0))
219+
220+
np.testing.assert_allclose(float(value), 10.0, atol=_READ_ATOL)
221+
np.testing.assert_allclose(float(derivative), 0.0, atol=_READ_ATOL)
222+
223+
180224
def test_stacked_read_tie_publishes_the_right_continuous_marginal():
181225
"""At an exact candidate crossing the production read selects the right winner.
182226

tests/solution/test_egm_interp.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,93 @@ def test_singleton_row_clamps_to_its_node():
145145
np.testing.assert_array_equal(np.asarray(marginal), [2.0, 2.0, 2.0])
146146

147147

148+
def test_singleton_row_is_constant_under_autodiff():
149+
"""A singleton row's constant clamp differentiates as a constant.
150+
151+
`jax.grad` with respect to the query must be exactly zero (finite, not
152+
NaN): asset-row mode differentiates the continuation read in the Euler
153+
slot, so a NaN tangent leaking from the padded bracket would poison the
154+
published Euler marginal even though the primal value is correct.
155+
"""
156+
xp = jnp.array([1.0, jnp.nan, jnp.nan])
157+
fp = jnp.array([5.0, jnp.nan, jnp.nan])
158+
slopes = jnp.array([2.0, jnp.nan, jnp.nan])
159+
160+
def read(query):
161+
return interp.interp_on_padded_grid(
162+
x_query=query, xp=xp, fp=fp, fp_slopes=slopes
163+
)
164+
165+
value, derivative = jax.value_and_grad(read)(jnp.asarray(1.0))
166+
167+
np.testing.assert_allclose(float(value), 5.0, atol=1e-12)
168+
np.testing.assert_allclose(float(derivative), 0.0, atol=1e-12)
169+
170+
171+
def test_singleton_row_passes_its_node_values_tangent_through():
172+
"""The singleton clamp is the node's value: unit derivative in that node.
173+
174+
Differentiating the read with respect to the row's single valid value must
175+
give exactly one — the clamp publishes that value verbatim — with no NaN
176+
contamination from the padded slots.
177+
"""
178+
xp = jnp.array([1.0, jnp.nan, jnp.nan])
179+
slopes = jnp.array([2.0, jnp.nan, jnp.nan])
180+
181+
def read(node_value):
182+
fp = jnp.array([jnp.nan, jnp.nan, jnp.nan]).at[0].set(node_value)
183+
return interp.interp_on_padded_grid(
184+
x_query=jnp.asarray(2.0), xp=xp, fp=fp, fp_slopes=slopes
185+
)
186+
187+
derivative = jax.grad(read)(jnp.asarray(5.0))
188+
189+
np.testing.assert_allclose(float(derivative), 1.0, atol=1e-12)
190+
191+
192+
def test_singleton_row_stays_constant_under_vmap_and_autodiff():
193+
"""The zero query derivative survives per-row `vmap` batching.
194+
195+
The production readers map the row read over a stacked candidate axis, so
196+
the degenerate-row handling must stay AD-safe inside `vmap` — a construct
197+
that re-evaluates both sides of a branch (as `lax.cond` lowered under
198+
`vmap` would) leaks the padded bracket's NaN tangent back in.
199+
"""
200+
xp = jnp.array([[1.0, 2.0, 3.0], [1.0, jnp.nan, jnp.nan]])
201+
fp = jnp.array([[1.0, 4.0, 9.0], [5.0, jnp.nan, jnp.nan]])
202+
slopes = jnp.array([[2.0, 4.0, 6.0], [2.0, jnp.nan, jnp.nan]])
203+
204+
def summed_read(query):
205+
def read_row(xp_row, fp_row, slopes_row):
206+
return interp.interp_on_padded_grid(
207+
x_query=query, xp=xp_row, fp=fp_row, fp_slopes=slopes_row
208+
)
209+
210+
return jnp.sum(jax.vmap(read_row)(xp, fp, slopes))
211+
212+
derivative = jax.grad(summed_read)(jnp.asarray(2.5))
213+
214+
# The regular row holds `x²` data, which the Hermite read reproduces
215+
# (derivative `2q = 5`); the singleton row contributes exactly zero.
216+
np.testing.assert_allclose(float(derivative), 5.0, atol=1e-12)
217+
218+
219+
def test_nan_query_reads_nan_on_a_singleton_row():
220+
"""A NaN query stays NaN even where the singleton clamp is constant.
221+
222+
A NaN query marks an upstream failure; the constant clamp must not convert
223+
it into a finite value — regular rows already propagate NaN queries through
224+
the bracket arithmetic, and singleton rows must fail just as loudly.
225+
"""
226+
xp = jnp.array([1.0, jnp.nan, jnp.nan])
227+
fp = jnp.array([5.0, jnp.nan, jnp.nan])
228+
229+
got = interp.interp_on_padded_grid(x_query=jnp.array([jnp.nan, 1.0]), xp=xp, fp=fp)
230+
231+
assert bool(jnp.isnan(got[0]))
232+
np.testing.assert_allclose(float(got[1]), 5.0, atol=1e-12)
233+
234+
148235
def test_empty_row_reads_nan():
149236
"""An all-NaN (poisoned) row reads NaN at every query, never a finite value.
150237

tests/solution/test_negm_validation.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
validate_negm_regimes,
2828
)
2929
from _lcm.regime_building.finalize import finalize_regimes
30-
from lcm import DiscreteGrid, ExtremeValueTasteShocks, categorical
30+
from lcm import DiscreteGrid, ExtremeValueTasteShocks, Phased, categorical
3131
from lcm.exceptions import ModelInitializationError
3232
from lcm.regime import Regime as UserRegime
3333
from lcm.typing import (
@@ -366,6 +366,35 @@ def test_finalize_composes_resources_as_base_minus_outer_cost():
366366
) == pytest.approx(5.0)
367367

368368

369+
def test_finalize_composes_resources_with_a_phased_base():
370+
"""A `Phased` cost-free base still yields the composed resources function.
371+
372+
The synthesized resources function reads the base and the cost by name, so
373+
phase resolution happens at the producer level: composition succeeds with a
374+
`Phased` base slot and the injected function is the plain difference of its
375+
two inputs in either phase.
376+
"""
377+
regime = _VALID.replace(
378+
functions={
379+
**dict(_VALID.functions),
380+
"resources_before_outer_cost": Phased(
381+
solve=_cost_free_base, simulate=_cost_free_base
382+
),
383+
},
384+
)
385+
386+
finalized = finalize_regimes(
387+
user_regimes={"alive": regime}, derived_categoricals={}
388+
)["alive"]
389+
composed = cast("UserFunction", finalized.functions["resources"])
390+
391+
assert float(
392+
composed(
393+
resources_before_outer_cost=jnp.asarray(7.0), credited=jnp.asarray(2.0)
394+
)
395+
) == pytest.approx(5.0)
396+
397+
369398
def test_missing_outer_cost_with_costful_resources_is_rejected():
370399
"""Omitting `NEGM.outer_cost` while resources reads the outer margin fails.
371400

0 commit comments

Comments
 (0)