1212read.
1313"""
1414
15+ import jax
1516import jax .numpy as jnp
1617
1718from lcm .typing import BoolND , Float1D , FloatND , ScalarFloat , ScalarInt
@@ -146,6 +147,208 @@ def interp_on_prepared_grid(
146147 Interpolated values with the shape of `x_query`.
147148
148149 """
150+ if fp_slopes is None :
151+ return _linear_prepared_read (x_query , search_grid , valid_length , xp , fp )
152+ return _hermite_prepared_read (x_query , search_grid , valid_length , xp , fp , fp_slopes )
153+
154+
155+ def _hermite_prepared_read_primal (
156+ x_query : FloatND ,
157+ search_grid : Float1D ,
158+ valid_length : ScalarInt ,
159+ xp : Float1D ,
160+ fp : Float1D ,
161+ fp_slopes : Float1D ,
162+ ) -> FloatND :
163+ """Hermite value read whose query tangent is defined analytically.
164+
165+ Plain autodiff of the branch program (clip boundaries, zero-weight
166+ guards, the right-continuous bracket search) returns a query derivative
167+ at exact grid nodes that is neither one-sided slope of the interpolant —
168+ and asset-row mode consumes `jax.grad` of this read in its Euler
169+ marginal, with deterministic grid alignments placing queries exactly on
170+ nodes. The custom JVP replaces only the query channel with the analytic
171+ derivative of the selected piece (`_hermite_query_derivative` — see its
172+ docstring for the published convention, including the node-slope rule at
173+ the exact last valid node); all other channels keep the primal program's
174+ tangents.
175+ """
176+ return _read_prepared_row (x_query , search_grid , valid_length , xp , fp , fp_slopes )
177+
178+
179+ def _hermite_prepared_read_jvp (primals , tangents ): # noqa: ANN001, ANN202
180+ x_query , search_grid , valid_length , xp , fp , fp_slopes = primals
181+ x_query_dot , _ , _ , xp_dot , fp_dot , fp_slopes_dot = tangents
182+
183+ def read_at_fixed_query (
184+ xp_in : Float1D , fp_in : Float1D , fp_slopes_in : Float1D
185+ ) -> FloatND :
186+ return _read_prepared_row (
187+ x_query , search_grid , valid_length , xp_in , fp_in , fp_slopes_in
188+ )
189+
190+ primal_out , passive_tangent = jax .jvp (
191+ read_at_fixed_query , (xp , fp , fp_slopes ), (xp_dot , fp_dot , fp_slopes_dot )
192+ )
193+ query_derivative = _hermite_query_derivative (
194+ x_query = x_query ,
195+ search_grid = search_grid ,
196+ valid_length = valid_length ,
197+ xp = xp ,
198+ fp = fp ,
199+ fp_slopes = fp_slopes ,
200+ )
201+ return primal_out , passive_tangent + query_derivative * x_query_dot
202+
203+
204+ # Explicit wrapping instead of `@jax.custom_jvp` decorator syntax: the
205+ # beartype import hook wraps every decorated `def` outermost, and wrapping a
206+ # `custom_jvp` instance binds it to its `__call__`, losing `defjvp`.
207+ _hermite_prepared_read = jax .custom_jvp (_hermite_prepared_read_primal )
208+ _hermite_prepared_read .defjvp (_hermite_prepared_read_jvp )
209+
210+
211+ def _hermite_query_derivative (
212+ * ,
213+ x_query : FloatND ,
214+ search_grid : Float1D ,
215+ valid_length : ScalarInt ,
216+ xp : Float1D ,
217+ fp : Float1D ,
218+ fp_slopes : Float1D ,
219+ ) -> FloatND :
220+ """Published query derivative of the Hermite value read.
221+
222+ The economics object the parent Euler inversion consumes — the second of
223+ the two derivative objects sharing `_hermite_bracket_derivatives` (the
224+ first is the right germ, the tie selector). Convention:
225+
226+ - strictly below the first node, and strictly above the last valid node:
227+ zero (the read clamps to a constant),
228+ - inside a bracket: the limited-Hermite piece's derivative (the secant
229+ where the correction is inapplicable),
230+ - exactly on an interior node: the right piece's derivative (the bracket
231+ search is right-continuous),
232+ - exactly on the *last* valid node: the node's own limited slope — NOT
233+ zero. Publishing zero at the top wealth node would feed `u'(c) = 0`
234+ into a parent Euler inversion at a reachable grid alignment; the germ's
235+ clamp semantics (`0` at and above the node) apply to tie *selection*
236+ only,
237+ - a zero-width located bracket (an end duplicate): zero — no slope is
238+ defined on it,
239+ - singleton row: zero (constant clamp); empty row: NaN (the read is NaN —
240+ the derivative is non-authoritative and carries the poison).
241+ """
242+ first , _ , _ , _ , zero_width = _hermite_bracket_derivatives (
243+ x_query = x_query ,
244+ search_grid = search_grid ,
245+ valid_length = valid_length ,
246+ xp = xp ,
247+ fp = fp ,
248+ fp_slopes = fp_slopes ,
249+ )
250+ first_node = search_grid [0 ]
251+ last_node = search_grid [jnp .maximum (valid_length - 1 , 0 )]
252+ outside = (x_query < first_node ) | (x_query > last_node )
253+ derivative = jnp .where (outside | zero_width , 0.0 , first )
254+ derivative = jnp .where (valid_length == 1 , 0.0 , derivative )
255+ return jnp .where (valid_length == 0 , jnp .nan , derivative )
256+
257+
258+ def _linear_prepared_read_primal (
259+ x_query : FloatND ,
260+ search_grid : Float1D ,
261+ valid_length : ScalarInt ,
262+ xp : Float1D ,
263+ fp : Float1D ,
264+ ) -> FloatND :
265+ """Linear value read whose query tangent is defined analytically.
266+
267+ Same contract as `_hermite_prepared_read`, with the analytic query
268+ derivative of the piecewise-linear interpolant: the located bracket's
269+ secant inside brackets and at nodes (right bracket), zero on both clamp
270+ rays and on degenerate rows.
271+ """
272+ return _read_prepared_row (x_query , search_grid , valid_length , xp , fp , None )
273+
274+
275+ def _linear_prepared_read_jvp (primals , tangents ): # noqa: ANN001, ANN202
276+ x_query , search_grid , valid_length , xp , fp = primals
277+ x_query_dot , _ , _ , xp_dot , fp_dot = tangents
278+
279+ def read_at_fixed_query (xp_in : Float1D , fp_in : Float1D ) -> FloatND :
280+ return _read_prepared_row (
281+ x_query , search_grid , valid_length , xp_in , fp_in , None
282+ )
283+
284+ primal_out , passive_tangent = jax .jvp (
285+ read_at_fixed_query , (xp , fp ), (xp_dot , fp_dot )
286+ )
287+ query_derivative = _linear_query_derivative (
288+ x_query = x_query ,
289+ search_grid = search_grid ,
290+ valid_length = valid_length ,
291+ xp = xp ,
292+ fp = fp ,
293+ )
294+ return primal_out , passive_tangent + query_derivative * x_query_dot
295+
296+
297+ # Same explicit wrapping as the Hermite pair (the beartype hook would bind a
298+ # decorated `custom_jvp` instance to its `__call__`).
299+ _linear_prepared_read = jax .custom_jvp (_linear_prepared_read_primal )
300+ _linear_prepared_read .defjvp (_linear_prepared_read_jvp )
301+
302+
303+ def _linear_query_derivative (
304+ * ,
305+ x_query : FloatND ,
306+ search_grid : Float1D ,
307+ valid_length : ScalarInt ,
308+ xp : Float1D ,
309+ fp : Float1D ,
310+ ) -> FloatND :
311+ """Analytic query derivative of the linear padded-row read.
312+
313+ Same bracket location as the read (`side="right"`, so a node query gets
314+ its right bracket) and the same published-derivative convention as
315+ `_hermite_query_derivative`, with the bracket's secant in place of the
316+ limited-Hermite derivative:
317+
318+ - strictly below the first node and strictly above the last valid node:
319+ zero; exactly on the last node: the last bracket's secant,
320+ - a non-finite value difference (a `-inf` endpoint): zero (the secant
321+ fallback convention),
322+ - a zero-width located bracket: zero,
323+ - singleton row: zero; empty row: NaN (poison-carrying).
324+ """
325+ upper = jnp .clip (
326+ jnp .searchsorted (search_grid , x_query , side = "right" ),
327+ 1 ,
328+ jnp .maximum (valid_length - 1 , 1 ),
329+ ).astype (jnp .int32 )
330+ lower = upper - 1
331+ bracket_width = xp [upper ] - xp [lower ]
332+ safe_width = jnp .where (bracket_width == 0.0 , 1.0 , bracket_width )
333+ df = fp [upper ] - fp [lower ]
334+ secant = jnp .where (jnp .isfinite (df ), df , 0.0 ) / safe_width
335+ first_node = search_grid [0 ]
336+ last_node = search_grid [jnp .maximum (valid_length - 1 , 0 )]
337+ outside = (x_query < first_node ) | (x_query > last_node )
338+ derivative = jnp .where (outside | (bracket_width == 0.0 ), 0.0 , secant )
339+ derivative = jnp .where (valid_length == 1 , 0.0 , derivative )
340+ return jnp .where (valid_length == 0 , jnp .nan , derivative )
341+
342+
343+ def _read_prepared_row (
344+ x_query : FloatND ,
345+ search_grid : Float1D ,
346+ valid_length : ScalarInt ,
347+ xp : Float1D ,
348+ fp : Float1D ,
349+ fp_slopes : Float1D | None ,
350+ ) -> FloatND :
351+ """Evaluate the padded-row interpolant (the primal branch program)."""
149352 # The bracket indices span the full query mesh (the dominant egm_step
150353 # working buffer at scale), and never exceed the grid length (a few
151354 # hundred), so int32 holds them with vast headroom. Under x64 `searchsorted`
@@ -273,10 +476,67 @@ def interp_right_germ_on_prepared_grid(
273476 Tuple of the right-finiteness flag and the first, second, and third
274477 right derivatives, each with the shape of `x_query`.
275478
479+ The germ is the *tie selector* over the published interpolants, one of two
480+ derivative objects with a deliberately different terminal convention: the
481+ germ's first derivative is zero at and above the last valid node (the read
482+ clamps right of it), while the published query derivative of the value
483+ read (`interp_on_prepared_grid` under autodiff) is the node's own limited
484+ slope *at* the last node and zero only strictly above — the economics the
485+ parent Euler inversion consumes. The two objects agree everywhere else and
486+ share one bracket/limiter implementation.
487+
488+ """
489+ first , second , third , endpoint_finite , _ = _hermite_bracket_derivatives (
490+ x_query = x_query ,
491+ search_grid = search_grid ,
492+ valid_length = valid_length ,
493+ xp = xp ,
494+ fp = fp ,
495+ fp_slopes = fp_slopes ,
496+ )
497+ # The read clamps to a constant strictly below the first node and at or
498+ # above the last valid one; the germ there is right-finite with all
499+ # derivatives exactly zero. (`search_grid` is `+inf` on the pad, so a
500+ # poisoned all-NaN row lands on the lower clamp.)
501+ first_node = search_grid [0 ]
502+ last_node = search_grid [jnp .maximum (valid_length - 1 , 0 )]
503+ on_clamp_ray = (x_query < first_node ) | (x_query >= last_node )
504+ right_finite = on_clamp_ray | endpoint_finite
505+ return (
506+ right_finite ,
507+ jnp .where (on_clamp_ray , 0.0 , first ),
508+ jnp .where (on_clamp_ray , 0.0 , second ),
509+ jnp .where (on_clamp_ray , 0.0 , third ),
510+ )
511+
512+
513+ def _hermite_bracket_derivatives (
514+ * ,
515+ x_query : FloatND ,
516+ search_grid : Float1D ,
517+ valid_length : ScalarInt ,
518+ xp : Float1D ,
519+ fp : Float1D ,
520+ fp_slopes : Float1D ,
521+ ) -> tuple [FloatND , FloatND , FloatND , BoolND , BoolND ]:
522+ """Derivatives of the located bracket's limited-Hermite piece.
523+
524+ The single bracket/limiter implementation behind both derivative objects —
525+ the right germ (tie selection) and the published query derivative (the
526+ value read's custom JVP): identical bracket location to
527+ `interp_on_prepared_grid` (`side="right"` puts an on-node query into the
528+ node's right bracket) and the same limiter as `_hermite_correction`, so
529+ these are the derivatives of exactly the polynomial the value read
530+ evaluates. In the bracket's local coordinate `t` the read is
531+ `p(t) = f_l + Δf t + c_l t + (c_u - 2 c_l) t² + (c_l - c_u) t³`.
532+
533+ Returns:
534+ Tuple of the first, second, and third derivatives of the located
535+ piece (the secant and zeros where the Hermite correction is
536+ inapplicable — the linear fallback), the bracket's
537+ endpoint-finiteness flag, and the zero-width-bracket flag.
538+
276539 """
277- # Identical bracket location to `interp_on_prepared_grid`: `side="right"`
278- # puts an on-node query into the node's right bracket, which is exactly the
279- # bracket whose germ the right-continuous read needs.
280540 upper = jnp .clip (
281541 jnp .searchsorted (search_grid , x_query , side = "right" ),
282542 1 ,
@@ -301,10 +561,6 @@ def interp_right_germ_on_prepared_grid(
301561 safe_df = jnp .where (jnp .isfinite (df ), df , 0.0 )
302562 secant = safe_df / safe_width
303563
304- # The same limiter as `_hermite_correction`, so these derivatives are the
305- # derivatives of exactly the polynomial the value read evaluates. In the
306- # bracket's local coordinate `t` the read is
307- # `p(t) = f_l + Δf t + c_l t + (c_u - 2 c_l) t² + (c_l - c_u) t³`.
308564 def limit (slope : FloatND ) -> FloatND :
309565 same_sign = slope * secant > 0.0
310566 limited = jnp .sign (secant ) * jnp .minimum (jnp .abs (slope ), 3.0 * jnp .abs (secant ))
@@ -333,20 +589,8 @@ def limit(slope: FloatND) -> FloatND:
333589 first = jnp .where (applicable , hermite_first , secant )
334590 second = jnp .where (applicable , hermite_second , 0.0 )
335591 third = jnp .where (applicable , hermite_third , 0.0 )
336- # The read clamps to a constant strictly below the first node and at or
337- # above the last valid one; the germ there is right-finite with all
338- # derivatives exactly zero. (`search_grid` is `+inf` on the pad, so a
339- # poisoned all-NaN row lands on the lower clamp.)
340- first_node = search_grid [0 ]
341- last_node = search_grid [jnp .maximum (valid_length - 1 , 0 )]
342- on_clamp_ray = (x_query < first_node ) | (x_query >= last_node )
343- right_finite = on_clamp_ray | (jnp .isfinite (fp_lower ) & jnp .isfinite (fp_upper ))
344- return (
345- right_finite ,
346- jnp .where (on_clamp_ray , 0.0 , first ),
347- jnp .where (on_clamp_ray , 0.0 , second ),
348- jnp .where (on_clamp_ray , 0.0 , third ),
349- )
592+ endpoint_finite = jnp .isfinite (fp_lower ) & jnp .isfinite (fp_upper )
593+ return first , second , third , endpoint_finite , bracket_width == 0.0
350594
351595
352596def _interp_between_nodes (
0 commit comments