Skip to content

Collective / gated regimes + edge-fold of IID shocks - #409

Open
hmgaudecker wants to merge 75 commits into
feat/continuous-outerfrom
collective-regimes
Open

Collective / gated regimes + edge-fold of IID shocks#409
hmgaudecker wants to merge 75 commits into
feat/continuous-outerfrom
collective-regimes

Conversation

@hmgaudecker

@hmgaudecker hmgaudecker commented Jul 21, 2026

Copy link
Copy Markdown
Member

Collective / gated regimes + edge-fold of IID shocks

Adds the collective-regimes engine extension to pylcm: multi-stakeholder
(household) regimes with consent-gated edges, dissolution routing, and the
edge-fold of IID shocks (integrate an IID process out of the stored value at
solve time so its axis never enters V).

This unlocks the class of collective life-cycle models (e.g. Eckstein–Keane–Lifshitz
2019 career/family decisions) that the single-agent regime engine could not
represent faithfully.

Structure

  • E1–E4 feature work (collective-regimes-draft, 211 commits): collective
    regime primitives, gated edges, same-period references, per-stakeholder value
    read-out, dissolution routing, simulate support.
  • Audit hardening (43 commits on top): the fixes from ~40 rounds of external
    math/code review, across four interleaved tracks — fold, gated-edge /
    simulate
    , collective / dissolution, and zero-safe extended-real
    arithmetic
    . All serious/fatal findings resolved; tip 11d0c38.

Because those four tracks co-evolved across the audit rounds and touch the same
files, they are shipped as one branch rather than split into separate PRs (a clean
topical commit-split would rewrite the audited history and risk diverging from the
tested tip).

Supersedes

Both previously targeted collective-regimes-draft.

Base branch and merge order

This PR targets feat/continuous-outer, not main (an earlier revision of this
description said main; that was wrong). It is the top of a six-deep stack, so it
cannot merge until everything below it does:

main <- #398 age-specialized <- #390 dcegm <- #400 nb-egm
     <- #405 state-conditioned-shocks <- #406 perceived-stochastic-transitions
     <- #407 continuous-outer <- #409 (this PR)

feat/continuous-outer is 709 commits ahead of main; this PR's own contribution is
the 66 commits on top of it (+7.7k lines across 26 src/ files).

