Files
Aura/docs/specs/0048-walk-forward-param-plane.md
T
Brummel 791b809413 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
2026-06-16 18:42:20 +02:00

281 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>`.