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:
@@ -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>`.
|
||||
Reference in New Issue
Block a user