Skip to content

Stochastics re-grounding (forecasts-only) + 4.0.5 integration - #349

Draft
jkiviluo wants to merge 84 commits into
mainfrom
feat/stochastics-reserve-v74
Draft

jkiviluo wants to merge 84 commits into
mainfrom
feat/stochastics-reserve-v74

Conversation

@jkiviluo

Copy link
Copy Markdown
Member

Re-grounds the rolling/stochastic model to the forecasts-only semantics and integrates main (4.0.5 / #346).

Stochastics re-grounding

  • The stochastic fan is forecasts only; "realized" is an optional designation, never a weighted fan member.
  • Non-anticipativity ties forecasts to each other (deterministic reference forecast), not to a realized anchor.
  • Continuity is temporal carry-in (root ← history; each branch-start ← parent's last step); the reference forecast owns the real period names when there is no realized designation.
  • Branch weighting is forecasts-only (phantom self-row contributes 0); the reported cost is the probability-weighted expected cost (post-process now applies the branch weight).
  • Investment is a single here-and-now decision by default; recourse (per-branch wait-and-see) stays an explicit opt-in. Validation, fixtures, and EVPI/VSS/RP hand-calcs re-derived to genuine forecast fans.
  • Rolling handoff carries the committed jump-end storage state of the realized trunk (was the discarded look-ahead horizon-end).

Integration

  • Merges origin/main (Indirect-unit capacity, input shares, per-unit min load and reserve fixes #346 indirect-unit capacity / input shares / per-unit min load / reserve fixes, on top of the 4.0.5 release).
  • Schema version collision resolved: main's v70 kept; the stochastics migrations renumbered 71–76 (FLEXTOOL_DB_VERSION=76). p_min_load composed (per-unit semantics + (d,t) capability); reserve-slack penalty combines shortfall_scale with branch weighting.

Full local suite green (4145 passed). Opened as a draft to exercise CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c

jkiviluo and others added 30 commits September 22, 2026 16:01
…pd_non_anticipativity)

Two always-on derived frames in _derived_branch.py, consumed by
nothing (recourse plan §6b Slice A; design
specs/sliceA_lineage_frames_design.md r3):

- dd_same_scenario (d, d_other): symmetric/reflexive pairs sharing a
  scenario leaf-path; domain = period_in_use only; all-pairs
  degenerate content for deterministic solves; leaf rule with the F6
  authority convention (branch-local member displaces the anchor).
- pd_non_anticipativity (d, b): period-level invest-NA pairs
  (strictly-pre-reveal anchor ties), parameterized on an optional
  branch_start frame (Slice E hook); provably empty for every
  currently-constructible solve under the min-anchor fallback.

Wiring: unconditional assignment in apply_branch_cluster (step 2b),
guarded-ctx input acquisition per the dtttdt_from_source null-guard
precedent, ValueError failure semantics on inconsistent per-solve
artefacts, Utf8-construct -> non-strict cast_dim -> live null
assertion -> deterministic Utf8-key sort.  FlexData registration
(two typed fields, None only pre-cascade), d_other axis synonym.
No consumers, no schema change, no migration, no autoscale entries
(nothing enters the LP), no dump-side change (_copy_meta already
glob-copies the rebuild inputs).

Tests: T1-T12 per design §4/§5 (17 tests) — five exact shape tables,
both mid-horizon shapes incl. the branch_start-driven [(p1, p1_low)]
pair, symmetry property, metadata exclusion, failure semantics,
_drop_master_rows pass-through, integration + enum hygiene on the
stoch_two_period fixture, and the first stochastic dump/reload
round-trip (pins the _copy_meta glob).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Slice B of the recourse-invest plan (specs/sliceB_walker_lineage_design.md).
Add a keyword-only lineage=pl.DataFrame|None=None parameter to the shared
period_walk_iterator, applied PRE-aggregation under the Slice A semi-join
contract (remove (e,d,d_all) iff both ends in period_in_use and (d,d_all)
not in lineage; history-anchored rows pass unconditionally).

- _derived_walks: _apply_lineage_filter (anchor_col/dall_col params,
  fixed-direction lineage-side non-strict cast, mandatory .unique(),
  marker left-join) + _assert_lineage_castable null guard; zero-cost
  None path behind a single if.
- Thread lineage=None through inventory #1-17 (cohort/variant/from_source
  chain in _derived_existing, _derived_npv, _derived_params) and apply
  the shared helper in the one non-walker pair-former #18
  edd_divest_active_from_source (anchor_col="d_divest", dall_col="d").
- _derived_branch: check_recourse_npv_preconditions +
  assert_recourse_npv_preconditions — the three named NPV proof
  obligations (leaf-weight constancy, cohort sum-to-1, byte-equal
  fan-member year rows), shipped as a pure checker with the F4
  soundness-scope docstring; added to __all__.

Inert: every production call passes lineage=None; no flag, no consumer
activation, no schema change, no autoscale entries, no LP change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Slice B test suite (specs/sliceB_walker_lineage_design.md §9).

Capability harness (solver-free, hand-built frames + stub source):
- W1 set-shape (history passes, cross-leaf both-ends-PIU pairs removed)
- W2 factor-side PRE-aggregation proof (sums 7.0/3.0/4.0 vs unfiltered
  7.0 — a post-aggregation filter cannot produce 3.0/4.0)
- W3 lineage=None 12-row unfiltered pin
- W4/W4b empty-frame contract (set: 4 history rows; factor: single
  (h2020,7.0) row, anchors absent not zeroed)
- W5a duplicate-pair .unique() guard; W5b uncastable-token ValueError;
  W5c Enum x Enum missing-token raises / all-present identical

Stochastic fixture pins (solver-required fixture, solver-free asserts):
- W6 edd_invest_set today-bug witness ((wind,p2040,p2040_low) absent =
  erratum E1 first-stage-visibility break)
- W7 edd/divest non-vacuity + repeat-determinism; NPV variant vacuity
  pins + invest-method overlay leg
- P1/P2 obligation checker clean on stoch/deterministic workdirs
- P3a-d hand-seeded obligation violations (i)/(ii)/(iii)
- P4 walker year-source blindness diagnostic (erratum E1 witness pair)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
The Slice B NPV obligation checker's year-row reader
(`_read_solve_data_csv`) fell back to a bare `pl.read_csv(...)` on the
workdir CSV when the Provider didn't carry the frame. That disk arm
tripped the post-Step-2 cascade invariant
(test_meta_provider_invariants::test_no_disk_csv_reads_in_cascade,
Rule 1) — the seed-funnel bug class the invariant guards.

Make the reader Provider-only (`_provider_get` -> `provider.get`),
matching every other reader in `_derived_branch.py`. The checker now
does no disk I/O; the caller owns placing the true emitted year CSVs
on the Provider (tests seed via seed_provider_from_dir / _mk_provider;
the Slice D activation site inherits the same solve_data frames the
rest of the cascade already consumes). Obligation (iii) semantics
preserved. SOUNDNESS SCOPE docstring + design (local) sec 6.2 updated
with the deviation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Add the opt-in solve.stochastic_invest_method enum (value list
stochastic_invest_methods: none/recourse, default none) via migration
v70.  'none' is byte-identical to prior behaviour; 'recourse' is
reserved for Slice D and hard-rejected by the Slice C guards.

- FLEXTOOL_DB_VERSION 69 -> 70; _migrate_v70_stochastic_invest_method
  mirrors the v64 solve.scaling exemplar (value list + definition +
  guarded solve_advanced grouping).
- PARAMETER_TYPES row ('stochastic_invest_method','solve') DIMENSIONLESS.
- _solve_config read/store + stochastic_invest_method_for resolver
  (absent -> 'none').
- Regenerated spinedb_schema.json master template + the two managed
  canonical DBs (templates_examples, howto_stochastics).
- export_settings.yaml split_params: surface the new solve knob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Three flag-independent / flag-keyed validation guards that keep the
recourse opt-in inert until Slice D lands.

- Guard 1 (flag hard-reject): stochastic_invest_method != none raises
  FlexToolConfigError at the top of _orchestration.run() (authoritative,
  covers rolling/contained solves via the roll-suffix strip) plus a
  fail-fast pass in _validate_model_solve over the top-level solves.
- Guard 2 (Benders x genuine stochastics): _is_genuinely_stochastic
  raises in _run_benders_solve (primary) and at the top of solve_benders
  (defensive).  Detector = period_branch_full d!=b b-tokens intersected
  with period_in_use_set, so the rolling realized-only bookkeeping shape
  does NOT false-positive.
- Guard 3 (handoff synthetic-name assertion): build_handoff_from_solution
  rejects any realized_invest / committed period that is a synthetic
  branch fan member; None-degrades when flex_data is unavailable.

New shared cycle-free module _stochastic_detect.py holds the single
token-extraction idiom (synthetic_branch_tokens) used by Guards 2 and 3
and the PIU-intersected is_genuinely_stochastic used by Guard 2.  Both
cast d/b to Utf8 before comparing — at runtime period_branch_full is
Enum-typed with disjoint d/b category sets, and comparing two enums
raises in polars (would otherwise crash Guard 3 on every stochastic
handoff); null b (deterministic shape) is dropped.

Empirically verified on stoch_two_period: genuine stochastic -> True
(the _rlz metadata members are excluded by the PIU intersection, the
_low branches survive); deterministic + rolling -> False.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
- test_v70_stochastic_invest_method.py (M1-M3): migration round-trip
  (definition present, default none, bound to stochastic_invest_methods,
  grouped solve_advanced; value list {none,recourse}); pre-v70 -> v70
  reach + idempotent re-run; master-template --verify up to date.
- test_recourse_guards.py (G1, G2a-c, G3): resolver normalisation +
  _validate_model_solve reject/pass + rolling-suffix-strip composition
  (G1); PIU-intersected detector on genuine/deterministic/self-row/
  rolling-realized-only shapes incl. the Enum disjoint-category
  regression pin and solve_benders defensive raise (G2); solver-based
  handoff clean-path / config-level synthetic raise / None-degrade (G3).

Detector shapes mirror the empirically verified stoch_two_period
runtime frames (Enum-typed d/b, PIU excludes _rlz metadata members).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Emit solve_data/stochastic_invest_method.csv (resolved per-solve mode)
next to the realized-invest emit; _recourse_invest_active reader
(provider-first, workdir fallback, absent -> False); FlexData.recourse_invest
bool stamped in apply_derived_c; _has_branch_invest_axis capability conjunct
reusing is_genuinely_stochastic. Dump-registry + accumulator-manifest lines
so a dump/reload round-trip preserves the flag. Flag defaults none -> no
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Define LineageFilterError(ValueError) in _solve_state; the two Slice B
lineage guards (_assert_lineage_castable, assert_recourse_npv_preconditions)
raise it. Narrow the four blanket except-Exception swallows in
apply_derived_c / apply_synthetic_invest_sets (edd lookback / edd_invest /
edd_divest_active / synthetic edd) to re-raise LineageFilterError so a
lineage failure is loud instead of silently reverting to unfiltered
first-stage sets. Raise path is unreachable while lineage is None
(flag-off byte-parity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
(A) _expand_invest_branch_periods — invest-side mirror of the dispatch
_expand_branch_periods; wraps the 5 axis-defining _solve_periods(invest_periods)
sites (ed_invest_set, ed_divest_set, ed_invest_forbidden, eager
ed_entity_annual_family, apply_npv) so period_invest gains {p_k, p_k_b}
under recourse. ctx/provider threaded through those readers + their
apply_derived_c / apply_synthetic callers. period_first + PIU-fallback
sites left unwrapped (design §3.1).

(B) _anchor_expand_explicit + module-scoped anchor-pairs holder set at each
Layer-4 boundary (apply_derived_c / apply_npv / apply_synthetic): explicit
per-period rows (invest_cost/discount/lifetime/fixed_cost Maps + per-period
caps) inherit their anchor's value onto branch periods, wired into the three
producers (_resolve_per_period_lf, _per_entity_period_cost,
_ed_explicit_period_param). Holder None flag-off/deterministic -> no-op ->
byte-parity.

Flag-off spot-suites (fanout, npv_cluster, lineage) green; new axis unit
tests cover expansion + anchor inheritance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Thread provider through period_walk_iterator into _p_years_d_lf (year side)
and _inflation_factors_lf -> _years_for_period_lf -> _years_for_period_from_source
(factor side). Add a provider-gated canonical arm to _years_for_period_from_source
reading the emitted p_years_represented.csv (which byte-copies fan-member
year rows via derive_years_represented's branch loop). Under recourse the
caller (D4 apply boundaries) forwards provider so branch periods resolve
their anchor's year-from-start -> correct annuity windows; provider=None
(flag-off) keeps the canonical arms dead -> today's fill_null behaviour
(the W6 today-bug pin) -> byte-parity. Plumbing only this phase (no caller
forwards provider yet); activation lands with lineage at D4.

Flag-off npv_cluster/walk/fanout suites green; new canonical-arm unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Compute the scenario-lineage frame (flex_data.dd_same_scenario) at the
three Layer-4 apply boundaries, gated on recourse AND a genuine branch
invest axis (capability conjunct). Forward lineage=... into the edd
builders threaded by Slice B: edd_invest_lookback_set, edd_invest_set_lf,
edd_divest_active (#18), the synthetic edd, and apply_npv's four
ed_*_from_source walks. Hoist assert_recourse_npv_preconditions above the
edd try/except blocks so a violation raises LineageFilterError (loud, D1).

alpha-1 walker Provider: a boundary-scoped module-global (_RECOURSE_WALK_PROVIDER)
armed alongside the anchor-pairs scope; period_walk_iterator falls back to
it so the canonical year/factor arms revive under the flag without threading
provider through ~12 NPV/edd caller signatures. None flag-off -> arms dead
-> W6 today-bug preserved.

Activation is inert flag-off (lineage None) -> byte-parity; 103-test
spot-suite (npv_cluster/fanout/lineage/axis/year) green. Flag-on edd
cross-leaf integration lands at D5 (Guard 1 lift + fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Objective: multiply ALL EIGHT invest/divest cost Params (process
annu/lf/lfd/annd + node annu_n/lf_n/lfd_n/annd_n) by pd_branch_weight[d]
via the _wt closure, gated on d.recourse_invest AND pd_branch_weight;
replace the .mod "NOT weighted" comment with the recourse rationale.
Flag-off skips the multiply (byte-parity).

Guard 1 LIFTED for 'recourse': _stochastic_invest_raw_guard validates the
RAW authored value (resolver collapses typos to 'none' so the guard must
read the raw dict); both sites (run() + _validate_model_solve) switched;
guard tests flipped per design §15.4 + NEW raw-typo rejection test.

Activation fixes found by the gate (recorded in design implementation
notes): (a) precondition checker needs the REAL workdir (Provider-only
readers key paths off it — design §8 snippet's None arg made obligations
fail spuriously); (b) lineage built ON-DEMAND per boundary via
dd_same_scenario_df (flex_data.dd_same_scenario populates only at
derived_g); (c) alpha-1 walker holders gain the workdir twin
(_RECOURSE_WALK_WORKDIR) so the canonical p_years_d arm goes live;
(d) _recourse_usable_ctx sanitizes the snapshot-reload SolveContext
(all-null d_anchor artifact) with provider-arm fall-through.

Gate fixture stoch_two_period_invest.json (+ builder, + conftest wiring):
supply-side branch variation (deterministic demand 100/130 + wind
upper_limit profile rlz .4/.4 low .2/.1 via group__unit -> peaker residual
60/90 / 80/120) because node-inflow branch resolution gates on group__node
and a stoch-group node would fire NA net-charge pinning.

D5 gate: objective 196875 EXACT; coefficients 525/262.5/1575/787.5;
per-leaf invest 60/80/30/40; committed output realized-only; edd
cross-leaf ABSENT (W6 re-pointed); pd_non_anticipativity empty; STRICT
autoscale clean. Flag-off parity: 128 tests green (lineage/walk/fanout/
guards/npv_cluster + recourse unit suites). ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Per-scenario-path total-capacity caps so a total cap applies once per
scenario leaf under recourse, never cross-scenario double-counting
(design sliceD §7 (G) / §14 (L)).

- d_leaf (d, leaf) frame in _derived_branch.py: partitions period_in_use
  into scenario leaves (real anchors -> "__realized", synthetic members
  -> time-branch id); registered on FlexData, built in apply_branch_cluster.
- model.py _emit_entity_total_cap: the four maxInvest/maxDivest entity
  total builders emit the legacy over=("d",) shape + name for a single
  leaf (deterministic / flag-off, byte-identical) and …_path indexed by
  (entity, leaf) for >1 leaf, masking the inner sum to each leaf.
- _cumulative_invest.py _emit_group_total_divest_cap: the divest-side
  group total gains the same per-path treatment (max/<= cap only).
- autoscale registry: maxInvest/maxDivest_entity_total_path(_n) (POWER/
  ENERGY) + maxDivestGroup_entity_total_path (group_capacity resolver);
  test_registry_coverage dynamic-name pins.
- Fixture invest_max_total tightened 250 -> 150 (§15.1): per-leaf totals
  90/120 non-binding, legacy all-d sum 210 would have bound — the
  asymmetry proves per-path. Gate test asserts two leaf rows.

Gate: objective 196,875 exact; STRICT autoscale clean; flag-off parity
(derived_branch/period_walk lineage, continuation fanout, recourse
guards, deterministic invest cumulative/total goldens) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Make the recourse handoff's drop of branch-named (non-realized-scenario)
v_invest rows EXPLICIT (design sliceD §10.1 (H)).

- build_handoff_from_solution: partition invest_by_ed on synthetic
  branch-token membership BEFORE the commit loop and debug-log the
  dropped mass; the commit gate previously dropped these by accident.
  The Slice C Guard 3 assertion stays as a belt-and-braces invariant
  check (no synthetic token may survive into committed rows), not the
  mechanism.  Flag-off / deterministic: synthetic set empty → no-op
  (byte-parity).
- Gate test: the pre-filter drops the low leaf's 80+40 = 120 MW, debug-
  logs it (periods p2035_low/p2040_low), and commits only the realized
  60/30; Guard 3 stays inert.

Guard 1 raw-value lift was already landed at D5.  Flag-off parity
(handoff cumulative carriers, continuation fanout, recourse guards) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Outputs, cost reconciliation, and the cross-scenario-coupling fixture
(design sliceD §11 (I) / §15.5 / §15.6).

Outputs (§11.2):
- read_highs_solution: under recourse AND output_horizon, union the
  horizon-expanded d_realize_dispatch_or_invest set (branch periods
  included) into the invest-axis canonical order, so non-realized
  branch v_invest rows surface.  Gated on both flags → byte-parity for
  deterministic models and for recourse without horizon output.
  Committed path stays realized-only (§11.1).

Costs (§11.3, choice I-a):
- calc_costs probability-weights the invest/divest + lifetime-fixed cost
  rows by pd_branch_weight under recourse, so the cost breakdown
  reconciles to the LP objective's (expected) invest contribution rather
  than over-reporting the realized spend by 1/w_realized.  read_parameters
  threads par.pd_branch_weight + par.recourse_invest.  Gated → factor 1.0
  off-flag (byte-parity; deterministic cost-aggregation goldens green).
- test_npv_cluster skips the eager-vs-lazy NPV byte-equality under
  recourse (the eager reference is not lineage-aware, §11.4).

Fixtures/tests:
- variant A gains a recourse_horizon scenario (model.output_horizon=yes);
  gate test asserts branch rows 80@p2035_low / 40@p2040_low surface.
- variant B (stoch_two_period_invest_na): extends the proven
  stochastics.json storage shape with a recourse_na scenario making the
  stochastic-group storage node investable.  Witnesses:
  non_anticipativity_storage_use fires (>0 rows), branch v_state == the
  NA-tied realized value, and the node-side annu_n objective coefficient
  carries pd_branch_weight (the F3 node-side pin).
- cost reconciliation e2e: reported realized invest cost 0.039375 M =
  0.25·(60·2100 + 30·1050); solver objective 0.196875 M.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
- how_to.md: replace the "only one period investment stochastic models
  are supported" line with the per-scenario (wait-and-see) recourse
  recipe — what the objective means (expected value of per-scenario
  plans, EVPI-style; not a hedged plan), the coupling change vs the
  default, the α-1 cross-comparability caveat, and the Benders exclusion
  (design sliceD §16).
- reference.md: document solve.stochastic_invest_method (none/recourse),
  the output_horizon interaction, and the Benders exclusion.
- CHANGELOG: Unreleased draft entry (public wording, no internal model
  names; schema change note).  No version bump — release is the owner's
  decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
The end-to-end cost-reconciliation test invokes a real solve; add the
``solver`` marker so it deselects under ``-m "not solver"`` like the
other recourse solver tests (it already carried ``slow``).

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

D0 introduced _recourse_invest_active with a provider-first / workdir-disk
fallback read of solve_data/stochastic_invest_method.csv.  The pl.read_csv
disk arm tripped test_meta_provider_invariants::test_no_disk_csv_reads_in_cascade
(Rule 1) — the same seed-funnel bug class fixed for the Slice B obligation
checker in 91db38b.

Make the reader Provider-only (_provider_has_key -> _provider_read), matching
every other reader in this module (_build_recourse_anchor_pairs et al.).  The
flag CSV reaches the cascade exclusively through the Provider on all
production paths: cascade entry points thread an explicit Provider, and the
snapshot-reload path (load_flextool off a dumped workdir) seeds the Provider
from solve_data/ via seed_provider_from_dir before the cascade runs, so it
carries the solve_data/stochastic_invest_method key by construction.  The
recourse gate is unchanged (returns True under flag='recourse' on both the
normal and reload paths).

Update test_recourse_plumbing to seed a Provider from the workdir (mirroring
the cascade) instead of relying on the removed disk arm; add a no-Provider
case asserting the byte-parity-safe default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
The v70 migration (_migrate_v70_stochastic_invest_method) attached
solve.stochastic_invest_method to the solve_advanced parameter_group only
"when that group exists", but probed existence with the raising
db.item(db.mapped_table("parameter_group"), name="solve_advanced"). In the
current spinedb-api, db.item RAISES SpineDBAPIError when the item is absent
(it does not return None), so the `is not None` guard never helped — it
crashed before returning.

solve_advanced is created by the v44 migration (recoloured in v45), so real
DBs migrated from < v44 and the shipped template always carry it. But a DB
built from scratch at a version above v44 never ran the v44 step and has no
parameter_groups at all — e.g. the minimal v64 fixtures that
tests/test_v65_migration.py and tests/test_v66_migration.py seed and then
migrate forward through v70. Those two tests crashed on the v70 step
(regression vs main, where they pass).

Fix: use the non-raising db.get_item guard and get-or-create the group —
create solve_advanced (mirroring v44's priority 87 and the post-v45 colour
b56f6f) only when the DB lacks it, then always attach the param. This keeps
the param in its canonical schema home and satisfies the v69 invariant that
every parameter belongs to a group; it is a clean forward add, not
crash-tolerance. The v70 fixture (lh2_three_region) already has the group,
so the migrate-from-v69 path is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
The three recourse process-globals (_RECOURSE_ANCHOR_PAIRS,
_RECOURSE_WALK_PROVIDER, _RECOURSE_WALK_WORKDIR) were reset only inside
the three flag-aware Layer-4 boundaries. Add _reset_recourse_scope()
called once at the top of _apply_db_overrides (the single per-solve
cascade entry, before pass 1a) so a future per-period producer wired
into an earlier cascade step cannot inherit a stale global from a prior
flag-on solve in a chained flag-on->flag-off multi-solve. The boundaries
still overwrite unconditionally; this is belt-and-suspenders.

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

The final cast(d_dtype, strict=False) could silently null an out-of-vocab
token, contradicting the docstring's claim that the round-trip "cannot
silently null a token." Mirror the sibling _derived_branch.d_leaf_lf: on
the non-Utf8 (Enum) path, collect and hard-raise on any nulled token
instead of dropping it. Cannot fire in valid runs -- anchor tokens arrive
already typed as d_dtype (lossless Utf8->Enum round-trip) and branch (br)
tokens are covered by the 4.0.4 vocabulary splice. Utf8-d callers (both
production call sites) skip the collect entirely, so no hot-path
laziness/perf regression. Docstring corrected to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Enable create_stochastic_periods to trigger the branch fan at a period
k>1 (mid-horizon reveal) while keeping pre-reveal periods as a single
real-named trunk (Option B, non-anticipativity by construction).

- _stochastic.py: replace the 'realized row at the solve first step'
  validation with a well-formedness check (period-boundary reveal +
  exactly-one-realized per branching period), admitting a boundary
  mid-horizon reveal byte-identically for fan-at-first-step.
- _timeline.py make_step_jump + _derived_params.py dtttdt_from_source:
  a branch copy with no earlier same-time-branch period now links its
  first step to the nearest anchor STRICTLY BEFORE the copy's own anchor
  (shared pre-reveal trunk end-state) instead of self-cycling. First-
  period fan self-cycles unchanged (empty anchor range) -> byte-parity.

Byte-parity gate green (recourse 196875, storage *_na goldens unmoved).

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

Option B correctness for the shared pre-reveal trunk:

- _lineage_leaves now returns (label, members) pairs (F4) so callers
  carry the authoritative leaf label instead of a brittle zip-alignment.
- d_leaf: partition -> RELATION. A shared pre-reveal anchor belongs to
  EVERY branch leaf's path, so the per-path total cap counts it into
  each leaf's total (byte-parity for fan-at-first/deterministic: no
  pre-reveal anchors).
- New dd_same_scenario_annuity lineage: the NPV annuity / fixed-cost
  window walks dedup branch copies of the same CALENDAR slot by anchor,
  so a shared trunk's window counts each future period once (window
  {p2035,p2040}=2), not once per branch (=3). Reconciles design
  §4.1 (capacity edd keeps the full frame with (p2035,p2040_low)) with
  §8.1 (annuity=2). Fixes the latent NPV annuity over-count that the
  shared trunk exposes. No-op for every non-shared-trunk solve.
- check_recourse_npv_preconditions obligation (i): re-scope leaf-weight
  constancy to the POST-REVEAL segment (exclude shared pre-reveal
  anchors, which carry the certain weight 1.0) per the function's own
  Slice-E note; the two-stage weighting is correct, not a violation.

Fixture stoch_two_period_hedge (RP mid-horizon 173250 / WS fan-at-first
157500) + conftest wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
- test_recourse_invest_hedge.py: T-RP (173250, hedge x=60/r=60/0), T-WS
  (157500, EVPI=15750>0), T-EEV (192937.5 hand-calc, VSS>0), T-F/T-V
  (mid-horizon fan + validation, solver-free unit harness), T-L/T-E2
  (dd_same_scenario shape A, d_leaf relation, pd_non_anticipativity empty,
  no invest-NA family), T-C (per-path cap two-leaf), T-S (storage NA fires
  on (p2040,p2040_low), branch copy links to shared trunk, make_step_jump
  <-> dtttdt parity).
- hedge fixture gains a hedge_storage scenario (stochastic-group storage
  node resv) for the storage-NA / continuity assertions.
- docs/how_to.md + CHANGELOG: mid-horizon hedged two-stage reveal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Completes the E1-E3 annuity-lineage addition (functions defined and
consumed by _derived_npv; finish the public export).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
The v70 stochastic_invest_method migration (Slice C) regenerated only
the 2 generate_canonical recipes; the 7 tests/fixtures and 8
canonical_databases JSONs were left at v69, failing the CI
test_fixtures/canonical_databases verify steps. Pure schema-additive
regen (new value list + param definition + version bump); no test
behavior or golden change (tests migrate JSON on load).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
sync_master_template runs migrate_database, whose sqlite engine handle
is still open when TemporaryDirectory cleanup runs -> WinError 32 on
Windows. Same check runs on the ubuntu-only template-check CI job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
jkiviluo and others added 30 commits September 29, 2026 23:25
Document the two per-entity knobs (construction_lead_time +
_method), the four seam-snap methods, the order-vs-commissioning /
cost-vs-availability split, the out-of-horizon (voluntary warn /
forced error) behaviour, and the orthogonal composition with the
invest non-anticipativity window for automatic stochastic staging.

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

The Slice H silent-failure guards were defeated by broad `except Exception`
swallows in the real solve path:

* MAJOR-1 — the §2.5 [F3] join-desync guard raised a bare `ValueError`
  that the edd builders' `except Exception` in apply_derived_c /
  apply_synthetic_invest_sets caught, silently reverting to the unlagged
  path. Give it a dedicated `CommissioningLagError` (ValueError subclass,
  mirroring LineageFilterError) and re-raise it ahead of the broad except
  at all edd sites.
* MINOR-3 — a commissioning_year_lf bug on a genuinely lag-configured
  model was swallowed to None → silently-unlagged wrong model. Route both
  build sites through a shared `_build_commission_lf` that re-raises when
  `construction_lead_time > 0` is configured, while still swallowing the
  no-lag case (helper returns None anyway → byte-parity).
* MINOR-2 — mirror the F5 forced-out-of-horizon assert into the synthetic
  invest path, which also threads commission_lf into the walk.
* NIT-4 — collect commissioning_year_lf's result once instead of twice.

No-lag / immediate models never reach any new raise, so existing models
stay byte-identical (recourse + invest-NA gates green, zero golden
changes).

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

The Slice H construction-lead-time STAGING tests failed in the full sweep
(green in isolation) because the commissioning-year helper silently
resolved its seam calendar from the WRONG solve.

Root cause: commissioning_year_lf's [F2] fallback reads the boundary-scoped
recourse walker process-globals (_RECOURSE_WALK_PROVIDER / _WORKDIR via
_recourse_walk_provider()/_recourse_walk_workdir() in _derived_params).
A real recourse solve arms those globals and leaves them ARMED when it
finishes; in production the next solve's cascade clears them
(_apply_db_overrides -> _reset_recourse_scope, the F1 guarantee).  But a
solver-free unit test that calls the helper (and period_walk_iterator)
directly never runs that cascade, so on the same pytest worker it inherited
a PRIOR recourse solve test's Provider + workdir and read that solve's
p_years_d.csv.  Its periods don't intersect the stub model's period_in_use
-> the seam set collapses to empty -> every snap is null -> commission_lf
comes back None -> the availability walk keeps yr_d -> capacity is available
from the ORDER period (all periods serving) instead of the lagged
commissioning period.  The staging assertion serving == {"p1"} then failed
with {"p1","p2","p3","p4"} — the lag silently vanished.

ea3795a (which added tests to the guard file) merely re-bucketed the
xdist loadfile distribution so the staging file co-located with a leaker
(test_recourse_invest_{hedge,na,gate}); the latent leak was always present.

Fix (two parts):

* Test isolation — add an autouse conftest fixture that resets the recourse
  walker process-globals before/after every engine_polars test, mirroring
  the existing axis-enums ContextVar-leak reset and giving every test the
  same clean slate the F1 cascade already guarantees a real solve.  This is
  the direct fix: helper AND walk both read the (now-clean) globals.

* Loud-on-empty guard (belt-and-suspenders) — commissioning_year_lf now
  raises CommissioningLagError when a lag-configured model
  (_construction_lag_configured true) hits a calendar DESYNC: _p_years_d_lf
  returns None, or the seam set over period_in_use is EMPTY.  This converts
  the dangerous silent-no-lag into a loud failure and closes the
  stale-but-wrong-provider hole the F3 join guard misses (empty overlap
  never fires F3).  Keyed on the EMPTY-seam / no-calendar signal, NOT on
  emptiness alone, so legitimate immediate / round-to-zero models (whose
  seam set is non-empty and simply snap back onto the order period) keep
  returning None — byte-parity preserved (no-lag/immediate unchanged, zero
  golden changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ
Add reserve_duration scalar to reserve__upDown__group (schema
parameter_definitions + parameter_types), plumb the loader
(_direct_params from_source + wiring, FlexData field, _warm deferred +
shape), and cover the helper with in-memory loader unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9031ce2dcb82451b0317852dd368019be3088f98)
…on (#322)

Add reserve_storage_up_floor / reserve_storage_down_headroom in
_reserve._add_storage_reserve_constraints: a storage-backed reserve
provider must hold enough stored energy (up/discharge) or free headroom
(down/charge) to sustain committed reserve MW for reserve_duration hours.
Efficiency-corrected via p_slope (coalesced to 1.0 over the provider set),
reliability excluded, ud pre-filtered before the topology join. Down
headroom replicates maxState's invest/divest cross-Enum capacity block
scaled by p_state_unitsize. No slack: with v_reserve=0 both reduce to
existing bounds, so a short battery just commits less reserve and pays the
vq_reserve penalty. Absent reserve_duration => byte-identical LP.

Dispatch wiring threads v_state/v_invest_n/v_divest_n into _reserve
(gated on has_storage/has_invest_n/has_divest_n). Registers the two
ENERGY constraint families + the DURATION param in the autoscale
registries. Synthetic engine tests (both directions, efficiency,
connection provider, no-op, feasibility) run under
FLEXTOOL_AUTOSCALE_STRICT=1; real-SpineDB loader e2e; docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 054b7a47bac8490a7f56d9a0e471a3388f0c9ee1)
)

Adds invest/divest-eligible storage coverage to the reserve_duration
synthetic suite so the DOWN-headroom capacity-replication block in
_reserve.py (the __at_reserve_down_{invest,divest} virtual-var views and
the cross-Enum n->e / n->p casts) is exercised through canonicalise() and
Layer-2 autoscale (STRICT).  Previously no synthetic fixture built an
invest/divest storage node, so v_invest_n / v_divest_n were never present
when the DOWN-headroom constraint was emitted.

- fixture: add invest/divest flags (edd_invest_set / edd_divest_active,
  p_entity_max_units, annuity cost) on the storage node bat.
- new tests: closed-form proof that the +v_invest.unitsize ceiling term is
  real (invest reaches 5, vq 0.2 -> 0, v_reserve 0.4 -> 0.5), plus an
  invest+divest build+solve+Layer-2 STRICT roundtrip that canonicalises
  both capacity views without raising.
- fix stale e2e comment: tests.json now ships at v71 with the
  reserve_duration definition (commit 30286318); the manual import_data is
  a harmless idempotent upsert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3e3fa37a6ebd294bf36a80a33d0ed34e9d0bb98a)
…_duration

Consolidate the reserve line onto the stochastics-finish line (construction-
lead-time, v73) and resolve the duplicate-v71 collision in a single schema
bump v73 -> v74:

- Rename the two unreleased stochastic non-anticipativity window knobs to
  positive names (user-facing DB params only; internal non-anticipativity
  mechanism names kept, since they genuinely implement non-anticipativity):
    non_anticipativity_periods        -> shared_operation_periods
    non_anticipativity_invest_periods -> shared_invest_periods
- Add reserve_duration (reserve__upDown__group, issue #322) to existing DBs
  via the v74 migration (reader/constraints cherry-picked from the reserve
  line; the v71 reserve migration was dropped to avoid the collision and
  re-authored at v74).
- _migrate_v74 does both (collision-safe rename helper + reserve_duration add,
  idempotent); FLEXTOOL_DB_VERSION 73 -> 74; fixtures/canonical DBs regen via
  the standard chain; new names registered in PARAMETER_TYPES (autoscale).

Verified (FLEXTOOL_AUTOSCALE_STRICT=1): migration v71-v74 23 passed; reserve
198 passed; stochastics/NA 31 passed (invest-NA tie binds under
shared_invest_periods, RP 173250); autoscale coverage 39 passed; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…axes

broadcast_to_period_time cast axis tokens with strict=False BEFORE the
null-pattern split, so an unknown/inactive period or timestep token became
null and was then misread as a "missing axis" default — broadcasting that
row across the whole axis (reproduced: a MAP_TIME row (b,t01) got both 50
and 60). Fix (spec fix_map_reading.md §3.4): capture period/time null masks
BEFORE the cast, drop rows whose token was non-null before but null after
(unknown/inactive; DEBUG-logged), and drive the null-pattern split off the
PRE-cast masks. Applied to all three branches (MAP_PERIOD_TIME four-way,
MAP_PERIOD, MAP_TIME).

No-op for valid data by construction (pre-cast masks == post-cast nulls when
nothing fails), so byte-identical for the normal case.

Tests: new test_broadcast_null_cast_fix.py (6, verified load-bearing against
the pre-fix logic). Cascade/byte-parity gate 822 passed byte-identical;
param-shape/instant-flow/silent-default suite 66 passed; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…arams

One parameter authored with different Map shapes across entities (e.g.
flowGroup.min_instant_flow: one entity Map(period), another Map(time)) was
silently corrupted: the reader chose ONE positional index-column list for the
whole parameter, so tokens landed in the wrong axis column and the production
scenario-trim then dropped the misplaced rows — outcome flipped with DB row
order, no warning. (spec fix_map_reading.md §3.1-§3.3, rev 2.)

Registry-scoped fix (entered ONLY for the 48 PERIOD_TIME_PARAMS keys — those
whose allow-list contains MAP_PERIOD/MAP_TIME/MAP_PERIOD_TIME):
- Reader (_spinedb_reader): _unroll_period_time_rows classifies each Map level
  per-row (index_name -> silent-label value-domain vs global axis vocab ->
  raise on ambiguity), canonical period-before-t column order, each row writes
  only its own axes. Shared classifier reused by parameter_shape_info.
- New parameter_shape_variants on SpineDbReader + InMemoryReader + optional on
  the InputSource Protocol.
- Resolver (_param_shapes): per-variant allow-list check — accepts a combined
  MAP_PERIOD_TIME for commodity.price (every row shape individually allowed),
  rejects a genuine 2-D row there, and rejects a time/2-D map on a period-only
  param. Raises FlexToolConfigError naming the offending entities; one INFO
  line per mixed (class,param).

Behavior change (CHANGELOG, spec §5 decided): an unaccepted shape on a
period-only parameter now RAISES (naming the entity) instead of being silently
dropped; keys matching both vocabularies raise FlexDataIntegrityError.

Byte-identical by construction for every non-PERIOD_TIME_PARAMS key and every
single-shape registry param (new branch not entered / same frame). Verified:
golden snapshot 5 passed (reader-level byte-identical across corpus); dual-axis
matrix 42 passed (13 cases x row-order x reader{enums,no-enums}); registry pin
4 passed; reader/shape regression 106 passed; cascade byte-parity 822 passed
byte-identical. ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
unit.min_load allows period/time/period-time Maps, but the minFlow_minload
constraint read it via _entity_scalar_explicit, which drops the Map index
column — a Map yielded multiple (unit,value) rows that polar_high then SUMMED
in the constraint, inflating the floor. Confirmed end-to-end: a period Map
{d1:0.4,d2:0.3} gave floor 0.7 in every period (an hourly Map would sum
thousands of values → floor >>1, unit can't run). (spec fix_map_reading.md §5.)

Fix: produce p_min_load as a (p,d,t) Param via the same _eff_param_to_pdt
template the efficiency curve already uses (resolve_param_shape +
broadcast_to_period_time), instead of the scalar read.
- _derived_params: p_min_load_pdt_from_source wired into apply_derived_b beside
  p_slope; added to DERIVED_B_FIELDS.
- _direct_params: dropped the scalar (p,) assignment + unused helper.
- input.py: field now (p,d,t); synthetic/rolling re-wire beside SECTION-1/
  RESERVE-1 so the ONLINE-gate field isn't None on early-return sub-solves.
- _dump_csvs: min_load moves to pdtProcess.csv (period+time), mirroring
  availability.
- model.py unchanged (polar_high broadcasts (p,d,t) identically to (p,)).

Scalar min_load byte-identical (0.4→0.4); only Map changes: period Map now
0.4/0.3 per period, time Map per timestep. Tests: new test_min_load_pdt.py
(6, producer + end-to-end), delta4 retargeted to the derived path; b04_uc +
flex_coal_min_load scalar parity green; min_load_rolling (synthetic) green;
csv-dump round-trip green. FLEXTOOL_AUTOSCALE_STRICT=1; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
Adds a solving e2e for the rolling stochastic fixture `1_week_rolling_wind`,
landed BEFORE the W1/W3 fix (spec stochastics_reassessment.md §E: coverage for
rolling must come first so the fix is observable and no future change silently
re-breaks it).

The rolling solve currently crashes at the E0 branch-reveal validation
(_stochastic.py:466-476, FlexToolConfigError). The test documents that via a
module-level strict xfail(raises=FlexToolConfigError) keyed on a one-line
switch (W1_W3_LANDED), so the suite stays green now and a single edit flips all
three tests to real pass-assertions once W1/W3 merge. strict=True guarantees any
accidental future pass xpass-fails the suite, forcing the flip.

Assertions (post-flip):
- solve completes: 7 rolls, each strictly optimal;
- PRIMARY structural signal: per roll, flex_data.period_branch_full (non-empty
  d!=b sub-cohort) + dt_non_anticipativity are populated — the exact precondition
  the operational-NA gate (model.py:4519-4522) requires;
- storage NA ties v_state across branches at tied (d,t) cells (two reconciliation
  advisories recorded at the flip switch for the W1/W3 implementer).

Builds the DB from JSON via stochastic_db_url (invariant #3); runs under
FLEXTOOL_AUTOSCALE_STRICT=1 (invariant #1). No engine code touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
The v74 fold (6c2a24f) renamed the user-facing solve params
non_anticipativity_periods -> shared_operation_periods and
non_anticipativity_invest_periods -> shared_invest_periods in the schema, but
export_settings.yaml still listed the old names. This failed the
TestSplitParamsSchemaSync guards (schema param missing from yaml + yaml param
stale vs schema) — the CLAUDE.md invariant #4 post-rename follow-up.

Rename both occurrences (solve_period split group + nested_periodic type map) to
the user-facing shared_* names. Internal mechanism names stay
non_anticipativity_* by design (_solve_config.py, db_migration.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
Replaces the E0 validation in create_stochastic_periods that crashed the
rolling stochastic UC. The old code validated EVERY stochastic_branches row
against the solve's first step and counted realized rows grouped by period
NAME — in rolling, each roll sees the full row set (all 7 roll-boundary
reveals under one period), so both checks fired (non-first-step rows + 7
realized across rolls) and every roll after the first crashed with
FlexToolConfigError. Uncaught by tests (spec stochastics_reassessment.md §A.1).

New rule: validate only the reveal that actually TRIGGERS this solve — the
first declared start_step reachable while walking this solve's sliced
active_time_list — matching the fan trigger loop so validation and fan agree on
the cohort. For that cohort: (i) it must sit at its period's first step in this
window (period-boundary reveal); (ii) exactly one realized:yes. This admits
both fan-at-first-per-roll (Model 2) and mid-horizon period-boundary reveals
(Model 1) without reintroducing Option-B's "first period can't branch" guard,
and preserves single-solve fan-at-first byte-for-byte.

Fix 1: a pre-reveal rolling roll (no reveal reachable in its window) stays a
deterministic trunk instead of false-raising; the "declared branches never
fire" safety-net raise is scoped to single solves, detected structurally via
solve_modes[complete_solves[solve]] == "rolling_window" (getattr defaults to
single, the safe direction).
Fix 2: rolling vs single strictness split. A rolling roll sees only its slice,
so a declared step that is another roll's legitimate trigger is
indistinguishable from a misaligned one — each reveal is validated in the roll
where it triggers (documented limitation). A SINGLE solve sees the whole reveal
picture, so every declared reveal is validated strictly (each at its period's
first step; one realized:yes per period) — restoring pre-W1 per-row strictness
and the single-solve midperiod-reveal rejection.

Adds 6 focused unit cases (pre-reveal-no-raise, fan-at-first, not-at-boundary,
two/zero realized, single-no-reveal-raises).

Empirically: all 7 rolls now solve optimally, but per-roll NA frames
(period_branch_full / dt_non_anticipativity) are still None — NA coupling in
rolling is W3. The coverage test's two NA assertions are therefore
xfail(strict, raises=AssertionError, reason="W3 pending") and
test_rolling_stochastic_solve_completes now asserts for real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
After W1 the rolling stochastic solve ran but NA never coupled branches: per
roll, flex_data.period_branch_full and dt_non_anticipativity came back None, so
the operational-NA gate (model.py:4519-4522) never fired. Root cause: rolling
sub-solves (1week_rolling_roll_N) are not declared in Spine, so the
synthetic-solve early-return in _apply_db_overrides fires and skips passes 3-10
— including apply_derived_g -> apply_branch_cluster, the sole producer of those
two frames. The per-roll emit was already correct (period__branch.csv with the
d!=b fan rows; dt_non_anticipativity_set.csv = the 24-step jump).

Fix: an NA-1 re-wire in the synthetic-solve branch (alongside the existing
RESERVE-1/SECTION-1/MINLOAD-1/INFLOW-1 re-wires) that populates ONLY
period_branch_full and dt_non_anticipativity, replicating apply_branch_cluster's
two seed-preserving assignments verbatim. It deliberately does NOT call the full
apply_branch_cluster, which would overwrite the correctly-seeded period_in_use_set
via an unsorted .unique() and reorder nested-invest set outputs (byte-parity
regression caught in review). Non-stochastic synthetic solves stay unaffected:
with no d!=b fan rows, period_branch_full is self-rows only -> db_pairs.height==0
-> the gate's inner guard skips the constraint.

tie = jump (dt_non_anticipativity = the committed jump, naturally). The storage
NA family ties NET CHARGE (per-(n,t) delta), not absolute v_state, so the
coverage test's storage assertion is rewritten to witness the net-charge delta
equality across the branch cohort (incl. the realized trunk) at tied timesteps;
both NA-coupling tests' xfail decorators are removed and now assert for real.

Deferred (NOT W3): absolute storage continuity (spec §B/§D.6) — forecast
branches cyclic-self-link their storage start while the realized trunk is
start-anchored, giving a constant realized<->forecast offset; closing it is a
separate anchor-repointing slice. Also flagged: period_branch_full now also
feeds the invest handoff guards and benders is_genuinely_stochastic per roll —
intended single-solve parity, untested for rolling-stochastic-invest (follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
The storage non-anticipativity family ties NET CHARGE (per-(n,t) flow delta,
model.py:219-234), not absolute v_state. In a rolling solve the untied
post-reveal tail breaks the telescoping that makes single-solve tie absolutely,
so the forecast branch copies floated by a constant v_state offset vs the
realized trunk (~0.06 in 1_week_rolling_wind) — i.e. §B/§D.6 branch-point
continuity was not delivered (W3 deferred this).

Adds a new constraint family non_anticipativity_storage_state_first (block "1b"
in _add_non_anticipativity_constraints, mirroring non_anticipativity_online_integer)
that pins absolute v_state[n,d,t_first] - v_state[n,b,t_first] == 0 across the
branch cohort at the FIRST tied step of each anchor period. Telescoping the
existing per-step net-charge ties over the contiguous jump off that absolute
anchor closes the whole tied range absolutely; the offset collapses to ~0. It is
optimum-conditional on zero balance slack + branch-invariant inflow (documented).
v_online/v_reserve need no analog — they already pin the absolute variable at
every tied step.

Proactive horizon-guard: emit per (n,d) ONLY when the tied set is a strict
subset of that node's per-period v_state timeline (an untied tail exists). On a
fully-tied solve (single-solve 2day_dispatch; rolling roll whose horizon==jump)
the node is already doubly-anchored (hard start + reference end), so the row is
redundant and SUPPRESSED — single solves receive zero new rows (byte-parity).
Verified: 2day emits 0 rows, 1_week_rolling_wind emits 18 (3 pairs x 6 rolls;
the horizon==jump final roll suppressed).

t_first is per-d (not a global min), robust to multi-reveal single solves. The
v_state-domain double inner-join (on (n,d,t) and (n,b,t)) drops state-less
cells. Registered in autoscale _layer2_types.py (static literal).

Test: test_rolling_na_ties_storage_across_branches keeps the net-charge witness
(diagnostic localization) AND adds an absolute-v_state cross-branch-equality
assertion (branches equal EACH OTHER at tied steps, not the nominal 0.5 — the
shared level is the soft roll_continue value in continuation rolls). Both keep
checked>0. Load-bearing: 0.06 fail without the family, ~0 with it.

Deferred (NOT this slice): the HARD roll-to-roll carry-in (all branches
inheriting the previous roll's actual jump end-state vs the current soft
roll_continue seed) — spec W8b. Touches neither make_step_jump nor dtttdt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
Investment is period-granular (v_invest is indexed (p,d)/(n,d), no timestep
axis) and a rolling solve advances past period beginnings, so a flat
rolling_window solve builds no v_invest at all — authored invest params are
silently ignored (root: _resolve_synthetic_solve reads the `_roll_N` suffix as
an invest-period anchor). The intended design is to run a separate INVESTMENT
solve BEFORE the rolling (operational) solves; rolling is for operations. This
is not a bug to fix and not a case to validate — but it should not be silent.

Adds a once-per-parent logger.warning in RecursiveSolveBuilder._process_rolling_solve
when a rolling_window solve ITSELF carries investment intent (invest_periods /
realized_invest_periods non-empty, or stochastic_invest_method != none) — keyed
on solve-authored params, NOT on invest-eligible entities existing, so the
correct invest-solve-then-rolling pattern never warns. Routed through a
module-level logger because the orchestrator deliberately raises the run logger
to ERROR during the cascade (_drive_cascade), which would swallow self.logger;
the module logger is the established engine-advisory convention and reaches
stdout via the CLI basicConfig. Log-only: no LP/frame/output change; an
idempotency guard keys the one-shot on the parent solve name.

Adds tests/engine_polars/test_rolling_stochastic_invest_noop.py pinning the
intended + safe behavior of rolling + stochastic + investment: all 7 rolls
optimal; the W3-activated period_branch_full consumers (is_genuinely_stochastic,
synthetic_branch_tokens, the handoff guards) run inert and never raise; the
known no-build (pd_invest_set is None per roll) is asserted as a tripwire so a
future change that starts building v_invest in rolling trips loudly; and the new
warning fires exactly once naming the solve. Fixture built by in-test injection
on a tmp DB (invariant #3); committed stochastics.json untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
Establishes the W8 premise that unblocks W4: in a stochastic model, realized
OPERATIONS can come from a separate deterministic realized-dispatch solve whose
investments are FIXED (via the existing invest→dispatch handoff) from the
stochastic solve. Proven by a new fixture + e2e with NO engine change — the
machinery (handoff capture + synthetic-invest drop + realized last-wins + output
union) already exists; W8.1 wires a stochastic→deterministic pair and locks the
behaviour in.

Fixture `w8_realized_dispatch` (new, JSON-built): model.solves =
[stoch_rd, stoch_rd_realized_dispatch]. stoch_rd is single_solve fan-at-first,
stochastic_invest_method=none (shared first-stage invest), branches
rlz 0.25 / high 0.375 / low 0.375 (sum 1.0), genuinely stochastic; it commits
v_invest[peaker]=60. stoch_rd_realized_dispatch is deterministic, listed AFTER
(required for last-wins), consumes the 60 MW as fixed existing capacity (mirrors
the proven lh2_invest→lh2_dispatch precedent).

e2e asserts: (a) both solves optimal; (b) dispatch receives the stochastic
solve's v_invest as fixed existing capacity (dispatch builds NO v_invest,
p_entity_invested[peaker]=60, peaker existing=0 in base so the 60 arrives only
via the handoff — not pre-baked); (c) last-wins empties the stochastic solve's
realized window (stoch realized_dispatch None; dispatch owns p2035/t0001-t0003);
(d) hand-calc dispatch objective 5,256,000 (60 MW · 8760 h · 10, annualized) at
rel=1e-9. Runs under FLEXTOOL_AUTOSCALE_STRICT=1.

Documented W4 boundary (not patched): the deterministic dispatch reads the
realized series only while that series is DETERMINISTIC; once forecasts diverge
from realized (W4), feeding the realized data to the dispatch solve needs a real
mechanism. W8.1 keeps realized operational data deterministic (readable by both)
with genuine forecast branches, fully exercising the handoff + last-wins + output
routing. Author-declared vs engine-automatic realized-dispatch is a deferred
W4-era decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…1 (W4a)

First slice of W4 (de-scenario the realized weight), now unblocked by W8.1. Adds
a new solve-level method `dispatch_realization_method` (schema v74→v75), value
list {within_solve (default), separate_dispatch}, as the foundation for the
engine-automatic realized-dispatch solve. Structural clone of the v70
stochastic_invest_method line.

- within_solve (default) = today's behaviour: realization happens inside the
  solve's committed/jump region, no second solve → BYTE-PARITY (no engine branch
  keys off the new value yet).
- separate_dispatch = the future auto-spawned realized-dispatch solve; HARD-REJECTED
  for now with a clear "not yet implemented (later slice)" FlexToolConfigError.

The method is orthogonal to solve_mode and documented as the 2-stage seed of a
future multi-stage market structure (W11); dispatch_realization (not just
realization) to disambiguate from the investment-realization params
(realized_invest_periods, stochastic_invest_method).

Mechanics (mirror v70): migration _migrate_v75_dispatch_realization_method writes
ONLY the value-list + parameter_definition (default within_solve) + solve_advanced
group attach — NO parameter_value rows, so existing DBs stay byte-identical;
reader + dispatch_realization_method_for accessor (absent/blank/unknown →
within_solve); autoscale ('dispatch_realization_method','solve')=DIMENSIONLESS.

Hard-reject guard _dispatch_realization_raw_guard reads the RAW authored value
(so a typo can't silently normalize) with allowed-set explicitly {"", within_solve}
— wired at BOTH v70 sites: the _validate_model_solve fail-fast pass AND the
authoritative run() site (after the _roll_N suffix strip), so contained/chained/
rolling sub-solves authoring separate_dispatch raise rather than silently running
the default path.

Schema artifacts regenerated (definition/value-list only, verified no value rows):
spinedb_schema.json, 10 canonical_databases/*.json, 7 tests/fixtures/*.json.
Tests: test_v75_dispatch_realization_method.py (migration/default/group/value-list/
idempotent/master-template-verify) + test_w4a_dispatch_realization_guard.py (both
guard sites, allowed-set, message). test_recourse_guards.py stub gains the new
attribute (the shared _validate_model_solve guard reads it).

Follow-ups: W4b (engine-automatic spawn of the realized-dispatch solve), W4c
(realized-branch projection so the dispatch solve reads divergent realized data),
W4d (realized operational weight→0, forecasts normalized), W4e (fixture/golden
migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…atch (W4b)

W4 slice 2. Makes solve.dispatch_realization_method=separate_dispatch (schema
added in W4a, previously hard-rejected) actually work: when a single TOP-LEVEL
solve authors it, the engine automatically registers a deterministic
realized-dispatch sibling <solve>__realized_dispatch that takes the FULL
override path — so it is indistinguishable from the author-declared dispatch
solve W8.1 proved. Generalizes W8.1 from author-declared to engine-synthesized.

Mechanism (dual in-memory registration, no DB mutation; prototyped end-to-end):
- SpineDbReader.register_derived_solve: mints a FRESH entity id and injects the
  sibling into _entities_by_class/_entity_by_id, cloning only the 4 dispatch-
  defining params (period_timeset/realized_periods/years_represented/solve_mode)
  into _param_rows under the fresh eid — so _solve_in_spine(sibling) is True and
  it routes the full apply_derived_a..g path (feature passes run → no synthetic-
  path silent drop). Reusing the parent eid would map rows back to the parent and
  break _solve_periods(sibling) → avoided.
- SolveConfig.spawn_realized_dispatch_sibling: inserts the sibling right after the
  parent in model_solve (positional → last-wins + handoff), copies the timeline
  scaffolding, SETs realized_periods[sibling]=parent's, and DROPs invest/
  stochastic/benders/decomposition/non_anticipativity/fix_storage/contains_solves/
  dispatch_realization_method. scaling/solver/decomposition are deliberately NOT
  copied: run() resolves those from the separate cascade SolveConfig, so the
  sibling uses engine defaults — correct (monolithic) for a deterministic dispatch.
- _spawn_realized_dispatch_siblings: called once in _drive_cascade after the
  reader is built, before native_run_model, on runner.state.solve.

Guard (_dispatch_realization_raw_guard, both wired sites) narrowed: accept
separate_dispatch ONLY for a single top-level single_solve (in model_solve, not
rolling, no contains_solves, not nested); reject the rest with a reason; keep
within_solve/absent byte-parity; sibling no-ops at the run()-site. Collision on a
pre-existing <solve>__realized_dispatch raises.

Storage: the sibling drops fix_storage_periods, so separate_dispatch is rejected
(positive guard, loud) for any parent authoring a non-empty fix_storage_periods —
storage pinning in the realized-dispatch solve is a later slice. Multi-period
storage WITHOUT fix_storage rides the monolithic single_solve balance + node-level
storage params (preserved on the full path).

Reuses the W8.1 invest→dispatch handoff + realized last-wins + output-union
unchanged. Byte-parity: spawn/guard are no-ops unless a solve authors
separate_dispatch, so existing within_solve/absent fixtures are untouched.

Tests: test_w4b_auto_dispatch.py reproduces the 4 W8.1 oracles against the
auto-spawned sibling (objective 5,256,000 at rel=1e-9; no v_invest, 60 MW handed
as fixed existing; last-wins empties the parent realized window) + a reserve
full-path proof (v_reserve present) + register/spawn/collision/storage-guard unit
tests. W4a guard tests rewritten to the narrowed semantics. W8.1 author-declared
test kept as the baseline.

Deferred: W4b-rolling (per-roll spawn), W4c (realized-branch projection for
divergent forecasts), W4d (realized operational weight→0), W4e (fixture/golden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
… (W4c)

W4 slice 3 — the hard piece, and the unblocker for W4d. The W4b auto-spawned
realized-dispatch sibling is deterministic and drops stochastic_branches, so its
solve_branch__time_branch scaffolding was empty; the per-solve fold that collapses
a stochastic 3d_map (branch,time_start,time)->(d,t) — _derived_profile
_profile_stochastic_lf / _pdt_lookup PdtLookup.get — then found no branch rows,
dropped the 3d_map, and the zero-fallback filled 0 (wind->0->garbage). So the
sibling only worked when realized data was deterministic (2d). This makes the
sibling read each stochastic parameter PROJECTED onto the realized branch, so the
stochastic solve's forecasts can genuinely diverge from the realized series.

Fix (minimal, additive — no fold/_param_shapes/reader change):
- SolveConfig.realized_dispatch_parent: dict[str,str], recorded by the W4b
  spawn_realized_dispatch_sibling (explicit map, not name-parsing).
- A module-level helper project_realized_branch_time_branch_rows + a gated post-pass
  in _native_run_model (after create_stochastic_periods, before the per-solve emit
  loop): solve_branch__time_branch_lists[sibling] = the parent's rows filtered by
  (p,p) in period__branch_lists[parent] — reusing the parent's already-materialized
  realized-branch rows; the (p,p) predicate (same one derive_solve_branch_weight
  uses) keeps realized-named rows and excludes synthetic forecast copies. The
  existing folds then select the realized branch unchanged. Covers a multi-period
  realized window (one realized row per period via the continuation loop).

The sibling stays non-genuinely-stochastic: is_genuinely_stochastic keys on
period_branch_full/period_in_use_set, NOT solve_branch__time_branch, so zero branch
LP variables are built (verified: sibling v_flow has 3 rows, no fan). Reviewed
consumers confirm the injected rows reach only the pbt/profile fold (and
derive_solve_branch_weight emits 1.0 = semantically "no branch").

Byte-parity: no-op without a sibling; inert on deterministic 2d-profile data
(W4b/W8.1 fixtures byte-identical). No new var/cstr/param.

Tests: test_w4c_divergent_dispatch.py — branch-distinct 3d_map wind
(rlz 0.50 / high 0.80 / low 0.10); the auto-spawned sibling dispatches the REALIZED
branch (wind 50, peaker 50, objective 4,380,000 at rel=1e-9) with negative controls
(NOT high 20, NOT low 90, NOT collapsed 100-VOLL); parent-divergence proves the data
is branch-distinct; last-wins; multi-period + empty helper unit tests; spawn records
the parent map. Runs under FLEXTOOL_AUTOSCALE_STRICT=1.

Next: W4d (realized operational weight->0, forecasts normalized) is now safe —
realized ops come entirely from this sibling reading the projected realized series.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…e pass)

Anti-fracture pass over the accumulated separate_dispatch feature (W4a-W4c)
before W4d — no behavior change (doc/comment + one guard test only).

The structure was already coherent (single orchestrator entry point
_spawn_realized_dispatch_siblings; single authoritative sibling->parent map
SolveConfig.realized_dispatch_parent that W4c reads; clean reader/SolveConfig
responsibility split). The one genuine, drift-prone fracture: "what a
realized-dispatch sibling inherits from its parent" is defined TWICE in two
vocabularies with nothing tying them — the reader's RAW Spine-param allow-list
(_REALIZED_DISPATCH_SIBLING_PARAMS, for the full override path) and the DERIVED
SolveConfig-dict copy/set in spawn_realized_dispatch_sibling (for
native_run_model). The two layers genuinely need raw vs derived forms (so no
clean single-list de-dup without risky surgery on working code), but the four
DEFINING facts must agree: period_timeset<->timesets_used_by_solves,
years_represented<->solve_period_years_represented, solve_mode<->solve_modes,
realized_periods<->realized_periods.

This pass:
- adds a single FEATURE MAP anchor at _spawn_realized_dispatch_siblings (the
  entry point) enumerating every touch-point (schema/guard/registration/
  projection/weighting) so the feature is navigable from one place, and names
  the dual-definition invariant + the W4d directive (reuse the authoritative
  realized_dispatch_parent / per-solve channel, do not add a parallel
  sibling-detection);
- cross-references the two config halves (reader allow-list <-> SolveConfig
  copy/set) so each points at the other and at the feature map;
- adds tests/engine_polars/test_realized_dispatch_coherence.py pinning the
  dual-definition invariant: the reader allow-list == the four defining facts,
  and spawn populates every defining fact's derived dict consistently with the
  parent. Either half drifting now fails loudly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…e_dispatch (W4d-A)

The payoff of the W8→W4a→W4b→W4c arc. In a separate_dispatch stochastic hedge
solve the OPERATIONAL branch weights now de-scenario realized: forecasts
normalize among THEMSELVES (realized excluded from the denominator) and realized's
own operational weight → 0 — safe because W4c's realized-dispatch sibling produces
realized operations, so the hedge solve no longer needs realized weighted. The
parent hedge objective becomes the expected FORECAST cost (+ invest annuity)
instead of the realized-in-cohort artifact.

within_solve (default) is UNCHANGED in this slice (realized→1.0 re-derivation is
W4d-B, deferred). INVEST weight (pd_branch_weight) is UNTOUCHED (W7 owns invest).

Decisive safety constraint: pd_branch_weight_lf (invest) and pdt_branch_weight_lf
(operational) both read the shared solve_branch_weight.csv (realized forced 1.0),
so the realized self-weight change lives ONLY in the OPERATIONAL producers —
derive_solve_branch_weight, pd_branch_weight_lf, and the recourse NPV precondition
(reads pd only) are literally untouched → the precondition passes unchanged
(dissolving the old "realized=1.0→cohort 2.0→crash").

Both pdt producers patched in lock-step (semantically byte-equivalent, verified):
- lazy _derived_branch.pdt_branch_weight_lf (the authoritative LP Param);
- eager _emit_period_calc.emit_branch_weights pdt loop (pdt_branch_weight.csv).
Rule: realized_set={d:(d,d)∈pb}; separate_dispatch → forecast d denom excludes
realized, realized d → 0.0; within_solve/absent → legacy arm verbatim; deterministic
pb-empty early-return untouched.

Method channel (coherence directive — the parent's own flag on the standard
per-solve solve_data channel, not a parallel sibling-detection): emit a one-row
solve_data/dispatch_realization_method.csv at _native_run_model ~:854 from
state.solve.dispatch_realization_method_for(complete_solve[solve]); both producers
read it (absent → within_solve → byte-parity). The auto-spawned sibling drops the
method → resolves within_solve → its realized ops are NOT zeroed (keeps obj 4,380,000).

Realized branch at weight 0 is free/degenerate in the parent but harmless: not
surfaced (sibling owns the realized window via last-wins) and does not corrupt
invest (annuity 283,500 unchanged; forecasts drive capacity).

Tests (FLEXTOOL_AUTOSCALE_STRICT=1): test_w4c_divergent_dispatch adds
test_parent_objective_is_forecast_only_expectation (parent v_obj == 5,101,500 at
rel=1e-9; negative control != 4,851,214.2857, the old realized-in-denominator
value) and updates test_parent_forecasts_genuinely_diverge to assert only the
forecast divergence {20,90} (the realized branch is now weight-0/degenerate, owned
by the sibling). Sibling assertions kept. within_solve goldens byte-identical
(gate is the per-solve marker, not forecast-existence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…y (R1)

The stochastic fan is forecasts-only; "realized" is an optional 0-or-1
designation (realized_yn=="yes"), not a mandatory weighted member. Invert
the W1 triggering-cohort validation accordingly:

- reject 2+ realized (was: reject != exactly one realized)
- reject cohorts with zero forecast (realized:no, weight>0) members
- accept n_real in {0,1} with >=1 forecast
- Block B population guard: found>1 raises; found==0 appends nothing
- keep the period/roll-boundary reveal admission check unchanged

Validation-only: no weight-math, fan-out exclusion, or precondition code
touched (those re-ground in R2/R4/W7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
Re-ground non-anticipativity to the corrected model: the stochastic fan
is forecasts only, so NA ties forecasts TO EACH OTHER via a deterministic
per-anchor-group reference-forecast star, never to the realized trunk.
Works at n_real=0.

model.py: in the db_pairs build (behind the existing height>0 gate),
group raw db_pairs by anchor trunk d; per group pick d_ref = lexically
smallest forecast token, build the star {(d_ref, b!=d_ref)}, and re-key
dt_non_anticipativity's anchor d -> d_ref (same t). Both frames must be
re-keyed: the five NA families inner-join db_pairs AND dtna on d, so a
db_pairs-only re-point emits zero constraints. Pass the re-keyed dtna via
a new dtna_override kwarg to _add_non_anticipativity_constraints. The
deterministic (no-fan) path stays byte-identical (empty raw db_pairs ->
no groups -> no call). No new var/cstr/param/autoscale name.

R-CONT: non_anticipativity_storage_state_first is load-bearing (proven)
and re-points for free via the shared re-grounded frames; kept, not
deleted (deletion deferred to W8b behind a float==0 guard). Docstrings
updated: ties are forecast-to-forecast, not to the realised trunk.

Tests: re-point every trunk-anchored NA assertion to forecast-to-forecast
(trunk excluded from over.d/over.b; forecasts tied equal) across
test_rolling_stochastic_na, test_recourse_invest_na,
test_configurable_nonanticipativity, test_non_anticipativity,
test_flex_2day_stochastic_dispatch_full_storage, and the two emission
tests; add a new n_real=0 fixture (build_stoch_two_period_nreal0) + test
witnessing the forecast star with no realized member. Re-ground the
2_day_stochastic_dispatch scenario golden + pinned objective
(269.406895209 -> 268.081475165; a relaxation, new <= old, verified).

Also completes R1 validation-test fallout in test_recourse_invest_hedge
(corrected-model messages; zero-realized cohorts with >=1 forecast now
accepted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…=0 (R-NAME)

Under the forecasts-only model a mid-horizon fan with no realized
designation had nothing owning the real (un-suffixed) period names, so
pre-reveal branch-keyed data fell through to the zero fallback and the
solve silently corrupted (e.g. the hedge over-invested because its
pre-reveal wind profile read as 0).

Fix: in create_stochastic_periods, when the realized-branch search finds
none (found==0) for a PRE-REVEAL period, bind that period's
solve_branch__time_branch to the reference forecast (d_ref = the
lexically-smallest forecast token, the same hub R-NA's star uses), so its
data binds correctly. Pre-reveal-scoped on purpose: binding the
post-reveal weight-0 phantom would join the recourse lineage chain and
trip the leaf-weight-constancy precondition. Keeps "realized" optional
(not mandated for mid-horizon).

Byte-parity on all existing tests: the guard fires only for the n_real=0
mid-horizon topology, which had no prior fixture. New witness fixture
build_stoch_two_period_midhorizon_nreal0 + test asserting the pre-reveal
profile binds nonzero, SBTB owns (pre_reveal_period, d_ref), and the
here-and-now invest is correct (fails for the right reasons if the guard
is removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…W7+R4+R3)

Complete the forecasts-only re-grounding of branch weighting and the
stochastic-value fixtures. The stochastic fan is forecasts only: the
realized/trunk self-row never enters the forecast weight denominator.

R2/W7 (weight rule): in the three branch-weight producers
(_derived_branch.pd_branch_weight_lf, pdt_branch_weight_lf, and the eager
_emit_period_calc.emit_branch_weights) compute the structural phantom set
(a trunk self-row (d,d) that also has forecast fan members), emit weight 0
for phantom anchors and exclude them from the denominator, so forecasts
renormalize to 1; a lone pre-reveal trunk with no fan member stays 1.0.
This applies to invest (pd) too (W7) and is unconditional — the W4d-A
separate-dispatch weight surgery, dispatch_realization_method_read, and
the solve_data/dispatch_realization_method.csv marker channel are removed
(spawn reads SolveConfig.dispatch_realization_method_for, not the CSV;
schema v75 + migration + resolver + spawn are kept). Also guard the
in-process re-solve path: trust ctx.period_branch only when its anchor
columns are null-free, else fall back to the raw period__branch (mirrors
_collect_lineage_inputs) so the phantom detection isn't defeated under
enum activation.

R4: the three recourse-NPV preconditions pass unchanged (phantom anchor
-> 0, forecasts sum to 1); docstrings updated and test_p3b re-pointed to a
genuinely malformed cohort (obligation (ii) is otherwise a renormalization
tautology).

R3 (fixtures): build_stoch_two_period{,_invest,_hedge} become genuine
{high, low} forecast fans (rlz retagged to a forecast). Hand-calcs
re-derived and driven-confirmed (hedge RP 173250 / EVPI 15750 / VSS
19687.5; recourse-gate 196875; continuation 70080000). For fan-at-first
recourse there is no here-and-now committed stage, so committed invest is
0 (faithful). Retired test_stoch_realized_gating_and_flows (Model 2
realized-dispatch is covered e2e by W8/W4b/W4c). Re-ground the Model 2
fixtures to declare realized as a weight-0 designation (behavior-
preserving: all W8/W4b/W4c objectives byte-identical).

Post-process (A): calc_costs/calc_slacks now apply pdt_branch_weight to
per-(d,t) operational and slack-penalty cost terms (new _branch_weight
helper + a pdt_branch_weight reader), so the reported "Total cost
(calculated)" is the probability-weighted expected cost matching the LP
objective; invest (already weighted), physical diagnostics, and the
capacity-margin penalty are left unweighted to match the LP exactly;
deterministic/single-branch output is byte-identical (weight 1).

2day scenario: retag its stray realized branch to a forecast (genuine
4-forecast fan; the realized designation was vestigial in a single solve
with no realization region) and regenerate the golden + expected_objective
(268.081475165 -> 66.9291187913 = the LP objective, zero slack). Add
test_single_invest_stochastic_ops pinning the default structure (single
per-period invest + branch-fanned operations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
The recourse lineage/annuity leaf labelled "__realized" is a misnomer
under the corrected model: it is the real-named calendar-anchor chain used
to dedupe a shared pre-reveal capacity's annuity to one charge per
calendar period (not per branch-copy) — it is NOT a realized scenario
(realized is an optional designation, never a weighted fan member).

Pure, behavior-preserving relabel: the leaf is an opaque LP-row key that
is produced in exactly one place (_lineage_leaves) and compared nowhere
(the annuity dedup tie-break keys off member==anchor, not the label), so
renaming "__realized" -> "__anchor" changes no numeric result. The
annuity over-count fix it guards is untouched (hedge RP=173250, annuity
windows K1/K2=2100/3150). Docstrings/comments across _derived_branch.py,
input.py, model.py re-grounded to describe the corrected model; the three
test assertion literals updated. Nothing deleted: dd_same_scenario,
dd_same_scenario_annuity, the dedup, and the per-path invest caps all stay
(they remain load-bearing for mid-horizon / multi-stage recourse-invest,
a kept capability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
… (H1)

The rolling-stochastic roll-to-roll storage carry (p_roll_continue_state)
read the wrong value: it preferred the in-memory flex_data.realized_dispatch,
which is loaded from the forecast-EXPANDED emit set (all branch periods ×
the full look-ahead window), so its lexically-last (d,t) was the discarded
HORIZON-END of a FORECAST branch (e.g. period1_upper@t0096), not the
committed JUMP-END of the realized TRUNK where the next roll continues.

Fix (value-only, soft — no model.py / no variable pin): prefer the Provider
anchor solve_data/realized_dispatch (trunk-only, committed jump-end, emitted
by emit_realized_dispatch before the handoff is built) and fall back to the
in-memory frame only when the Provider key is absent (unit-test synthesis).
This restores the GMPL value source. Driven on 1_week_rolling_wind: all
continuation rolls now carry the trunk jump-end (0.0) instead of 2500.

Pre-existing (orphan-root) defect, latent on current fixtures because the
soft carry is operationally inert under every wired storage-binding method
(so solutions are byte-identical); it would distort a model whose storage
economics bind across the roll boundary. A solution-level witness is
therefore not constructible, so the regression guard is producer-level:
test_rolling_handoff_carries_trunk_committed_jump_end_storage asserts the
carried value is the trunk committed jump-end (fails on the old 2500,
checking both the wrong-step and wrong-branch defects).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
Integrate main's indirect-unit capacity / input shares / per-unit min load
/ reserve fixes (#346) and the 4.0.5 release into feat/stochastics-reserve-v74.

Schema version collision resolved: base was v69; main added one migration
(v70); this branch added six (to v75). Both defined a different v70. Kept
main's v70 (capacity-coefficient / input-share migration) at slot 70 and
RENUMBERED the branch's six migrations to 71-76 (FLEXTOOL_DB_VERSION=76);
the migrations are disjoint and order-insensitive. Renamed the six migration
test files test_v70..v75 -> test_v71..v76. Canonical DBs + fixtures
regenerated to a consistent v76 (sync_master_json_template / test_fixtures /
canonical_databases / generate_canonical); the stochastics 2day retag
survived migrate-all.

Engine files model.py (+1543 from main), _derived_params, read_parameters,
drop_levels auto-merged. Three genuine code conflicts resolved deliberately:
- p_min_load: composed structurally — main's per-unit min-load semantics
  live in the minFlow_minload constraint (sum over a unit's outputs), our
  (d,t) map-reading capability is the producer shape; a (p,d,t) p_min_load
  flows through the per-unit constraint unchanged (both sides' tests pass).
- calc_slacks reserve-slack penalty: combined main's shortfall_scale/reindex
  with our Option-A pdt_branch_weight; shortfall_scale subsumes the old
  reservation factor (single-apply, no double-count), matching the LP.
- input.py synthetic-solve wiring: additive union (MINLOAD-1 + DELAY-1 +
  CAPCOEF-1). CHANGELOG: both release-note sets.

Full suite green (4145 passed). Merge performed on scratch branch
integrate/main-4.0.5-stochastics; feat/stochastics-reserve-v74 untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c
…y cast

The construction-lead-time commission_lf desync guard relied on polars
<2.0 nulling an i64->Enum key cast under strict=False, then detecting the
zero surviving lag rows and raising CommissioningLagError. polars >=2.0
treats i64->enum as an unsupported-type cast and raises ComputeError
outright (regardless of strict), so CI (polars 2.0) surfaced the raw
polars error instead of the dedicated guard error (and risked the
downstream `except Exception` hiding it). Catch the polars cast error in
the survivor-check and raise the same CommissioningLagError — the
non-empty pre-cast overlap with an unmaterializable typed join IS the
dtype desync the guard exists to catch. Byte-parity on polars 1.40 (the
null-survivors path is unchanged); fixes the sole polars-2.0 failure in
the 4.0.5 merge CI (test_construction_lead_time_guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c

This branch has not been deployed

No deployments
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