plan: 0058 family-trace-persistence

Three tasks (MC, sweep, walk-forward) wiring per-member trace persistence
into the family closures + a full-suite/lint gate. Each task threads its
builder signature and every caller together so the workspace compiles
green per task.

refs #104
This commit is contained in:
2026-06-20 18:51:32 +02:00
parent 04f13af88a
commit f33b427883
+558
View File
@@ -0,0 +1,558 @@
# Family-member trace persistence — Implementation Plan
> **Parent spec:** `docs/specs/0058-family-trace-persistence.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Persist each family member's equity/exposure streams to disk (opt-in
`--trace` on `aura sweep|mc|walkforward`), so any single member is chartable
today and the C21 family-comparison view has per-member data.
**Architecture:** Persist *inside* each per-member closure (aura-cli), reusing
`persist_traces` verbatim — the engine and the family types are untouched. A new
`--trace <name>` opt-in gates persistence (without it, byte-unchanged). Each
member is a standalone run-dir at `runs/traces/<name>/<member_key>/`; keys are
content-derived and deterministic (MC `seed{N}`, sweep `f{fast}s{slow}`,
walk-forward `oos{ns}`) because the engine HOFs are `Fn + Sync` (members run in
parallel — a runtime counter would be non-deterministic, a C1 break).
**Tech Stack:** `crates/aura-cli/src/main.rs` (the only production file) +
`crates/aura-cli/tests/cli_run.rs` (integration tests, spawn-with-temp-cwd).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/main.rs` — arg dispatch + `USAGE` (`--trace`
forms); `run_sweep`/`run_mc`/`run_walkforward` gain `persist: bool`;
`sweep_family`/`mc_family`/`walkforward_family`/`run_oos` gain
`trace: Option<&str>` and persist per member when set; a new
`sweep_member_key` helper; `#[cfg(test)]` caller fixups.
- Test: `crates/aura-cli/tests/cli_run.rs` — three new spawn tests (one per
family kind) asserting per-member dirs round-trip as columnar SoA and that a
plain (no-`--trace`) run writes no trace tree.
Each task is self-contained: it changes one family's builder signature **and
every caller of that builder** (dispatch arm, `run_*`, the `#[cfg(test)]` report
helper, and the in-`main.rs` integration test at `:1144-1156`), so the workspace
compiles green at the end of every task. The three families are independent
(distinct callers), so Task order is MC → sweep → walk-forward (MC is the spec's
worked template).
---
### Task 1: Monte-Carlo member-trace persistence (the template)
**Files:**
- Modify: `crates/aura-cli/src/main.rs``persist_traces` neighbourhood (new helper not needed for MC), `run_mc` (`:721`), `mc_family` (`:678-702`), dispatch arms (`:957-958`), `USAGE` (`:929`), `mc_report` caller (`:747`), integration test (`:1148`)
- Test: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Write the failing test**
Append to `crates/aura-cli/tests/cli_run.rs` (uses the existing `BIN`,
`temp_cwd`, `json_array_body`):
```rust
/// Property: `aura mc --trace <name>` persists one standalone, round-tripping
/// run-dir per draw seed (the built-in MC family draws seeds 1,2,3), and a plain
/// `aura mc` (no --trace) writes no trace tree (opt-in; byte-unchanged path).
#[test]
fn mc_trace_persists_a_member_dir_per_seed() {
let cwd = temp_cwd("mc-trace");
// opt-in OFF: plain `aura mc` persists no per-member trace dirs.
let plain = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
assert!(plain.status.success(), "plain mc exit: {:?}", plain.status);
assert!(!cwd.join("runs/traces").exists(), "plain mc must write no trace files");
// opt-in ON: one standalone run-dir per draw seed.
let traced = Command::new(BIN)
.args(["mc", "--trace", "mc1"])
.current_dir(&cwd)
.output()
.expect("spawn aura mc --trace");
assert!(traced.status.success(), "traced mc exit: {:?}", traced.status);
for seed in [1, 2, 3] {
let dir = cwd.join(format!("runs/traces/mc1/seed{seed}"));
assert!(dir.join("index.json").exists(), "seed{seed} index.json missing");
assert!(dir.join("equity.json").exists(), "seed{seed} equity tap missing");
assert!(dir.join("exposure.json").exists(), "seed{seed} exposure tap missing");
// the persisted equity tap is columnar SoA (C7): kind tag + ts/column parity.
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
assert!(equity.contains("\"kinds\":[\"F64\"]"), "seed{seed} F64 tag: {equity}");
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
assert_eq!(col_len, ts_len, "seed{seed} SoA parity: ts={ts_len} col={col_len}; {equity}");
}
let _ = std::fs::remove_dir_all(&cwd);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-cli --test cli_run mc_trace_persists_a_member_dir_per_seed`
Expected: FAIL — `aura mc --trace mc1` is not a recognised arg form yet, so the
binary prints usage to stderr and exits 2; `traced.status.success()` is false
(the first assert in the opt-in-ON block fires).
- [ ] **Step 3: Write minimal implementation**
(a) Dispatch arms — `main.rs:957-958` (add the `persist` argument + the new
`--trace` arm):
```rust
// before
["mc"] => run_mc("mc"),
["mc", "--name", n] => run_mc(n),
// after
["mc"] => run_mc("mc", false),
["mc", "--name", n] => run_mc(n, false),
["mc", "--trace", n] => run_mc(n, true),
```
(b) `USAGE``main.rs:929`, the `aura mc` alternative:
```
// before: ... | aura mc [--name <n>] | ...
// after: ... | aura mc [--name <n>|--trace <n>] | ...
```
(c) `run_mc``main.rs:721` (signature + the `mc_family` call):
```rust
// before
fn run_mc(name: &str) {
let reg = default_registry();
let family = mc_family();
// after
fn run_mc(name: &str, persist: bool) {
let reg = default_registry();
let family = mc_family(persist.then_some(name));
```
(d) `mc_family``main.rs:678-702` (thread `trace`, collect rows once, build the
manifest before folding, persist the `seed{seed}` member when `trace` is set):
```rust
fn mc_family(trace: Option<&str>) -> McFamily {
let base_point: Vec<Scalar> = Vec::new();
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 };
let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(spec.source(seed))];
let window = window_of(&sources).expect("non-empty synthetic stream");
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed,
SYNTHETIC_PIP_SIZE,
);
if let Some(name) = trace {
persist_traces(&format!("{name}/seed{seed}"), &manifest, &eq_rows, &ex_rows);
}
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
RunReport { manifest, metrics: summarize(&equity, &exposure) }
})
}
```
(e) `#[cfg(test)]` callers of `mc_family()` — pass `None` (no behaviour change):
```rust
// main.rs:747 (in `fn mc_report`): mc_family() -> mc_family(None)
// main.rs:1148 (integration test): mc_family() -> mc_family(None)
```
- [ ] **Step 4: Run test to verify it passes + workspace gates**
Run: `cargo test -p aura-cli --test cli_run mc_trace_persists_a_member_dir_per_seed`
Expected: PASS.
Run: `cargo build --workspace`
Expected: 0 errors (every `mc_family` caller threaded).
Run: `cargo test -p aura-cli --bin aura mc_report`
Expected: PASS — `mc_report_is_deterministic_and_one_line_per_seed` unchanged
(it calls `mc_family(None)`, byte-identical metrics).
---
### Task 2: Sweep member-trace persistence
**Files:**
- Modify: `crates/aura-cli/src/main.rs` — new `sweep_member_key` helper (after `persist_traces`, `:175`), `run_sweep` (`:500`), `sweep_family` (`:448-476`), dispatch arms (`:953-954`), `USAGE` (`:929`), `sweep_report` caller (`:483`), integration test (`:1145`)
- Test: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Write the failing test**
Append to `crates/aura-cli/tests/cli_run.rs`:
```rust
/// Property: `aura sweep --trace <name>` persists one standalone, round-tripping
/// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4
/// points, content-keyed f2s4/f2s5/f3s4/f3s5; a plain `aura sweep` writes nothing.
#[test]
fn sweep_trace_persists_a_member_dir_per_grid_point() {
let cwd = temp_cwd("sweep-trace");
let plain = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
assert!(plain.status.success(), "plain sweep exit: {:?}", plain.status);
assert!(!cwd.join("runs/traces").exists(), "plain sweep must write no trace files");
let traced = Command::new(BIN)
.args(["sweep", "--trace", "swp"])
.current_dir(&cwd)
.output()
.expect("spawn aura sweep --trace");
assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status);
for key in ["f2s4", "f2s5", "f3s4", "f3s5"] {
let dir = cwd.join(format!("runs/traces/swp/{key}"));
assert!(dir.join("index.json").exists(), "{key} index.json missing");
assert!(dir.join("equity.json").exists(), "{key} equity tap missing");
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
}
let _ = std::fs::remove_dir_all(&cwd);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-cli --test cli_run sweep_trace_persists_a_member_dir_per_grid_point`
Expected: FAIL — `aura sweep --trace swp` is not a recognised arg form yet
(usage error, exit 2); `traced.status.success()` is false.
- [ ] **Step 3: Write minimal implementation**
(a) New helper — add after `persist_traces` (`main.rs:175`). Derives the
deterministic, collision-free key from the two grid axes that vary in the
built-in sweep (the singleton axes do not affect distinctness; the spec leaves
the exact rendering to the plan):
```rust
/// The content-derived member key for a swept grid point: the two varying axes
/// of the built-in grid (`signals.trend.fast.length`, `signals.trend.slow.length`).
/// Deterministic + collision-free over that grid (distinct points differ in ≥1
/// of these). `named` is the `zip_params(&space, point)` the manifest already builds.
fn sweep_member_key(named: &[(String, Scalar)]) -> String {
let val = |name: &str| {
named.iter().find(|(n, _)| n.as_str() == name).map(|(_, v)| v.as_i64()).unwrap_or(0)
};
format!("f{}s{}", val("signals.trend.fast.length"), val("signals.trend.slow.length"))
}
```
(b) Dispatch arms — `main.rs:953-954`:
```rust
// before
["sweep"] => run_sweep("sweep"),
["sweep", "--name", n] => run_sweep(n),
// after
["sweep"] => run_sweep("sweep", false),
["sweep", "--name", n] => run_sweep(n, false),
["sweep", "--trace", n] => run_sweep(n, true),
```
(c) `USAGE``main.rs:929`, the `aura sweep` alternative:
```
// before: ... | aura sweep [--name <n>] | ...
// after: ... | aura sweep [--name <n>|--trace <n>] | ...
```
(d) `run_sweep``main.rs:500` (signature + the `sweep_family` call):
```rust
// before
fn run_sweep(name: &str) {
let reg = default_registry();
let family = sweep_family();
// after
fn run_sweep(name: &str, persist: bool) {
let reg = default_registry();
let family = sweep_family(persist.then_some(name));
```
(e) `sweep_family``main.rs:448-476` (thread `trace`; collect rows once; build
the named params + manifest before folding; persist the `f{fast}s{slow}` member):
```rust
fn sweep_family(trace: Option<&str>) -> SweepFamily {
let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space();
bp.axis("signals.trend.fast.length", [2, 3])
.axis("signals.trend.slow.length", [4, 5])
.axis("signals.momentum.fast.length", [2])
.axis("signals.momentum.slow.length", [4])
.axis("signals.momentum.signal.length", [3])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("exposure.scale", [0.5])
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_cells(point)
.expect("grid points are kind-checked against param_space");
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(showcase_prices()))];
let window = window_of(&sources).expect("non-empty showcase stream");
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let named = zip_params(&space, point);
let key = sweep_member_key(&named);
let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE);
if let Some(name) = trace {
persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows);
}
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
RunReport { manifest, metrics: summarize(&equity, &exposure) }
})
.expect("the built-in named grid matches the sample param-space")
}
```
(f) `#[cfg(test)]` callers of `sweep_family()` — pass `None`:
```rust
// main.rs:483 (in `fn sweep_report`): sweep_family() -> sweep_family(None)
// main.rs:1145 (integration test): sweep_family() -> sweep_family(None)
```
- [ ] **Step 4: Run test to verify it passes + workspace gates**
Run: `cargo test -p aura-cli --test cli_run sweep_trace_persists_a_member_dir_per_grid_point`
Expected: PASS.
Run: `cargo build --workspace`
Expected: 0 errors (every `sweep_family` caller threaded).
Run: `cargo test -p aura-cli --bin aura sweep_report`
Expected: PASS — `sweep_report_is_deterministic` and
`sweep_report_renders_four_points_in_odometer_order` unchanged
(`sweep_family(None)`, byte-identical).
---
### Task 3: Walk-forward OOS member-trace persistence
**Files:**
- Modify: `crates/aura-cli/src/main.rs``run_walkforward` (`:522`), `walkforward_family` (`:544-563`), `run_oos` (`:599-616`), dispatch arms (`:955-956`), `USAGE` (`:929`), `walkforward_report` caller (`:662`), integration test (`:1154`)
- Test: `crates/aura-cli/tests/cli_run.rs`
Note: only the **OOS** member (`run_oos`) is persisted. The in-sample sub-sweeps
inside `sweep_over` (`:567`) are **not** persisted and `sweep_over` is left
untouched (the spec excludes them).
- [ ] **Step 1: Write the failing test**
Append to `crates/aura-cli/tests/cli_run.rs` (the OOS key is `oos<ns>` with a
deterministic but synthetic timestamp, so the test asserts the member **count**
and that each entry is `oos`-keyed and round-trips, rather than pinning the ns):
```rust
/// Property: `aura walkforward --trace <name>` persists one standalone,
/// round-tripping run-dir per OOS window (the built-in roll = 3 windows), each
/// keyed `oos<ns>`; a plain `aura walkforward` writes nothing.
#[test]
fn walkforward_trace_persists_a_member_dir_per_oos_window() {
let cwd = temp_cwd("wf-trace");
let plain = Command::new(BIN).arg("walkforward").current_dir(&cwd).output().expect("spawn aura walkforward");
assert!(plain.status.success(), "plain walkforward exit: {:?}", plain.status);
assert!(!cwd.join("runs/traces").exists(), "plain walkforward must write no trace files");
let traced = Command::new(BIN)
.args(["walkforward", "--trace", "wf1"])
.current_dir(&cwd)
.output()
.expect("spawn aura walkforward --trace");
assert!(traced.status.success(), "traced walkforward exit: {:?}", traced.status);
let base = cwd.join("runs/traces/wf1");
let mut members: Vec<String> = std::fs::read_dir(&base)
.expect("read wf1 trace dir")
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
.collect();
members.sort();
assert_eq!(members.len(), 3, "built-in roll = 3 OOS windows -> 3 member dirs; got {members:?}");
for key in &members {
assert!(key.starts_with("oos"), "member key must be oos<ns>: {key}");
let dir = base.join(key);
assert!(dir.join("index.json").exists(), "{key} index.json missing");
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
}
let _ = std::fs::remove_dir_all(&cwd);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-cli --test cli_run walkforward_trace_persists_a_member_dir_per_oos_window`
Expected: FAIL — `aura walkforward --trace wf1` is not a recognised arg form yet
(usage error, exit 2); `traced.status.success()` is false.
- [ ] **Step 3: Write minimal implementation**
(a) Dispatch arms — `main.rs:955-956`:
```rust
// before
["walkforward"] => run_walkforward("walkforward"),
["walkforward", "--name", n] => run_walkforward(n),
// after
["walkforward"] => run_walkforward("walkforward", false),
["walkforward", "--name", n] => run_walkforward(n, false),
["walkforward", "--trace", n] => run_walkforward(n, true),
```
(b) `USAGE``main.rs:929`, the `aura walkforward` alternative:
```
// before: ... | aura walkforward [--name <n>] | ...
// after: ... | aura walkforward [--name <n>|--trace <n>] | ...
```
(c) `run_walkforward``main.rs:522` (signature + the `walkforward_family` call):
```rust
// before
fn run_walkforward(name: &str) {
let reg = default_registry();
let result = walkforward_family();
// after
fn run_walkforward(name: &str, persist: bool) {
let reg = default_registry();
let result = walkforward_family(persist.then_some(name));
```
(d) `walkforward_family``main.rs:544-563` (thread `trace` into the `run_oos`
call; `sweep_over` stays unchanged):
```rust
// before
fn walkforward_family() -> WalkForwardResult {
...
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);
// after
fn walkforward_family(trace: Option<&str>) -> WalkForwardResult {
...
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, trace);
```
(e) `run_oos``main.rs:599-616` (signature gains `trace`; collect rows once;
persist the `oos<ns>` member keyed by the OOS start `from.0`):
```rust
// before
fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp, f64)>, RunReport) {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let space = bp.param_space();
let mut h = bp
.bootstrap_with_cells(params)
.expect("chosen params pre-validated by the in-sample GridSpace::new");
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(walkforward_window_source(from, to))];
let window = window_of(&sources).expect("non-empty out-of-sample window");
h.run(sources);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let report = RunReport {
manifest: sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE),
metrics: summarize(&equity, &exposure),
};
(equity, report)
}
// after
fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>) -> (Vec<(Timestamp, f64)>, RunReport) {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let space = bp.param_space();
let mut h = bp
.bootstrap_with_cells(params)
.expect("chosen params pre-validated by the in-sample GridSpace::new");
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(walkforward_window_source(from, to))];
let window = window_of(&sources).expect("non-empty out-of-sample window");
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let manifest = sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE);
if let Some(name) = trace {
persist_traces(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows);
}
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let report = RunReport { manifest, metrics: summarize(&equity, &exposure) };
(equity, report)
}
```
(f) `#[cfg(test)]` callers of `walkforward_family()` — pass `None`:
```rust
// main.rs:662 (in `fn walkforward_report`): walkforward_family() -> walkforward_family(None)
// main.rs:1154 (integration test): walkforward_family() -> walkforward_family(None)
```
- [ ] **Step 4: Run test to verify it passes + workspace gates**
Run: `cargo test -p aura-cli --test cli_run walkforward_trace_persists_a_member_dir_per_oos_window`
Expected: PASS.
Run: `cargo build --workspace`
Expected: 0 errors (every `walkforward_family` / `run_oos` caller threaded).
Run: `cargo test -p aura-cli --bin aura walkforward_report`
Expected: PASS — `walkforward_report_is_deterministic` and
`walkforward_report_has_one_oos_line_per_window_plus_summary` unchanged.
---
### Task 4: Full-suite + lint gate
**Files:** none (verification only).
- [ ] **Step 1: Whole workspace test suite**
Run: `cargo test --workspace`
Expected: PASS — the three new `cli_run` tests plus every pre-existing test
(family determinism, single-run trace, chart-viewer guards) green. No existing
stdout-pinning test shifts (the no-`--trace` path is byte-unchanged).
- [ ] **Step 2: Lint gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: exit 0, no warnings.
- [ ] **Step 3: Manual smoke (optional, observable payoff)**
Run: `cd "$(mktemp -d)" && aura sweep --trace swp >/dev/null && aura chart swp/f2s4 | head -c 64`
Expected: the first bytes of a self-contained uPlot HTML page — a single sweep
member charts with the existing viewer, no new view-side code.