Collective / gated regimes + edge-fold of IID shocks - #409
Conversation
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
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
for more information, see https://pre-commit.ci
Documentation build overview
45 files changed ·
|
…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
…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
c2d8650 to
563d80b
Compare
…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
Head-vs-base failure diff — this branch causes 8 failures, all in
|
| 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_*.pyfiles are UNMEASURED on both sides — that
chunk exited143(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.
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
Fixed —
|
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".
… (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
|
@/tmp/claude-1000/-home-hmg-econ-dev-pylcm-lcm-replications-src-lcm-reps-ecksteinCareerFamilyDecisions2019/da153776-cb2a-4e8a-9fc3-5b37c71b1f06/scratchpad/pr409-r3.md |
`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
`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)
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
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
collective-regimes-draft, 211 commits): collectiveregime primitives, gated edges, same-period references, per-stakeholder value
read-out, dissolution routing, simulate support.
math/code review, across four interleaved tracks — fold, gated-edge /
simulate, collective / dissolution, and zero-safe extended-real
arithmetic. All
serious/fatalfindings resolved; tip11d0c38.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
94b9fdais folded intothis branch.
3a7d8f5isfolded into this branch.
Both previously targeted
collective-regimes-draft.Base branch and merge order
This PR targets
feat/continuous-outer, notmain(an earlier revision of thisdescription said
main; that was wrong). It is the top of a six-deep stack, so itcannot merge until everything below it does:
feat/continuous-outeris 709 commits ahead ofmain; this PR's own contribution isthe 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.pyandsrc/_lcm/solution/grid_search.py, and neither file exists onmainor onfeat/age-specialized— both are introduced byfeat/dcegm(#390). 12 of the 26src/files in this PR's own diff also fail to apply ontomain. The coupling runsthrough 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-outerfails 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 thisbranch against 4 on the base. The bulk came from
# type: ignore[...]comments:mypy is not configured in this repo and
tydoes not honour mypy's pragma, so thosesilenced nothing —
Regime(**base)alone accounted for nine, since**kwargs: objectmakes 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
objectnow carry realCallable/UserFunction/MarkovTransitiontypes, andFloatNDparameters fed barePython floats are now wrapped in
jnp.asarray(...)at the call site — widening thoseannotations instead would have deleted a real jaxtyping/beartype check to quiet a
linter.
tyis now clean, which the base branch is not.