From 6ce00ee053eb41772a3d376f823b03fd6203cc16 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 16 Jun 2026 18:52:32 +0200 Subject: [PATCH] plan: 0048 walk-forward param plane Two-task execution plan for spec 0048: Task 1 lands the whole aura-engine/walkforward.rs cut atomically (struct shapes, walk_forward seam, param_stability rewrite, scalar_as_f64 deletion, fixture migration, new Bool-slot test) gated on a crate-only `cargo test -p aura-engine`; Task 2 threads the aura-cli caller and runs the workspace gates. Keeps a windows.is_empty() guard in param_stability to preserve the documented empty-on-no-windows contract (C1). refs #75 --- docs/plans/0048-walk-forward-param-plane.md | 604 ++++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 docs/plans/0048-walk-forward-param-plane.md diff --git a/docs/plans/0048-walk-forward-param-plane.md b/docs/plans/0048-walk-forward-param-plane.md new file mode 100644 index 0000000..0dcaf3b --- /dev/null +++ b/docs/plans/0048-walk-forward-param-plane.md @@ -0,0 +1,604 @@ +# Walk-forward param plane: space on the family, tag-free chosen_params, schema-checked param_stability — Implementation Plan + +> **Parent spec:** `docs/specs/0048-walk-forward-param-plane.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run this +> plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make the walk-forward param plane C7-clean and symmetric with the sweep param +plane — kinds live once on `WalkForwardResult.space`, per-window chosen points become +tag-free `Vec`, and `param_stability` coerces against the schema (once per slot) not +the value (once per window-value) — a single, behaviour-preserving (C1) cut. + +**Architecture:** β1 changes the two struct shapes; [A] threads `space` through +`walk_forward`/`walk_forward_with_threads` as a by-value sibling of the run-window closure +(the `Fn` generic untouched); [C]/[D] rewrite `param_stability` to a per-slot coercer over +`result.space` and delete `scalar_as_f64`. The cut is compile-coupled within +`aura-engine`, so Task 1 lands all of `walkforward.rs` atomically (its gate is an +`aura-engine`-only build/test — `aura-cli` is a separate crate, threaded in Task 2). + +**Tech Stack:** `crates/aura-engine/src/walkforward.rs` (the bulk), +`crates/aura-cli/src/main.rs` (`walkforward_family`), `crates/aura-registry/src/lineage.rs` +(verify-only). Types from `aura_core`: `Cell`, `ParamSpec`, `ScalarKind`. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-engine/src/walkforward.rs` — struct shapes, `walk_forward`(`_with_threads`) signatures+bodies, `param_stability` rewrite, delete `scalar_as_f64`, migrate in-file test fixtures, add the Bool-slot test. +- Modify: `crates/aura-cli/src/main.rs:452-475` — `walkforward_family`: compute `space` once, thread into `walk_forward`, simplify the closure. +- Verify (no edit): `crates/aura-registry/src/lineage.rs:167-168` — reads `oos_report` only; confirm by grep. + +--- + +## Task 1: `aura-engine/walkforward.rs` — the atomic param-plane cut + +**Files:** +- Modify: `crates/aura-engine/src/walkforward.rs` + +This whole task is one `aura-engine` compile unit: the struct field-type change, the +signature change, and the `param_stability` rewrite must land together (any subset leaves +a type error). All in-file callers (the test block) are threaded inside this task, so the +task-closing gate (`cargo test -p aura-engine`) is satisfiable. `aura-cli`'s caller is a +separate crate, deferred to Task 2. + +- [ ] **Step 1: Fix the production imports** + +`crates/aura-engine/src/walkforward.rs:15` is currently: + +```rust +use crate::{MetricStats, RunReport, Scalar, Timestamp}; +``` + +Replace with (drop `Scalar` — unused after this task; add `ParamSpec` + `ScalarKind`, both +re-exported by `aura-engine`'s `lib.rs`; add `Cell` direct from `aura_core`, mirroring +`sweep.rs:7`, since `aura-engine` does not re-export `Cell`): + +```rust +use crate::{MetricStats, ParamSpec, RunReport, ScalarKind, Timestamp}; +use aura_core::Cell; +``` + +- [ ] **Step 2: β1 — `WindowRun.chosen_params: Vec` → `Vec` + doc** + +At `walkforward.rs:123-132`, the struct is: + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct WindowRun { + /// The chosen params, kept as self-describing [`Scalar`]s — the record carrier + /// (serializable). `param_stability` reduces them to `f64` only at the + /// statistic boundary (a distribution over windows is intrinsically + /// real-valued); the typed value is preserved up to that one reduction. + pub chosen_params: Vec, + pub oos_equity: Vec<(Timestamp, f64)>, + pub oos_report: RunReport, +} +``` + +Replace with (field type + doc rewrite; the kind now lives on the family, not the value): + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct WindowRun { + /// The chosen params as the tag-free coordinate [`Cell`]s the inner sweep + /// produced (C7: the kind lives once on [`WalkForwardResult::space`], not at the + /// value). The named/typed view is `zip_params(&result.space, &chosen_params)` — + /// the same idiom as [`SweepFamily::named_params`](crate::SweepFamily). + pub chosen_params: Vec, + pub oos_equity: Vec<(Timestamp, f64)>, + pub oos_report: RunReport, +} +``` + +- [ ] **Step 3: β1 — `WalkForwardResult` gains `space: Vec`** + +At `walkforward.rs:147-160`, the struct is: + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct WalkForwardResult { + /// Per-window outcomes in roll order (window 0 first), independent of thread + /// completion (C1). + pub windows: Vec, + /// The stitched continuous OOS pip-equity curve: each window's OOS segment + /// offset by the running sum of all prior segments' final cumulative values, + /// so equity carries forward across the IS/OOS boundaries (continuous, not a + /// per-window sawtooth). An empty OOS segment contributes `0.0` to the running + /// offset and no points, so an equity-less window leaves the curve unbroken. + /// For the canonical `step == oos_len` tiling the segment timestamps are also + /// contiguous. + pub stitched_oos_equity: Vec<(Timestamp, f64)>, +} +``` + +Insert the `space` field as the first field (mirrors `SweepFamily.space`): + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct WalkForwardResult { + /// The param-space schema (kinds + slot names), once for the whole family — + /// the kinds the per-window [`WindowRun::chosen_params`] cells are read against + /// (C7). Identical for every window (same blueprint). Mirrors + /// [`SweepFamily::space`](crate::SweepFamily); the two stay distinct types. + pub space: Vec, + /// Per-window outcomes in roll order (window 0 first), independent of thread + /// completion (C1). + pub windows: Vec, + /// The stitched continuous OOS pip-equity curve: each window's OOS segment + /// offset by the running sum of all prior segments' final cumulative values, + /// so equity carries forward across the IS/OOS boundaries (continuous, not a + /// per-window sawtooth). An empty OOS segment contributes `0.0` to the running + /// offset and no points, so an equity-less window leaves the curve unbroken. + /// For the canonical `step == oos_len` tiling the segment timestamps are also + /// contiguous. + pub stitched_oos_equity: Vec<(Timestamp, f64)>, +} +``` + +- [ ] **Step 4: [A] — `walk_forward` signature + the forwarding call** + +At `walkforward.rs:170-176`, the public `walk_forward` is: + +```rust +pub fn walk_forward(roller: WindowRoller, run_window: F) -> WalkForwardResult +where + F: Fn(WindowBounds) -> WindowRun + Sync, +{ + let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + walk_forward_with_threads(roller, nthreads, run_window) +} +``` + +Add the `space` parameter (a by-value sibling of the closure) and forward it; also add a +one-line doc note on the param. Replace with: + +```rust +pub fn walk_forward(roller: WindowRoller, space: Vec, run_window: F) -> WalkForwardResult +where + F: Fn(WindowBounds) -> WindowRun + Sync, +{ + let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + walk_forward_with_threads(roller, space, nthreads, run_window) +} +``` + +- [ ] **Step 5: [A] — `walk_forward_with_threads` signature + body (arity `debug_assert` + space onto the result)** + +At `walkforward.rs:184-202`, the function is: + +```rust +fn walk_forward_with_threads( + roller: WindowRoller, + nthreads: usize, + run_window: F, +) -> WalkForwardResult +where + F: Fn(WindowBounds) -> WindowRun + Sync, +{ + let bounds: Vec = roller.collect(); + debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll"); + let runs = run_indexed(bounds.len(), nthreads, |i| run_window(bounds[i])); + let windows: Vec = bounds + .into_iter() + .zip(runs) + .map(|(bounds, run)| WindowOutcome { bounds, run }) + .collect(); + let stitched_oos_equity = stitch(&windows); + WalkForwardResult { windows, stitched_oos_equity } +} +``` + +Replace with (add `space` param after `roller`; `run_indexed` line unchanged — `space` is +never captured by the `Sync` closure; add the arity `debug_assert` on `windows` since +`runs` is already moved into the `.zip`; move `space` onto the result): + +```rust +fn walk_forward_with_threads( + roller: WindowRoller, + space: Vec, + nthreads: usize, + run_window: F, +) -> WalkForwardResult +where + F: Fn(WindowBounds) -> WindowRun + Sync, +{ + let bounds: Vec = roller.collect(); + debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll"); + let runs = run_indexed(bounds.len(), nthreads, |i| run_window(bounds[i])); + let windows: Vec = bounds + .into_iter() + .zip(runs) + .map(|(bounds, run)| WindowOutcome { bounds, run }) + .collect(); + let stitched_oos_equity = stitch(&windows); + debug_assert!( + windows.iter().all(|w| w.run.chosen_params.len() == space.len()), + "every window's chosen point must match the param-space arity (same blueprint)" + ); + WalkForwardResult { space, windows, stitched_oos_equity } +} +``` + +- [ ] **Step 6: [C]/[D] — rewrite `param_stability` (schema-pass coercer, type-blind loop, no-windows guard) + doc** + +At `walkforward.rs:223-248`, the doc + function are: + +```rust +/// On-demand param-stability summary: one [`MetricStats`](crate::MetricStats) per +/// param slot, over the chosen values (`Scalar` coerced to `f64`) across all +/// windows — reuses [`MetricStats::from_values`](crate::MetricStats::from_values). +/// A pure reduction over `result.windows[*].run.chosen_params`, NOT stored on the +/// result (sweep-precedent: a summary is computed on demand, not cached), so it +/// stays non-redundant and the caller can compute any other measure (std, IQR, +/// distinct-count) from the same raw substrate. One entry per param slot, in slot +/// order; empty if there are no windows or the strategy has no params. The slot +/// count is taken from the first window (all windows of one blueprint share the +/// param-space arity, C11). +pub fn param_stability(result: &WalkForwardResult) -> Vec { + let Some(first) = result.windows.first() else { + return Vec::new(); + }; + let nslots = first.run.chosen_params.len(); + (0..nslots) + .map(|slot| { + let vals: Vec = result + .windows + .iter() + .map(|w| scalar_as_f64(w.run.chosen_params[slot])) + .collect(); + MetricStats::from_values(&vals) + }) + .collect() +} +``` + +Replace with (read kinds from `result.space` into a per-slot coercer **before** the +reduction; keep the documented "empty if no windows or no params" contract via the +`is_empty` guard + the empty-`space` ⇒ empty-`coerce` path): + +```rust +/// On-demand param-stability summary: one [`MetricStats`](crate::MetricStats) per +/// param slot, over the chosen values across all windows — reuses +/// [`MetricStats::from_values`](crate::MetricStats::from_values). A pure reduction +/// over `result.windows[*].run.chosen_params`, NOT stored on the result +/// (sweep-precedent: a summary is computed on demand, not cached). One entry per +/// param slot, in slot order; empty if there are no windows or the strategy has no +/// params. The numeric coercion is resolved once per slot against the schema +/// (`result.space`, C7) — never per window-value: an `i64`/`f64` slot maps to its +/// value, a `bool` slot to `0.0`/`1.0` (so `mean` is the fraction of windows that +/// chose `true`). A `timestamp` slot is structurally impossible (C20: a timestamp +/// is a structural axis, never a numeric knob). +pub fn param_stability(result: &WalkForwardResult) -> Vec { + if result.windows.is_empty() { + return Vec::new(); + } + let coerce: Vec 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(), + ScalarKind::Bool => |c: Cell| c.bool() as i64 as f64, + ScalarKind::Timestamp => { + unreachable!("timestamp is a structural axis (C20), never a param knob") + } + }) + .collect(); + coerce + .iter() + .enumerate() + .map(|(slot, f)| { + let vals: Vec = + result.windows.iter().map(|w| f(w.run.chosen_params[slot])).collect(); + MetricStats::from_values(&vals) + }) + .collect() +} +``` + +- [ ] **Step 7: Delete `scalar_as_f64`** + +At `walkforward.rs:250-260`, delete the whole doc + function: + +```rust +/// Coerce a numeric `Scalar` to `f64` for stability statistics — the one place a +/// distribution over windows demands a real-valued reduction. Params are +/// `i64`/`f64` typed values; a non-numeric scalar in a param slot is a wiring +/// bug, surfaced like the engine's other "checked at wiring" violations. +fn scalar_as_f64(s: Scalar) -> f64 { + match s.kind() { + aura_core::ScalarKind::I64 => s.as_i64() as f64, + aura_core::ScalarKind::F64 => s.as_f64(), + other => unreachable!("non-numeric param scalar: {other:?}"), + } +} +``` + +(Its only call site, in `param_stability`, was removed in Step 6.) + +- [ ] **Step 8: Migrate test fixtures to `Cell` + add a `space` fixture helper, and drop the now-unused test `use`** + +In `walkforward.rs`, the test block opens (`:264-266`) with: + +```rust + use super::*; + use crate::{summarize, RunManifest}; + use aura_core::Scalar; +``` + +Remove the `use aura_core::Scalar;` line (Scalar is no longer used in the tests after this +step; `Cell`/`ParamSpec`/`ScalarKind` reach the tests via `use super::*;`): + +```rust + use super::*; + use crate::{summarize, RunManifest}; +``` + +Then add a shared 2-slot space fixture helper (place it right after the `dummy_report` +helper, before `outcome` at `:283`): + +```rust + /// The 2-slot param-space the run-window fixtures choose into: an i64 length + /// and an f64 scale. Matches `run_window_fixture` / `mk` arity (debug_assert). + fn fixture_space() -> Vec { + vec![ + ParamSpec { name: "len".to_string(), kind: ScalarKind::I64 }, + ParamSpec { name: "scale".to_string(), kind: ScalarKind::F64 }, + ] + } +``` + +Migrate the `outcome` helper signature (`:283`) — `chosen: Vec` → `Vec`: + +```rust + fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec) -> WindowOutcome { +``` + +(Its callers all pass `vec![]`, so they are type-only churn and need no edit.) + +Migrate `run_window_fixture` (`:299`) — the `chosen_params` literal: + +```rust + chosen_params: vec![Scalar::i64(k % 3), Scalar::f64(k as f64 * 0.5)], +``` + +becomes: + +```rust + chosen_params: vec![Cell::from_i64(k % 3), Cell::from_f64(k as f64 * 0.5)], +``` + +- [ ] **Step 9: Thread `space` into the in-file `walk_forward` / `walk_forward_with_threads` calls** + +In `walk_forward_runs_one_window_per_split_in_roll_order` (`:315`): + +```rust + let result = walk_forward(roller, run_window_fixture); +``` + +becomes: + +```rust + let result = walk_forward(roller, fixture_space(), run_window_fixture); +``` + +In `walk_forward_is_deterministic_across_thread_counts` (`:330-333`): + +```rust + let one = walk_forward_with_threads(cfg(), 1, run_window_fixture); + let many = walk_forward_with_threads(cfg(), 8, run_window_fixture); + assert_eq!(one, many); + assert_eq!(one, walk_forward(cfg(), run_window_fixture)); +``` + +becomes (every call passes the same `fixture_space()`, so the `assert_eq!` over +`WalkForwardResult` — which now includes `space` — still holds): + +```rust + let one = walk_forward_with_threads(cfg(), fixture_space(), 1, run_window_fixture); + let many = walk_forward_with_threads(cfg(), fixture_space(), 8, run_window_fixture); + assert_eq!(one, many); + assert_eq!(one, walk_forward(cfg(), fixture_space(), run_window_fixture)); +``` + +- [ ] **Step 10: Migrate the `param_stability` test (Cell points + space fixture; MetricStats identical)** + +In `param_stability_reduces_chosen_params_per_slot` (`:374-388`), the `mk` helper and the +result literal are: + +```rust + let mk = |a: i64, b: f64| WindowOutcome { + bounds: WindowBounds { + is: (Timestamp(0), Timestamp(0)), + oos: (Timestamp(0), Timestamp(0)), + }, + run: WindowRun { + chosen_params: vec![Scalar::i64(a), Scalar::f64(b)], + oos_equity: vec![], + oos_report: dummy_report(), + }, + }; + let result = WalkForwardResult { + windows: vec![mk(2, 1.0), mk(2, 1.0), mk(3, 2.0), mk(3, 2.0)], + stitched_oos_equity: vec![], + }; +``` + +Replace with (Cell points; add the `space` field; the asserted `MetricStats` at `:390-393` +stay byte-identical — `I64→f64` / `F64` coerce to the same values): + +```rust + let mk = |a: i64, b: f64| WindowOutcome { + bounds: WindowBounds { + is: (Timestamp(0), Timestamp(0)), + oos: (Timestamp(0), Timestamp(0)), + }, + run: WindowRun { + chosen_params: vec![Cell::from_i64(a), Cell::from_f64(b)], + oos_equity: vec![], + oos_report: dummy_report(), + }, + }; + let result = WalkForwardResult { + space: fixture_space(), + windows: vec![mk(2, 1.0), mk(2, 1.0), mk(3, 2.0), mk(3, 2.0)], + stitched_oos_equity: vec![], + }; +``` + +(The four assertions below it — `stab.len()==2`, `stab[0].mean==2.5`, `stab[0].p50==2.5`, +`stab[1].mean==1.5` — are unchanged.) + +- [ ] **Step 11: Add the new Bool-slot test (the one genuinely new behaviour, [D])** + +Insert this test immediately after `param_stability_reduces_chosen_params_per_slot` +(after `:394`): + +```rust + #[test] + fn param_stability_reduces_bool_slot_to_fraction_true() { + // spec §Testing: a Bool param slot coerces 0/1, so mean = fraction of + // windows that chose `true`. 3 of 4 true -> 0.75. + let mk = |flag: bool| WindowOutcome { + bounds: WindowBounds { + is: (Timestamp(0), Timestamp(0)), + oos: (Timestamp(0), Timestamp(0)), + }, + run: WindowRun { + chosen_params: vec![Cell::from_bool(flag)], + oos_equity: vec![], + oos_report: dummy_report(), + }, + }; + let result = WalkForwardResult { + space: vec![ParamSpec { name: "flag".to_string(), kind: ScalarKind::Bool }], + windows: vec![mk(true), mk(false), mk(true), mk(true)], + stitched_oos_equity: vec![], + }; + let stab = param_stability(&result); + assert_eq!(stab.len(), 1); + assert_eq!(stab[0].mean, 0.75); + } +``` + +- [ ] **Step 12: Build + test the `aura-engine` crate (partial gate — `aura-cli` is Task 2)** + +Run: `cargo test -p aura-engine` +Expected: PASS — compiles with 0 errors; all `aura-engine` tests green, including the three +migrated walk-forward tests (`walk_forward_runs_one_window_per_split_in_roll_order`, +`walk_forward_is_deterministic_across_thread_counts`, +`param_stability_reduces_chosen_params_per_slot` with **identical** MetricStats) and the new +`param_stability_reduces_bool_slot_to_fraction_true`. (`aura-cli` is not built by `-p +aura-engine`; its caller is threaded in Task 2.) + +--- + +## Task 2: `aura-cli/walkforward_family` + workspace gates + +**Files:** +- Modify: `crates/aura-cli/src/main.rs:452-475` +- Verify (no edit): `crates/aura-registry/src/lineage.rs` + +- [ ] **Step 1: Thread `space` into `walkforward_family` and simplify the closure** + +At `crates/aura-cli/src/main.rs:452-475`, the function is: + +```rust +fn walkforward_family() -> WalkForwardResult { + let sources: Vec> = + vec![Box::new(VecSource::new(walkforward_prices()))]; + let span = window_of(&sources).expect("non-empty synthetic stream"); + let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling) + .expect("built-in walk-forward config fits the 60-bar synthetic span"); + walk_forward(roller, |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 { + // best.params is the enumerated cell winner; reconstruct the + // self-describing report record (Option A) via the in-sample space. + chosen_params: best + .params + .iter() + .zip(&is_family.space) + .map(|(c, ps)| Scalar::from_cell(ps.kind, *c)) + .collect(), + oos_equity, + oos_report, + } + }) +} +``` + +Replace with (compute the space once before the roll — same blueprint every window — pass +it to `walk_forward`, and simplify `chosen_params` to the tag-free winner directly): + +```rust +fn walkforward_family() -> WalkForwardResult { + let sources: Vec> = + vec![Box::new(VecSource::new(walkforward_prices()))]; + let span = window_of(&sources).expect("non-empty synthetic stream"); + let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling) + .expect("built-in walk-forward config fits the 60-bar synthetic span"); + let space = sample_blueprint_with_sinks().0.param_space(); + 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 { + // The tag-free sweep winner is the chosen point; its kinds live on + // WalkForwardResult.space (computed once above from the same blueprint). + chosen_params: best.params, + oos_equity, + oos_report, + } + }) +} +``` + +- [ ] **Step 2: Build the whole workspace (the deferred compile gate from Task 1)** + +Run: `cargo build --workspace --all-targets` +Expected: PASS — 0 errors. The `walk_forward` signature change + the `chosen_params` / +`WalkForwardResult` shape change are now threaded at every site across all crates. + +- [ ] **Step 3: Confirm `aura-registry/lineage.rs` is unaffected** + +Run: `grep -n "chosen_params\|\.space\|WalkForwardResult {" crates/aura-registry/src/lineage.rs` +Expected: no hit for `chosen_params`, no `WalkForwardResult { … }` literal, no `.space` +read — only `w.run.oos_report` is touched (around `:168`). (A bare match confirming the +file reads `oos_report` only; if any `chosen_params`/`space` hit appears, STOP — the recon +said there is none.) + +- [ ] **Step 4: The four project gates** + +Run: `cargo build --workspace --all-targets` +Expected: PASS — 0 errors. + +Run: `cargo test --workspace` +Expected: PASS — all tests green (the migrated WF tests with identical MetricStats, the new +Bool-slot test, and the CLI in-binary `walkforward_report_*` shape tests at +`main.rs:914/921`, which pin line-count + substring only and are untouched by a +behaviour-preserving change). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: PASS — clean. (In particular: no unused-import warning for the dropped `Scalar` +in `walkforward.rs`, and no unused `Scalar` in `aura-cli` — it stays used at other CLI +sites.) + +Run: `cargo doc --workspace --no-deps 2>&1` +Expected: PASS — clean (the `WindowRun` / `WalkForwardResult` / `param_stability` doc edits +resolve; intra-doc links `SweepFamily` / `MetricStats::from_values` resolve). + +- [ ] **Step 5: Acceptance spot-check greps** + +Run: `grep -rn "scalar_as_f64" crates/` +Expected: no hits (the function and its call site are gone). + +Run: `grep -n "chosen_params: Vec\|pub space: Vec" crates/aura-engine/src/walkforward.rs` +Expected: both present (one each). + +Run: `grep -n "unreachable!" crates/aura-engine/src/walkforward.rs` +Expected: exactly one hit, inside the `param_stability` schema pass (the `Timestamp` arm) — +no `unreachable!` in any per-window-value path.