Skip to content

Commit 5c4aeff

Browse files
hmgaudeckerclaude
andcommitted
Stream the asset-row refine-to-query into the FUES scan
In asset-row mode the per-node solve refines a full NaN-padded upper envelope of static length n_pad and interpolates it at exactly one query (resources_at_node) to publish a scalar (V_node, policy_node), then discards it. Batched over combos x Euler-nodes, that n_pad scratch axis is the binder that OOMs large solves. Fold the single-query interpolation into the upper-envelope scan so the n_pad rows never materialize: - Extract `_interp_between_nodes` in interp.py — the pure two-bracket-node arithmetic plus the Hermite correction, shared by `interp_on_prepared_grid` and the streamed path. The existing interp unit tests pin it (behavior- preserving). This guarantees the streamed value cannot diverge from row-then-interp: only which two nodes differs. - Add `refine_to_bracket` + `QueryBracket` in fues.py — a new scan driver reusing `_inspect_candidate` verbatim, changing only the emission sink: each step's up-to-3 emitted points fold into an O(1) bracket-capture carry (first/second, rolling prev/last, lo = latest emitted with grid <= q, hi = first with grid > q, running counts), no [n_input,3] stack and no [n_pad] rows. Post-scan, the bracket reproduces clip(searchsorted(side="right"), 1, max(n_kept-1, 1)) node-for-node: the side="right" tie-break at a duplicated kink (right copy wins the lower slot), the below-first clamp (first, second), and the at-or-above-last clamp (second-last, last; single-live falls back to the NaN-padded slot like the row path). Geometry only — no utility, borrowing limit, or floor. - Add `publish_node_from_bracket` in asset_row.py — the scalar-bracket counterpart of `_publish_node_V_and_policy`: the shared `_interp_between_nodes` with the value Hermite slope = grad(utility) at the two bracket policies only, plus the constrained floor and the n_kept > n_pad overflow poison, preserved identically. Wire the asset-row node solve to refine_to_bracket -> publish_node_from_bracket and drop the n_pad envelope from that path. Scope: asset-row mode only. Single-post-state mode publishes the refined envelope AS its inter-period carry (queried later at many parent points), so step_core's single-post-state path keeps calling `refine` unchanged. The new equivalence test pins the streamed pair against refine + _publish_node_V_and_policy for the same candidates + query, to fp tolerance: smooth, kinked, multi-crossing, all-dead, single-live, overflow, and query below-first / above-last / exactly on a duplicated kink abscissa. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 11e7669 commit 5c4aeff

7 files changed

Lines changed: 817 additions & 17 deletions

File tree

