spec: 0048 walk-forward param plane

Settled-design spec for the walk-forward param-plane cut: WalkForwardResult gains space: Vec<ParamSpec>, WindowRun.chosen_params Vec<Scalar> -> Vec<Cell>, walk_forward takes the space by value (Fn-closure generic untouched), and param_stability schema-checks a per-slot coercer (Bool -> 0/1, Timestamp unreachable per C20) before a type-blind reduction, deleting scalar_as_f64 + its per-value unreachable.

Behaviour-preserving (C1): param_stability yields identical MetricStats over the existing fixtures. Overturns 0047's Fork A (chosen_params stays Vec<Scalar>) — the WF structs carry no serde derives, so the serializability defense is moot; the substantive case is C7 purity + SweepFamily symmetry. grounding-check PASS.

refs #75
This commit is contained in:
2026-06-16 18:42:20 +02:00
parent 86746e3d5d
commit 791b809413
+280
View File
@@ -0,0 +1,280 @@
# Walk-forward param plane: space on the family, tag-free `chosen_params`, schema-checked `param_stability` — Design Spec
**Date:** 2026-06-16
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
> Seeding issue: `Brummel/Aura#75`. Overturns the "Fork A" out-of-scope note from
> cycle 0047 (`refactor(aura-core): Scalar as a native tagged enum …`, 86746e3).
## Goal
Make the walk-forward param plane C7-clean and symmetric with the sweep param plane:
the param **kinds** live once on the family (`space: Vec<ParamSpec>`), the per-window
chosen points are tag-free (`Vec<Cell>`), and `param_stability` does its one numeric
coercion against the **schema** (once per slot) instead of against the **value** (once
per window-value, guarded by a per-value `unreachable!`).
This is a single, indivisible, behaviour-preserving (C1) cut. It removes a redundancy
(the param kind, constant per slot, was carried N×M times at the value) and a latent
unsafety (the per-value `unreachable!` re-asserting an invariant the schema already
fixes).
### Why now / why this is not still Fork A
Cycle 0047 kept `WindowRun.chosen_params` as `Vec<Scalar>`, justified by "Scalar is now
serializable, so a `WindowRun` is a self-describing record." That defense does not hold:
`WalkForwardResult` and `WindowRun` carry **no serde derives** — they are never
serialized. Only the embedded `oos_report` (a `RunReport`, already typed via the 0047
`RunManifest.params` lift) is. So `chosen_params` is a **pure in-memory substrate** for
`param_stability`, and the substantive case (C7 purity, `SweepFamily` symmetry, per-value
`unreachable!` elimination) wins over a moot serialization defense — the same
argument-form that retired the `{kind, cell}` Scalar struct in 0047: "it works" does not
beat "it fits structurally."
## Architecture
`SweepFamily` already shows the C7-clean shape in the same crate (`sweep.rs`): the family
owns `space: Vec<ParamSpec>`; each point's coordinate is a tag-free `Vec<Cell>`; the named
view is recombined on demand via `zip_params(&space, point)`. Walk-forward is the lone
asymmetry — it dissolves the point to `Scalar` early and discards the space. This cycle
makes walk-forward share that **idiom** (`space` + tag-free `Cell` points + `zip_params`),
while the two stay **distinct types**: Sweep's axis *is* the param-space (`points[i].params`
is an enumerated **input** coordinate); walk-forward's axis is **time** (C12 axis 3),
`chosen_params` is the inner optimizer's **output**, and the walk-forward family
additionally carries `bounds`, per-window `oos_equity`, and `stitched_oos_equity` — none of
which Sweep has. No unified container, no shared trait: the shared substance is the
convention plus the free function `zip_params`, not the struct.
Three coupled parts, indivisible (β1's `Vec<Cell>` forces [A]'s space-threading, which is
what lets [C]/[D] read kinds from the schema):
- **β1 — data shape.** `WalkForwardResult` gains `space: Vec<ParamSpec>`;
`WindowRun.chosen_params: Vec<Scalar>``Vec<Cell>`.
- **[A] — the `walk_forward` seam.** The `space` is threaded as an explicit by-value
parameter, *beside* the run-window closure; the `Fn` generic is untouched.
- **[C]/[D] — schema-checked reduction.** `param_stability` builds a per-slot coercer from
`result.space` *before* a type-blind reduction loop; `scalar_as_f64` and its per-value
`unreachable!` are deleted.
## Concrete code shapes
### What the cycle delivers (the consuming code, after)
`param_stability` is the one real consumer of `chosen_params`. Its public signature is
**unchanged** (the space is on the result now); its body becomes schema-checked and
type-blind:
```rust
// crates/aura-engine/src/walkforward.rs — param_stability, AFTER
pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
// [C] ONE schema pass, before any reduction: resolve a coercer per slot.
// Kind is touched here only — nslots-many times, never per window-value.
let coerce: Vec<fn(Cell) -> f64> = result
.space
.iter()
.map(|ps| match ps.kind {
ScalarKind::I64 => (|c: Cell| c.i64() as f64) as fn(Cell) -> f64,
ScalarKind::F64 => |c: Cell| c.f64(),
// [D] Bool -> 0/1 keeps the coercer TOTAL over the knob kinds
// (mean = fraction "on"). Timestamp is never a knob (C20) -> a
// single schema-level unreachable, not a per-value one.
ScalarKind::Bool => |c: Cell| c.bool() as i64 as f64,
ScalarKind::Timestamp => unreachable!("timestamp is a structural axis (C20), never a param knob"),
})
.collect();
// The reduction loop is now type-blind: no kind, no match, no per-value unreachable.
coerce
.iter()
.enumerate()
.map(|(slot, f)| {
let vals: Vec<f64> =
result.windows.iter().map(|w| f(w.run.chosen_params[slot])).collect();
MetricStats::from_values(&vals)
})
.collect()
}
```
### North-star slice — the one production caller, after
`walkforward_family` (in `aura-cli`) is the real walk-forward author surface. With β1 +
[A] its closure gets *simpler* — it no longer reconstructs `Scalar`s, and the space is
computed once:
```rust
// crates/aura-cli/src/main.rs — walkforward_family, AFTER
fn walkforward_family() -> WalkForwardResult {
// ... sources, span, roller ...
let space = sample_blueprint_with_sinks().0.param_space(); // ONCE; same blueprint every window
walk_forward(roller, space, |w: WindowBounds| {
let is_family = sweep_over(w.is.0, w.is.1);
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1);
WindowRun {
chosen_params: best.params, // already Vec<Cell>; the from_cell+zip map is gone
oos_equity,
oos_report,
}
})
}
```
The typed-but-tag-free shape is what a downstream walk-forward consumer reads: the named
view is `zip_params(&result.space, &result.windows[i].run.chosen_params)` — exactly the
`SweepFamily::named_params` idiom. (An inherent `WalkForwardResult::named_params` mirroring
it is a natural follow-on, **out of scope** for this cycle.)
### Before → after, per load-bearing change
**1. The two structs (β1).**
```rust
// walkforward.rs — BEFORE
pub struct WindowRun {
pub chosen_params: Vec<Scalar>,
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
}
pub struct WalkForwardResult {
pub windows: Vec<WindowOutcome>,
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
}
// AFTER
pub struct WindowRun {
pub chosen_params: Vec<Cell>, // tag-free coordinate (kind lives on the family)
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
}
pub struct WalkForwardResult {
pub space: Vec<ParamSpec>, // the kinds, once (mirrors SweepFamily.space)
pub windows: Vec<WindowOutcome>,
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
}
```
`WindowOutcome` (`{ bounds, run }`) is unchanged. The derives on both structs
(`Clone, Debug, PartialEq`) are unchanged — no serde is added (the structs are not
serialized; `ParamSpec` already derives `PartialEq`/`Eq`).
**2. The `walk_forward` seam ([A]).**
```rust
// walkforward.rs — BEFORE
pub fn walk_forward<F>(roller: WindowRoller, run_window: F) -> WalkForwardResult
where F: Fn(WindowBounds) -> WindowRun + Sync { /**/ }
fn walk_forward_with_threads<F>(roller: WindowRoller, nthreads: usize, run_window: F) -> WalkForwardResult
where F: Fn(WindowBounds) -> WindowRun + Sync { /**/ }
// AFTER — space is a sibling parameter; the Fn bound is IDENTICAL
pub fn walk_forward<F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult
where F: Fn(WindowBounds) -> WindowRun + Sync { /**/ }
fn walk_forward_with_threads<F>(roller: WindowRoller, space: Vec<ParamSpec>, nthreads: usize, run_window: F) -> WalkForwardResult
where F: Fn(WindowBounds) -> WindowRun + Sync {
// … collect bounds, run_indexed (UNCHANGED — space is never captured by the Sync closure) …
debug_assert!(
runs.iter().all(|r| r.chosen_params.len() == space.len()),
"every window's chosen point must match the param-space arity (same blueprint)"
);
WalkForwardResult { space, windows, stitched_oos_equity } // space moved in at the end
}
```
The space is schema metadata, passed by value and moved onto the result; it is never
captured by the `Sync` run-window closure, so `run_indexed` (the parallelism core,
`sweep.rs`) needs **zero** change. The `debug_assert` guards the one new caller contract:
the externally-supplied `space` must match the closure-produced points' arity — guaranteed
because every window runs the same blueprint, so its param-space (arity + kinds) is
identical (the bootstrap invariant the current code already relies on when it reads
`nslots` from the first window).
**3. `scalar_as_f64` is deleted.** The per-value coercion + `unreachable!`
(`walkforward.rs` ~`:254`) is removed entirely; the schema-pass coercer in `param_stability`
replaces it.
## Components
- **`crates/aura-engine/src/walkforward.rs`** — the bulk. Struct defs (`WindowRun`,
`WalkForwardResult`); `walk_forward` / `walk_forward_with_threads` signatures + bodies
(+ the arity `debug_assert`); `param_stability` rewrite (schema-pass + type-blind loop);
delete `scalar_as_f64`. Imports gain `ParamSpec`; the `chosen_params` path no longer
needs `Scalar`. The in-file test fixtures migrate (see Testing strategy).
- **`crates/aura-cli/src/main.rs`** — `walkforward_family`: compute `space` once at the top,
pass it to `walk_forward`, simplify the closure to `chosen_params: best.params`. The other
`WalkForwardResult` readers in this file (`result.windows``oos_report`,
`result.stitched_oos_equity` → total pips) are **unaffected** — they never touch
`chosen_params` or `space`.
- **`crates/aura-registry/src/lineage.rs`** — reads `result.windows[..].run.oos_report`
only; **unaffected** (verified: no `chosen_params` / `space` reader). Listed so the
planner confirms it during implement.
## Data flow
- **Kinds (schema):** caller `bp.param_space()``walk_forward(space, …)` → moved onto
`WalkForwardResult.space`. Computed once; identical for every window (same blueprint).
- **Points (values):** run-window closure → `best.params` (`Vec<Cell>`, the sweep winner)
`WindowRun.chosen_params`. Tag-free; never carries a kind.
- **Reduction:** `param_stability` reads kinds from `result.space` (once per slot → coercer
table) and values from `result.windows[*].run.chosen_params[slot]` (per window) →
`MetricStats::from_values`.
- **Named view (existing idiom, on demand):** `zip_params(&result.space, &chosen_params)`
recombines slot names with typed values — the same mechanism `SweepFamily::named_params`
uses.
## Error handling
- **Arity coupling:** a `debug_assert` in `walk_forward_with_threads` checks every window's
`chosen_params.len() == space.len()`. No release-mode runtime error variant is added — a
mismatch is a wiring bug (caller passed the wrong blueprint's space), surfaced like the
engine's other "checked at wiring" violations.
- **Non-numeric slot:** `Bool` is handled (→ 0/1), not an error. `Timestamp` in a param
slot is structurally impossible (C20: a timestamp is a structural axis, never a numeric
knob), so the schema pass carries a single `unreachable!` on that arm — reached once per
slot at most, never per window-value. This replaces the old per-value `unreachable!`.
- No new `CompileError` / runtime-error variants; no change to any error type.
## Testing strategy
Behaviour-preserving (C1): the migration re-spells fixtures, not values.
- **Existing tests stay green, adapted to the new shapes:**
- `walk_forward_runs_one_window_per_split_in_roll_order` and
`walk_forward_is_deterministic_across_thread_counts` — thread the new `space` argument
into the `walk_forward` / `walk_forward_with_threads` calls; the run-window fixture's
`WindowRun` now yields `chosen_params: Vec<Cell>` (`Scalar::i64/f64(x)`
`Cell::from_i64/from_f64(x)`). Window bounds / determinism assertions are unchanged.
- `param_stability_reduces_chosen_params_per_slot` — the `WindowRun` literals use `Cell`
points and the `WalkForwardResult` literal gains a two-slot `space` fixture
(`ParamSpec { name, kind: I64 }`, `ParamSpec { name, kind: F64 }`). The asserted
`MetricStats` are **identical** (the `I64→f64` / `F64` coercions are value-identical to
the deleted `scalar_as_f64`).
- **One genuinely new behaviour — the `Bool → 0/1` arm.** No built-in grid produces a
`Bool` param, so add a focused `param_stability` test with a `Bool` slot in `space` and
`Cell::from_bool` points across windows, asserting `mean` = the fraction of `true`
windows. This keeps the new coercer arm non-dead and tested.
- **The four project gates:** `cargo build --workspace --all-targets`;
`cargo test --workspace`; `cargo clippy --workspace --all-targets -- -D warnings`;
`cargo doc --workspace --no-deps` — all clean.
## Acceptance criteria
1. `WindowRun.chosen_params` is `Vec<Cell>`; `WalkForwardResult` has `space: Vec<ParamSpec>`;
neither struct gains a serde derive.
2. `walk_forward` and `walk_forward_with_threads` take `space: Vec<ParamSpec>` by value; the
`Fn(WindowBounds) -> WindowRun + Sync` bound is unchanged; `run_indexed` is unchanged.
3. `walkforward_family` computes the space once and passes `chosen_params: best.params`
(no `from_cell` reconstruction).
4. `param_stability` reads `result.space`, builds a per-slot coercer before a type-blind
reduction loop, and contains no per-value `unreachable!`; `scalar_as_f64` is deleted.
Its public signature is unchanged.
5. The arity `debug_assert` is present in `walk_forward_with_threads`.
6. The three existing walk-forward tests are green with **identical** `MetricStats`; the new
`Bool`-slot `param_stability` test is green.
7. All four gates clean.
8. Spot-check greps: `scalar_as_f64` absent from `crates/`; no `unreachable!` in a per-value
path in `walkforward.rs`; `WindowRun.chosen_params: Vec<Cell>`; `WalkForwardResult` has
`space: Vec<ParamSpec>`.