Skip to content

Commit da5fd88

Browse files
hmgaudeckerclaude
andcommitted
DC-EGM stacked envelope: left-germ tie ownership and below-support secant extension
Two repairs to the shared read primitives, both counterexample-driven (the downstream loop's round-14 findings F3/F4): - A tie at a shared terminal abscissa, where every candidate clamps and the right germ cannot discriminate, is now owned by the branch that carries the envelope on the supported left neighborhood, so the published marginal stays inside the envelope's generalized gradient at the boundary. New `interp_left_germ_on_{padded,prepared}_grid` readers share the single bracket/limiter core (now side-parameterized); `right_germ_winner` gains left stages (left-finite, then smallest first / largest second / smallest third left derivative) after the right stages, in both the standalone envelope and the production stacked read. Index fallback now applies only to branches identical on both sides. - The padded-row read extends the first bracket's secant below the first node (the canonical state-grid convention: a below-support query is priced on the edge slope, not credited with a boundary value no action attains) and the analytic query derivative publishes that secant there; zero remains only strictly above the last valid node. The extension lives in the shared two-node arithmetic, so the streamed asset-row publisher inherits it and streamed-vs-row equivalence holds unchanged. Clamping is kept at and above the last node, where refined rows can end in crossing-inserted near-duplicates whose secant is no usable extrapolant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123hk3uzBgBeZown4eBBQ2C
1 parent a69f40a commit da5fd88

9 files changed

Lines changed: 531 additions & 80 deletions

src/_lcm/egm/continuation.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from _lcm.dtypes import canonical_float_dtype
2626
from _lcm.egm.carry import EGMCarry
2727
from _lcm.egm.interp import (
28+
interp_left_germ_on_prepared_grid,
2829
interp_on_prepared_grid,
2930
interp_right_germ_on_prepared_grid,
3031
locate_on_grid,
@@ -923,30 +924,44 @@ def interp_row(
923924
# Below a candidate's own first finite coh node its support has not
924925
# started: mask the read to `-inf` so the edge clamp cannot hand an
925926
# infeasible lifted candidate a boundary value that wins the max. The
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(
927+
# `-inf` also pins the marginal to zero below. The germs of each
928+
# candidate's value read feed the tie rule at the candidate max; they
929+
# need no support mask of their own — the left germ is dead at or
930+
# below the first finite node by construction, and a below-support
931+
# candidate enters the tie set only when every candidate is below
932+
# support, where all published marginals are exactly zero regardless
933+
# of the winner.
934+
def germ_rows(
932935
search_grid: Float1D,
933936
valid_length: ScalarInt,
934937
xp: Float1D,
935938
fp: Float1D,
936939
fp_slopes: Float1D,
937940
x_query: ScalarFloat,
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(
941+
) -> tuple[
942+
tuple[ScalarBool, ScalarFloat, ScalarFloat, ScalarFloat],
943+
tuple[ScalarBool, ScalarFloat, ScalarFloat, ScalarFloat],
944+
]:
945+
"""Right and left germs of one value row at its query; per `jax.vmap`."""
946+
right_germ = interp_right_germ_on_prepared_grid(
941947
x_query=x_query,
942948
search_grid=search_grid,
943949
valid_length=valid_length,
944950
xp=xp,
945951
fp=fp,
946952
fp_slopes=fp_slopes,
947953
)
954+
left_germ = interp_left_germ_on_prepared_grid(
955+
x_query=x_query,
956+
search_grid=search_grid,
957+
valid_length=valid_length,
958+
xp=xp,
959+
fp=fp,
960+
fp_slopes=fp_slopes,
961+
)
962+
return right_germ, left_germ
948963

949-
right_germ_at_child = jax.vmap(right_germ_row)(
964+
right_germ_at_child, left_germ_at_child = jax.vmap(germ_rows)(
950965
search_rows, valid_rows, grid_rows, value_rows, marginal_rows, queries_flat
951966
)
952967
row_lower = jnp.min(
@@ -976,6 +991,9 @@ def right_germ_row(
976991
right_germ_at_child=tuple(
977992
component.reshape(block_shape) for component in right_germ_at_child
978993
),
994+
left_germ_at_child=tuple(
995+
component.reshape(block_shape) for component in left_germ_at_child
996+
),
979997
)
980998

981999
for passive_value, passive_grid in zip(
@@ -1024,6 +1042,7 @@ def _collapse_stacked_candidates(
10241042
value_at_child: FloatND,
10251043
marginal_at_child: FloatND,
10261044
right_germ_at_child: tuple[BoolND, FloatND, FloatND, FloatND],
1045+
left_germ_at_child: tuple[BoolND, FloatND, FloatND, FloatND],
10271046
) -> tuple[FloatND, FloatND]:
10281047
"""Collapse the candidate axis by the exact hard max at the query.
10291048
@@ -1032,10 +1051,13 @@ def _collapse_stacked_candidates(
10321051
tie resolves right-continuously in the value read itself: the tied
10331052
candidates are compared by the complete right germ of their own value
10341053
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
1054+
one-sided derivatives), so the branch whose read actually wins immediately
1055+
to the right owns the (economic) marginal the parent's Euler inversion
1056+
consumes; right-identical candidates (a shared terminal abscissa, where
1057+
every candidate clamps) are separated by their left germs so the marginal
1058+
stays inside the envelope's generalized gradient at the boundary, and only
1059+
branches identical on both sides fall back to the lowest index. (The
1060+
germs use the unscaled interpolant derivatives: the composed gradient is
10391061
shared by all candidates of a cell and positive, so scaling could never
10401062
reorder them.) A cell whose candidates are all `-inf` (no live support)
10411063
keeps the `(-inf, 0)` infeasible contract: every masked marginal is
@@ -1046,13 +1068,19 @@ def _collapse_stacked_candidates(
10461068
marginal_at_child: Gradient-scaled candidate marginal reads, same shape.
10471069
right_germ_at_child: Tuple of the right-finiteness flag and the first
10481070
three right derivatives of the candidate value reads, same shape.
1071+
left_germ_at_child: Tuple of the left-finiteness flag and the first
1072+
three left derivatives of the candidate value reads, same shape.
10491073
10501074
Returns:
10511075
Tuple of the winner's value and marginal, with the candidate axis
10521076
collapsed.
10531077
10541078
"""
1055-
winner = right_germ_winner(value=value_at_child, right_germ=right_germ_at_child)
1079+
winner = right_germ_winner(
1080+
value=value_at_child,
1081+
right_germ=right_germ_at_child,
1082+
left_germ=left_germ_at_child,
1083+
)
10561084
winner_marginal = jnp.take_along_axis(marginal_at_child, winner, axis=-1)[..., 0]
10571085
# The published value is the maximum itself: identical to the winner's read
10581086
# at any tie, and NaN-propagating when a poisoned candidate row (whose NaN

0 commit comments

Comments
 (0)