Skip to content

Commit 0bca78c

Browse files
hmgaudeckerclaude
andcommitted
NEGM stacked envelope: complete right-germ tie ownership
The first right derivative alone does not identify the branch that wins immediately right of an exact value tie: two limited Hermite pieces can agree to first order and differ in curvature, and a piece whose right bracket ends in -inf got derivative zero — tying a genuinely finite branch — so the lowest-index fallback could publish the marginal of a candidate that loses for every small step right. The value read's local pieces are cubics or constant clamps, so the right germ is finite-dimensional and the comparison can be exhaustive: - interp_right_germ_on_{padded,prepared}_grid replaces the derivative-only read, returning (right_finite, first, second, third) — the same bracket location, limiter, and clamp rays; secant fallback carries zero curvature; a non-finite bracket endpoint clears the right-finiteness flag. - right_germ_winner (shared by outer_envelope_at_query and the production _collapse_stacked_candidates) ranks the tie set by staged exact comparisons: right-finiteness, then the three derivatives, then the lowest index — now reached only by locally identical pieces. The scalar NumPy host reference is extended to the full germ; the equal-slope/different-curvature and dying-right-bracket ties are red-first regressions in both readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123hk3uzBgBeZown4eBBQ2C
1 parent aef2010 commit 0bca78c

6 files changed

Lines changed: 311 additions & 143 deletions

File tree