src/_lcm/egm/asset_row.py

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
bind_continuation,
2525
)
2626
from _lcm.egm.interp import (
27+
_interp_between_nodes,
2728
interp_on_padded_grid,
2829
)
2930
from _lcm.egm.step_core import (
@@ -32,6 +33,9 @@
3233
_EgmKernelPieces,
3334
_get_compute_node,
3435
)
36+
from _lcm.egm.upper_envelope.fues import (
37+
QueryBracket,
38+
)
3539
from _lcm.typing import (
3640
RegimeName,
3741
)
@@ -171,17 +175,19 @@ def utility_of_action(action_value: ScalarFloat) -> ScalarFloat:
171175
# Same `-inf` masking as the default per-combo computation: dead
172176
# candidates become the envelope scan's absent form (NaN).
173177
candidate_dead = jnp.isneginf(candidate_value)
174-
refined_grid, refined_policy, refined_value, n_kept = pieces.refine(
178+
# The node reads its refined envelope at exactly one query
179+
# (`resources_at_node`), so the scan streams the bracketing pair
180+
# instead of materializing the NaN-padded `n_pad` envelope rows —
181+
# the per-(combo, node) envelope working set is O(1), not O(n_pad).
182+
bracket = pieces.refine_to_bracket(
175183
endog_grid=jnp.where(candidate_dead, jnp.nan, candidate_grid),
176184
policy=jnp.where(candidate_dead, jnp.nan, candidate_policy),
177185
value=jnp.where(candidate_dead, jnp.nan, candidate_value),
186+
x_query=resources_at_node,
178187
)
179188

180-
V_node, policy_node = _publish_node_V_and_policy(
181-
refined_grid=refined_grid,
182-
refined_policy=refined_policy,
183-
refined_value=refined_value,
184-
n_kept=n_kept,
189+
V_node, policy_node = publish_node_from_bracket(
190+
bracket=bracket,
185191
n_pad=pieces.n_pad,
186192
resources_at_node=resources_at_node,
187193
borrowing_limit=pieces.borrowing_limit,
@@ -372,3 +378,127 @@ def _publish_node_V_and_policy(
372378
V_node = jnp.where(overflowed, jnp.nan, V_node).astype(dtype)
373379
policy_node = jnp.where(overflowed, jnp.nan, policy_node).astype(dtype)
374380
return V_node, policy_node
381+
382+
383+
def publish_node_from_bracket(
384+
*,
385+
bracket: QueryBracket,
386+
n_pad: int,
387+
resources_at_node: ScalarFloat,
388+
borrowing_limit: ScalarFloat,
389+
utility_of_action: Callable[[ScalarFloat], ScalarFloat],
390+
discounted_expected_value_at_limit: ScalarFloat,
391+
) -> tuple[ScalarFloat, ScalarFloat]:
392+
"""Publish one asset node's value and optimal action from its query bracket.
393+
394+
The streamed counterpart of `_publish_node_V_and_policy`: it consumes the
395+
two envelope nodes that `refine_to_bracket` captured around
396+
`resources_at_node` instead of the full NaN-padded refined row, so the
397+
`n_pad` envelope is never materialized. The published economics are
398+
identical:
399+
400+
- The value is the cubic-Hermite read of the envelope between the two
401+
bracket nodes (the value slope at each node is `grad(utility_of_action)`
402+
at that node's policy, the envelope-theorem marginal), floored at the
403+
closed-form constrained value, which is a feasible-policy lower bound.
404+
- Below the lowest envelope node the closed-form constrained value is
405+
published outright; the winning branch sets the action (the closed-form
406+
`R - borrowing_limit` where constrained wins, the interpolated policy
407+
otherwise).
408+
- Envelope overflow (`n_kept > n_pad`) NaN-poisons both outputs, identical
409+
to the row path, so the solve loop's NaN diagnostics surface the offending
410+
(regime, period).
411+
412+
Because the value and policy arithmetic is the shared `_interp_between_nodes`
413+
primitive — the same one the row path reaches through
414+
`interp_on_padded_grid` — the streamed publish cannot diverge from
415+
row-then-interpolate: only the bracket-finding differs.
416+
417+
Args:
418+
bracket: The query bracket from `refine_to_bracket`.
419+
n_pad: Static length of the envelope-refinement workspace (the overflow
420+
threshold).
421+
resources_at_node: Resources at this exogenous Euler node (the row's
422+
single publish query).
423+
borrowing_limit: Lower bound of the savings grid.
424+
utility_of_action: Utility with everything but the continuous action
425+
bound.
426+
discounted_expected_value_at_limit: Discounted expected continuation
427+
value at the lowest savings node.
428+
429+
Returns:
430+
Tuple of the node's published value and published optimal action.
431+
432+
"""
433+
dtype = resources_at_node.dtype
434+
overflowed = bracket.n_kept > n_pad
435+
436+
# The value Hermite slope is the envelope-theorem marginal `u'(c*)`, masked
437+
# exactly as the row path: NaN where the node's policy is NaN (a padded
438+
# slot), 0.0 where the node's value is `-inf` (an infeasible endpoint), so
439+
# `_interp_between_nodes` falls back to the linear rule on those brackets.
440+
slope_lower = _node_value_slope(
441+
policy=bracket.lower_policy,
442+
value=bracket.lower_value,
443+
utility_of_action=utility_of_action,
444+
)
445+
slope_upper = _node_value_slope(
446+
policy=bracket.upper_policy,
447+
value=bracket.upper_value,
448+
utility_of_action=utility_of_action,
449+
)
450+
451+
value_interpolated = _interp_between_nodes(
452+
x_query=resources_at_node,
453+
xp_lower=bracket.lower_grid,
454+
xp_upper=bracket.upper_grid,
455+
fp_lower=bracket.lower_value,
456+
fp_upper=bracket.upper_value,
457+
slope_lower=slope_lower,
458+
slope_upper=slope_upper,
459+
)
460+
policy_interpolated = _interp_between_nodes(
461+
x_query=resources_at_node,
462+
xp_lower=bracket.lower_grid,
463+
xp_upper=bracket.upper_grid,
464+
fp_lower=bracket.lower_policy,
465+
fp_upper=bracket.upper_policy,
466+
)
467+
468+
closed_form_action = resources_at_node - borrowing_limit
469+
value_constrained = jnp.where(
470+
closed_form_action > 0.0,
471+
utility_of_action(jnp.maximum(closed_form_action, jnp.finfo(dtype).tiny))
472+
+ discounted_expected_value_at_limit,
473+
-jnp.inf,
474+
)
475+
below_refined = (closed_form_action > 0.0) & (
476+
resources_at_node <= bracket.first_grid
477+
)
478+
constrained_wins = below_refined | (value_constrained >= value_interpolated)
479+
V_node = jnp.where(
480+
below_refined,
481+
value_constrained,
482+
jnp.maximum(value_interpolated, value_constrained),
483+
)
484+
policy_node = jnp.where(constrained_wins, closed_form_action, policy_interpolated)
485+
V_node = jnp.where(overflowed, jnp.nan, V_node).astype(dtype)
486+
policy_node = jnp.where(overflowed, jnp.nan, policy_node).astype(dtype)
487+
return V_node, policy_node
488+
489+
490+
def _node_value_slope(
491+
*,
492+
policy: ScalarFloat,
493+
value: ScalarFloat,
494+
utility_of_action: Callable[[ScalarFloat], ScalarFloat],
495+
) -> ScalarFloat:
496+
"""Envelope-theorem value slope at one bracket node, masked like the row.
497+
498+
The slope is `grad(utility_of_action)` at the node's policy — NaN where the
499+
policy is a padded NaN slot, 0.0 where the node value is `-inf` — matching
500+
the per-node masking the row path applies before interpolating.
501+
"""
502+
slope = jax.grad(utility_of_action)(jnp.where(jnp.isnan(policy), 1.0, policy))
503+
slope = jnp.where(jnp.isnan(policy), jnp.nan, slope)
504+
return jnp.where(jnp.isneginf(value), 0.0, slope)

src/_lcm/egm/interp.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,63 @@ def interp_on_prepared_grid(
151151
jnp.maximum(valid_length - 1, 1),
152152
).astype(jnp.int32)
153153
lower = upper - 1
154-
xp_lower = xp[lower]
155-
fp_lower = fp[lower]
156-
fp_upper = fp[upper]
157-
bracket_width = xp[upper] - xp_lower
154+
return _interp_between_nodes(
155+
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],
162+
)
163+
164+
165+
def _interp_between_nodes(
166+
*,
167+
x_query: FloatND,
168+
xp_lower: FloatND,
169+
xp_upper: FloatND,
170+
fp_lower: FloatND,
171+
fp_upper: FloatND,
172+
slope_lower: FloatND | None = None,
173+
slope_upper: FloatND | None = None,
174+
) -> FloatND:
175+
"""Interpolate a query between its two bracketing grid nodes.
176+
177+
The pure two-node arithmetic of the padded-grid interpolant, shared by
178+
`interp_on_prepared_grid` (which gathers the bracket from a full row) and
179+
the streamed asset-row publish (which captures the bracket directly during
180+
the upper-envelope scan). Having both paths reduce to this one function
181+
guarantees the streamed value cannot diverge from the row-then-interpolate
182+
value: only *which two nodes* differs, not the arithmetic on them.
183+
184+
The bracket must already be edge-clamped to a real pair of nodes (queries
185+
below the first node bracket the first pair, queries at or above the last
186+
bracket the last pair). The interpolant is then edge-safe by construction:
187+
188+
- At a zero-width bracket (a duplicated kink abscissa) the relative position
189+
is forced to `1.0`, so the right node's value applies and the zero width
190+
is never used as a divisor.
191+
- A `-inf` endpoint yields `-inf` wherever it carries positive weight
192+
(instead of the NaN of `fp_lower + rel * (fp_upper - fp_lower)`) and
193+
contributes exactly nothing at weight zero.
194+
195+
Args:
196+
x_query: Point(s) at which to evaluate the interpolant.
197+
xp_lower: Lower bracket node abscissa.
198+
xp_upper: Upper bracket node abscissa.
199+
fp_lower: Function value at the lower node.
200+
fp_upper: Function value at the upper node.
201+
slope_lower: Node derivative at the lower node; `None` selects linear
202+
interpolation.
203+
slope_upper: Node derivative at the upper node; `None` selects linear
204+
interpolation.
205+
206+
Returns:
207+
Interpolated value(s) with the shape of `x_query`.
208+
209+
"""
210+
bracket_width = xp_upper - xp_lower
158211
safe_width = jnp.where(bracket_width == 0.0, 1.0, bracket_width)
159212
# Zero-width brackets arise only when a duplicated abscissa sits at the end
160213
# of the non-NaN prefix; queries there are at or above the duplicate, so
@@ -172,16 +225,16 @@ def interp_on_prepared_grid(
172225
linear = jnp.where(weight_lower > 0.0, weight_lower * fp_lower, 0.0) + jnp.where(
173226
relative_position > 0.0, relative_position * fp_upper, 0.0
174227
)
175-
if fp_slopes is None:
228+
if slope_lower is None or slope_upper is None:
176229
return linear
177230
return linear + _hermite_correction(
178231
relative_position=relative_position,
179232
bracket_width=bracket_width,
180233
safe_width=safe_width,
181234
fp_lower=fp_lower,
182235
fp_upper=fp_upper,
183-
slope_lower=fp_slopes[lower],
184-
slope_upper=fp_slopes[upper],
236+
slope_lower=slope_lower,
237+
slope_upper=slope_upper,
185238
)
186239

187240

src/_lcm/egm/step.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
_EgmKernelPieces,
162162
_get_solve_one_combo,
163163
)
164-
from _lcm.egm.upper_envelope import get_upper_envelope
164+
from _lcm.egm.upper_envelope import get_bracket_finder, get_upper_envelope
165165
from _lcm.egm.validation import _reachable_target_names, savings_stage_reads_euler_state
166166
from _lcm.engine import StateActionSpace
167167
from _lcm.grids import ContinuousGrid, Grid
@@ -754,6 +754,7 @@ def _build_kernel_pieces(
754754
),
755755
build_H_kwargs=_get_build_H_kwargs(functions),
756756
refine=get_upper_envelope(solver=solver, n_refined=n_pad),
757+
refine_to_bracket=get_bracket_finder(solver=solver),
757758
continuation_plan=continuation_plan,
758759
)
759760

src/_lcm/egm/step_core.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from _lcm.egm.interp import (
2828
interp_on_padded_grid,
2929
)
30+
from _lcm.egm.upper_envelope.fues import QueryBracket
3031
from _lcm.typing import (
3132
ActionName,
3233
RegimeName,
@@ -101,7 +102,10 @@ class _EgmKernelPieces:
101102
"""Closure assembling the Bellman aggregator's keyword arguments."""
102103

103104
refine: Callable[..., tuple[Float1D, Float1D, Float1D, ScalarInt]]
104-
"""The configured upper-envelope backend."""
105+
"""The configured upper-envelope backend (single-post-state carry)."""
106+
107+
refine_to_bracket: Callable[..., QueryBracket]
108+
"""The streaming single-query bracket finder (asset-row publish)."""
105109

106110
continuation_plan: ContinuationPlan
107111
"""Build-time statics of the per-savings-node continuation aggregation."""

src/_lcm/egm/upper_envelope/__init__.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@
88
(`_lcm.egm.upper_envelope.fues`).
99
"""
1010

11+
from collections.abc import Callable
1112
from typing import Protocol, runtime_checkable
1213

13-
from _lcm.egm.upper_envelope.fues import refine_envelope
14+
from _lcm.egm.upper_envelope.fues import (
15+
QueryBracket,
16+
refine_envelope,
17+
refine_to_bracket,
18+
)
1419
from lcm.solvers import DCEGM
15-
from lcm.typing import Float1D, ScalarInt
20+
from lcm.typing import Float1D, ScalarFloat, ScalarInt
1621

1722

1823
@runtime_checkable
@@ -69,3 +74,47 @@ def fues_backend(
6974

7075
msg = f"Unknown upper-envelope backend: {solver.upper_envelope!r}."
7176
raise ValueError(msg)
77+
78+
79+
def get_bracket_finder(*, solver: DCEGM) -> Callable[..., QueryBracket]:
80+
"""Build the streaming single-query bracket finder for the asset-row solve.
81+
82+
The geometry-only counterpart of `get_upper_envelope` for asset-row mode,
83+
where the refined envelope is read at exactly one query per node: it returns
84+
the two bracketing envelope nodes (plus the first node and the kept count)
85+
without materializing the NaN-padded `n_pad` rows. It is FUES-local and
86+
deliberately not on the `UpperEnvelopeBackend` Protocol — the backend
87+
returns envelope geometry; the asset-row module owns the EGM economics
88+
(utility gradients, the borrowing limit, the constrained floor).
89+
90+
Args:
91+
solver: The regime's DC-EGM solver configuration; the `fues_*` fields
92+
parametrize the scan.
93+
94+
Returns:
95+
The configured bracket finder.
96+
97+
"""
98+
if solver.upper_envelope == "fues":
99+
100+
def fues_bracket_finder(
101+
*,
102+
endog_grid: Float1D,
103+
policy: Float1D,
104+
value: Float1D,
105+
x_query: ScalarFloat,
106+
) -> QueryBracket:
107+
"""Run the streaming FUES scan with the solver's thresholds."""
108+
return refine_to_bracket(
109+
endog_grid=endog_grid,
110+
policy=policy,
111+
value=value,
112+
x_query=x_query,
113+
jump_thresh=solver.fues_jump_thresh,
114+
n_points_to_scan=solver.fues_n_points_to_scan,
115+
)
116+
117+
return fues_bracket_finder
118+
119+
msg = f"Unknown upper-envelope backend: {solver.upper_envelope!r}."
120+
raise ValueError(msg)

0 commit comments

Comments
 (0)