Skip to content

Commit c3423c9

Browse files
hmgaudeckerclaude
andcommitted
NEGM: compose resources as base minus the declared outer cost
The stacked-carry lift is exact only when the inner resources are affine in the declared outer cost with coefficient exactly -1 — a property no finite set of runtime probes can certify on an arbitrary user function (an interaction term can vanish at every probe binding and still bend the translation elsewhere). So the affineness becomes structural: - With `NEGM.outer_cost` declared, the regime defines the cost-free base `<inner.resources>_before_outer_cost` and `finalize_regimes` composes `resources = base - cost` — the single point before validation and processing, so every consumer (inner EGM solve, budget constraint, parent child-resources read) sees the composed function. A user-defined resources function alongside a declared cost, or a missing base, is rejected at model build. - Validation replaces the cost-opaque resources funnel check with the direct requirement that the base does not read the outer post-decision; the cost-ancestor and no-adjustment checks stand. - The shift builder derives purely from the declared cost DAG; the two runtime consistency probes (reference-perturbed differences, the autodiff coefficient check) are deleted as redundant-by-construction, which also removes their float32 tolerance surface. - All NEGM models restructure mechanically (they were already written in `base - cost` form); DS-2024 oracle gates unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123hk3uzBgBeZown4eBBQ2C
1 parent 0bca78c commit c3423c9

12 files changed

Lines changed: 337 additions & 454 deletions

