3333from _lcm .egm .interp import (
3434 interp_and_derivative_on_prepared_grid ,
3535 interp_on_prepared_grid ,
36+ interp_right_germ_on_prepared_grid ,
3637 locate_on_grid ,
3738 prepare_padded_grid ,
3839)
3940from _lcm .egm .nbegm import jump_moving_state_names
41+ from _lcm .egm .outer_envelope import right_germ_winner
4042from _lcm .egm .regime_introspection import (
4143 _get_child_discrete_actions ,
4244 _get_child_resources_arg_names ,
6769 Float1D ,
6870 FloatND ,
6971 IntND ,
72+ ScalarBool ,
7073 ScalarFloat ,
7174 ScalarInt ,
7275)
@@ -1432,16 +1435,41 @@ def value_and_slope_row(
14321435 # Below a candidate's own first finite coh node its support has not
14331436 # started: mask the read to `-inf` so the edge clamp cannot hand an
14341437 # infeasible lifted candidate a boundary value that wins the max. The
1435- # `-inf` also pins the marginal to zero below. The upper support edge
1436- # feeds the right-continuous tie rule at the candidate max.
1438+ # `-inf` also pins the marginal to zero below. The right germ of each
1439+ # candidate's value read feeds the tie rule at the candidate max; it
1440+ # needs no support mask of its own — a below-support candidate enters
1441+ # the tie set only when every candidate is below support, where all
1442+ # published marginals are exactly zero regardless of the winner.
1443+ def right_germ_row (
1444+ search_grid : Float1D ,
1445+ valid_length : ScalarInt ,
1446+ xp : Float1D ,
1447+ fp : Float1D ,
1448+ fp_slopes : Float1D ,
1449+ x_query : ScalarFloat ,
1450+ ) -> tuple [ScalarBool , ScalarFloat , ScalarFloat , ScalarFloat ]:
1451+ """Right germ of one value row at its query; positional per `jax.vmap`."""
1452+ return interp_right_germ_on_prepared_grid (
1453+ x_query = x_query ,
1454+ search_grid = search_grid ,
1455+ valid_length = valid_length ,
1456+ xp = xp ,
1457+ fp = fp ,
1458+ fp_slopes = fp_slopes ,
1459+ )
1460+
1461+ right_germ_at_child = jax .vmap (right_germ_row )(
1462+ search_rows , valid_rows , grid_rows , value_rows , marginal_rows , queries_flat
1463+ )
14371464 row_lower = jnp .min (
14381465 jnp .where (jnp .isfinite (grid_rows ), grid_rows , jnp .inf ), axis = 1
14391466 )
1440- row_upper = jnp .max (
1441- jnp .where (jnp .isfinite (grid_rows ), grid_rows , - jnp .inf ), axis = 1
1442- )
1443- candidate_right_available = queries_flat < row_upper
1444- value_at_child = jnp .where (queries_flat < row_lower , - jnp .inf , value_at_child )
1467+ # Only rows with a finite first node have a support to fall below;
1468+ # `row_lower` is `+inf` on an all-NaN (poisoned) row, whose NaN read
1469+ # must reach the candidate maximum fail-loud instead of becoming an
1470+ # ordinary infeasible `(-inf, 0)` pair.
1471+ below_row_support = (queries_flat < row_lower ) & jnp .isfinite (row_lower )
1472+ value_at_child = jnp .where (below_row_support , - jnp .inf , value_at_child )
14451473 # `-inf` entries interpolate pointwise to `-inf` (never NaN) and carry
14461474 # exactly-zero marginal utility, so an infeasible-everywhere row reads as
14471475 # the `-inf` / zero pair while a row with isolated `-inf` nodes (e.g. a
@@ -1458,10 +1486,12 @@ def value_and_slope_row(
14581486 # interpolates the nodewise outer maximum
14591487 # `sum_k w_k max_j W_j(q; d_k)` rather than the lower bound
14601488 # `max_j sum_k w_k W_j(q; d_k)`.
1461- value_at_child , marginal_at_child = _collapse_outer_candidate_axis (
1489+ value_at_child , marginal_at_child = _collapse_stacked_candidates (
14621490 value_at_child = value_at_child ,
14631491 marginal_at_child = marginal_at_child ,
1464- candidate_right_available = candidate_right_available .reshape (block_shape ),
1492+ right_germ_at_child = tuple (
1493+ component .reshape (block_shape ) for component in right_germ_at_child
1494+ ),
14651495 )
14661496
14671497 value_at_child , marginal_at_child = _blend_passive_axes (
@@ -1485,56 +1515,6 @@ def value_and_slope_row(
14851515 return smoothed_value , smoothed_marginal
14861516
14871517
1488- def _collapse_outer_candidate_axis (
1489- * ,
1490- value_at_child : FloatND ,
1491- marginal_at_child : FloatND ,
1492- candidate_right_available : BoolND ,
1493- ) -> tuple [FloatND , FloatND ]:
1494- """Collapse a stacked NEGM child's candidate axis by the hard max at the query.
1495-
1496- Publishes the winner's marginal (Danskin). An exact value tie resolves
1497- right-continuously and support-aware: among tied candidates, one whose
1498- support continues right of the query beats one ending there, and the
1499- largest marginal breaks the rest — so the branch that wins immediately to
1500- the right owns the derivative the parent's Euler inversion consumes. The
1501- rank uses the gradient-scaled marginal; the composed gradient is shared by
1502- all candidates of a cell and positive, so it never reorders them. A cell
1503- whose candidates are all `-inf` (no live support) keeps the `(-inf, 0)`
1504- infeasible contract: every masked marginal is exactly zero.
1505-
1506- Args:
1507- value_at_child: Read values with a trailing candidate axis.
1508- marginal_at_child: Read marginals with the same shape.
1509- candidate_right_available: Whether each candidate's support continues
1510- right of the query, with the same shape.
1511-
1512- Returns:
1513- Tuple of the value and marginal with the candidate axis collapsed.
1514-
1515- """
1516- best_value = jnp .max (value_at_child , axis = - 1 , keepdims = True )
1517- bounded_slope = jnp .arctan (marginal_at_child ) / jnp .pi + 0.5
1518- rank = jnp .where (
1519- value_at_child >= best_value ,
1520- candidate_right_available .astype (bounded_slope .dtype ) + bounded_slope ,
1521- - jnp .inf ,
1522- )
1523- winner = jnp .argmax (rank , axis = - 1 , keepdims = True )
1524- # The published value is the maximum itself: identical to the winner's
1525- # read at any tie, and NaN-propagating — a poisoned candidate row (loud
1526- # upper-envelope overflow) must surface fail-loud instead of losing the
1527- # rank comparison to a finite competitor. The marginal is pinned to NaN
1528- # alongside so the published pair never looks healthy over a poisoned
1529- # cell.
1530- collapsed_value = best_value [..., 0 ]
1531- collapsed_marginal = jnp .take_along_axis (marginal_at_child , winner , axis = - 1 )[..., 0 ]
1532- return (
1533- collapsed_value ,
1534- jnp .where (jnp .isnan (collapsed_value ), jnp .nan , collapsed_marginal ),
1535- )
1536-
1537-
15381518def _blend_passive_axes (
15391519 * ,
15401520 value_at_child : FloatND ,
@@ -1574,14 +1554,68 @@ def _blend_passive_axes(
15741554 weight_lower > 0.0 , weight_lower * value_at_child [lower ], 0.0
15751555 ) + jnp .where (weight_upper > 0.0 , weight_upper * value_at_child [upper ], 0.0 )
15761556 # Marginal rows are finite everywhere (exactly 0.0 on infeasible
1577- # rows), so a plain blend is safe .
1557+ # rows), so a plain blend never produces NaN .
15781558 marginal_at_child = (
15791559 weight_lower * marginal_at_child [lower ]
15801560 + weight_upper * marginal_at_child [upper ]
15811561 )
1562+ # A positive-weight blend of a dead node with a live one yields a `-inf`
1563+ # value alongside a finite averaged marginal; re-pin the marginal to zero
1564+ # so the `(-inf, 0)` infeasible pair survives the passive blend and no
1565+ # finite marginal of an infeasible cell reaches the Euler inversion.
1566+ marginal_at_child = jnp .where (jnp .isneginf (value_at_child ), 0.0 , marginal_at_child )
15821567 return value_at_child , marginal_at_child
15831568
15841569
1570+ def _collapse_stacked_candidates (
1571+ * ,
1572+ value_at_child : FloatND ,
1573+ marginal_at_child : FloatND ,
1574+ right_germ_at_child : tuple [BoolND , FloatND , FloatND , FloatND ],
1575+ ) -> tuple [FloatND , FloatND ]:
1576+ """Collapse the candidate axis by the exact hard max at the query.
1577+
1578+ Publishes the winner's marginal (Danskin). This runs *before* the passive
1579+ blend so the blend interpolates the nodewise outer maximum. An exact value
1580+ tie resolves right-continuously in the value read itself: the tied
1581+ candidates are compared by the complete right germ of their own value
1582+ interpolants (`right_germ_winner` — right-finiteness, then the first three
1583+ one-sided derivatives, then the lowest index among locally identical
1584+ pieces), so the branch whose read actually wins immediately to the right
1585+ owns the (economic) marginal the parent's Euler inversion consumes. (The
1586+ germ uses the unscaled interpolant derivatives: the composed gradient is
1587+ shared by all candidates of a cell and positive, so scaling could never
1588+ reorder them.) A cell whose candidates are all `-inf` (no live support)
1589+ keeps the `(-inf, 0)` infeasible contract: every masked marginal is
1590+ exactly zero.
1591+
1592+ Args:
1593+ value_at_child: Candidate value reads; the candidate axis is last.
1594+ marginal_at_child: Gradient-scaled candidate marginal reads, same shape.
1595+ right_germ_at_child: Tuple of the right-finiteness flag and the first
1596+ three right derivatives of the candidate value reads, same shape.
1597+
1598+ Returns:
1599+ Tuple of the winner's value and marginal, with the candidate axis
1600+ collapsed.
1601+
1602+ """
1603+ winner = right_germ_winner (value = value_at_child , right_germ = right_germ_at_child )
1604+ winner_marginal = jnp .take_along_axis (marginal_at_child , winner , axis = - 1 )[..., 0 ]
1605+ # The published value is the maximum itself: identical to the winner's read
1606+ # at any tie, and NaN-propagating when a poisoned candidate row (whose NaN
1607+ # empties the tie set) must surface fail-loud. The marginal is pinned to
1608+ # NaN alongside so the published pair never looks healthy over a poisoned
1609+ # cell — the germ selector falls back to a finite competitor's index when
1610+ # the NaN empties the tie set, and that competitor's marginal must not
1611+ # ship beside a NaN value.
1612+ collapsed_value = jnp .max (value_at_child , axis = - 1 )
1613+ return (
1614+ collapsed_value ,
1615+ jnp .where (jnp .isnan (collapsed_value ), jnp .nan , winner_marginal ),
1616+ )
1617+
1618+
15851619def _build_child_reads (
15861620 * ,
15871621 user_regimes : Mapping [RegimeName , UserRegime ],
0 commit comments