src/_lcm/egm/continuation.py

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
from _lcm.egm.carry import EGMCarry
2727
from _lcm.egm.interp import (
2828
interp_on_prepared_grid,
29-
interp_right_derivative_on_prepared_grid,
29+
interp_right_germ_on_prepared_grid,
3030
locate_on_grid,
3131
prepare_padded_grid,
3232
)
33+
from _lcm.egm.outer_envelope import right_germ_winner
3334
from _lcm.egm.regime_introspection import (
3435
_get_child_discrete_actions,
3536
_get_child_resources_arg_names,
@@ -56,9 +57,11 @@
5657
)
5758
from lcm.regime import Regime as UserRegime
5859
from lcm.typing import (
60+
BoolND,
5961
Float1D,
6062
FloatND,
6163
IntND,
64+
ScalarBool,
6265
ScalarFloat,
6366
ScalarInt,
6467
)
@@ -920,18 +923,21 @@ def interp_row(
920923
# Below a candidate's own first finite coh node its support has not
921924
# started: mask the read to `-inf` so the edge clamp cannot hand an
922925
# infeasible lifted candidate a boundary value that wins the max. The
923-
# `-inf` also pins the marginal to zero below. The right derivative of
924-
# each candidate's value read feeds the tie rule at the candidate max.
925-
def right_slope_row(
926+
# `-inf` also pins the marginal to zero below. The right germ of each
927+
# candidate's value read feeds the tie rule at the candidate max; it
928+
# needs no support mask of its own — a below-support candidate enters
929+
# the tie set only when every candidate is below support, where all
930+
# published marginals are exactly zero regardless of the winner.
931+
def right_germ_row(
926932
search_grid: Float1D,
927933
valid_length: ScalarInt,
928934
xp: Float1D,
929935
fp: Float1D,
930936
fp_slopes: Float1D,
931937
x_query: ScalarFloat,
932-
) -> ScalarFloat:
933-
"""Right-derive one value row at its query; positional per `jax.vmap`."""
934-
return interp_right_derivative_on_prepared_grid(
938+
) -> tuple[ScalarBool, ScalarFloat, ScalarFloat, ScalarFloat]:
939+
"""Right germ of one value row at its query; positional per `jax.vmap`."""
940+
return interp_right_germ_on_prepared_grid(
935941
x_query=x_query,
936942
search_grid=search_grid,
937943
valid_length=valid_length,
@@ -940,7 +946,7 @@ def right_slope_row(
940946
fp_slopes=fp_slopes,
941947
)
942948

943-
right_slope_at_child = jax.vmap(right_slope_row)(
949+
right_germ_at_child = jax.vmap(right_germ_row)(
944950
search_rows, valid_rows, grid_rows, value_rows, marginal_rows, queries_flat
945951
)
946952
row_lower = jnp.min(
@@ -952,7 +958,6 @@ def right_slope_row(
952958
# ordinary infeasible `(-inf, 0)` pair.
953959
below_row_support = (queries_flat < row_lower) & jnp.isfinite(row_lower)
954960
value_at_child = jnp.where(below_row_support, -jnp.inf, value_at_child)
955-
right_slope_at_child = jnp.where(below_row_support, 0.0, right_slope_at_child)
956961
# `-inf` entries interpolate pointwise to `-inf` (never NaN) and carry
957962
# exactly-zero marginal utility, so an infeasible-everywhere row reads as
958963
# the `-inf` / zero pair while a row with isolated `-inf` nodes (e.g. a
@@ -968,7 +973,9 @@ def right_slope_row(
968973
value_at_child, marginal_at_child = _collapse_stacked_candidates(
969974
value_at_child=value_at_child,
970975
marginal_at_child=marginal_at_child,
971-
right_slope_at_child=right_slope_at_child.reshape(block_shape),
976+
right_germ_at_child=tuple(
977+
component.reshape(block_shape) for component in right_germ_at_child
978+
),
972979
)
973980

974981
for passive_value, passive_grid in zip(
@@ -1016,47 +1023,41 @@ def _collapse_stacked_candidates(
10161023
*,
10171024
value_at_child: FloatND,
10181025
marginal_at_child: FloatND,
1019-
right_slope_at_child: FloatND,
1026+
right_germ_at_child: tuple[BoolND, FloatND, FloatND, FloatND],
10201027
) -> tuple[FloatND, FloatND]:
10211028
"""Collapse the candidate axis by the exact hard max at the query.
10221029
10231030
Publishes the winner's marginal (Danskin). This runs *before* the passive
10241031
blend so the blend interpolates the nodewise outer maximum. An exact value
1025-
tie resolves right-continuously in the value read itself: among tied
1026-
candidates the largest right derivative of the value interpolant wins — the
1027-
Fritsch-Carlson-limited slope of the bracket right of the query, exactly
1028-
zero on the clamp ray at or above a candidate's last node — so the branch
1029-
whose read actually wins immediately to the right owns the (economic)
1030-
marginal the parent's Euler inversion consumes; `argmax` resolves exact
1031-
rank ties to the lowest index. (The rank uses the unscaled interpolant
1032-
slope: the composed gradient is shared by all candidates of a cell and
1033-
positive, so scaling could never reorder them.) A cell whose candidates are
1034-
all `-inf` (no live support) keeps the `(-inf, 0)` infeasible contract:
1035-
every masked marginal is exactly zero.
1032+
tie resolves right-continuously in the value read itself: the tied
1033+
candidates are compared by the complete right germ of their own value
1034+
interpolants (`right_germ_winner` — right-finiteness, then the first three
1035+
one-sided derivatives, then the lowest index among locally identical
1036+
pieces), so the branch whose read actually wins immediately to the right
1037+
owns the (economic) marginal the parent's Euler inversion consumes. (The
1038+
germ uses the unscaled interpolant derivatives: the composed gradient is
1039+
shared by all candidates of a cell and positive, so scaling could never
1040+
reorder them.) A cell whose candidates are all `-inf` (no live support)
1041+
keeps the `(-inf, 0)` infeasible contract: every masked marginal is
1042+
exactly zero.
10361043
10371044
Args:
10381045
value_at_child: Candidate value reads; the candidate axis is last.
10391046
marginal_at_child: Gradient-scaled candidate marginal reads, same shape.
1040-
right_slope_at_child: Right derivatives of the candidate value reads,
1041-
same shape.
1047+
right_germ_at_child: Tuple of the right-finiteness flag and the first
1048+
three right derivatives of the candidate value reads, same shape.
10421049
10431050
Returns:
10441051
Tuple of the winner's value and marginal, with the candidate axis
10451052
collapsed.
10461053
10471054
"""
1048-
best_value = jnp.max(value_at_child, axis=-1, keepdims=True)
1049-
rank = jnp.where(
1050-
value_at_child >= best_value,
1051-
right_slope_at_child,
1052-
-jnp.inf,
1053-
)
1054-
winner = jnp.argmax(rank, axis=-1, keepdims=True)
1055+
winner = right_germ_winner(value=value_at_child, right_germ=right_germ_at_child)
10551056
winner_marginal = jnp.take_along_axis(marginal_at_child, winner, axis=-1)[..., 0]
10561057
# The published value is the maximum itself: identical to the winner's read
10571058
# at any tie, and NaN-propagating when a poisoned candidate row (whose NaN
10581059
# empties the tie set) must surface fail-loud.
1059-
return best_value[..., 0], winner_marginal
1060+
return jnp.max(value_at_child, axis=-1), winner_marginal
10601061

10611062

10621063
def _build_child_reads(

src/_lcm/egm/interp.py

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import jax.numpy as jnp
1414

15-
from lcm.typing import Float1D, FloatND, ScalarFloat, ScalarInt
15+
from lcm.typing import BoolND, Float1D, FloatND, ScalarFloat, ScalarInt
1616

1717

1818
def interp_on_padded_grid(
@@ -170,31 +170,32 @@ def interp_on_prepared_grid(
170170
return jnp.where(valid_length == 0, jnp.nan, result)
171171

172172

173-
def interp_right_derivative_on_padded_grid(
173+
def interp_right_germ_on_padded_grid(
174174
*,
175175
x_query: FloatND,
176176
xp: Float1D,
177177
fp: Float1D,
178178
fp_slopes: Float1D,
179-
) -> FloatND:
180-
"""Compute the right derivative of the Hermite value read on a padded row.
179+
) -> tuple[BoolND, FloatND, FloatND, FloatND]:
180+
"""Compute the right germ of the Hermite value read on a padded row.
181181
182182
Same row contract as `interp_on_padded_grid`; see
183-
`interp_right_derivative_on_prepared_grid` for the derivative semantics.
183+
`interp_right_germ_on_prepared_grid` for the germ semantics.
184184
185185
Args:
186-
x_query: Points at which to evaluate the right derivative; any shape.
186+
x_query: Points at which to evaluate the right germ; any shape.
187187
xp: Weakly ascending grid row with NaNs only in the tail.
188188
fp: Function values on `xp`, NaN-padded in lockstep with `xp`.
189189
fp_slopes: Derivatives of `fp` with respect to `xp` at the `xp` nodes,
190190
NaN-padded in lockstep.
191191
192192
Returns:
193-
Right derivatives with the shape of `x_query`.
193+
Tuple of the right-finiteness flag and the first, second, and third
194+
right derivatives, each with the shape of `x_query`.
194195
195196
"""
196197
search_grid, valid_length = prepare_padded_grid(xp)
197-
return interp_right_derivative_on_prepared_grid(
198+
return interp_right_germ_on_prepared_grid(
198199
x_query=x_query,
199200
search_grid=search_grid,
200201
valid_length=valid_length,
@@ -204,37 +205,40 @@ def interp_right_derivative_on_padded_grid(
204205
)
205206

206207

207-
def interp_right_derivative_on_prepared_grid(
208+
def interp_right_germ_on_prepared_grid(
208209
*,
209210
x_query: FloatND,
210211
search_grid: Float1D,
211212
valid_length: ScalarInt,
212213
xp: Float1D,
213214
fp: Float1D,
214215
fp_slopes: Float1D,
215-
) -> FloatND:
216-
"""Compute the right derivative of the Hermite value read at each query.
217-
218-
This is the one-sided derivative of the *value interpolant* that
219-
`interp_on_prepared_grid` evaluates with `fp_slopes` — the local slope
220-
governing the read immediately to the right of the query — not a read of
221-
the slope row itself. The two differ exactly where a tie-owner decision
222-
needs the truth: the Fritsch-Carlson limiter may cap a node's raw slope,
223-
and the edge clamps flatten the read outside the valid range. Semantics:
224-
225-
- Strictly inside a bracket: the derivative of that bracket's limited
226-
cubic Hermite (the secant where the correction is inapplicable — the
227-
linear fallback).
228-
- Exactly on a node: the derivative at the left edge of the node's *right*
229-
bracket (the bracket search is right-continuous), i.e. the node's
230-
limited slope with respect to that bracket's secant.
231-
- Strictly below the first node, and at or above the last valid node: zero
232-
— the read clamps to a constant there.
233-
- A bracket with a non-finite value difference (e.g. a `-inf` endpoint):
234-
zero, matching the linear rule's flat, clamp-like behavior there.
216+
) -> tuple[BoolND, FloatND, FloatND, FloatND]:
217+
"""Compute the right germ of the Hermite value read at each query.
218+
219+
The germ is the complete local description of the *value interpolant* that
220+
`interp_on_prepared_grid` evaluates with `fp_slopes` immediately to the
221+
right of the query — not a read of the slope row itself. Each local piece
222+
is a cubic (or a constant clamp), so the germ is finite-dimensional: a
223+
right-finiteness flag plus the first, second, and third one-sided
224+
derivatives determine the read on a right neighborhood exactly. The germ
225+
differs from the raw slope row exactly where a tie-owner decision needs the
226+
truth: the Fritsch-Carlson limiter may cap a node's raw slope, the edge
227+
clamps flatten the read outside the valid range, and a `-inf` bracket
228+
endpoint kills the read immediately right of a finite node. Semantics:
229+
230+
- Strictly inside a bracket: the derivatives of that bracket's limited
231+
cubic Hermite (the secant and zero curvature where the correction is
232+
inapplicable — the linear fallback).
233+
- Exactly on a node: the derivatives at the left edge of the node's *right*
234+
bracket (the bracket search is right-continuous).
235+
- Strictly below the first node, and at or above the last valid node: the
236+
read clamps to a constant — right-finite with all derivatives zero.
237+
- A bracket with a non-finite endpoint value: not right-finite (the read
238+
is `-inf` on the bracket's interior), derivatives zero.
235239
236240
Args:
237-
x_query: Points at which to evaluate the right derivative; any shape.
241+
x_query: Points at which to evaluate the right germ; any shape.
238242
search_grid: The row's `+inf`-padded search key from
239243
`prepare_padded_grid`.
240244
valid_length: The row's non-NaN prefix length from
@@ -244,12 +248,13 @@ def interp_right_derivative_on_prepared_grid(
244248
fp_slopes: Node derivatives, NaN-padded in lockstep.
245249
246250
Returns:
247-
Right derivatives with the shape of `x_query`.
251+
Tuple of the right-finiteness flag and the first, second, and third
252+
right derivatives, each with the shape of `x_query`.
248253
249254
"""
250255
# Identical bracket location to `interp_on_prepared_grid`: `side="right"`
251256
# puts an on-node query into the node's right bracket, which is exactly the
252-
# bracket whose derivative the right-continuous read needs.
257+
# bracket whose germ the right-continuous read needs.
253258
upper = jnp.clip(
254259
jnp.searchsorted(search_grid, x_query, side="right"),
255260
1,
@@ -274,37 +279,52 @@ def interp_right_derivative_on_prepared_grid(
274279
safe_df = jnp.where(jnp.isfinite(df), df, 0.0)
275280
secant = safe_df / safe_width
276281

277-
# The same limiter as `_hermite_correction`, so this derivative is the
278-
# derivative of exactly the polynomial the value read evaluates.
282+
# The same limiter as `_hermite_correction`, so these derivatives are the
283+
# derivatives of exactly the polynomial the value read evaluates. In the
284+
# bracket's local coordinate `t` the read is
285+
# `p(t) = f_l + Δf t + c_l t + (c_u - 2 c_l) t² + (c_l - c_u) t³`.
279286
def limit(slope: FloatND) -> FloatND:
280287
same_sign = slope * secant > 0.0
281288
limited = jnp.sign(secant) * jnp.minimum(jnp.abs(slope), 3.0 * jnp.abs(secant))
282289
return jnp.where(same_sign, limited, 0.0)
283290

284291
coeff_lower = safe_width * limit(slope_lower) - safe_df
285292
coeff_upper = safe_df - safe_width * limit(slope_upper)
286-
hermite_slope = (
293+
hermite_first = (
287294
safe_df
288295
+ (1.0 - 2.0 * relative_position)
289296
* ((1.0 - relative_position) * coeff_lower + relative_position * coeff_upper)
290297
+ relative_position * (1.0 - relative_position) * (coeff_upper - coeff_lower)
291298
) / safe_width
299+
hermite_second = (
300+
2.0 * (coeff_upper - 2.0 * coeff_lower)
301+
+ 6.0 * (coeff_lower - coeff_upper) * relative_position
302+
) / safe_width**2
303+
hermite_third = 6.0 * (coeff_lower - coeff_upper) / safe_width**3
292304
applicable = (
293305
(bracket_width > 0.0)
294306
& jnp.isfinite(fp_lower)
295307
& jnp.isfinite(fp_upper)
296308
& jnp.isfinite(slope_lower)
297309
& jnp.isfinite(slope_upper)
298310
)
299-
derivative = jnp.where(applicable, hermite_slope, secant)
311+
first = jnp.where(applicable, hermite_first, secant)
312+
second = jnp.where(applicable, hermite_second, 0.0)
313+
third = jnp.where(applicable, hermite_third, 0.0)
300314
# The read clamps to a constant strictly below the first node and at or
301-
# above the last valid one; its right derivative there is exactly zero.
302-
# (`search_grid` is `+inf` on the pad, so a poisoned all-NaN row lands on
303-
# the lower clamp and stays zero.)
315+
# above the last valid one; the germ there is right-finite with all
316+
# derivatives exactly zero. (`search_grid` is `+inf` on the pad, so a
317+
# poisoned all-NaN row lands on the lower clamp.)
304318
first_node = search_grid[0]
305319
last_node = search_grid[jnp.maximum(valid_length - 1, 0)]
306320
on_clamp_ray = (x_query < first_node) | (x_query >= last_node)
307-
return jnp.where(on_clamp_ray, 0.0, derivative)
321+
right_finite = on_clamp_ray | (jnp.isfinite(fp_lower) & jnp.isfinite(fp_upper))
322+
return (
323+
right_finite,
324+
jnp.where(on_clamp_ray, 0.0, first),
325+
jnp.where(on_clamp_ray, 0.0, second),
326+
jnp.where(on_clamp_ray, 0.0, third),
327+
)
308328

309329

310330
def _interp_between_nodes(

0 commit comments

Comments
 (0)