src/_lcm/egm/negm_validation.py

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,15 @@
3636
the last passive row axis, so a (hard) discrete action or a passive
3737
continuous state declared after the durable is rejected — the per-durable
3838
candidate lift would otherwise address the wrong axis,
39-
- outer-cost contract: the inner resources may depend on the outer
40-
post-decision only through the declared `NEGM.outer_cost` function, which
41-
itself may read only the durable state, the outer post-decision, and params
42-
(with `outer_cost=None`, resources must be independent of the outer
43-
post-decision) — otherwise no constant credited-cost translation onto a
44-
common cash-on-hand axis exists and the stacked lift would be wrong,
39+
- outer-cost contract: with a declared `NEGM.outer_cost`, the resources
40+
function is composed by `finalize_regimes` as
41+
`<resources>_before_outer_cost - <outer_cost>` (affine in the cost by
42+
construction; a user-defined resources function is rejected at
43+
finalization); the declared cost may read only the durable state, the outer
44+
post-decision, and params, and the cost-free base must not read the outer
45+
post-decision (with `outer_cost=None`, resources must be independent of the
46+
outer post-decision) — otherwise no constant credited-cost translation onto
47+
a common cash-on-hand axis exists and the stacked lift would be wrong,
4548
- the no-adjustment candidate is a unary function of the durable state — it is
4649
evaluated as `keep(durable)` in the credited-cost lift and the
4750
child-resources query map.
@@ -360,29 +363,27 @@ def _fail_if_outer_cost_contract_violated(
360363
cash-on-hand axis by crediting a constant per (durable, outer-node) cell.
361364
That constant exists exactly when the inner resources depend on the outer
362365
post-decision through a single additive cost term that itself varies only
363-
with the durable margin. The structural half of that contract is enforced
364-
here, fail-closed:
365-
366-
- `NEGM.outer_cost` declared ⇒ it must be a regime function; its DAG
367-
ancestors may contain no state or action other than the durable state and
368-
the outer post-decision (params are fine); and the inner resources — with
369-
the cost function made opaque — must not reach the outer post-decision
370-
through any other channel,
366+
with the durable margin. The contract is enforced fail-closed:
367+
368+
- `NEGM.outer_cost` declared ⇒ the resources function is composed by
369+
`finalize_regimes` as `<resources>_before_outer_cost - <outer_cost>`, so
370+
its affine use of the cost holds by construction (a user-defined
371+
resources function is rejected at finalization). Here: the cost must be
372+
a regime function whose DAG ancestors contain no state or action other
373+
than the durable state and the outer post-decision (params are fine),
374+
and the cost-free base must not read the outer post-decision — its only
375+
outer-margin channel is the subtracted cost itself,
371376
- `NEGM.outer_cost=None` ⇒ the inner resources must be independent of the
372377
outer post-decision altogether (every candidate already shares the
373378
keeper's axis; the shift is zero).
374-
375-
The additive *use* of the cost inside resources (coefficient exactly `-1`,
376-
no nonlinear wrapping) is not a graph property; the shift builder probes it
377-
at runtime and raises `InvalidParamsError` on a mismatch.
378379
"""
379380
inner = solver.inner
380381
durable_state = solver.outer_post_decision.removeprefix("next_")
381-
resources_func = functions.get(inner.resources)
382-
if resources_func is None:
383-
return
384382

385383
if solver.outer_cost is None:
384+
resources_func = functions.get(inner.resources)
385+
if resources_func is None:
386+
return
386387
resources_ancestors = _dag_ancestors(
387388
functions=functions, target_func=resources_func
388389
)
@@ -425,21 +426,22 @@ def _fail_if_outer_cost_contract_violated(
425426
)
426427
raise ModelInitializationError(msg)
427428

428-
opaque_functions = _without(functions=functions, names={solver.outer_cost})
429-
resources_ancestors = _dag_ancestors(
430-
functions=opaque_functions, target_func=resources_func
431-
)
432-
if solver.outer_post_decision in resources_ancestors:
433-
msg = (
434-
f"In regime '{regime_name}', the inner resources "
435-
f"'{inner.resources}' reads the outer post-decision "
436-
f"'{solver.outer_post_decision}' outside the declared outer cost "
437-
f"'{solver.outer_cost}'. The NEGM lift requires all outer-margin "
438-
"dependence of resources to flow through `NEGM.outer_cost` — route "
439-
f"it through '{solver.outer_cost}', or use `GridSearch` for this "
440-
"regime."
441-
)
442-
raise ModelInitializationError(msg)
429+
base_func = functions.get(f"{inner.resources}_before_outer_cost")
430+
if base_func is not None:
431+
base_ancestors = _dag_ancestors(functions=functions, target_func=base_func)
432+
if solver.outer_post_decision in base_ancestors:
433+
msg = (
434+
f"In regime '{regime_name}', the cost-free resources base "
435+
f"'{inner.resources}_before_outer_cost' reads the outer "
436+
f"post-decision '{solver.outer_post_decision}'. It must not "
437+
"read the outer post-decision: pylcm composes "
438+
f"`{inner.resources} = {inner.resources}_before_outer_cost - "
439+
f"{solver.outer_cost}`, so the base's only outer-margin "
440+
"channel is the subtracted declared cost itself. Route the "
441+
f"dependence through '{solver.outer_cost}', or use "
442+
"`GridSearch` for this regime."
443+
)
444+
raise ModelInitializationError(msg)
443445

444446

445447
def _fail_if_no_adjustment_candidate_not_unary(

src/_lcm/regime_building/finalize.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@
1212

1313
from collections.abc import Mapping
1414
from types import MappingProxyType
15+
from typing import cast
16+
17+
from dags import get_annotations, with_signature
18+
from dags.annotations import ensure_annotations_are_strings
1519

1620
from _lcm.grids import DiscreteGrid
1721
from _lcm.typing import FunctionName, RegimeName
1822
from _lcm.user_regime_validation import _validate_completeness
1923
from _lcm.utils.error_messages import format_messages
2024
from lcm.exceptions import ModelInitializationError, RegimeInitializationError
25+
from lcm.phased import Phased
2126
from lcm.regime import Regime as UserRegime
27+
from lcm.solvers import NEGM
2228
from lcm.temporal_aggregation import H_linear
29+
from lcm.typing import FloatND, UserFunction
2330

2431
# A user `Regime` after model-build finalization. Runtime-equivalent to
2532
# `lcm.regime.Regime`; internal signatures use this alias to mark values
@@ -67,6 +74,11 @@ def finalize_regimes(
6774
# Terminal regimes don't need H since Q = U directly (no E_next_V).
6875
if user_regime.transition is not None and "H" not in functions:
6976
functions["H"] = H_linear
77+
_compose_negm_resources(
78+
regime_name=regime_name,
79+
user_regime=user_regime,
80+
functions=functions,
81+
)
7082
finalized = user_regime.replace(
7183
functions=functions, derived_categoricals=merged
7284
)
@@ -79,6 +91,90 @@ def finalize_regimes(
7991
return MappingProxyType(result)
8092

8193

94+
def _compose_negm_resources(
95+
*,
96+
regime_name: RegimeName,
97+
user_regime: UserRegime,
98+
functions: dict[FunctionName, UserFunction | Phased | None],
99+
) -> None:
100+
"""Inject `resources = base - outer_cost` for an NEGM regime with a cost.
101+
102+
The NEGM stacked-carry lift is exact only when the inner resources are
103+
affine in the declared outer cost with coefficient exactly `-1`. That is
104+
not a property finitely many probes can certify on an arbitrary user
105+
function, so pylcm performs the subtraction itself: the user supplies the
106+
cost-free base `<resources>_before_outer_cost`, and the resources function
107+
every consumer sees — the inner EGM solve, the budget constraint, the
108+
parent's child-resources read — is composed here, at the single point
109+
before validation and processing. Mutates `functions` in place; regimes
110+
whose solver is not `NEGM`, or whose `outer_cost` is `None`, are untouched
111+
(they define the resources function directly).
112+
113+
Raises:
114+
ModelInitializationError: If the regime defines the resources function
115+
itself, or the cost-free base or the declared cost is missing.
116+
117+
"""
118+
solver = user_regime.solver
119+
if not isinstance(solver, NEGM) or solver.outer_cost is None:
120+
return
121+
resources_name = solver.inner.resources
122+
base_name = f"{resources_name}_before_outer_cost"
123+
cost_name = solver.outer_cost
124+
125+
if resources_name in functions:
126+
msg = (
127+
f"Regime '{regime_name}' defines the resources function "
128+
f"'{resources_name}' alongside a declared `NEGM.outer_cost` "
129+
f"('{cost_name}'). With a declared outer cost the resources "
130+
f"function is composed by pylcm as `{base_name} - {cost_name}`, "
131+
"so its affine use of the cost holds by construction — define "
132+
f"the cost-free base '{base_name}' instead of '{resources_name}'."
133+
)
134+
raise ModelInitializationError(msg)
135+
if base_name not in functions:
136+
msg = (
137+
f"Regime '{regime_name}' declares `NEGM.outer_cost` "
138+
f"('{cost_name}') but no cost-free resources base "
139+
f"'{base_name}'. pylcm composes the resources function as "
140+
f"`{base_name} - {cost_name}` — declare the base function."
141+
)
142+
raise ModelInitializationError(msg)
143+
if cost_name not in functions:
144+
msg = (
145+
f"NEGM.outer_cost '{cost_name}' is not a declared function of "
146+
f"regime '{regime_name}'. The credited outer cost must be a "
147+
"regime function reading only the durable state, the outer "
148+
"post-decision, and params."
149+
)
150+
raise ModelInitializationError(msg)
151+
152+
base_annotation = _return_annotation(functions[base_name])
153+
cost_annotation = _return_annotation(functions[cost_name])
154+
155+
@with_signature(
156+
args={base_name: base_annotation, cost_name: cost_annotation},
157+
return_annotation=base_annotation,
158+
)
159+
def composed_resources(**kwargs: FloatND) -> FloatND:
160+
return kwargs[base_name] - kwargs[cost_name]
161+
162+
composed_resources.__name__ = resources_name
163+
functions[resources_name] = cast("UserFunction", composed_resources)
164+
165+
166+
def _return_annotation(func: UserFunction | Phased | None) -> str:
167+
"""Return a function's stringified return annotation, defaulting to `FloatND`.
168+
169+
The composed resources function copies its producers' annotations so the
170+
DAG's annotation-consistency check stays satisfied.
171+
"""
172+
if not callable(func):
173+
return "FloatND"
174+
annotations = ensure_annotations_are_strings(get_annotations(func))
175+
return annotations.get("return", "FloatND")
176+
177+
82178
def _merge_derived_categoricals(
83179
*,
84180
regime_name: RegimeName,

0 commit comments

Comments
 (0)