Skip to content

Commit e7c6a3e

Browse files
hmgaudeckerclaude
andcommitted
Merge origin/feat/dcegm: right-germ tie rule + sanitized bracket reads
Resolution per the cross-session ruling (nbegm-dcegm-merge-handoff): - `_aggregate_child_choices` adopts the dcegm support mask (`below_row_support` with the finite-`row_lower` guard) and the per-row right-germ computation; the candidate collapse is `_collapse_stacked_candidates` (germ-based right-continuous tie rule), with the NaN-pinned marginal hardening ported in so a poisoned candidate never publishes a finite pair; the arctan rank collapse is deleted. - `interp_on_prepared_grid` adopts the sanitized dummy brackets and degenerate overrides; the paired `interp_and_derivative_on_prepared_grid` uses the same pattern (singleton `(fp[0], 0)`, empty `(NaN, NaN)`, NaN-query re-pin on both channels). - `_blend_passive_axes` stays as a helper with the `-inf` marginal re-pin as its last statement. - Test files unioned; the stacked-read poison regression re-targeted at the germ collapse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNQkLG5QzXcgGdpgH6sN4u
2 parents 981ca99 + d644992 commit e7c6a3e

21 files changed

Lines changed: 1792 additions & 421 deletions

src/_lcm/egm/asset_row.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,18 @@ def publish_node_from_bracket(
516516
fp_lower=bracket.lower_policy,
517517
fp_upper=bracket.upper_policy,
518518
)
519+
# Degenerate envelopes mirror the row read's contract exactly
520+
# (`interp_on_prepared_grid`): a single kept node — always the bracket's
521+
# lower slot, the upper is a NaN pad — is a constant clamp on both sides,
522+
# and an empty envelope reads NaN.
523+
single_node = bracket.n_kept == 1
524+
empty_envelope = bracket.n_kept == 0
525+
value_interpolated = jnp.where(single_node, bracket.lower_value, value_interpolated)
526+
value_interpolated = jnp.where(empty_envelope, jnp.nan, value_interpolated)
527+
policy_interpolated = jnp.where(
528+
single_node, bracket.lower_policy, policy_interpolated
529+
)
530+
policy_interpolated = jnp.where(empty_envelope, jnp.nan, policy_interpolated)
519531

520532
closed_form_action = resources_at_node - borrowing_limit
521533
value_constrained = jnp.where(

src/_lcm/egm/continuation.py

Lines changed: 94 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
from _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
)
3940
from _lcm.egm.nbegm import jump_moving_state_names
41+
from _lcm.egm.outer_envelope import right_germ_winner
4042
from _lcm.egm.regime_introspection import (
4143
_get_child_discrete_actions,
4244
_get_child_resources_arg_names,
@@ -67,6 +69,7 @@
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-
15381518
def _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+
15851619
def _build_child_reads(
15861620
*,
15871621
user_regimes: Mapping[RegimeName, UserRegime],

0 commit comments

Comments
 (0)