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
This commit is contained in:
@@ -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<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-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<Scalar>` → `Vec<Cell>` + 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<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):
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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`):
|
||||
|
||||
```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<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:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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):
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```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<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):
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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>`:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```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<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):
|
||||
|
||||
```rust
|
||||
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.
|
||||
Reference in New Issue
Block a user