feat(family-traces): persist per-member streams for sweep/MC/walk-forward
Family runs (sweep, Monte-Carlo, walk-forward) ran a full harness per
member and drained its equity+exposure recorders, but immediately folded
the streams to metrics and discarded them — so the C21 family-comparison
view had no per-member data to chart. A new opt-in `--trace <name>` on
`aura sweep|mc|walkforward` now persists each member's streams to disk,
mirroring the single-run `aura run --trace` path. Any single member is
chartable today via `aura chart <name>/<member_key>`; the comparison view
itself is a later cycle.
Design:
- Persist INSIDE each per-member closure (aura-cli), reusing persist_traces
verbatim — the engine and the family types are untouched. The closures
already held the drained rows; they now bind them once, build the
manifest, persist when --trace is set, then fold as before.
- Content-derived, deterministic member keys (MC seed{N}, sweep f{fast}s{slow},
walk-forward oos{ns}). The engine HOFs are Fn + Sync — members run in
parallel, so a runtime ordinal counter would be non-deterministic (a C1
break); keys derive from what each closure receives deterministically.
sweep_member_key panics (naming the axis) if the grid lacks a key axis,
rather than silently collapsing two points to a shared dir.
- Each member is a standalone run-dir at runs/traces/<name>/<member_key>/
(persist_traces with a slash-name; TraceStore nests via create_dir_all),
so the existing single-run viewer charts a member with no view-side code.
- Opt-in: without --trace, stdout and the run registry are byte-unchanged;
the cardinality cost of N members x full resolution stays user-chosen.
Out of scope (deferred): the family-comparison view (overlay/small-multiples),
decimation/LOD, a binary trace container.
Verified locally: cargo build --workspace, cargo test --workspace (all green,
incl. 3 new family persistence tests + a sweep determinism test + 2
sweep_member_key unit tests), cargo clippy --workspace --all-targets
-D warnings (clean). The pre-existing family determinism and single-run
trace tests pass unchanged (no-trace path is byte-identical).
closes #104
This commit is contained in:
@@ -678,3 +678,157 @@ fn runs_bare_subcommand_is_strict_and_exits_two() {
|
||||
let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
|
||||
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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");
|
||||
assert!(dir.join("exposure.json").exists(), "{key} exposure 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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Property (C1 / Fork-C content-derived keys): `aura sweep --trace` is
|
||||
/// reproducible — re-running it produces the SAME member-dir set and
|
||||
/// BYTE-IDENTICAL tap files. The whole reason member keys are derived from the
|
||||
/// grid point's content (not a runtime ordinal counter) is that members run in
|
||||
/// parallel across threads (C1: parallelism *across* sims); a thread-race in the
|
||||
/// keying or the persisted bytes would silently regress determinism. A runtime
|
||||
/// counter would shuffle which member lands in `f2s4` vs `f3s5` run-to-run; this
|
||||
/// test fails loudly if that drift is ever reintroduced.
|
||||
#[test]
|
||||
fn sweep_trace_is_byte_deterministic_across_runs() {
|
||||
let collect = |tag: &str| -> std::collections::BTreeMap<String, String> {
|
||||
let cwd = temp_cwd(tag);
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--trace", "det"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --trace");
|
||||
assert!(out.status.success(), "{tag} sweep exit: {:?}", out.status);
|
||||
|
||||
// Map each member dir's tap files to their bytes (member_key/file -> content).
|
||||
let base = cwd.join("runs/traces/det");
|
||||
let mut taps = std::collections::BTreeMap::new();
|
||||
for member in std::fs::read_dir(&base).expect("read det trace dir") {
|
||||
let member = member.expect("dir entry").file_name().to_string_lossy().into_owned();
|
||||
for file in ["index.json", "equity.json", "exposure.json"] {
|
||||
let body = std::fs::read_to_string(base.join(&member).join(file))
|
||||
.unwrap_or_else(|e| panic!("{tag} read {member}/{file}: {e}"));
|
||||
taps.insert(format!("{member}/{file}"), body);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
taps
|
||||
};
|
||||
|
||||
let first = collect("sweep-det-1");
|
||||
let second = collect("sweep-det-2");
|
||||
|
||||
// Same member-dir set, run-to-run (content-keyed, not order-keyed).
|
||||
let first_keys: Vec<&String> = first.keys().collect();
|
||||
let second_keys: Vec<&String> = second.keys().collect();
|
||||
assert_eq!(first_keys, second_keys, "member-dir set drifted across runs");
|
||||
|
||||
// Every tap byte-identical across the two runs (the index.json git-commit
|
||||
// field is build-identity, constant within one build, so it compares equal
|
||||
// here too — this pins run-to-run determinism, C1).
|
||||
assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user