spec: 0058 family-trace-persistence (boss-signed)
Persist each family member's equity/exposure streams to disk (opt-in --trace on sweep|mc|walkforward), mirroring the single-run trace path, so the C21 family-comparison view has per-member data and any single member is chartable today. Write side only; the comparison view, decimation, and a binary trace container are deferred. Autonomously signed under /boss on the Step-5 grounding-check PASS. refs #104
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
# Family-member trace persistence — Design Spec
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** orchestrator + Claude
|
||||
|
||||
Reference issue: Brummel/Aura #104. Fork decisions (A–D) and the member-key
|
||||
correction are recorded on that issue's thread (the run's decision log).
|
||||
|
||||
## Goal
|
||||
|
||||
Make each family member's recorded streams land on disk, so the C21
|
||||
family-comparison view has per-member data to chart and any single member is
|
||||
chartable *today* with the existing single-run viewer.
|
||||
|
||||
Single backtest runs already do this: `aura run --trace <name>` drains the
|
||||
equity + exposure recorders and persists them via `persist_traces`
|
||||
(`crates/aura-cli/src/main.rs:161`) → `TraceStore`
|
||||
(`crates/aura-registry/src/trace_store.rs:40`), under `runs/traces/<name>/`.
|
||||
Family runs (`sweep` / `mc` / `walkforward`) do not: each member *does* run a
|
||||
full harness with both recorders and *is* drained inside its per-member closure,
|
||||
but the raw rows are immediately folded to `metrics: summarize(...)` and
|
||||
discarded. The streams the comparison view needs exist transiently and are
|
||||
thrown away.
|
||||
|
||||
This cycle is the **write side only**. The family-comparison *view* (how to
|
||||
render N members — overlay / small-multiples / rank-top-N), decimation / LOD, and
|
||||
a binary trace container are explicitly out of scope (each its own later cycle).
|
||||
|
||||
## Architecture
|
||||
|
||||
Four derived decisions (recorded on #104):
|
||||
|
||||
- **A — persist in the closure; engine untouched.** The three per-member
|
||||
closures live in aura-cli and already hold the drained rows. When persistence
|
||||
is enabled they call the existing `persist_traces` directly, before folding to
|
||||
metrics. The engine's `sweep` / `monte_carlo` / `walk_forward` and the
|
||||
`SweepFamily` / `McFamily` / `WalkForwardResult` types are **not** changed.
|
||||
- **B — opt-in.** A new `--trace` opt-in on `aura sweep|mc|walkforward` gates
|
||||
persistence. Without it, behaviour is byte-unchanged (no member dirs written —
|
||||
the cardinality cost of N members × full resolution stays user-chosen).
|
||||
- **C — content-derived deterministic member keys.** The engine higher-order fns
|
||||
are `F: Fn(...) + Sync` (`sweep.rs:314`, `mc.rs:124`, `walkforward.rs:187`):
|
||||
members run in parallel across threads (C1 parallelism *across* sims). A
|
||||
runtime ordinal counter would be non-deterministic (thread-race → C1
|
||||
violation). Keys are therefore derived from what each closure receives
|
||||
deterministically (see the member-key table).
|
||||
- **D — standalone run-dir per member.** Each member is persisted under
|
||||
`runs/traces/<name>/<member_key>/` as a complete run-dir (its own `index.json`
|
||||
+ `equity.json` + `exposure.json`), by calling `persist_traces` with a
|
||||
slash-containing name. `TraceStore::write` resolves that to nested directories;
|
||||
the existing single-run viewer charts any one member as-is, with no view-side
|
||||
code.
|
||||
|
||||
`--trace <name>` sets both the family registry name and the trace prefix to
|
||||
`<name>` (a member registers under the family, and its streams land under
|
||||
`runs/traces/<name>/`). This avoids a redundant second name while keeping
|
||||
`--trace <name>` taking an argument, exactly as the single-run form does.
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### User-facing surface (the acceptance evidence)
|
||||
|
||||
```console
|
||||
# opt in: register the family under "swp" AND persist each member's streams
|
||||
$ aura sweep --trace swp
|
||||
{"family_id":"swp-1","report":{...}} # stdout unchanged from `aura sweep --name swp`
|
||||
... one line per grid point ...
|
||||
|
||||
$ tree runs/traces/swp
|
||||
runs/traces/swp
|
||||
├── f2s4/ # one standalone run-dir per grid point (content-keyed)
|
||||
│ ├── index.json
|
||||
│ ├── equity.json
|
||||
│ └── exposure.json
|
||||
├── f2s5/ ├── …
|
||||
├── f3s4/ ├── …
|
||||
└── f3s5/ └── …
|
||||
|
||||
# any single member charts with the existing viewer, no new code:
|
||||
$ aura chart swp/f2s4 > member.html
|
||||
|
||||
$ aura mc --trace mc1 # one dir per draw seed: runs/traces/mc1/seed{1,2,3}/
|
||||
$ aura walkforward --trace wf1 # one dir per OOS window: runs/traces/wf1/oos{from-ns}/
|
||||
```
|
||||
|
||||
Without `--trace`, every byte of stdout and the run registry is identical to
|
||||
today, and no `runs/traces/<name>/` tree is created.
|
||||
|
||||
### Member-key derivation (per family kind, all deterministic + collision-free)
|
||||
|
||||
| Family | Closure receives | Member key |
|
||||
|--------------|-------------------------|-----------------------------------------|
|
||||
| Monte-Carlo | `seed: u64` | `seed{seed}` (e.g. `seed1`) |
|
||||
| sweep | `point: &[Cell]` | a stable join of the grid point's param values (e.g. `f{fast}s{slow}`); deterministic + unique across the grid |
|
||||
| walk-forward | `w: WindowBounds` | `oos{w.oos.0}` (OOS window start ns; unique per window) |
|
||||
|
||||
The exact rendering of the sweep key is the planner's to fix; the contract is
|
||||
**deterministic, collision-free, and stable across runs** (distinct grid points
|
||||
differ in ≥1 swept axis). The closure already builds the named params via
|
||||
`zip_params(&space, point)` for the manifest, so the key is derivable locally
|
||||
with no engine change.
|
||||
|
||||
### Arg dispatch (before → after, `main.rs:953-958`)
|
||||
|
||||
```rust
|
||||
// before
|
||||
["sweep"] => run_sweep("sweep"),
|
||||
["sweep", "--name", n] => run_sweep(n),
|
||||
["walkforward"] => run_walkforward("walkforward"),
|
||||
["walkforward","--name",n]=> run_walkforward(n),
|
||||
["mc"] => run_mc("mc"),
|
||||
["mc", "--name", n] => run_mc(n),
|
||||
|
||||
// after — add the --trace opt-in (persist = true); run signatures gain `persist: bool`
|
||||
["sweep"] => run_sweep("sweep", false),
|
||||
["sweep", "--name", n] => run_sweep(n, false),
|
||||
["sweep", "--trace", n] => run_sweep(n, true),
|
||||
["walkforward"] => run_walkforward("walkforward", false),
|
||||
["walkforward","--name",n]=> run_walkforward(n, false),
|
||||
["walkforward","--trace",n]=> run_walkforward(n, true),
|
||||
["mc"] => run_mc("mc", false),
|
||||
["mc", "--name", n] => run_mc(n, false),
|
||||
["mc", "--trace", n] => run_mc(n, true),
|
||||
```
|
||||
|
||||
`USAGE` (`main.rs:929`) gains the `[--trace <name>]` alternative for the three
|
||||
family subcommands.
|
||||
|
||||
### Closure persistence (before → after, Monte-Carlo as the template — `mc_family`:678)
|
||||
|
||||
```rust
|
||||
// before: rows collected inline into f64_field, then folded; raw rows lost.
|
||||
fn mc_family() -> McFamily {
|
||||
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
|
||||
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
||||
/* … build sources, run … */ 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);
|
||||
RunReport { manifest: sim_optimal_manifest(/* … */), metrics: summarize(&equity, &exposure) }
|
||||
})
|
||||
}
|
||||
|
||||
// after: collect rows ONCE, build the manifest first, persist when enabled,
|
||||
// then fold. `trace: Option<&str>` threads the opt-in prefix into the closure
|
||||
// (a shared &str — Fn + Sync hold). Engine call site unchanged.
|
||||
fn mc_family(trace: Option<&str>) -> McFamily {
|
||||
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
|
||||
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
||||
/* … build sources, run … */ 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(/* … */);
|
||||
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) }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`sweep_family`:448 and `run_oos`:599 (the walk-forward OOS member) take the same
|
||||
shape: thread `trace: Option<&str>`, collect rows once, build the manifest,
|
||||
persist under `<name>/<member_key>` when `trace` is `Some`, then fold. The
|
||||
sweep IS sweeps inside `sweep_over` (`walkforward_family`) are **not** persisted
|
||||
— only the OOS member is the walk-forward family's recorded run (it is what
|
||||
`append_family` stores).
|
||||
|
||||
## Components
|
||||
|
||||
- **`crates/aura-cli/src/main.rs`** — the only file changed:
|
||||
- arg dispatch + `USAGE` (the `--trace` forms);
|
||||
- `run_sweep` / `run_mc` / `run_walkforward` gain a `persist: bool` and pass
|
||||
`Some(name)` / `None` down to their family builders;
|
||||
- `sweep_family` / `mc_family` / `walkforward_family` (and `run_oos`) gain a
|
||||
`trace: Option<&str>` and call `persist_traces` per member when set;
|
||||
- the `#[cfg(test)]` helpers (`sweep_report`, `mc_report`,
|
||||
`walkforward_report`) call the builders with `None` (no behaviour change).
|
||||
- **`persist_traces` (`main.rs:161`)** — reused verbatim. It already accepts an
|
||||
arbitrary `name`; a slash-containing name is a nested member dir.
|
||||
- **`TraceStore` (`trace_store.rs`)** — reused verbatim. `write` joins the name
|
||||
under `traces/` and `create_dir_all`s it, so `"swp/f2s4"` yields
|
||||
`traces/swp/f2s4/`. No registry/store change.
|
||||
- **Engine (`sweep` / `mc` / `walkforward`)** — untouched.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
per family member (parallel, disjoint — C1):
|
||||
build harness → run → drain eq_rows, ex_rows (already happens)
|
||||
│
|
||||
├─ if --trace: persist_traces("<name>/<member_key>", manifest, eq_rows, ex_rows)
|
||||
│ → TraceStore::write → runs/traces/<name>/<member_key>/{index,equity,exposure}.json
|
||||
│
|
||||
└─ fold: f64_field → summarize → RunReport (already happens)
|
||||
→ append_family(...) (registry metrics — unchanged)
|
||||
```
|
||||
|
||||
The persist step is a pure side-effect on the disjoint per-member path; it does
|
||||
not feed back into the fold or the registry. Determinism (C1) of *what each
|
||||
member computes* is untouched; only a write is added.
|
||||
|
||||
## Error handling
|
||||
|
||||
`persist_traces` already maps an I/O failure to `eprintln! + std::process::exit(2)`
|
||||
(`main.rs:171-174`), the CLI's usage-error convention. Reused as-is: a member
|
||||
trace that cannot be written aborts the process with exit 2, at parity with the
|
||||
single-run path. (A worker-thread `exit` under the parallel families is abrupt
|
||||
but acceptable — it matches existing fatal-I/O semantics; converting to a
|
||||
propagated `Result` is a deliberate non-goal here.) With no `--trace`, no I/O is
|
||||
attempted and the path is unchanged.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
New integration coverage (aura-cli), one property per test, observable behaviour
|
||||
only:
|
||||
|
||||
1. **sweep persists one round-tripping run-dir per grid point.**
|
||||
`run_sweep("t", true)` (or the dispatched form) then `TraceStore::open(tmp)
|
||||
.read("t/<key>")` for each member returns equity + exposure taps with the
|
||||
expected SoA shape; the dir count equals the grid size.
|
||||
2. **mc persists one dir per seed; wf one per OOS window.** Same round-trip,
|
||||
keyed by `seed{N}` / `oos{from}`.
|
||||
3. **member keys are deterministic + distinct.** Two `persist=true` runs produce
|
||||
identical member-dir names and byte-identical tap files (C1); keys across one
|
||||
family are pairwise distinct.
|
||||
4. **`persist=false` writes nothing and changes no stdout.** No
|
||||
`runs/traces/<name>/` tree is created, and the family's stdout / registry
|
||||
output is byte-identical to the pre-change behaviour (guards the
|
||||
byte-unchanged claim).
|
||||
|
||||
Tests isolate the trace root to a temp dir (as the existing trace tests do) so
|
||||
they neither read nor write the repo's gitignored `runs/`. The existing family
|
||||
determinism tests (`walkforward_report_is_deterministic`, the sweep/mc report
|
||||
tests) continue to pass unchanged (they call the builders with `None`).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Applying aura's feature-acceptance criterion:
|
||||
|
||||
- **The audience naturally reaches for it.** A trader running a sweep / MC /
|
||||
walk-forward wants to *see* individual members' equity curves, not only the
|
||||
aggregate metric line — the family-comparison is the C21 differentiator. This
|
||||
cycle puts the per-member streams on disk, the precondition for that view; a
|
||||
single member is already chartable via `aura chart <name>/<key>`.
|
||||
- **It measurably adds capability without redundancy.** Streams that were
|
||||
produced-then-discarded are now persisted, reusing the single-run
|
||||
`persist_traces` / `TraceStore` path verbatim — no second persistence
|
||||
mechanism.
|
||||
- **It reintroduces no failure class the core constraints guard against.**
|
||||
Determinism (C1) is preserved by content-derived keys (no runtime-order
|
||||
dependence) and by persistence being a disjoint per-member side-effect; SoA
|
||||
(C7) and the sink/recorder contract (C8) are upheld (the same `ColumnarTrace`
|
||||
taps as single runs); the engine is untouched; without `--trace` behaviour is
|
||||
byte-unchanged.
|
||||
|
||||
Concretely, the cycle is accepted when:
|
||||
|
||||
- `aura sweep --trace s` / `aura mc --trace m` / `aura walkforward --trace w`
|
||||
each write one standalone, round-tripping run-dir per member under
|
||||
`runs/traces/<name>/<member_key>/`;
|
||||
- member keys are deterministic, stable across runs, and pairwise distinct;
|
||||
- the same commands without `--trace` create no trace dirs and emit
|
||||
byte-identical stdout / registry output;
|
||||
- `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D
|
||||
warnings` are green.
|
||||
Reference in New Issue
Block a user