Skip to content

Commit 6bf6e89

Browse files
committed
Merge remote-tracking branch 'origin/feat/nb-egm' into feat/nested-nbegm-ez
2 parents 8188f22 + 226e846 commit 6bf6e89

4 files changed

Lines changed: 52 additions & 8 deletions

File tree

src/_lcm/egm/continuation.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,14 +1787,14 @@ def _is_stochastic(
17871787
else:
17881788
row_values = ()
17891789
row_block_shape = ()
1790-
# A NEGM child carries a stacked candidate axis (keeper + one per outer
1791-
# node). Detected structurally off the solver spec — `outer_grid` is the
1792-
# NEGM outer margin's grid — because importing the solver class here
1793-
# would cycle through the kernel-builder modules that import this one.
1794-
outer_grid = getattr(target_regime.solver, "outer_grid", None)
1795-
n_outer_candidates = (
1796-
int(outer_grid.to_jax().shape[0]) + 1 if outer_grid is not None else 0
1797-
)
1790+
# The candidate-axis length comes from the child solver's own
1791+
# declaration: only a solver whose published carry actually stacks
1792+
# outer candidates (NEGM's keeper + per-node rows) reports a nonzero
1793+
# count. Merely having an outer margin is not enough — a solver that
1794+
# folds its outer axis before publishing (the nested N-NB-EGM upper
1795+
# envelope) carries no candidate axis, and broadcasting queries over a
1796+
# phantom axis would desync rows and queries in the read.
1797+
n_outer_candidates = target_regime.solver.n_stacked_carry_candidates
17981798
reads[target] = _ChildRead(
17991799
next_state_func=get_next_state_function_for_solution(
18001800
transitions=transitions[target],

src/_lcm/solution/contract.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,20 @@ def carry_rows_share_state_grid(self) -> bool:
319319
"""
320320
return False
321321

322+
@property
323+
def n_stacked_carry_candidates(self) -> int:
324+
"""Length of the published carry's stacked outer-candidate axis.
325+
326+
A solver that publishes one carry row per outer durable candidate
327+
(keeper plus one per outer-grid node), stacked on an axis before the
328+
grid axis, declares that axis length here; a reading parent broadcasts
329+
its queries over exactly that many candidates and collapses them by
330+
the hard max. A solver whose carry has no candidate axis — no outer
331+
margin, or an outer margin already folded inside the solve — declares
332+
`0`, and the parent queries each carry row once.
333+
"""
334+
return 0
335+
322336
def validate(self, *, context: SolverBuildContext) -> None: # noqa: B027
323337
"""Check the regime is in scope for this solver. Default: no-op."""
324338

src/_lcm/solution/solvers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,11 @@ def requires_continuation_carries(self) -> bool:
484484
"""NEGM nests a DC-EGM solve that inverts the Euler equation."""
485485
return True
486486

487+
@property
488+
def n_stacked_carry_candidates(self) -> int:
489+
"""The published carry stacks the keeper plus one row per outer node."""
490+
return int(self.outer_grid.to_jax().shape[0]) + 1
491+
487492
def build_period_kernels(self, *, context: SolverBuildContext) -> SolutionKernels:
488493
"""Build one NEGM period adapter per period, wrapping the inner kernels.
489494

tests/solution/test_continuation_stacked_read.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
_collapse_stacked_candidates,
1919
)
2020
from _lcm.egm.interp import interp_on_padded_grid, prepare_padded_grid
21+
from lcm.solvers import GridSearch
2122
from tests.conftest import X64_ENABLED
23+
from tests.test_models import negm_kinked_toy as negm_toy
2224

2325
# The host comparison reruns the identical interpolation arithmetic, so the two
2426
# sides agree to the active float precision's roundoff, not better.
@@ -466,3 +468,26 @@ def test_a_poisoned_candidate_at_a_nonzero_index_keeps_the_collapse_nan():
466468

467469
assert bool(jnp.isnan(value[0]))
468470
assert bool(jnp.isnan(marginal[0]))
471+
472+
473+
def test_negm_solver_declares_its_stacked_candidate_count():
474+
"""A NEGM solver's published carry stacks one row per outer node plus keeper.
475+
476+
The parent's continuation read sizes its candidate axis off this
477+
declaration, so it must equal the outer-grid length plus the keeper slot.
478+
"""
479+
assert (
480+
negm_toy.NEGM_SOLVER.n_stacked_carry_candidates
481+
== negm_toy.OUTER_GRID.n_points + 1
482+
)
483+
484+
485+
def test_non_stacking_solvers_declare_zero_stacked_candidates():
486+
"""A solver that publishes an already-collapsed carry declares no candidates.
487+
488+
An outer margin folded inside the solver (or no outer margin at all)
489+
leaves the carry without a candidate axis; the parent read must then
490+
query each carry row once instead of broadcasting queries over a
491+
phantom axis.
492+
"""
493+
assert GridSearch().n_stacked_carry_candidates == 0

0 commit comments

Comments
 (0)