Files
Aura/docs/plans/0048-walk-forward-param-plane.md
T
Brummel 6ce00ee053 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
2026-06-16 18:52:32 +02:00

24 KiB

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<Cell>, 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-475walkforward_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:

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):

use crate::{MetricStats, ParamSpec, RunReport, ScalarKind, Timestamp};
use aura_core::Cell;
  • Step 2: β1 — WindowRun.chosen_params: Vec<Scalar>Vec<Cell> + doc

At walkforward.rs:123-132, the struct is:

#[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<Scalar>,
    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):

#[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<Cell>,
    pub oos_equity: Vec<(Timestamp, f64)>,
    pub oos_report: RunReport,
}
  • Step 3: β1 — WalkForwardResult gains space: Vec<ParamSpec>

At walkforward.rs:147-160, the struct is:

#[derive(Clone, Debug, PartialEq)]
pub struct WalkForwardResult {
    /// Per-window outcomes in roll order (window 0 first), independent of thread
    /// completion (C1).
    pub windows: Vec<WindowOutcome>,
    /// 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):

#[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<ParamSpec>,
    /// Per-window outcomes in roll order (window 0 first), independent of thread
    /// completion (C1).
    pub windows: Vec<WindowOutcome>,
    /// 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:

pub fn walk_forward<F>(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:

pub fn walk_forward<F>(roller: WindowRoller, space: Vec<ParamSpec>, 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:

fn walk_forward_with_threads<F>(
    roller: WindowRoller,
    nthreads: usize,
    run_window: F,
) -> WalkForwardResult
where
    F: Fn(WindowBounds) -> WindowRun + Sync,
{
    let bounds: Vec<WindowBounds> = 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<WindowOutcome> = 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):

fn walk_forward_with_threads<F>(
    roller: WindowRoller,
    space: Vec<ParamSpec>,
    nthreads: usize,
    run_window: F,
) -> WalkForwardResult
where
    F: Fn(WindowBounds) -> WindowRun + Sync,
{
    let bounds: Vec<WindowBounds> = 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<WindowOutcome> = 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:

/// 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<MetricStats> {
    let Some(first) = result.windows.first() else {
        return Vec::new();
    };
    let nslots = first.run.chosen_params.len();
    (0..nslots)
        .map(|slot| {
            let vals: Vec<f64> = 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):

/// 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<MetricStats> {
    if result.windows.is_empty() {
        return Vec::new();
    }
    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(),
            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<f64> =
                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:

/// 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:

    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::*;):

    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):

    /// 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<ParamSpec> {
        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<Scalar>Vec<Cell>:

    fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> 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:

            chosen_params: vec![Scalar::i64(k % 3), Scalar::f64(k as f64 * 0.5)],

becomes:

            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):

        let result = walk_forward(roller, run_window_fixture);

becomes:

        let result = walk_forward(roller, fixture_space(), run_window_fixture);

In walk_forward_is_deterministic_across_thread_counts (:330-333):

        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):

        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:

        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):

        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):

    #[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:

fn walkforward_family() -> WalkForwardResult {
    let sources: Vec<Box<dyn aura_engine::Source>> =
        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):

fn walkforward_family() -> WalkForwardResult {
    let sources: Vec<Box<dyn aura_engine::Source>> =
        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<Cell>\|pub space: Vec<ParamSpec>" 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.