|
24 | 24 | bind_continuation, |
25 | 25 | ) |
26 | 26 | from _lcm.egm.interp import ( |
| 27 | + _interp_between_nodes, |
27 | 28 | interp_on_padded_grid, |
28 | 29 | ) |
29 | 30 | from _lcm.egm.step_core import ( |
|
32 | 33 | _EgmKernelPieces, |
33 | 34 | _get_compute_node, |
34 | 35 | ) |
| 36 | +from _lcm.egm.upper_envelope.fues import ( |
| 37 | + QueryBracket, |
| 38 | +) |
35 | 39 | from _lcm.typing import ( |
36 | 40 | RegimeName, |
37 | 41 | ) |
@@ -171,17 +175,19 @@ def utility_of_action(action_value: ScalarFloat) -> ScalarFloat: |
171 | 175 | # Same `-inf` masking as the default per-combo computation: dead |
172 | 176 | # candidates become the envelope scan's absent form (NaN). |
173 | 177 | candidate_dead = jnp.isneginf(candidate_value) |
174 | | - refined_grid, refined_policy, refined_value, n_kept = pieces.refine( |
| 178 | + # The node reads its refined envelope at exactly one query |
| 179 | + # (`resources_at_node`), so the scan streams the bracketing pair |
| 180 | + # instead of materializing the NaN-padded `n_pad` envelope rows — |
| 181 | + # the per-(combo, node) envelope working set is O(1), not O(n_pad). |
| 182 | + bracket = pieces.refine_to_bracket( |
175 | 183 | endog_grid=jnp.where(candidate_dead, jnp.nan, candidate_grid), |
176 | 184 | policy=jnp.where(candidate_dead, jnp.nan, candidate_policy), |
177 | 185 | value=jnp.where(candidate_dead, jnp.nan, candidate_value), |
| 186 | + x_query=resources_at_node, |
178 | 187 | ) |
179 | 188 |
|
180 | | - V_node, policy_node = _publish_node_V_and_policy( |
181 | | - refined_grid=refined_grid, |
182 | | - refined_policy=refined_policy, |
183 | | - refined_value=refined_value, |
184 | | - n_kept=n_kept, |
| 189 | + V_node, policy_node = publish_node_from_bracket( |
| 190 | + bracket=bracket, |
185 | 191 | n_pad=pieces.n_pad, |
186 | 192 | resources_at_node=resources_at_node, |
187 | 193 | borrowing_limit=pieces.borrowing_limit, |
@@ -372,3 +378,127 @@ def _publish_node_V_and_policy( |
372 | 378 | V_node = jnp.where(overflowed, jnp.nan, V_node).astype(dtype) |
373 | 379 | policy_node = jnp.where(overflowed, jnp.nan, policy_node).astype(dtype) |
374 | 380 | return V_node, policy_node |
| 381 | + |
| 382 | + |
| 383 | +def publish_node_from_bracket( |
| 384 | + *, |
| 385 | + bracket: QueryBracket, |
| 386 | + n_pad: int, |
| 387 | + resources_at_node: ScalarFloat, |
| 388 | + borrowing_limit: ScalarFloat, |
| 389 | + utility_of_action: Callable[[ScalarFloat], ScalarFloat], |
| 390 | + discounted_expected_value_at_limit: ScalarFloat, |
| 391 | +) -> tuple[ScalarFloat, ScalarFloat]: |
| 392 | + """Publish one asset node's value and optimal action from its query bracket. |
| 393 | +
|
| 394 | + The streamed counterpart of `_publish_node_V_and_policy`: it consumes the |
| 395 | + two envelope nodes that `refine_to_bracket` captured around |
| 396 | + `resources_at_node` instead of the full NaN-padded refined row, so the |
| 397 | + `n_pad` envelope is never materialized. The published economics are |
| 398 | + identical: |
| 399 | +
|
| 400 | + - The value is the cubic-Hermite read of the envelope between the two |
| 401 | + bracket nodes (the value slope at each node is `grad(utility_of_action)` |
| 402 | + at that node's policy, the envelope-theorem marginal), floored at the |
| 403 | + closed-form constrained value, which is a feasible-policy lower bound. |
| 404 | + - Below the lowest envelope node the closed-form constrained value is |
| 405 | + published outright; the winning branch sets the action (the closed-form |
| 406 | + `R - borrowing_limit` where constrained wins, the interpolated policy |
| 407 | + otherwise). |
| 408 | + - Envelope overflow (`n_kept > n_pad`) NaN-poisons both outputs, identical |
| 409 | + to the row path, so the solve loop's NaN diagnostics surface the offending |
| 410 | + (regime, period). |
| 411 | +
|
| 412 | + Because the value and policy arithmetic is the shared `_interp_between_nodes` |
| 413 | + primitive — the same one the row path reaches through |
| 414 | + `interp_on_padded_grid` — the streamed publish cannot diverge from |
| 415 | + row-then-interpolate: only the bracket-finding differs. |
| 416 | +
|
| 417 | + Args: |
| 418 | + bracket: The query bracket from `refine_to_bracket`. |
| 419 | + n_pad: Static length of the envelope-refinement workspace (the overflow |
| 420 | + threshold). |
| 421 | + resources_at_node: Resources at this exogenous Euler node (the row's |
| 422 | + single publish query). |
| 423 | + borrowing_limit: Lower bound of the savings grid. |
| 424 | + utility_of_action: Utility with everything but the continuous action |
| 425 | + bound. |
| 426 | + discounted_expected_value_at_limit: Discounted expected continuation |
| 427 | + value at the lowest savings node. |
| 428 | +
|
| 429 | + Returns: |
| 430 | + Tuple of the node's published value and published optimal action. |
| 431 | +
|
| 432 | + """ |
| 433 | + dtype = resources_at_node.dtype |
| 434 | + overflowed = bracket.n_kept > n_pad |
| 435 | + |
| 436 | + # The value Hermite slope is the envelope-theorem marginal `u'(c*)`, masked |
| 437 | + # exactly as the row path: NaN where the node's policy is NaN (a padded |
| 438 | + # slot), 0.0 where the node's value is `-inf` (an infeasible endpoint), so |
| 439 | + # `_interp_between_nodes` falls back to the linear rule on those brackets. |
| 440 | + slope_lower = _node_value_slope( |
| 441 | + policy=bracket.lower_policy, |
| 442 | + value=bracket.lower_value, |
| 443 | + utility_of_action=utility_of_action, |
| 444 | + ) |
| 445 | + slope_upper = _node_value_slope( |
| 446 | + policy=bracket.upper_policy, |
| 447 | + value=bracket.upper_value, |
| 448 | + utility_of_action=utility_of_action, |
| 449 | + ) |
| 450 | + |
| 451 | + value_interpolated = _interp_between_nodes( |
| 452 | + x_query=resources_at_node, |
| 453 | + xp_lower=bracket.lower_grid, |
| 454 | + xp_upper=bracket.upper_grid, |
| 455 | + fp_lower=bracket.lower_value, |
| 456 | + fp_upper=bracket.upper_value, |
| 457 | + slope_lower=slope_lower, |
| 458 | + slope_upper=slope_upper, |
| 459 | + ) |
| 460 | + policy_interpolated = _interp_between_nodes( |
| 461 | + x_query=resources_at_node, |
| 462 | + xp_lower=bracket.lower_grid, |
| 463 | + xp_upper=bracket.upper_grid, |
| 464 | + fp_lower=bracket.lower_policy, |
| 465 | + fp_upper=bracket.upper_policy, |
| 466 | + ) |
| 467 | + |
| 468 | + closed_form_action = resources_at_node - borrowing_limit |
| 469 | + value_constrained = jnp.where( |
| 470 | + closed_form_action > 0.0, |
| 471 | + utility_of_action(jnp.maximum(closed_form_action, jnp.finfo(dtype).tiny)) |
| 472 | + + discounted_expected_value_at_limit, |
| 473 | + -jnp.inf, |
| 474 | + ) |
| 475 | + below_refined = (closed_form_action > 0.0) & ( |
| 476 | + resources_at_node <= bracket.first_grid |
| 477 | + ) |
| 478 | + constrained_wins = below_refined | (value_constrained >= value_interpolated) |
| 479 | + V_node = jnp.where( |
| 480 | + below_refined, |
| 481 | + value_constrained, |
| 482 | + jnp.maximum(value_interpolated, value_constrained), |
| 483 | + ) |
| 484 | + policy_node = jnp.where(constrained_wins, closed_form_action, policy_interpolated) |
| 485 | + V_node = jnp.where(overflowed, jnp.nan, V_node).astype(dtype) |
| 486 | + policy_node = jnp.where(overflowed, jnp.nan, policy_node).astype(dtype) |
| 487 | + return V_node, policy_node |
| 488 | + |
| 489 | + |
| 490 | +def _node_value_slope( |
| 491 | + *, |
| 492 | + policy: ScalarFloat, |
| 493 | + value: ScalarFloat, |
| 494 | + utility_of_action: Callable[[ScalarFloat], ScalarFloat], |
| 495 | +) -> ScalarFloat: |
| 496 | + """Envelope-theorem value slope at one bracket node, masked like the row. |
| 497 | +
|
| 498 | + The slope is `grad(utility_of_action)` at the node's policy — NaN where the |
| 499 | + policy is a padded NaN slot, 0.0 where the node value is `-inf` — matching |
| 500 | + the per-node masking the row path applies before interpolating. |
| 501 | + """ |
| 502 | + slope = jax.grad(utility_of_action)(jnp.where(jnp.isnan(policy), 1.0, policy)) |
| 503 | + slope = jnp.where(jnp.isnan(policy), jnp.nan, slope) |
| 504 | + return jnp.where(jnp.isneginf(value), 0.0, slope) |
0 commit comments