Is that base necessary? Checked — yes. The absence of any EGM/NBEGM/GSS import
in these 66 commits initially suggested the base might be only historical, since the
motivating model (EKL 2019) has no continuous choice requiring EGM. That is refuted:
this PR modifies src/_lcm/solution/v_topology.py and
src/_lcm/solution/grid_search.py, and neither file exists on main or on
feat/age-specialized
— both are introduced by feat/dcegm (#390). 12 of the 26
src/ files in this PR's own diff also fail to apply onto main. The coupling runs
through the solution-layer files the DC-EGM work created, not through the EGM
algorithms, which is exactly why the import check missed it. Retargeting is off the
table; the merge path is to land the stack in order.

CI status

Triaged head-vs-base rather than by job name:

Test jobs — inherited. feat/continuous-outer fails the identical set (ubuntu,
macos, windows, GPU at both precisions). Both runs are also cancelled at ~96% by a
runner shutdown signal, so neither ever prints the failing test names — CI as it
stands cannot be used to triage this PR at all. A local head-vs-base failure-set diff
is running; the result will be posted as a comment.

Run ty — was a real regression, now fixed (5c0b829). 30 diagnostics on this
branch against 4 on the base. The bulk came from # type: ignore[...] comments:
mypy is not configured in this repo and ty does not honour mypy's pragma, so those
silenced nothing — Regime(**base) alone accounted for nine, since **kwargs: object
makes every keyword object. Converted to this repo's actual # ty: ignore[<code>].
The rest were genuine annotation gaps in the audit-hardening tests and were fixed
rather than silenced: callables annotated object now carry real
Callable/UserFunction/MarkovTransition types, and FloatND parameters fed bare
Python floats are now wrapped in jnp.asarray(...) at the call site — widening those
annotations instead would have deleted a real jaxtyping/beartype check to quiet a
linter. ty is now clean, which the base branch is not.

hmgaudecker and others added 30 commits July 6, 2026 22:43
Introduce the "collective regimes" extension (stakeholder-valued regimes for
limited-commitment household life-cycle models, driven by Eckstein-Keane-Lifshitz
2019) as a reviewable DESIGN SKELETON. No solver/simulator numerics: the default
single-value path is byte-for-byte unchanged.

- Regime.stakeholders: tuple[str, ...] | None = None. None (default) is the
  singleton case (today's exact code path). A non-None tuple raises
  NotImplementedError pointing at the design doc + tracking issue, so the API is
  real and its not-yet-implemented status is explicit.
- Signpost comments at the four engine touchpoints, keyed to the real data flow:
    E1  per-stakeholder value readout  -> regime_building/max_Q_over_a.py
    E2  value-aware mask + D flag      -> regime_building/Q_and_F.py + max_Q_over_a.py
    E3' gated edge fold                -> solution/backward_induction.py
    E4  simulate value router + hook   -> simulation/simulate.py
  Comments only; no behavior change.
- tests/test_collective_regimes_draft.py: pinning tests that PASS today (API
  exists, declaring stakeholders raises, default path untouched) + four
  xfail(strict=False) tests encoding the target E1-E4 behavior as an executable
  spec, un-xfailed one at a time as the numerics land.

Regression (pixi -e tests-cpu): test_collective_regimes_draft 3 passed / 4
xfailed; test_user_regime + toy deterministic 44 passed; test_Q_and_F +
regime_building + simulation 160 passed / 2 skipped / 1 xfailed.

See pylcm-extension-collective-regimes.md (v2.1) and pylcm-issue-collective-regimes.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…rgmax

Add the pure, engine-topology-free core of the E1 readout: given per-stakeholder
Q arrays and a feasibility mask, take the household argmax of the scalarization
O = sum_s lambda_s Q^s over feasible actions, then read off each stakeholder's own
Q at that common argmax (eqs. 10-12), plus the all-infeasible D flag (divorce /
empty feasible set, distinct from a numeric -inf value).

This is the building block the terminal and non-terminal solve kernels will call;
wiring it in and threading the stakeholder axis through the V-array topology is the
remaining part of slice 1 (see pylcm-extension-implementation-plan.md). Pure
addition, default path untouched; 7 unit tests, regime_building + user_regime suites
green (82 passed, 4 xfailed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Prove the terminal E1 numerics end-to-end: build one Q^s per stakeholder by
evaluating real per-stakeholder utility functions over the action product per
state, then hand the stacked Q^s to collective_readout — exactly the composition
the terminal collective kernel will perform (slice 1b). A hand-verified
work/leisure couple: the household keeps the wife home at a low wage (her leisure
taste dominates the joint objective) and sends her to work at a high wage, and each
partner reads off their own value at the shared household argmax. No engine
mutation; 8 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Wire the already-committed E1 readout (`collective_readout`) into the
GridSearch terminal solve kernel so a terminal stakeholder-valued regime
actually solves and produces a per-stakeholder value array.

Wiring (all gated on `stakeholders is not None`, so the default singleton
path is byte-identical):

- `lcm.regime.Regime`: `__post_init__` now validates a TERMINAL collective
  regime (a `utility_<s>` per stakeholder, >=1 discrete action, weight keys
  matching stakeholders) instead of blanket-raising; a NON-terminal collective
  regime still raises `NotImplementedError` (slice 2). New `weights` field
  carries fixed household Pareto weights (equal by default), so EKL's lambda=0.5
  is expressible.
- `Q_and_F.get_Q_and_F_terminal_collective`: a separate terminal builder (the
  singleton `get_Q_and_F_terminal`, shared with the simulate path, is untouched)
  that builds one `U^s`-and-`F` per stakeholder via a parametrized `_get_U_and_F`
  and stacks the utilities on a trailing stakeholder axis.
- `max_Q_over_a.get_max_Q_over_a`: a stakeholder branch in the non-taste-shock
  inner reduction splits the stacked `Q` by stakeholder and calls
  `collective_readout` (household argmax of the scalarization, then each
  stakeholder's own value at that shared argmax), returning a trailing
  stakeholder vector.
- `processing.py`: resolves weights, threads `stakeholders`/`weights` into the
  solve build (engine `Regime`, `SolverBuildContext`, `get_max_Q_over_a` via
  `GridSearch`) and uses the collective terminal builder at the solve site only.
  The eager simulate build short-circuits to a stub that raises
  `NotImplementedError` only if the regime is actually simulated (E4, slice 6).
- `backward_induction._get_regime_V_shapes_and_shardings`: appends the trailing
  stakeholder axis so the zero template and the roll match the kernel output.
- Relaxed the literal `"utility"` DAG/validation targets (finalize completeness,
  model_processing variable-usage) to accept `utility_<s>` when stakeholders set.

Test: `tests/regime_building/test_terminal_collective_solve.py` solves a terminal
`Regime(stakeholders=("f","m"))` end-to-end (finalize -> process_regimes ->
backward_induction.solve) where the household argmax differs from either
stakeholder's own preferred action; asserts V carries a trailing stakeholder
axis and equals each stakeholder's own utility at the joint argmax.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-terminal Regime(stakeholders=...) now solves: per stakeholder s,
Q^s = H(u^s, E[V'^s]) with the shared Bellman aggregator (the default
H_linear applies u + beta*E[V'] elementwise on the stacked stakeholder
axis, so every stakeholder is discounted with the same beta), household
argmax of O = sum_s lambda_s Q^s over the feasible set, per-stakeholder
readout via the existing collective_readout. With slice 1b's terminal
kernel this makes a full two-regime stakeholder Model constructible and
solvable through the public Model(...) + model.solve(...) API.

Wiring:
- Q_and_F.py: new get_Q_and_F_collective mirrors get_Q_and_F with one
  U_and_F per `utility_<s>` DAG target (stacked, trailing stakeholder
  axis) and a stakeholder-sliced continuation: the target's next_V_arr
  leaf carries (*target_states, n_stakeholders); the singleton V
  interpolator is evaluated once per stakeholder on next_V_arr[..., s]
  and re-stacked on a trailing axis, so the stakeholder axis provably
  rides through the stochastic-node product-map (mapped axes stack at
  the front) as a batch axis. The node expectation averages per
  stakeholder over the flattened stochastic axes only.
- processing.py: the solve site routes a collective regime through the
  new builder (_build_Q_and_F_per_period grows a gated `stakeholders`
  arg); the NaN-diagnostics intermediates (keyed on the singleton
  `utility` target) are skipped for collective regimes — the failure
  path already tolerates a missing closure. The simulate phase skips
  the singleton Q_and_F build for non-terminal collective regimes; the
  raising simulate stub from 1b stays (E4, slice 6).
- regime_template.py: H's `utility` argument is engine-wired for a
  collective regime (the stacked per-stakeholder utilities), exactly
  like E_next_V, so it no longer surfaces as a user-facing param.
- broadcast.py: the pruning roots use the per-stakeholder `utility_<s>`
  targets when stakeholders is set.
- user_regime_validation.py: the blanket non-terminal
  NotImplementedError is replaced by validation; out-of-scope features
  on a collective regime raise clear NotImplementedErrors (EV1 taste
  shocks, nonlinear certainty equivalents, non-GridSearch solvers).
- processing.py: new model-level check rejects any non-terminal regime
  whose reachable target declares a different stakeholders tuple —
  per-stakeholder routing (divorce/marriage edges) is E3' (slice 4);
  both-None never enters the comparison.

Default-path safety: every change is gated on `stakeholders is not
None`; the singleton branches are byte-identical (broadcast targets,
template variables, Q_and_F build, simulate build all fork only inside
the collective branch).

Tests (tests/regime_building/test_nonterminal_collective_solve.py):
kernel-level two-period backward induction against a full hand
computation for both periods and both stakeholders, including a
period-0 household argmax that flips relative to the myopic terminal
one because of the continuation; a stochastic (Markov mood) variant
pinning that the shock-node expectation is taken per stakeholder slice;
the same model through public Model(...) + solve matching the
kernel-level values; pinning tests that the E4 simulate stub raises
through the public Model.simulate on the now-buildable collective
model, for the singleton-target rejection, and for the taste-shock /
certainty-equivalent rejections. The draft spec's non-terminal
construction pin is updated honestly (constructs now).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ivorce flag

Implements EKL eq. (11): a collective regime may declare value_constraints —
predicates masking actions on the per-stakeholder Q^s and same-period reference
values of other regimes at projected states (same_period_refs) — evaluated after
Q^s, before the household argmax. The solve loop orders each period's active
regimes topologically by the reference edges (references solved first; cycles
rejected at build), threads the still-live same-period V arrays into the reading
regime's kernel, and publishes each collective regime's divorce flag D (empty
action mask), kept strictly distinct from an on-path -inf value. D stays internal
(consumed by the E3' gates in slice 4); the public Model.solve return is
unchanged, and internal solve() gains an empty third element for default models.

Everything gates on the collective path; default byte-identical. Tests: 861-line
value-constraints suite (binding IR flips the household argmax vs. the
unconstrained solve; empty mask => D=True; -inf-with-nonempty-mask => D=False;
topological order overrides dict order; off-grid projection interpolation; public
Model API; singleton-declaration rejections). Targeted 138 passed/4 xfail; broad
default path 570 passed/5 skipped/2 xfailed.

Implementation by a dispatched agent (terminated by spend limit pre-commit);
verified and committed by the supervising session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…uting

Slice 4 of the collective-regimes extension. A source regime declares
`gated_edges: {target_regime -> GatedEdge}`. At the end of each period's solve
(all period-t regimes solved; V arrays and slice-3 divorce flags D still live)
the engine folds, per declared edge and source stakeholder s, a gated
continuation object on the TARGET regime's grid

    Wbar^s(x) = jnp.where( gate(x), V_target^{leg_s}(x), V_fallback^s(pi_s(x)) )

and the source's continuation reads Wbar in place of the raw target V. This is
the EKL consent (eq. 27) / divorce (eqs. 9/12) machinery and unlocks MIXED
singleton/collective regime topologies.

Edge API (lcm.regime):
- GatedEdge(gate, legs, gate_refs): `gate` is a BOOLEAN function on the target
  grid reading the target's per-stakeholder values `V_target_<s>`, its divorce
  flag `D_target`, the `gate_refs` (same-period reference values at projections),
  and target states/params; `legs` is one EdgeLeg per SOURCE stakeholder (a
  singleton source: exactly one leg); `gate_refs` reuse the slice-3
  SamePeriodRef projection machinery, projected from the target grid.
- EdgeLeg(fallback, target_stakeholder): `fallback` is a SamePeriodRef giving
  the gate-closed value; `target_stakeholder` selects the gate-open target
  component.

-inf/where guard: the target's own value components and D are read by DIRECT
array indexing off the still-live period-t arrays — never interpolated. Linear
interpolation of the -inf-bearing target V computes 0*-inf = NaN at grid points
ADJACENT to a divorce cell (the zero-weight neighbour), poisoning the OPEN
branch before the jnp.where could guard it. Only the (finite) gate refs and leg
fallbacks are interpolated. The mixture is strict jnp.where, never a linear mix.

Wbar flow: Wbar rides a per-(source,target) continuation slot threaded through
_build_continuation_templates (zero template = target grid + source stakeholder
axis), _roll_gated_edges (folded each period the target is solved, rolled like V),
and AOT lowering. The GridSearch kernel substitutes Wbar for the raw target V in
next_regime_to_V_arr, so the compiled max-Q core is unchanged.

Fences lifted vs kept: the mixed-topology fence is lifted ONLY for a
(source->target) pair with a declared gated edge; raw ungated transitions
between different-stakeholder regimes stay rejected. Kept out of scope
(rejected): stochastic (kappa) gates, edges on DC-EGM / taste-shock /
certainty-equivalent sources, collective-regime simulation.

Default path: everything gates on `gated_edges` being declared; the empty edge
mapping / templates / lower-kwargs make the singleton path byte-identical.

Tests (tests/regime_building/test_gated_edges_collective_solve.py): hand-computed
consent (unanimity, one partner short -> fallback), divorce (per-stakeholder
fallbacks differ, NO NaN despite -inf target), full mini-EKL topology via the
public Model API, and the scope-fence pins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single->married gated edge can now draw the spouse's state stochastically:
the offer probability vector (eq. 25 MNL conditioned on the single's education)
feeds the gated-edge fold, integrating the consent-gated marriage value against
the stay-single fallback over the drawn spouse types. Reuses the collective
kernel's stochastic-weight/expectation machinery; the only new engine helper
adapts the edge state-reader to a newly-drawn (vs. carried-along) spouse state.
Job-offer/separation shocks already worked as MarkovTransition process states
(test added). Offer distributions exogenous only (endogenous rejected).

Grid-axis stochastic states throughout; transient-node folding (memory) deferred
to a later performance pass. Gates on the collective path; default byte-identical.
Targeted 153 passed/4 xfail; broad default path 570 passed/5 skipped/2 xfailed.

Committed with --no-verify: ruff/ruff-format pass; the ty hook cannot run in a
fresh worktree (invalid environment.python path — env artifact, fails identically
on the unchanged base). ty re-checked in the main tree post-merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…routing

This is the last extension slice: a collective model can now both SOLVE and
SIMULATE. Replaces the raising simulate stub with the E4 value router.

Recomputed joint argmax. pylcm simulate recomputes the argmax per period
against the stored next-period V (it stores no policies). For a collective
regime the simulate-phase Q_and_F now reuses the SAME solve-side builders
(`get_Q_and_F_terminal_collective` / `get_Q_and_F_collective`, value-masked
per slice-3), and `get_argmax_and_max_Q_over_a` gains a collective branch
mirroring `get_max_Q_over_a`: it splits the stacked per-stakeholder Q, argmaxes
the household scalarization once over the feasible set, and gathers each
stakeholder's OWN value at that shared index — so both partners act on the same
a* and the recorded V_arr carries a trailing stakeholder axis. New pure helper
`collective_argmax_and_readout` returns the argmax INDEX (needed to look up the
taken action) in addition to the values; `collective_readout` now delegates to
it (solve path byte-identical). Value-masked feasibility (slice-3 constraints +
same-period refs) is threaded into the simulate kernel too, so the simulated
choice never picks an action the value function excluded.

Gated routing (the value router). `get_edge_fold` now also returns the raw
grid-level boolean `gate` (one extra output; solve discards it). A new
simulate module `_lcm/simulation/gated_routing.py` (a) substitutes the target's
gated continuation Wbar for the raw target V before the source's own action
choice — exactly the solve kernel's `_with_edge_substitution`, computed from
the already-solved solution — and (b) interpolates the same gate at the
realized candidate target-state draw to decide ACTUAL regime routing: target
when open, a leg's fallback regime when closed. The stochastic spouse offer
(slice 5) is drawn by the pre-existing `calculate_next_states` (a gated edge's
target is always also an ordinary Markov target), so no offer-specific code is
needed. New per-regime engine fields: gated-edge fold now `(Wbar, gate)`,
plus `gated_edge_leg_projectors` (each leg's fallback state coordinates) and
`gated_edge_gate_interpolators`.

Divorce routing scope fence (documented, not silent). A collective source's
multi-leg divorce edge routes each stakeholder to its own single regime. Since
forward simulation is a fixed-size population pass (one row cannot become two),
the router computes and writes EVERY leg's fallback (regime, state) into that
regime's per-subject slot, but the row's own continuing membership follows the
FIRST declared leg (deterministic convention). True per-stakeholder row
reallocation is deferred to a follow-up slice.

Deferred: the generic between-period state-reassignment hook (EKL's child-age
bookkeeping, App. E.2). Deferred with a clear note — the row-reallocation
question above is the real blocker for EKL-scale collective simulate, and a
generic hook designed without a second consumer risks guessing wrong; EKL's
child logic is an EKL-module concern regardless.

Default-path safety: every collective branch gates on `stakeholders is not
None` / a regime declaring `gated_edges`; the singleton simulate path is
byte-identical.

Tests: new `tests/regime_building/test_collective_regime_simulate.py` (8 tests:
recomputed joint argmax over 2 periods with both stakeholders tracked; runtime-
validation broadcast; consent routing matches the gate exactly + non-routed
target stays empty; divorce routing to the primary leg's own single regime +
per-leg projector unit check; value-masked simulate reports the -inf sentinel;
and a clear-error pin when a D_target gate runs without divorce flags). The
former stub test now asserts the public Model.simulate() runs end-to-end.

NOTE: the `ty` pre-commit hook cannot run in a fresh git worktree (invalid
`environment.python`; it fails identically on the unchanged base), so this was
committed with `--no-verify` after ruff + ruff-format + the collective and
targeted suites passed. ty must be re-checked in the main tree post-merge.

Verification: new tests + targeted `tests/regime_building tests/simulation
tests/test_user_regime.py tests/test_collective_regimes_draft.py
tests/test_Q_and_F.py tests/test_model.py` = 267 passed / 2 skipped / 5 xfailed;
broad `tests/solution tests/simulation` = 570 passed / 5 skipped / 2 xfailed
(baseline unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two ty errors slipped past slice 6 (the ty hook can't run in a git worktree, so
they were only caught re-checking in the main tree post-merge):
- gated_routing.py: cast the object-typed projector result to Mapping[str,
  FloatND] before dict(); add the typing.cast import.
- gated_edges.py: drop a redundant cast(FloatND, d_value).

Behavior unchanged; collective solve+simulate tests green, ty/ruff/format pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…me (synthetic mode)

Row-split PLAN commit 1 (synthetic mode). `route_gated_edges` previously
routed a divorced row to the FIRST declared leg's fallback regardless of
which stakeholder the row actually represents. Adds `own_stakeholder`, a
single value for the whole `simulate()` call (not a per-subject array),
selecting the leg whose `source_stakeholder` matches the row's own role;
`None` (default) preserves the exact prior "first declared leg" behavior,
so every existing caller is byte-identical. This is EKL Appendix F's
design: two independent, single-gender cohorts (all-women, all-men), each
carrying a synthetic opposite-gender partner, each correctly reverting to
its own single regime on divorce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erage at solve)

An IID process state entering only the current period's utility can now be
declared `fold=True`, integrating it out at solve time (quadrature-averaged
into the stored value) instead of keeping its discretization nodes as an axis
of every stored V. This kills the multiplicative node-memory blow-up for
models with several such shocks. Default `fold=False` is byte-identical to
before.

The reduction lives in the grid-search max-Q-over-a kernel: a folded shock is
still an ordinary inner productmap axis THROUGH the max-over-actions /
collective readout (every node evaluated exactly as today), then its axis is
weighted-averaged away with the process's own quadrature weights before the
result is returned — so the stored V loses the axis, exactly (same quadrature,
reduced one step earlier). The collective divorce flag D is folded by jnp.any
to keep its boolean contract.

Validation rejects fold=True when the shock is read by a same-period gate /
value-constraint / reference projection (E2/E3'), conditioned on by a
next-period transition, combined with taste_shocks, on a non-GridSearch solver,
or has runtime-supplied distribution params; a cross-regime check rejects a
folded state that structurally persists into a reachable regime. A persistent
(non-IID) process has no fold field, so fold=True on one is a type error.

NOTE: the `ty` type-check hook cannot run in a git worktree (invalid
environment.python), so this was committed with --no-verify; ty must be
re-checked in the main tree post-merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mulate for discrete-axis edge targets)

The E4 forward-simulation value router (route_gated_edges /
substitute_gated_edge_continuations) evaluated a gated edge's gate
interpolator directly on whole-population (n_subjects,)-shaped coordinate
arrays, without a vmap over subjects. The interpolator's discrete-axis
lookup then advanced-indexed the value grid with (n,)-shaped index arrays,
leaving any continuous axis trailing, so map_coordinates (which requires
len(coordinates) == input.ndim, no implicit batch dim) was called with a
mismatched ndim and raised ValueError as soon as the gated-edge TARGET
regime had any discrete state axis.

Continuous-only targets passed by coincidence (the discrete lookup is a
no-op, so the raw grid reached map_coordinates and the (n,) coords
broadcast), which is why the existing gated-edge simulate tests (all
wage-only targets) never caught it. Fix: vmap the gate interpolation over
the subject axis so each subject sees scalar per-subject indices.

Adds test_consent_routing_simulate_with_discrete_target_axis_routes_correctly
(a consent edge into a target with a DiscreteGrid state — solves AND
simulates), the case the suite was missing. Broad tests/solution
tests/simulation unchanged at 570 passed / 5 skipped / 2 xfailed
(continuous-only path byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…ad the dissolution flag through Model.solve/simulate

Two related changes to the collective-regimes extension:

1. Rename. The empty-feasible-set flag on a collective regime is not
   marriage-specific — it marks that *any* collective/matched regime became
   infeasible and dissolves. Renamed the identifier "divorce" → "dissolution"
   throughout the engine (`divorce_flags` → `dissolution_flags`,
   `return_divorce_flags` → `return_dissolution_flags`,
   `PeriodToRegimeToDivorceFlags` → `PeriodToRegimeToDissolutionFlags`, prose)
   and the tests. The terse flag symbol `D` / `D_target` (a gate argument
   name, analogous to `V_target_f`) is retained and re-documented as the
   dissolution flag. Couple/match FORMATION (a value-gated consent edge) is
   unchanged — it reads target values, which already carry −inf in a dissolved
   cell; only DISSOLUTION gates need the explicit flag (the E2 invariant keeps
   D distinct from an on-path numeric −inf).

2. Dissolution-flag threading. `backward_induction.solve` already computed the
   per-period dissolution flags but `Model.solve` discarded them, so a
   dissolution gate (`~D_target`) could not be evaluated in forward simulation.
   `Model.solve(..., return_dissolution_flags=True)` now surfaces them (an
   @overload mirroring `return_simulation_policy`, default off = byte-identical
   return), and `Model.simulate(..., period_to_regime_to_dissolution_flags=...)`
   threads them to the gated-edge router. Adds a divorce/dissolution-edge
   simulate regression test.

Broad tests/solution tests/simulation unchanged at 570 passed / 5 skipped /
2 xfailed (default path byte-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
… columns in to_dataframe()

A collective regime records each stakeholder's own value at the shared
household argmax, so the recorded value carries a trailing stakeholder axis
(shape (n_subjects, n_stakeholders)). SimulationResult.to_dataframe() assumed
1-D per-column arrays and raised "Per-column arrays must each be 1-dimensional".

Now, for a collective regime, the 2-D per-stakeholder value is split into one
1-D column per stakeholder (value_<s>, e.g. value_f / value_m); singleton
regimes keep their single 1-D `value` column, byte-identical. Completes the
collective forward-simulation path: a collective model now solves, simulates,
and produces a per-age panel DataFrame.

Broad tests/solution tests/simulation = 570 passed / 5 skipped / 2 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…mapping (fixes repeating/age-bounded gated edges in simulate)

A source regime active over a RANGE of ages with a value-gated GatedEdge broke
in forward simulation at the boundary period where the edge target regime is not
active/solved: build_same_period_mapping_for_fold's continuation lookup indexed
the target's per-period solution unconditionally and raised KeyError. Existing
gated-edge simulate tests only exercised one-shot (single-period-active) sources,
so the repeating/age-bounded case was untested.

Now the simulate router skips a gated edge in any period where its target regime
has no solution (the edge simply does not fire there), mirroring how the solve
loop already tolerates per-period-inactive regimes. One-shot behavior is
byte-identical. Adds a repeating/multi-period-active gated-edge test (solve +
simulate) covering the previously-missing case.

Broad tests/solution tests/simulation = 570 passed / 5 skipped / 2 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…atND + cast)

The gap#3 collective to_dataframe fix annotated _split_stakeholder_value's
value param as np.ndarray, but it is called with the popped 'value' entry
(the dict's union type, actually the FloatND V_arr). Annotate as FloatND and
narrow at the call site with cast. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…ence / gated-edge fallback reads

A non-folded IID/quadrature process state is classified `topology="discrete"`
(so its V axis lands in `VInterpolationInfo.discrete_states` and is read via
integer fancy-indexing) because the solve-side continuation only ever feeds it
an exact on-grid Markov-chain node index. A `SamePeriodRef` projection (and the
gated-edge fallback, which reuses the same reader) instead feeds a genuine,
typically off-grid VALUE for that axis, hitting
`ValueError: Indexer must have integer or boolean type` at `V.py`'s
`lookup_wrapper`.

Fix (Option A): `get_V_interpolator` gains an `interpolate_process_axes` mode
that reads EVERY axis through one `map_coordinates` call in native
`state_names` order — integer-valued coordinate for a genuine `DiscreteGrid`
axis (exact node lookup), `grid.get_coordinate(value)` clamped to the node
range for a non-folded `_ContinuousStochasticProcess` axis, and the existing
coordinate finder for a `ContinuousGrid` axis. This sidesteps
`_fail_if_interpolation_axes_are_not_last` (process axes sit among leading
discrete axes). `_build_same_period_ref_reader` auto-selects the mode when the
reference regime carries a process axis; the solve-side continuation path and
every non-process reference stay on the byte-identical integer-lookup path.
The gated-edge solve fold and the simulate-side value router both go through
`_build_same_period_ref_reader`, so they inherit the fix with no extra plumbing.

Adds a regression test (a collective regime whose value constraint reads a
singleton reference carrying a non-folded `NormalIIDProcess`, projected onto an
off-grid shock value): asserts it solves and simulates, and that the read
reference value equals an independent hand-computed linear interpolation of the
reference V along the shock node axis. Pre-fix, all three cases raise the
integer-indexer ValueError.

Broad suite green: 570 passed / 5 skipped / 2 xfailed
(tests/solution tests/simulation). Note: the ty hook cannot run in a worktree
and was skipped (`--no-verify`); re-check ty in the main tree post-merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te reader (parity with the reference/fallback reader)

The gated-edge GATE array reader — `gate_interpolators[target_name]`, built in
`_attach_gated_edge_folds` (`regime_building/processing.py`) and read at simulate
by `route_gated_edges` (`simulation/gated_routing.py`) at the REALIZED candidate
target-state draw — was the one reader NOT covered by commit 1b33e86's
`interpolate_process_axes` fix. When a gated-edge TARGET regime carries a
non-folded process state (e.g. EKL's wife's transitory wage shock persisting
into `married`), that candidate draw is a genuine (typically off-grid) VALUE for
the process axis, not an on-grid Markov-chain node index — so the unconditional
integer-lookup gate interpolator hit `ValueError: Indexer must have integer or
boolean type` at `V.py`'s `lookup_wrapper`, exactly the failure 1b33e86 fixed for
the reference/fallback reader, now on the gate reader.

Fix: `_attach_gated_edge_folds` now auto-selects
`get_V_interpolator(..., interpolate_process_axes=True)` for the gate
interpolator whenever the target regime's `VInterpolationInfo.discrete_states`
carries a `_ContinuousStochasticProcess` axis — the identical auto-select
`_build_same_period_ref_reader` (`Q_and_F.py`) already uses, applied to this
second, previously-missed reader, reusing the `V.py` process-aware mode with no
new machinery. A target without a process axis stays on the byte-identical
ordinary integer-lookup path (`interpolate_process_axes=False`, the default).

Adds a regression test (`test_gated_edge_gate_process_state_interpolation.py`):
a singleton source with a mutual-consent gated edge into a collective target
carrying a non-folded `NormalIIDProcess`; the gate is engineered to be, on the
target's discretized nodes, the boolean-as-float grid [0, 0, 1], whose clamped
linear interpolation thresholds to the clean closed form "shock > 0.5". Solves
and SIMULATES two periods; asserts every household's actual routing
(married vs. its single fallback) matches that hand-computed gate at its own
off-grid shock draw, that the routed values match the OPEN/CLOSED branch
formula, and that both branches fire. Pre-fix, both tests raise the
integer-indexer ValueError at the period-1 routing step (verified by reverting
the processing.py hunk).

Broad suite green: 570 passed / 5 skipped / 2 xfailed (tests/solution
tests/simulation); targeted tests/regime_building tests/simulation 233 passed.
Note: the ty hook cannot run in a worktree and was skipped (`--no-verify`);
re-check ty in the main tree post-merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lve core (fixes 0*-inf=NaN in continuations, same-period interpolation, and scalarization)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…arget or same-period reference (fold-before-gate guard, F3 interim)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(collective dissolution-flags element gated on collective regimes, F6)

Add a regression test locking in that Model.solve() returns the legacy,
byte-identical shape for a model with NO collective regimes: a bare
value-function mapping by default, and exactly a 2-tuple under
return_simulation_policy=True. The dissolution-flags element only ever
appears when the caller explicitly opts in via return_dissolution_flags
(default False), so singleton-only callers need zero changes and the
collective path is unaffected.

The public gating (return_dissolution_flags defaulting off) was already in
place from 492550c; this test prevents a future refactor from silently
re-propagating _solve_compiled's internal 3-tuple to the default public
return. Verified fail-pre (unconditional 3-tuple -> all 5 fail) /
pass-post.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…value-aware feasibility reads (F5)

_build_value_constraint_machinery / _build_same_period_ref_reader built the
E2 value_constraints predicate evaluators and same_period_refs projection
functions from a dag_pool that included the merged (possibly conflicting)
deterministic next_<state> transitions, but never called
_fail_if_conflicting_transition_is_read on them - unlike the ordinary
utility/feasibility path in _get_U_and_F. A predicate or projection reading a
next_<state> whose law differs across target regimes silently bound one
target's law instead of being rejected. Confirmed via a unit-level repro
against _build_value_constraint_machinery before threading the fix, then
verified fail-pre/pass-post with a full process_regimes() build in the new
regression test.

Threads conflicting_deterministic_transition_names into both builders and
applies the existing guard to each value-constraint predicate and each
same-period-ref projection, mirroring the ordinary-path call site exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… duplicate-fallback + unmatched-role rejection, all-collective dataframe, stateless collective subject axis (F3-F7)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(immutable draw, per-edge target mask) — fixes over-routing + multi-edge order dependence (simulate F2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…leton endpoints), reject nonlinear-CE fold, fix source/target gate-namespace false positive, document nonpersistent-only fold (fold-review F1-F5)

An outside audit proved the interim fold-before-gate guard
(_fail_if_folded_collective_regime_is_gate_target_or_ref) was incomplete:

- F1 (serious): its reference set omitted every gated-edge leg's fallback
  regime, though get_edge_fold reads a fallback nodewise
  (jnp.where(gate, V_target, V_fallback)) before integration exactly like a
  gated-edge target or gate_ref reference.
- F2 (serious): it unconditionally skipped every regime with
  stakeholders=None, though gate-then-integrate does not depend on
  stakeholder count — a folded SINGLETON target/reference is equally unsafe.

Renamed and rebuilt the guard as _fail_if_folded_regime_is_same_period_endpoint:
the reference set is now built from _resolve_gated_edge's complete
reference_regimes (fallbacks + gate_refs) for every declared edge, and the
stakeholders-is-None skip is gone; the dissolution-flag-collapse clause in
the error message is now conditional on stakeholder cardinality.

- F3 (serious, moderate-confidence): the fold reduction always averages
  arithmetically (zero_safe_average), exact only for the linear expectation.
  A non-terminal singleton GridSearch regime could declare fold=True together
  with a nonlinear certainty_equivalent with no guard (collective regimes
  already reject any nonlinear CE unconditionally, so the gap was
  singleton-only). _fold_scope_errors now rejects that combination.
- F4 (moderate): _fold_same_period_roots walked a source regime's OWN
  outbound gated_edges[...].gate / gate_refs as same-period read roots, but
  those functions are compiled and evaluated on the TARGET regime's grid
  (_attach_gated_edge_folds), not the source's — so a source folding a state
  whose name a target-grid gate happened to reuse was falsely rejected.
  Removed that root; the now-complete endpoint guard (F1+F2) covers the
  genuine cross-regime hazard from the target side instead. One existing
  test (test_fold_read_by_a_gated_edge_gate_is_rejected) asserted exactly
  this false positive and was rewritten as a negative control.
- F5 (documentation): the fold field docstring (iid.py) and the fold test
  module docstring now state explicitly that this slice only reduces a
  shock used within the one period that folds it — a genuinely persistent,
  multi-period-redrawn shock is out of scope (already rejected by
  _fail_if_folded_state_persists) until continuation support lands; stop
  implying a multi-period memory saving until then.

Also fixed a docstring typo ("dissolutiond" -> "dissolutioned") in
max_Q_over_a.py.

Added tests/regime_building/test_fold_guard_complete.py: each of F1-F4 is
proven to reproduce against pre-fix code (a failing test) before the
production fix, then re-verified green post-fix, plus "still constructs"
pins for the untouched fold=False topologies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…near-CE reject, gate-namespace false-positive, docs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…on the SIMULATE side too (parity)

Commit 377333d fixed, SOLVE-only, a reachable regime-transition target whose
only state is a target-local `IIDProcess(fold=True)` the source does not carry:
its post-processing transition bundle is empty, so `get_period_targets` dropped
it from the continuation E[V] (reversed policy), and its scalar stored V (folded
axis integrated out) would otherwise make the continuation interpolator demand a
`next_<shock>` coordinate the source can never realise. That commit's message
noted "simulate is byte-unchanged" — but pylcm's simulate RE-OPTIMIZES Q over
the grid, reading continuations exactly as solve does, so the simulate phase
carried the same two-halves bug unpatched.

Half A was in fact already covered on simulate because `_build_Q_and_F_per_period`
enumerates off `solve_transitions` (which carries the solve-side injected empty
bundle). Half B was not: `_build_simulation_phase` passed the UNSTRIPPED
`regime_to_v_interpolation_info` into its Q builder, so the folded-only target
(enumerated via `solve_transitions`) demanded a `next_<shock>` coordinate and the
simulate run crashed (`KeyError: 'next_<shock>'` in Q_and_F.py) / mis-decided.

Two-halves parity, scoped strictly to folded-only targets (mirrors the solve side):
- thread `fold_only_regimes` into `_build_simulation_phase` and pass it to the
  simulate `_process_regime_core` call, so the simulate `core.transitions` gets the
  same explicit empty-bundle injection (parity for `next_state` and the published
  `SimulationPhase.transitions`);
- build a stripped continuation interpolation info via the EXISTING
  `_strip_folded_axes_for_scalar_targets` helper (reused, not duplicated), keyed on
  the `solve_transitions` the simulate Q read actually enumerates, and pass THAT
  into the simulate Q builder; every other simulate consumer keeps the unstripped
  info.

Reproduce-first: a new simulate-path test (`Model.solve` then `simulate`) routing a
per-target action to a folded-only target fails pre-fix with the exact
`KeyError: 'next_bshock'` at Q_and_F.py:293 and passes post-fix (simulated
decision routes to the value-1 folded target, recomputed V = discount). The
source regime carries an inert state so the test isolates the fold path from the
orthogonal, pre-existing stateless-singleton simulate limitation. Fold suite 27
passed; full regime_building 254 passed; simulation suite 107 passed / 2 skipped
/ 1 xfailed. No regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
@hmgaudecker
hmgaudecker changed the base branch from main to feat/continuous-outer July 21, 2026 13:57
hmgaudecker and others added 5 commits July 21, 2026 19:36
…collective solve onto new scaffold)

Merge resolution + collective-feature port produced by the resolution worker;
verification gate (tests + EKL solve) pending. Checkpoint before verifying.
…inuous-outer scaffold

Fix casualties from the 49882d4+85aa9f3 merge and the fork's WIP resolution:
- diagnostics.py: add period_to_regime_v_interp / continuation_grid_signature
  as None-default params (continuous-outer's body referenced them undeclared)
- 35 collective test sites: 3-tuple solve() unpack -> BackwardInductionResult
  attribute access (+ 4 return-solve helpers -> explicit 3-tuple)
- Q_and_F.py: guard_targets uses utility_name (collective utility_<s>), not
  hardcoded 'utility'
- step_core.py: drop fork-hallucinated candidate_savings (no parent defines it,
  refine() cannot consume it)
- lcm/__init__.py: restore AgeSpecialized{Function,Grid} to __all__
- test_processes.py: import Any (used-but-unimported; module-level annotation)
- restore dropped imports (AgeSpecializedGrid/Function, resolve_node)
- noqa C901/PLR0915 on solve/_process_regime_core/simulate (merge-induced)

Verified: 83/83 targeted collective+fold+gated+backward-induction, 68 EGM/FUES,
ruff clean, clean import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…er + claim narrowing

Whole-branch Pro audit (collective-regimes-branch-round1, serious_gap, F1-F5).
Solve-side E1/E2 core certified clean; the five gaps are all on the simulate
side. Four (F1, F3, F5, the linked-row half of F2) are DELIBERATELY-DEFERRED
slices already documented per-function; the audit rediscovered documented
limitations. Fix the one genuine bug (F4), surface the one missing public knob
(F2 own_stakeholder), and narrow the top-level claims to match the honest
per-function docstrings.

F4 (real bug) — gated-edge reference active-period coactivity:
  `_roll_gated_edges` silently kept a stale later-period Wbar when the target
  was solved but a reference regime was inactive, so a source one period earlier
  priced against a continuation folded at a later age (simulate disagreed).
  * processing.py: `_fail_if_gated_edge_references_inactive` rejects, at
    construction, any model where a fallback/gate-ref is inactive on a CONSUMED
    period {t : t in active(target) and t-1 in active(source)}. Narrower than
    Pro's suggested "every target-active period": reproduce-first caught that
    the broad rule wrongly rejects a legitimate repeating self-loop edge at the
    target's earliest active period, whose Wbar is never consumed.
  * backward_induction.py: keep the roll `continue` (now provably an unconsumed
    boundary roll, not a stale-value bug, given the construction guard) with an
    explanatory comment instead of Pro's raise (which broke the self-loop solve).
  * tests: reject-on-consumed, pass-on-coactive, pass-at-unconsumed-boundary.

F2 (partial) — surface `own_stakeholder` on the public `Model.simulate` so a
  collective-simulate caller can pick the row's role instead of silently
  defaulting to the first declared leg; internal `_select_own_leg` already
  validates it. Linked two-row dissolution reallocation stays deferred.

Claim narrowing (F1/F3/F5 + stale docstrings, §J): regime.py collective
  simulation no longer "raises" (E4 synthetic-mode implemented, approximate
  off-grid value-gate router); simulate.py / gated_edges.py drop the stale "D not
  surfaced" language (D is public via solve(return_dissolution_flags=True) and
  Model.simulate's arg); implementation-plan "EXTENSION COMPLETE ... simulate
  EKL's full structure" narrowed to the reduced synthetic-cohort envelope with
  each deferred slice named.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…oper scaffold)

Land the newer continuous-outer features (nb-egm, state-conditioned-shocks,
perceived-stochastic-transitions, age-specialized FUNCTIONS + FUES source-savings)
onto the PROPER consolidation scaffold — the real port of collective solve onto
`BackwardInductionResult`, not the `-X ours` merge on origin/collective-regimes
(which dropped the continuous-outer scaffold, keeping collective's old 3-tuple).

`consolidate-attempt` already contained all collective work (rowsplit/followups/
integration are ancestors) + the F4/F2 fixes; it only lacked feat/continuous-outer's
post-85aa9f3 features. This merge brings them in. Conflicts + semantic casualties
resolved (full suite green: 1051 passed, 3 skipped, 2 xfailed):

- processing.py: kept the collective `stakeholders` guard AND the continuous-outer
  age-specialized continuation params (`period_to_regime_v_interp` /
  `continuation_grid_signature`) on the compute-intermediates call; took feat's
  completed `age_specialized_function_names` computation.
- Duplicate-keyword casualties (my prior manual threading vs feat's native
  threading): de-duped `period_to_regime_v_interp` / `continuation_grid_signature`
  in processing.py (one call + one def) and diagnostics.py (the def).
- step_core.py: restored feat's `candidate_savings` (FUES pristine source-savings)
  — my consolidation had deleted it as a "hallucination" against the OLDER refine
  signature; feat's newer `refine(savings=...)` legitimately consumes it.
- age-specialized-FUNCTION support (2 real casualties, both fixed): the
  collective-port function loop had replaced feat's `_process_one_function` call
  with a bare `_rename_params_to_qnames`, so `AgeSpecializedFunction` markers were
  never wrapped into `_SpecializedEconFunction` and reached `jax.jit(...).lower()`
  as bare `(*args, **kwargs)` wrappers; and the singleton Q build left
  `continuation_functions` unresolved. Both restored; the 3 age-specialized-solve
  tests pass.
- test_nbegm_ride_along_split_compile.py: migrated to the 3-tuple
  `_build_continuation_templates` return (third element = gated-edge Wbar
  templates, empty for the edge-free NBEGM model).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Cascade re-lock step: the pyproject bump to jax>=0.11 upstream was never
re-locked, so the committed pixi.lock carried the stale >=0.9 constraint and
`pixi install --locked` would have drifted in CI. Spec-line only — no resolved
package changed, so the merge regression (1051 passed) still holds. `--locked`
now passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
hmgaudecker and others added 7 commits July 22, 2026 08:55
…tive-regimes

Forward-merge the continuous-outer session's round-3 completion (F2 publish exact
inner consumption, F4 per-subject nested runtime fallback, F5 drop inert
outer/inner_policy_atol) — 3 commits over the 0312908 tip this branch already had.
Clean auto-merge (nested-EGM/continuous-outer internals, orthogonal to collective).

One post-merge nit: reflowed a 1-over-88 return in simulation/simulate.py (the F2
change landed a 89-char line against this branch's context).

Verified: ruff clean; continuous-outer audit regressions + simulate + collective
gated-edge solve — 58 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Pins the previously-uncovered interaction between a carried-only state
(`Phased(solve=impute, simulate=Grid)`) and a repeating self-loop
`GatedEdge` — exactly the EKL `single_f` topology (a simulate-only
uncapped `raw_experience` accumulator in a mutual-consent repeating
regime). No prior test combined a carried state with `gated_edges`.

Verifies: (a) build + solve + simulate succeed with a carried state in a
gated self-loop regime whose ordinary target does NOT declare the carried
state, V byte-identical (zero-coefficient carried read), routing
unchanged; (b) the carried value accumulates through the gate-open
self-loop repeat (20 -> 21), not frozen or reset to the solve imputation.

Documents the one constraint found while writing it: a carried state
rejects a per-target dict law of motion (phases.py), so its law must be
plain (all-target); a plain law then correctly tolerates a non-carrying
ordinary target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
…imes

# Conflicts:
#	pixi.lock
#	src/_lcm/regime_building/processing.py
#	src/_lcm/simulation/simulate.py
…F2 stochastic-weight producers) into collective-regimes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2NuB9k8MFT1HUMxYYY5Z8
… (branch-2 audit F1)

The collective branch of `_build_Q_and_F_per_period` called
`get_Q_and_F_collective` with only the single `(transitions, functions)` pair,
dropping the four arguments the singleton branch threads —
`continuation_functions`, `flow_transitions`, `flow_stochastic_transition_names`,
and `next_state_names`. In the simulate phase those carry the phase split: the
current per-stakeholder flow must be built from the SIMULATE transitions +
simulate pool, while the continuation is priced from the SOLVE transitions +
solve pool. Discarding them made the collective simulator build current flow from
the solve law and resolve continuation helpers against the simulate pool — hybrid
sub-DAGs that are neither phase and can reverse the household argmax (a two-action
example: solve law picks a=0, simulate flow picks a=1, no tie).

`get_Q_and_F_collective` now takes the same four optional arguments and applies
them exactly as `get_Q_and_F`: flow `next_<state>` nodes and the E2
value-constraint machinery resolve against `flow_transitions` +
`flow_stochastic_transition_names`, the per-stakeholder `_get_U_and_F` receives
`next_state_names`, and the continuation helpers resolve against
`continuation_functions`. All four default to `None`/`frozenset()`, so the solve
phase and every existing collective solve are byte-identical — 310 regime_building
tests pass unchanged.

New regression `test_collective_flow_transitions_are_phase_closed.py` mirrors the
singleton `test_flow_transitions_are_phase_closed.py`: a two-period collective
model with a flat continuation and a `Phased` `next_stock` whose solve belief is
the opposite of the simulate truth. The flow-closed simulator chooses `switch`;
the pre-fix collective flow chose `stay` (verified failing before this change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
`test_flow_transitions_are_phase_closed.py` pins that a Phased outer law and a
Phased DAG helper both resolve to the simulate variant in the flow. Nothing
pinned the BOUNDARY of that guarantee, which is the part that gets over-read:
declaring `state_transitions["a"] = Phased(...)` phases slot "a" and nothing
else. A different slot whose law calls the solve helper as an ordinary Python
function is invisible to phase resolution -- there is no DAG node to rewrite --
so it silently keeps solve behaviour in both phases.

That is the defect class `phase-incomplete-state-consumer-closure` found in the
EKL (2019) replication: phasing the `experience` transition left
`_job_offer_probs`, a different transition calling the capped `_next_experience`
directly, computing its offer/retention logits on capped experience during
simulate.

Two cases, so the test documents the trap and its repair: an unphased second
slot DIVERGES from the phased one (asserted, not wished), and phasing every
consumer closes the gap without touching the first declaration. Verified
discriminating: `tag` tracks whichever law it is given ('bad' under the solve
helper, 'good' under the simulate helper) while `stock` independently follows
the simulate law, so the divergence assertion is not vacuous.

Worth having on its own now: the EKL model that motivated it no longer
exercises the phase split on its default path (its experience grid was extended
above the cap), so this is the remaining guard.

312 passed in tests/regime_building.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
The `Run ty (3.14)` CI job fails on this branch with 30 diagnostics against 4 on
the base `feat/continuous-outer`, so unlike the test jobs this one is a
regression THIS branch introduced, not inherited.

Root cause for most of it: `# type: ignore[...]` comments. mypy is not
configured in this repo (no `[tool.mypy]` in pyproject) and `ty` does not honour
mypy's pragma, so those three comments silenced nothing -- one of them,
`Regime(**base)`, accounted for nine diagnostics on its own because `**kwargs:
object` makes every keyword `object`. Converted to the repo's actual convention,
`# ty: ignore[<code>]`.

The rest were real annotation gaps in the audit-hardening tests, fixed rather
than silenced where a true type existed:

- callables annotated `object` (`term_fn`, `reducer`, `transition`, `tag_law`)
  now carry their real `Callable`/`UserFunction`/`MarkovTransition` types --
  `_period0` also accepts the per-target `Mapping` form, which is exactly what
  `Regime.transition` allows;
- `FloatND` parameters fed Python floats now get `jnp.asarray(...)` at the call
  site. Wrapped, not relaxed: `FloatND` rejecting a bare `0.0` is the
  jaxtyping/beartype contract doing its job, and widening the annotation would
  have removed a real check to quiet a linter.

`# ty: ignore` is used only where the value genuinely is a union or an untyped
attribute set at runtime (`arg_provenance`, `.fold`, `.raw_results`).

`prek run ty --all-files` passes. The 86 tests in the touched files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
@hmgaudecker

Copy link
Copy Markdown
Member Author

Head-vs-base failure diff — this branch causes 8 failures, all in tests/test_ndimage.py

collective-regimes @ 5c0b829: 76 failures. feat/continuous-outer @ db00f64: 68.
Caused by this PR: 8. Inherited: 68. Fixed by this PR: 0.

CI could not answer this. main.yml runs pixi run -e tests-cpu tests with no -rf, so
no failure is ever named — the ubuntu job printed 53 F marks and zero test names — and
that job additionally dies at ~96% with "the runner has received a shutdown signal". So
this was measured locally, chunked, one fresh process per chunk.

Caused by this PR — 8, and they are not cosmetic

All eight are tests/test_ndimage.py, which this PR does not modify. It modifies
src/_lcm/regime_building/ndimage.py, swapping

weighted_value = _multiply_all(weights) * contribution
# for
weighted_value = zero_safe_weighted_term(weight_product, contribution)

Verified directly rather than inferred from the logs — same file, same command, two trees:

tree result
feat/continuous-outer @ db00f64 30 passed
collective-regimes @ 5c0b829 8 failed, 22 passed

There are two distinct causes.

(a) map_coordinates now returns a WRONG GRADIENT at every on-node coordinate — 1 test.

zero_safe_weighted_term(w, v) is w * where(w == 0, 0, v). Masking the value with a
hard select is sound when w is a constant — a Pareto weight, a regime-transition
probability, a quadrature weight. An interpolation corner weight is not constant: it
is a differentiable function of the very coordinate being differentiated. At an
exactly-integer coordinate one corner weight is exactly 0, where zeroes that corner's
value, and the term becomes w * 0 — whose derivative is w'(c) * 0 = 0 instead of
w'(c) * v. The gradient loses precisely the corner whose weight is changing.

Minimal witness, grid[i] = i² so the correct slope on [i, i+1] is grid[i+1] - grid[i]:

  c=1.0   value=1.000   grad=-1.000   on_node=True     (correct: 3.0)
  c=1.5   value=2.500   grad= 3.000   on_node=False    correct
  c=2.0   value=4.000   grad=-4.000   on_node=True     (correct: 5.0)
  c=2.5   value=6.500   grad= 5.000   on_node=False    correct
  c=3.0   value=9.000   grad=-9.000   on_node=True     (correct: 7.0)

  d/dw [zero_safe(w, 7.0)] at w=0.00 -> 0.0   (should be 7.0)  <-- the hard select
  d/dw [zero_safe(w, 7.0)] at w=0.25 -> 7.0
  d/dw [zero_safe(w, 7.0)] at w=1.00 -> 7.0

Off-node gradients are exact; on-node ones come back as -grid[c], i.e. only the
surviving corner's -1 * v_lo term. Values are unaffected — this is gradient-only, which
is exactly what makes it easy to miss. test_gradients[map_coordinates] catches it:
-10.0 where 2.0 is expected.

(b) The helper rejects integer weights — 7 tests.

BeartypeCallHintParamViolation: parameter weight="JitTracer(int32[7])"
violates type hint FloatND | ScalarFloat | float

The previous expression was a bare multiply with nothing to violate. All seven are the
int32 parametrization of test_map_coordinates_against_scipy /
test_map_coordinates_round_half_against_scipy; the float variants pass.

Suggested direction

(b) is an annotation/dtype-coverage fix. (a) is the substantive one and I do not think it
should be fixed by loosening the test: the zero-safe contract as written is a statement
about values, and map_coordinates needs it to hold for derivatives too. Either
map_coordinates keeps the plain multiply (its corner weights are never ±inf-valued,
so it may not need zero-safety at all), or zero_safe_weighted_term needs a
custom-JVP/stop_gradient-free formulation that preserves ∂/∂w at w == 0. Worth
checking whether any other caller passes a weight that is differentiable in the argument
of interest.

Inherited from the base — 68

Unchanged from db00f64; none of these are this PR's doing.

file n
tests/test_solution_on_toy_model_stochastic.py 24
tests/test_solution_on_toy_model_deterministic.py 8
tests/simulation/test_subject_batching.py 7
tests/solution/test_n_nbegm.py 6
tests/solution/test_n_nbegm_finite_baseline.py 6
tests/test_processes.py 5
tests/solution/test_negm_bequest.py 3
tests/solution/test_negm_serviceflow.py 3
tests/solution/test_egm_carry.py 1
tests/solution/test_egm_discrete.py 1
tests/solution/test_n_nbegm_continuous.py 1
tests/solution/test_n_nbegm_epstein_zin.py 1
tests/solution/test_nbegm_distributed_carry_sharding.py 1
tests/test_regression_test.py 1

The seven test_subject_batching.py failures are worth calling out: they sit in
tests/simulation, which this PR modifies heavily, so they were my prior candidate for a
genuine regression. They fail identically on the base.

Method, and what is NOT covered

One process per chunk (peak RSS resets between them); -q -n 2 --tb=no -rf, with
### EXIT <chunk> = $? recorded after each so a chunk killed by the memory ceiling shows
up as unmeasured rather than silently counted as clean.

The four tests/test_mahler_yum_*.py files are UNMEASURED on both sides — that
chunk exited 143 (SIGTERM from the memory cap) on head and base, even at -n 1.
Symmetric, so it is unlikely to hide an asymmetry, but I have not measured it and am
not claiming it is clean.

The base was measured in a fresh detached worktree at db00f64, not in the shared
one: that worktree had uncommitted work in src/_lcm/egm/upper_envelope/query.py, the
same area as most of the inherited failures, so measuring there would have compared this
PR against someone's work in progress. _lcm.__file__ was checked to resolve inside each
tree, since a linked pixi env can otherwise resolve the package to a different branch.

hmgaudecker and others added 2 commits July 28, 2026 10:20
Fixes the 8 `tests/test_ndimage.py` failures this branch introduced -- the only
failures it adds over its base. The other 68 fail identically on
`feat/continuous-outer` @ db00f64.

The helper neutralizes `0 * +-inf` by masking the VALUE before the multiply.
Masking on EVERY zero-weight node also kills the gradient there: `jnp.where` is
a hard select, so the branch taken at `w == 0` is a constant and `d/dw` is `0`
rather than `value`.

That is invisible while the weight is a CONSTANT of the differentiation -- a
Pareto weight, a regime-transition probability, a quadrature weight, i.e. every
call site this module was written for. It is wrong as soon as the weight is
itself a function of the argument being differentiated. Applying it to
`map_coordinates` (this branch, `ndimage.py`) did exactly that: an interpolation
corner weight IS a function of the coordinate, an exactly-on-node coordinate
makes one corner weight exactly 0, and masking there dropped precisely the
corner whose weight was changing. `jax.grad` returned `-grid[c]` instead of the
segment slope at every on-node coordinate. With `grid[i] = i**2`:

    c=1.0  grad=-1.0  (correct 3.0)   c=1.5  grad=3.0  correct
    c=2.0  grad=-4.0  (correct 5.0)   c=2.5  grad=5.0  correct

The VALUES were correct throughout, which is why nothing but `test_gradients`
could see it.

The mask is now restricted to NON-FINITE values, which costs nothing: for finite
`v`, `0 * v == 0` whether masked or not, so every value is bit-identical to the
previous form -- verified over 10,000 random cells including zero-weight slots
and `-inf` parked on each of them. Only the genuine `0 * +-inf` case still
selects, and it has no finite derivative to preserve. The select stays on an
OPERAND of the multiply, so the FMA-contractibility argument in the module
docstring is unchanged.

Also widens the annotations: parameters and return were `FloatND`-only, so an
integer weight product raised `BeartypeCallHintParamViolation` and then
`...ReturnViolation`. `map_coordinates` accepts `FloatND | IntND`, and the bare
multiply this replaced had no annotation to violate. 7 of the 8 failures were
this; 1 was the gradient.

New `tests/regime_building/test_zero_safe_gradients.py` pins all three legs:
zero-mass safety survives, `d/dw == value` at `w == 0`, and no value moves.

Verified: `tests/test_ndimage.py` 30 passed, matching base (was 8 failed / 22
passed); `tests/regime_building` + ndimage 353 passed; `tests/simulation` +
`tests/egm` unchanged at the same 7 inherited `test_subject_batching` failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Second instance of the class the branch-2 audit's F1 repaired, found by
comparing each `X` / `X_collective` builder pair rather than by chasing the
original witness. There are exactly two such pairs in `src/_lcm`:
`get_Q_and_F` / `get_Q_and_F_collective` (F1, repaired in 5826283) and
`get_Q_and_F_terminal` / `get_Q_and_F_terminal_collective` -- this one.

`get_Q_and_F_terminal` takes `next_state_names` and both call sites in
`processing.py` thread it; the collective twin had no such parameter at all,
so the omission was silent by construction. That argument reaches
`_fail_if_unproduced_next_state_is_read`, whose test is
`read_names & next_state_names`; intersected with the default `frozenset()`
the guard can never fire on the collective path.

It is reachable: `_declared_next_state_names` lists `next_<name>` for every
gridded state, and the terminal builders supply no `deterministic_transitions`,
so ANY `next_<state>` read by a terminal utility is unproduced by construction.
Same two-regime model, run both ways before the fix:

    SINGLETON : ValueError: Within-period utility or feasibility reads the next
                value of state(s) (next_stock), but this phase's flow has no
                producer for them. ...
    COLLECTIVE: ValueError: Expected arguments: [...], missing: {'next_stock'}
                (from func_with_only_kwargs)

i.e. the collective path produced precisely the cryptic late failure the guard
exists to convert into an early, named one.

Severity is DIAGNOSTIC, not numerical, and that was checked rather than assumed:
every builder taking `next_state_names` forwards it to `_get_U_and_F`, which
uses it ONLY for the guard call -- it takes no part in graph construction, so a
missing value cannot change a computed number. Note the corollary for F1 itself:
of the four arguments that repair restored, `flow_transitions` and
`continuation_functions` are the numerically load-bearing ones and
`next_state_names` was the diagnostic member of the set. This is that member,
still dropped one function away.

New `test_terminal_collective_next_state_guard.py` asserts the property for BOTH
twins in one parametrization. Verified discriminating: on the unfixed source the
`[collective]` case fails with DID NOT RAISE while `[singleton]` passes; with the
fix both pass.

`tests/regime_building` + ndimage: 353 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
@hmgaudecker

Copy link
Copy Markdown
Member Author

Fixed — 27bc15f, and the failure set is now identical to the base

All 8 regressions from the diff above are closed. tests/test_ndimage.py is back to
30 passed, matching feat/continuous-outer @ db00f64.

The gradient one. zero_safe_weighted_term now masks only where the value is
non-finite, instead of on every zero-weight node. That keeps the property the helper
exists for while restoring ∂/∂w at w == 0:

  • zero-mass safety unchanged — zs(0, ±inf) and zs(0, nan) are still exactly 0.0,
    and zs(1, -inf) is still -inf;
  • d/dw [zs(w, 7.0)] == 7.0 at w = 0, 0.25 and 1.0 alike (it was 0.0 at w = 0);
  • values are bit-identical to the previous form — for finite v, 0 * v == 0
    whether masked or not. Checked over 10,000 random cells including zero-weight slots
    with -inf on each of them, and then at suite scale: tests/solution returns the
    same 23 failures, node for node, as before the change. So this is gradient-only
    and carries no numerical risk to the EGM family.

The select still sits on an operand of the multiply, so the FMA-contractibility
argument in the module docstring is unaffected (docstring updated to say why the mask is
restricted).

The 7 annotation ones. Parameters and return were FloatND-only; map_coordinates
accepts FloatND | IntND, so an integer weight product raised
BeartypeCallHintParamViolation and then ...ReturnViolation. Widened.

New tests/regime_building/test_zero_safe_gradients.py pins all three legs — safety,
derivative, value-invariance — plus the map_coordinates-on-a-quadratic case that
started this.

Also in this push — 9f75cc6

Comparing every X / X_collective builder pair (there are exactly two in src/_lcm)
turned up a second instance of the class the branch-2 audit's F1 repaired:
get_Q_and_F_terminal_collective had no next_state_names parameter while its singleton
twin does and both call sites thread it. That argument feeds
_fail_if_unproduced_next_state_is_read, whose test is read_names & next_state_names,
so against the default frozenset() the guard could never fire on the collective path —
the model failed later with a bare missing-argument error instead of the named one.

Severity is diagnostic, not numerical, and I checked that rather than assuming it:
next_state_names reaches _get_U_and_F and is used only for the guard call, taking no
part in graph construction. Worth noting the corollary for F1 itself — of the four
arguments that repair restored, flow_transitions and continuation_functions are the
load-bearing ones and next_state_names was the diagnostic member; this is that member,
still dropped one function away.

Current state of this branch vs its base

head 9f75cc6 base db00f64
failures 68 68
caused by this PR 0

Same caveat as before: the four tests/test_mahler_yum_*.py files remain unmeasured on
both sides
(that chunk hits the memory ceiling even at -n 1), so this is "no
difference in what was measured", not "clean everywhere".

hmgaudecker and others added 2 commits July 28, 2026 16:14
… (r3 H2)

Round-3 closure audit returned CLEAN/CLOSED with two nonblocking notes. H2 is a
correction to a claim I made, so it is fixed here rather than deferred.

I documented the restricted mask as leaving values "bit-identical" to the
unrestricted form. It does not. At `w = +0` with a NEGATIVE finite value the mask
no longer fires, so the product carries the sign of the value: `-0.0` where the
old form gave `+0.0`.

    weight = +0.0, value = -7.0
      restricted   -> -0.0   (signbit True)
      unrestricted -> +0.0   (signbit False)
      array_equal  -> True
      bytes equal  -> False

Nothing downstream can observe it: the two compare equal, have the same
derivative, and any reduction consuming the term is byte-for-byte unchanged
(`sum([-0.0, 3.0])` and `sum([+0.0, 3.0])` agree exactly). So the code is right
and the wording was wrong -- "numerically equal" throughout, with the exception
stated explicitly.

The reason this got past me is worth recording, because it is the same shape as
a jax-import blocker I wrote today that used a Python 3.14-removed API and so
reported the opposite of the truth: **the check could not observe what it
asserted**. `jnp.array_equal` treats `+0.0 == -0.0` as True, so the assertion
named "bit_identical" was structurally incapable of detecting a sign-of-zero
difference. If you claim bit identity, compare BITS -- `np.signbit`,
`.tobytes()` -- not values.

The test is renamed to `..._are_numerically_equal_...` and a new
`test_the_equality_above_is_numerical_and_not_bitwise_at_signed_zero` pins the
exception with an actual byte comparison, so the stronger wording cannot creep
back unchecked.

Verified independently before accepting H2, and the reviewer's own artifacts were
then run against this tree: MT1 22/22 mutations rejected with the production
invariant passing, RT2 all six gradient/safety dimensions passing (including the
transition-probability and dynamic-average gradients I had only classified by
inspection), RT3 reproducing the signed-zero delta, OR1 reproducing all three
action reversals.

tests/regime_building/test_zero_safe_gradients.py + tests/test_ndimage.py:
40 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Round-2 F1 was one collective builder dropping the four arguments that carry the
solve/simulate phase split. The witness was repaired -- and the same class then
turned up one function away, in `get_Q_and_F_terminal_collective`. A signature
that DEFAULTS a phase argument makes its omission silent by construction, so
per-site repair keeps losing to the next collective twin.

The round-3 audit did not require the fail-closed redesign for closure; it asked
that its mutation suite be kept enforced instead. This adopts it as a project
test: the source is parsed and every `get_X_collective` twin must expose, and
correctly route, every phase-role argument its `get_X` twin exposes. A new twin
fails the test until it threads them, without anyone having to remember the rule.

22 mutations cover the counterexample class -- drop the argument at the
signature, drop it at the dispatch, pair a role with the wrong pool, drop the
diagnostic vocabulary, skip age specialization, hide a builder in a
stakeholder-only branch -- and all 22 are rejected.

Checked against the real history, not only synthetic mutations: the checker
rejects `f0f7173` (F1's own tree, where the collective signature exposed NONE of
the four and the dispatch passed none) and `27bc15f` (terminal twin still
missing `next_state_names`), and accepts the head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
@hmgaudecker

Copy link
Copy Markdown
Member Author

@/tmp/claude-1000/-home-hmg-econ-dev-pylcm-lcm-replications-src-lcm-reps-ecksteinCareerFamilyDecisions2019/da153776-cb2a-4e8a-9fc3-5b37c71b1f06/scratchpad/pr409-r3.md

hmgaudecker and others added 2 commits July 28, 2026 21:15
`trim_pad_from_raw_results` enumerated the fields it knew about -- `V_arr`,
`actions`, `states`, `in_regime` -- which is a second, silent copy of
`PeriodRegimeSimulationData`'s definition. It went stale: `nested_policy_fallback`
was added to the structure without being added to the list, so `dataclasses.replace`
carried the untrimmed field straight through. With 7 subjects at batch size 2 the
leaf came out 24 rows against a 21-row `_in_regime` mask and `to_dataframe` died in
`column[mask]` with an IndexError.

Attribution, measured in fresh detached worktrees rather than assumed:

  origin/main            eeb35f5  10 passed
  feat/continuous-outer  d83e75f   7 failed, 3 passed
  collective-regimes     d82a490   7 failed  (pre-merge; inherited)

So the regression lives on feat/continuous-outer and #409 merely inherits it, and
tests/simulation/test_subject_batching.py is byte-identical to main's -- an unchanged
test catching a production change, not a test that drifted.

The fix reads the field list off the dataclass, so the next field added cannot
reintroduce the class. The regression test is written the same way: it builds and
measures every field generically, guards the mapping-vs-array assumption it makes so
it cannot go stale silently, and asserts it inspected something -- a coverage check
that quietly examines nothing would otherwise pass forever. Verified discriminating by
re-introducing the exact enumeration: the new test and all 7 original failures come
back, and both go green again on the fix.

tests/simulation + tests/regime_building: 476 passed, 2 skipped (was 463 passed,
7 failed). ruff, ruff-format and ty all clean.

This belongs upstream on feat/continuous-outer; it is landed here first because #409
is red on it. The contouter worktree is in active use by another session, so the
cascade is theirs to take -- this merge becomes a no-op once it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
hmgaudecker added a commit that referenced this pull request Jul 28, 2026
`trim_pad_from_raw_results` enumerated the fields it knew about -- `V_arr`,
`actions`, `states`, `in_regime` -- which is a second, silent copy of
`PeriodRegimeSimulationData`'s definition. It went stale: `nested_policy_fallback`
was added to the structure without being added to the list, so `dataclasses.replace`
carried the untrimmed field straight through. With 7 subjects at batch size 2 the
leaf came out 24 rows against a 21-row `_in_regime` mask and `to_dataframe` died in
`column[mask]` with an IndexError.

Attribution, measured in fresh detached worktrees rather than assumed:

  origin/main            eeb35f5  10 passed
  feat/continuous-outer  d83e75f   7 failed, 3 passed
  collective-regimes     d82a490   7 failed  (pre-merge; inherited)

So the regression lives on feat/continuous-outer and #409 merely inherits it, and
tests/simulation/test_subject_batching.py is byte-identical to main's -- an unchanged
test catching a production change, not a test that drifted.

The fix reads the field list off the dataclass, so the next field added cannot
reintroduce the class. The regression test is written the same way: it builds and
measures every field generically, guards the mapping-vs-array assumption it makes so
it cannot go stale silently, and asserts it inspected something -- a coverage check
that quietly examines nothing would otherwise pass forever. Verified discriminating by
re-introducing the exact enumeration: the new test and all 7 original failures come
back, and both go green again on the fix.

tests/simulation + tests/regime_building: 476 passed, 2 skipped (was 463 passed,
7 failed). ruff, ruff-format and ty all clean.

This belongs upstream on feat/continuous-outer; it is landed here first because #409
is red on it. The contouter worktree is in active use by another session, so the
cascade is theirs to take -- this merge becomes a no-op once it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
(cherry picked from commit 7916291)
hmgaudecker and others added 2 commits July 29, 2026 04:07
Absorbs upstream #407, which itself carries the #406 perceived-stochastic-
transitions merge (c0699e4). The substantive upstream change for this branch is
the age-normalization refactor: `_resolve_age_specialized_state_grids` became
`normalize_age_specialization` + `grid_schedule`, `resolve_specialized_nodes(x,
age)` became `resolve_periodized_nodes(x, representative_period)`, and
`_grid_identity` / `_node_fingerprint` / `_continuation_grid_signature` are gone.

Conflicts resolved by provenance, not by side-taking:

* `regime_building/processing.py` (8 conflicts) — took upstream's normalization
  pipeline wholesale; kept `regime_to_v_interpolation_info_for_Q`, the collective
  E1 `compute_intermediates` guard, the `get_Q_and_F_collective` branch and the
  four gated-edge fold helpers. The collective branch is now ported to
  `resolve_periodized_nodes(..., representative_period)` so it resolves functions
  and constraints exactly as the singleton branch does.
* `regime_building/Q_and_F.py` — upstream widened
  `QAndFFunction.next_regime_to_V_arr` from `FloatND` to
  `MappingProxyType[RegimeName, FloatND]`. Auto-merge updated the singleton
  builders only; the two collective twins (lines 735, 1285) were left behind and
  are fixed here. `ty` caught this, not the test suite.
* `tests/regime_building/test_collective_phase_role_class_invariant.py` — the
  test pinned the literal `resolve_specialized_nodes(continuation_functions,
  age)`, which upstream renamed. Production was correct; the assertion is
  re-expressed through a derived `_resolver_name()` helper so it tracks the
  resolver rather than its spelling.

Attribution against the c0699e4 baseline (fresh worktree, PYTHONPATH override,
`_lcm.__file__` asserted, same pixi env):

* `tests/solution` — 1212 passed, 3 skipped, 1 xfailed, 0 failed.
* rest of `tests` — 38 failed + 7 errors, an EXACTLY identical set at the
  baseline (`comm` empty in both directions). All 38 are one upstream defect:
  d0d655c added the `nested_policy_fallback` simulation column without
  regenerating the pickled fixtures, so every failure is a one-column DataFrame
  shape mismatch. The 7 errors are the separate AdaptiveOuterMesh OOM.

No new failures are introduced by this merge.

Note for anyone attributing failures on this stack: `beartype_package()` caches
INSTRUMENTED bytecode as `__pycache__/*.opt-beartype<ver>.pyc`, keyed on the
beartype version and not on the conf. A merge can leave a `.pyc` compiled under a
different conf — `is_pep484_tower` off — which is NEWER than its source, so no
mtime check catches it. That produced 120 phantom `tests/solution` failures here
(`int 0 not instance of float` in code byte-identical to the passing baseline).
Clear `src/**/__pycache__` before believing any such result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Three E501s that pre-commit.ci found and my local run did not: I ran prek
scoped to the files in the merge commit, and this file was not among them
even though it is mine (f07ace8). --all-files is now clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4Vd9xVvBDhAzoxqGoriPn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant