Skip to content

Commit e57ca73

Browse files
hmgaudeckerclaude
andcommitted
Exact MSS ties + same-point crossing residual + read-contract hardenings
Round-14 external-audit repairs, all confirmed by execution first: - `_lexicographic_winner` publishes the exact stored maximum as the side value (the tie winner owns policy and slope convention only), and value ties are exact stored equality — the only translation-invariant tie set. `same_record` likewise compares exactly (one genuine record is bitwise equal). A tolerance window on absolute magnitudes let a common cardinal shift hand the node to a near-maximal competitor's lower record. - A crossing tie needs more than offset proximity: a line joins the group only if it passes through the same numerical point (value residual at the emitted crossing within the cancellation-error scale of the locally computed magnitudes). Distinct crossings at large local offsets are enumerated sequentially instead of being handed to the steepest line. - The distributed-fold spec compares with a mixed relative/scale-anchored bound (values can sit near zero, where a pure-relative few-ulp bound is spuriously strict) after asserting identical finite masks. - The child read checks the carry block's leading shape against the solver's stacked-candidate declaration before broadcasting and names the violated contract, instead of failing as a vmap axis-size mismatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNQkLG5QzXcgGdpgH6sN4u
1 parent ed3bd82 commit e57ca73

5 files changed

Lines changed: 231 additions & 21 deletions

File tree

src/_lcm/egm/continuation.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,11 @@ def _aggregate_child_choices(
13311331
# Leading axes of the blocks: the child's passive nodes, then its
13321332
# discrete-action combos (then the candidate axis of a stacked NEGM child).
13331333
block_shape = value_block.shape[:-1]
1334+
_fail_if_carry_shape_mismatches_declaration(
1335+
block_shape=block_shape,
1336+
row_queries_shape=row_queries.shape,
1337+
n_outer_candidates=n_outer_candidates,
1338+
)
13341339
if n_outer_candidates:
13351340
# The stacked candidates share the lifted common-coh axis, so every
13361341
# candidate row of a block cell is read at that cell's single query and
@@ -1567,6 +1572,35 @@ def _blend_passive_axes(
15671572
return value_at_child, marginal_at_child
15681573

15691574

1575+
def _fail_if_carry_shape_mismatches_declaration(
1576+
*,
1577+
block_shape: tuple[int, ...],
1578+
row_queries_shape: tuple[int, ...],
1579+
n_outer_candidates: int,
1580+
) -> None:
1581+
"""Check the carry block's leading shape against the stacking declaration.
1582+
1583+
The declaration and the published carry must agree before any
1584+
broadcasting: a mismatch would otherwise surface as an opaque vmap
1585+
axis-size error deep in the batched interpolation instead of naming the
1586+
violated solver contract.
1587+
"""
1588+
expected_block_shape = (
1589+
(*row_queries_shape, n_outer_candidates)
1590+
if n_outer_candidates
1591+
else row_queries_shape
1592+
)
1593+
if block_shape != expected_block_shape:
1594+
msg = (
1595+
"The child's published carry block has leading shape "
1596+
f"{block_shape}, but its solver declares "
1597+
f"n_stacked_carry_candidates={n_outer_candidates}, which requires "
1598+
f"{expected_block_shape}. The solver's declaration must match the "
1599+
"candidate-axis structure of the carry it publishes."
1600+
)
1601+
raise ValueError(msg)
1602+
1603+
15701604
def _collapse_stacked_candidates(
15711605
*,
15721606
value_at_child: FloatND,

src/_lcm/egm/upper_envelope/mss.py

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -214,18 +214,18 @@ def refine_envelope_with_support(
214214
[jnp.full((1,), -jnp.inf, dtype=query_grid.dtype), query_grid[:-1]]
215215
)
216216
is_new = query_grid > prev_grid
217-
# Record equality is a few-ulp window: two computations of the same
218-
# quantity agree to within a handful of rounding steps of the stored
219-
# magnitude, and gaps below that scale are indistinguishable in storage.
220-
# A wider cushion would swallow genuinely representable gaps whenever a
221-
# common cardinal shift raises the value level — the winner would then
222-
# depend on an arbitrary value normalization. A missed tie merely
223-
# duplicates the node, which the read resolves by its side convention.
217+
# Record equality is exact: when both side winners are the same branch,
218+
# its value and policy at the node come from the same link evaluated at
219+
# the same query — identical arithmetic, so genuinely one record is
220+
# bitwise equal. Any tolerance window here scales with the absolute value
221+
# level and would merge representable records under a common cardinal
222+
# shift. A missed merge merely duplicates the node, which the read
223+
# resolves by its side convention.
224224
tolerance = 8.0 * float(jnp.finfo(query_grid.dtype).eps)
225225
same_record = (
226226
(left_side.branch == right_side.branch)
227-
& jnp.isclose(left_side.value, right_side.value, rtol=tolerance, atol=0.0)
228-
& jnp.isclose(left_side.policy, right_side.policy, rtol=tolerance, atol=0.0)
227+
& (left_side.value == right_side.value)
228+
& (left_side.policy == right_side.policy)
229229
)
230230
node_left_valid = left_side.exists & is_new
231231
node_right_valid = right_side.exists & is_new & ~(left_side.exists & same_record)
@@ -493,23 +493,25 @@ def _lexicographic_winner(
493493
masked_value = jnp.where(covers, value_at, -jnp.inf)
494494
best_value = jnp.max(masked_value, axis=1)
495495
exists = jnp.isfinite(best_value)
496-
# Value ties are a few-ulp window of the stored magnitude — the scale at
497-
# which two computations of the same envelope value are indistinguishable.
498-
# A wider cushion would let a common cardinal value shift merge branches
499-
# whose gap is genuinely representable. A missed tie merely skips the
496+
# A value tie is exact stored equality — the only translation-invariant
497+
# tie set: any tolerance window scales with the absolute value level, so
498+
# a common cardinal shift would pull genuinely representable gaps inside
499+
# it and let a lower record own the node. A missed tie (two computations
500+
# of the same crossing value a rounding step apart) merely skips the
500501
# slope tie-break and duplicates the node downstream — never merges
501502
# records.
502-
tolerance = 8.0 * jnp.finfo(value_at.dtype).eps
503-
tie = covers & jnp.isclose(
504-
masked_value, best_value[:, None], rtol=tolerance, atol=0.0
505-
)
503+
tie = covers & (masked_value == best_value[:, None])
506504
slope_score = jnp.where(tie, slope_sign * links.value_slope[None, :], -jnp.inf)
507505
link = jnp.argmax(slope_score, axis=1).astype(jnp.int32)
506+
# The published value is the exact maximum itself, not the tie winner's
507+
# own read: the winner owns the policy and the slope convention, but a
508+
# numerical tie rule must never replace the hard maximum of the stored
509+
# records by a near-maximal competitor's lower value.
508510
return _SideWinner(
509511
exists=exists,
510512
link=link,
511513
branch=jnp.where(exists, links.segment[link], -1).astype(jnp.int32),
512-
value=jnp.take_along_axis(value_at, link[:, None], axis=1)[:, 0],
514+
value=best_value,
513515
policy=jnp.take_along_axis(policy_at, link[:, None], axis=1)[:, 0],
514516
)
515517

@@ -617,7 +619,28 @@ def step(
617619
offset_next = jnp.min(offset_masked, axis=1)
618620
found = jnp.isfinite(offset_next)
619621
offset_tolerance = crossing_ulp * jnp.where(found, offset_next, 0.0)
620-
tie = valid & (offset <= (offset_next + offset_tolerance)[:, None])
622+
near_minimal = valid & (offset <= (offset_next + offset_tolerance)[:, None])
623+
# Offset proximity alone cannot certify a simultaneous crossing: the
624+
# window scales with the offset itself, so inside it two crossings can
625+
# sit many representable positions apart, and handing the group to the
626+
# steepest line would skip the branch that wins between them. A line
627+
# joins the tie only if it also passes through the same numerical
628+
# point — its value at the crossing agrees with the winner line's to a
629+
# few ulp of the locally computed magnitudes (the cancellation-error
630+
# scale of evaluating the difference). A genuinely distinct crossing
631+
# fails the residual test and is enumerated on a later step instead.
632+
x_next_safe = x_current + jnp.where(found, offset_next, 0.0)
633+
value_at_next = links.value_at(x_next_safe[:, None])
634+
winner_value_next = jnp.take_along_axis(value_at_next, winner[:, None], axis=1)
635+
residual_scale = jnp.maximum(jnp.abs(value_at_next), jnp.abs(winner_value_next))
636+
same_point = jnp.abs(value_at_next - winner_value_next) <= (
637+
8.0 * jnp.finfo(query_grid.dtype).eps * residual_scale
638+
)
639+
# The exact-minimum line(s) define the crossing being emitted, so they
640+
# belong to the tie unconditionally — the residual test only decides
641+
# which *other* near-minimal lines genuinely pass through the point.
642+
is_earliest = valid & (offset <= offset_next[:, None])
643+
tie = (near_minimal & same_point) | is_earliest
621644
incoming = jnp.argmax(
622645
jnp.where(tie, links.value_slope[None, :], -jnp.inf), axis=1
623646
).astype(jnp.int32)

tests/solution/test_continuation_stacked_read.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import jax
1212
import jax.numpy as jnp
1313
import numpy as np
14+
import pytest
1415

1516
from _lcm.egm.carry import EGMCarry
1617
from _lcm.egm.continuation import (
@@ -491,3 +492,34 @@ def test_non_stacking_solvers_declare_zero_stacked_candidates():
491492
phantom axis.
492493
"""
493494
assert GridSearch().n_stacked_carry_candidates == 0
495+
496+
497+
def test_a_carry_without_the_declared_candidate_axis_fails_with_a_contract_error():
498+
"""A stacking declaration that disagrees with the carry shape names itself.
499+
500+
When the declared candidate count is positive but the carry block carries
501+
no matching candidate axis, the read must raise a solver-contract error
502+
that names the declaration — not fall through to an opaque vmap axis-size
503+
mismatch deep in the batched interpolation.
504+
"""
505+
carry = EGMCarry(
506+
endog_grid=jnp.broadcast_to(jnp.array([0.0, 5.0, 10.0]), (2, 3)),
507+
value=jnp.ones((2, 3)),
508+
marginal_utility=jnp.zeros((2, 3)),
509+
taste_shock_scale=jnp.asarray(0.0),
510+
)
511+
prepared_search_grid, prepared_valid_length = _prepare(carry)
512+
513+
with pytest.raises(ValueError, match="n_stacked_carry_candidates"):
514+
_aggregate_child_choices(
515+
carry=carry,
516+
prepared_search_grid=prepared_search_grid,
517+
prepared_valid_length=prepared_valid_length,
518+
has_taste_shocks=False,
519+
child_index=(),
520+
child_passive_values=(jnp.asarray(0.0),),
521+
child_passive_grids=(jnp.asarray([0.0]),),
522+
row_queries=jnp.asarray([4.0, 4.0]),
523+
row_gradients=jnp.asarray([1.0, 1.0]),
524+
n_outer_candidates=2,
525+
)

tests/solution/test_mss_invariance_and_support.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,113 @@ def test_two_distinct_representable_crossings_are_both_emitted():
361361
)
362362
truth = intercepts + slopes * 1.25
363363
np.testing.assert_allclose(read_policy, float(policies[truth.argmax()]), rtol=1e-5)
364+
365+
366+
def _read_two_branch_row_at_origin(shift: float) -> tuple[float, float]:
367+
"""Refine a two-branch f32 correspondence and read value/policy at the origin.
368+
369+
Branch values at the origin node differ by four units — a genuinely
370+
representable gap at every level used by the callers — so the published
371+
read must be the higher branch (value `4 + shift`, policy 1000) no matter
372+
what common cardinal level `shift` puts the values at.
373+
"""
374+
origin = np.float32(2000.0)
375+
width = np.float32(100.0)
376+
policies = np.array([1000.0, 800.0], dtype=np.float32)
377+
slopes = np.float32(10000.0) / policies
378+
intercepts = np.array([4.0, 0.0], dtype=np.float32) + np.float32(shift)
379+
380+
grid, policy, value = [], [], []
381+
for slope, intercept, action in zip(slopes, intercepts, policies, strict=True):
382+
grid.extend([origin, origin + width])
383+
policy.extend([action, action])
384+
value.extend([intercept, intercept + slope * width])
385+
386+
refined_grid, refined_policy, refined_value, _ = refine_envelope(
387+
endog_grid=jnp.asarray(np.asarray(grid, dtype=np.float32)),
388+
policy=jnp.asarray(np.asarray(policy, dtype=np.float32)),
389+
value=jnp.asarray(np.asarray(value, dtype=np.float32)),
390+
n_refined=16,
391+
)
392+
query = jnp.asarray(origin, dtype=jnp.float32)
393+
got_value = float(
394+
interp_on_padded_grid(x_query=query, xp=refined_grid, fp=refined_value)
395+
)
396+
got_policy = float(
397+
interp_on_padded_grid(x_query=query, xp=refined_grid, fp=refined_policy)
398+
)
399+
return got_value, got_policy
400+
401+
402+
def test_a_large_common_value_shift_cannot_lower_the_published_maximum():
403+
"""The published value is the exact stored maximum at any cardinal level.
404+
405+
A representable four-unit branch gap survives a common shift of `1e7` in
406+
f32 (both shifted values are distinct floats), so the read must publish
407+
the higher branch's value and policy — never a near-maximal competitor's
408+
lower record.
409+
"""
410+
base_value, base_policy = _read_two_branch_row_at_origin(0.0)
411+
shifted_value, shifted_policy = _read_two_branch_row_at_origin(10_000_000.0)
412+
413+
np.testing.assert_allclose(base_value, 4.0, rtol=0.0, atol=0.0)
414+
np.testing.assert_allclose(base_policy, 1000.0, rtol=0.0, atol=0.0)
415+
np.testing.assert_allclose(shifted_value, 10_000_004.0, rtol=0.0, atol=0.0)
416+
np.testing.assert_allclose(shifted_policy, 1000.0, rtol=0.0, atol=0.0)
417+
418+
419+
def test_two_distinct_crossings_at_large_local_offsets_are_both_emitted():
420+
"""Crossings one unit apart at local offset `1e6` are enumerated in order.
421+
422+
At that offset f32 spacing is `0.0625`, so the two crossing abscissae are
423+
sixteen ulp apart — genuinely distinct points whose middle branch owns the
424+
envelope between them. A tie may coalesce crossings only when the lines
425+
actually meet at the same numerical point; here the third line sits about
426+
twenty ulp of the value scale below the winner at the first crossing, so
427+
the scan must emit both switches and the strictly-between read must
428+
follow the middle branch.
429+
"""
430+
origin = np.float32(5_000_000.0)
431+
width = np.float32(1_000_010.0)
432+
policies = np.array([4_000_000.0, 2_500_000.0, 1_000_000.0], dtype=np.float32)
433+
slopes = 1.0 / policies
434+
first_crossing = np.float32(1_000_000.0)
435+
second_crossing = np.float32(1_000_001.0)
436+
intercepts = np.array(
437+
[
438+
0.0,
439+
(slopes[0] - slopes[1]) * first_crossing,
440+
(slopes[0] - slopes[1]) * first_crossing
441+
+ (slopes[1] - slopes[2]) * second_crossing,
442+
],
443+
dtype=np.float32,
444+
)
445+
446+
grid, policy, value = [], [], []
447+
for slope, intercept, action in zip(slopes, intercepts, policies, strict=True):
448+
grid.extend([origin, origin + width])
449+
policy.extend([action, action])
450+
value.extend([intercept, intercept + slope * width])
451+
452+
refined_grid, refined_policy, refined_value, n_kept = refine_envelope(
453+
endog_grid=jnp.asarray(np.asarray(grid, dtype=np.float32)),
454+
policy=jnp.asarray(np.asarray(policy, dtype=np.float32)),
455+
value=jnp.asarray(np.asarray(value, dtype=np.float32)),
456+
n_refined=32,
457+
)
458+
local_grid = np.asarray(refined_grid)[: int(n_kept)] - origin
459+
460+
assert np.isclose(local_grid, first_crossing, rtol=0.0, atol=0.25).sum() == 2
461+
assert np.isclose(local_grid, second_crossing, rtol=0.0, atol=0.25).sum() == 2
462+
463+
local_query = np.float32(1_000_000.5)
464+
query = jnp.asarray(origin + local_query, dtype=jnp.float32)
465+
got_value = float(
466+
interp_on_padded_grid(x_query=query, xp=refined_grid, fp=refined_value)
467+
)
468+
got_policy = float(
469+
interp_on_padded_grid(x_query=query, xp=refined_grid, fp=refined_policy)
470+
)
471+
truth = intercepts + slopes * local_query
472+
np.testing.assert_allclose(got_value, float(truth.max()), rtol=1e-5, atol=1e-9)
473+
np.testing.assert_allclose(got_policy, float(policies[truth.argmax()]))

tests/solution/test_nbegm_stochastic_distributed_fold.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
(2) differ in size, a mis-targeted fold axis is a hard broadcasting error, so the
88
distributed solve must both build and reproduce the non-distributed value at
99
reassociation level: XLA fuses the read arithmetic differently per axis layout,
10-
so the two solves agree to a few ulp of the value scale, not bit-for-bit.
10+
so the two solves agree to a few ulp of the value scale, not bit-for-bit. The
11+
comparison mixes a relative and a scale-anchored absolute component because
12+
value entries can sit near zero, where a pure-relative few-ulp bound would be
13+
spuriously strict.
1114
"""
1215

1316
import numpy as np
@@ -37,4 +40,12 @@ def test_stochastic_fold_is_invariant_to_distributing_a_ride_state():
3740
# is `(kind, income, liquid)` where the plain solve is `(income, kind,
3841
# liquid)`; align the two before the exact-value comparison.
3942
distributed_v = np.moveaxis(np.asarray(distributed[period]["alive"]), 0, 1)
40-
np.testing.assert_allclose(distributed_v, plain_v, rtol=1e-15, atol=0.0)
43+
np.testing.assert_array_equal(np.isfinite(distributed_v), np.isfinite(plain_v))
44+
eps = 8.0 * np.finfo(plain_v.dtype).eps
45+
finite = np.isfinite(plain_v)
46+
scale = float(
47+
max(np.abs(plain_v[finite]).max(), np.abs(distributed_v[finite]).max())
48+
)
49+
np.testing.assert_allclose(
50+
distributed_v[finite], plain_v[finite], rtol=eps, atol=eps * scale
51+
)

0 commit comments

Comments
 (0)