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:
2026-06-20 19:56:36 +02:00
parent f33b427883
commit d3cb5f8052
2 changed files with 279 additions and 60 deletions
+125 -60
View File
@@ -174,6 +174,27 @@ fn persist_traces(
} }
} }
/// 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.
///
/// A key-driving axis that is absent from `named` is a grid/key drift, not data:
/// the collision-free promise above would silently break (two points collapsing
/// to a shared `f…s…` dir that overwrite each other's traces). So a missing axis
/// panics, naming the axis — the same caller-bug-is-a-panic idiom as `Scalar::as_i64`.
fn sweep_member_key(named: &[(String, Scalar)]) -> String {
let val = |name: &str| {
named
.iter()
.find(|(n, _)| n.as_str() == name)
.unwrap_or_else(|| panic!("sweep_member_key: grid is missing the key axis '{name}'"))
.1
.as_i64()
};
format!("f{}s{}", val("signals.trend.fast.length"), val("signals.trend.slow.length"))
}
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6 /// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
/// 3-step union-spine alignment — no tap privileged, no point dropped: /// 3-step union-spine alignment — no tap privileged, no point dropped:
/// (1) xs = the sorted, deduped union of every tap's timestamps; /// (1) xs = the sorted, deduped union of every tap's timestamps;
@@ -445,7 +466,7 @@ fn sample_blueprint() -> Composite {
/// the same report. Each point builds a fresh blueprint (fresh sink channels), /// the same report. Each point builds a fresh blueprint (fresh sink channels),
/// bootstraps it under the point vector, runs it, and folds the drained sinks to /// bootstraps it under the point vector, runs it, and folds the drained sinks to
/// metrics — the per-point closure the engine `sweep` drives disjointly. /// metrics — the per-point closure the engine `sweep` drives disjointly.
fn sweep_family() -> SweepFamily { fn sweep_family(trace: Option<&str>) -> SweepFamily {
let bp = sample_blueprint_with_sinks().0; let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space(); let space = bp.param_space();
bp.axis("signals.trend.fast.length", [2, 3]) bp.axis("signals.trend.fast.length", [2, 3])
@@ -465,12 +486,17 @@ fn sweep_family() -> SweepFamily {
vec![Box::new(VecSource::new(showcase_prices()))]; vec![Box::new(VecSource::new(showcase_prices()))];
let window = window_of(&sources).expect("non-empty showcase stream"); let window = window_of(&sources).expect("non-empty showcase stream");
h.run(sources); h.run(sources);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0); let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0); let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
RunReport { let named = zip_params(&space, point);
manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, SYNTHETIC_PIP_SIZE), let key = sweep_member_key(&named);
metrics: summarize(&equity, &exposure), 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") .expect("the built-in named grid matches the sample param-space")
} }
@@ -480,7 +506,7 @@ fn sweep_family() -> SweepFamily {
#[cfg(test)] #[cfg(test)]
fn sweep_report() -> String { fn sweep_report() -> String {
let mut out = String::new(); let mut out = String::new();
for pt in &sweep_family().points { for pt in &sweep_family(None).points {
out.push_str(&pt.report.to_json()); out.push_str(&pt.report.to_json());
out.push('\n'); out.push('\n');
} }
@@ -494,12 +520,13 @@ fn default_registry() -> Registry {
Registry::open("runs/runs.jsonl") Registry::open("runs/runs.jsonl")
} }
/// `aura sweep [--name <n>]`: run the built-in sweep, persist it as a *family* /// `aura sweep [--name <n>|--trace <n>]`: run the built-in sweep, persist it as a
/// (related records sharing one `family_id`, C18/C21) via `append_family`, and /// *family* (related records sharing one `family_id`, C18/C21) via `append_family`,
/// print each point's record line carrying the assigned id. /// and print each point's record line carrying the assigned id. With `--trace`,
fn run_sweep(name: &str) { /// also persist each member's streams under `runs/traces/<n>/<member_key>/` (opt-in).
fn run_sweep(name: &str, persist: bool) {
let reg = default_registry(); let reg = default_registry();
let family = sweep_family(); let family = sweep_family(persist.then_some(name));
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
Ok(id) => id, Ok(id) => id,
Err(e) => { Err(e) => {
@@ -512,16 +539,17 @@ fn run_sweep(name: &str) {
} }
} }
/// `aura walkforward [--name <n>]`: run a built-in rolling walk-forward over the /// `aura walkforward [--name <n>|--trace <n>]`: run a built-in rolling walk-forward
/// sample blueprint + a synthetic windowed source. Per window: sweep the built-in /// over the sample blueprint + a synthetic windowed source. Per window: sweep the
/// grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3, /// built-in grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3,
/// where aura-cli bridges engine + registry), run the chosen params out-of-sample. /// where aura-cli bridges engine + registry), run the chosen params out-of-sample.
/// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`, /// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`,
/// print each carrying the assigned id, then the stitched summary line. /// print each carrying the assigned id, then the stitched summary line. With
/// Deterministic (C1). /// `--trace`, also persist each OOS member's streams under
fn run_walkforward(name: &str) { /// `runs/traces/<n>/oos<ns>/` (opt-in). Deterministic (C1).
fn run_walkforward(name: &str, persist: bool) {
let reg = default_registry(); let reg = default_registry();
let result = walkforward_family(); let result = walkforward_family(persist.then_some(name));
let id = let id =
match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result))
{ {
@@ -541,7 +569,7 @@ fn run_walkforward(name: &str) {
/// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3 /// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3
/// windows. Each window sweeps the built-in grid in-sample, optimizes by /// windows. Each window sweeps the built-in grid in-sample, optimizes by
/// total_pips (axis 2), and runs the chosen params out-of-sample. /// total_pips (axis 2), and runs the chosen params out-of-sample.
fn walkforward_family() -> WalkForwardResult { fn walkforward_family(trace: Option<&str>) -> WalkForwardResult {
let sources: Vec<Box<dyn aura_engine::Source>> = let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(walkforward_prices()))]; vec![Box::new(VecSource::new(walkforward_prices()))];
let span = window_of(&sources).expect("non-empty synthetic stream"); let span = window_of(&sources).expect("non-empty synthetic stream");
@@ -551,7 +579,7 @@ fn walkforward_family() -> WalkForwardResult {
walk_forward(roller, space, |w: WindowBounds| { walk_forward(roller, space, |w: WindowBounds| {
let is_family = sweep_over(w.is.0, w.is.1); 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 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); let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace);
WindowRun { WindowRun {
// The tag-free sweep winner is the chosen point; its kinds live on // The tag-free sweep winner is the chosen point; its kinds live on
// WalkForwardResult.space (computed once above from the same blueprint). // WalkForwardResult.space (computed once above from the same blueprint).
@@ -596,7 +624,12 @@ fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily {
/// Run the chosen params over an out-of-sample window; return the recorded /// Run the chosen params over an out-of-sample window; return the recorded
/// pip-equity segment (for stitching) and the OOS RunReport (the C18 record). /// pip-equity segment (for stitching) and the OOS RunReport (the C18 record).
fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp, f64)>, RunReport) { 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 (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let space = bp.param_space(); let space = bp.param_space();
let mut h = bp let mut h = bp
@@ -606,12 +639,15 @@ fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp,
vec![Box::new(walkforward_window_source(from, to))]; vec![Box::new(walkforward_window_source(from, to))];
let window = window_of(&sources).expect("non-empty out-of-sample window"); let window = window_of(&sources).expect("non-empty out-of-sample window");
h.run(sources); h.run(sources);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0); let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0); let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let report = RunReport { let manifest = sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE);
manifest: sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE), if let Some(name) = trace {
metrics: summarize(&equity, &exposure), 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) (equity, report)
} }
@@ -659,7 +695,7 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource {
/// helper (mirrors `sweep_report`). /// helper (mirrors `sweep_report`).
#[cfg(test)] #[cfg(test)]
fn walkforward_report() -> String { fn walkforward_report() -> String {
let result = walkforward_family(); let result = walkforward_family(None);
let mut out = String::new(); let mut out = String::new();
for w in &result.windows { for w in &result.windows {
out.push_str(&w.run.oos_report.to_json()); out.push_str(&w.run.oos_report.to_json());
@@ -675,7 +711,7 @@ fn walkforward_report() -> String {
/// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`, /// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`,
/// varying the *seed* rather than a tuning param. The seed -> `Source` /// varying the *seed* rather than a tuning param. The seed -> `Source`
/// construction lives inside the per-draw closure (eager-agnostic, #71). /// construction lives inside the per-draw closure (eager-agnostic, #71).
fn mc_family() -> McFamily { fn mc_family(trace: Option<&str>) -> McFamily {
let base_point: Vec<Scalar> = Vec::new(); let base_point: Vec<Scalar> = Vec::new();
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| { monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
@@ -683,21 +719,24 @@ fn mc_family() -> McFamily {
let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(spec.source(seed))]; let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(spec.source(seed))];
let window = window_of(&sources).expect("non-empty synthetic stream"); let window = window_of(&sources).expect("non-empty synthetic stream");
h.run(sources); h.run(sources);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0); let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0); let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
RunReport { let manifest = sim_optimal_manifest(
manifest: sim_optimal_manifest( vec![
vec![ ("sma_fast".to_string(), Scalar::i64(2)),
("sma_fast".to_string(), Scalar::i64(2)), ("sma_slow".to_string(), Scalar::i64(4)),
("sma_slow".to_string(), Scalar::i64(4)), ("exposure_scale".to_string(), Scalar::f64(0.5)),
("exposure_scale".to_string(), Scalar::f64(0.5)), ],
], window,
window, seed,
seed, SYNTHETIC_PIP_SIZE,
SYNTHETIC_PIP_SIZE, );
), if let Some(name) = trace {
metrics: summarize(&equity, &exposure), 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) }
}) })
} }
@@ -715,12 +754,13 @@ fn mc_aggregate_json(agg: &McAggregate) -> String {
.to_string() .to_string()
} }
/// `aura mc [--name <n>]`: run the built-in Monte-Carlo family, persist it to the /// `aura mc [--name <n>|--trace <n>]`: run the built-in Monte-Carlo family, persist
/// family store via `append_family` (C18/C21), print each draw's record line /// it to the family store via `append_family` (C18/C21), print each draw's record
/// (carrying the assigned `family_id`) plus the aggregate line. /// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`,
fn run_mc(name: &str) { /// also persist each draw's streams under `runs/traces/<n>/seed<seed>/` (opt-in).
fn run_mc(name: &str, persist: bool) {
let reg = default_registry(); let reg = default_registry();
let family = mc_family(); let family = mc_family(persist.then_some(name));
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
Ok(id) => id, Ok(id) => id,
Err(e) => { Err(e) => {
@@ -744,7 +784,7 @@ fn run_mc(name: &str) {
/// computation, separate from the store-dependent id. /// computation, separate from the store-dependent id.
#[cfg(test)] #[cfg(test)]
fn mc_report() -> String { fn mc_report() -> String {
let family = mc_family(); let family = mc_family(None);
let mut out = String::new(); let mut out = String::new();
for draw in &family.draws { for draw in &family.draws {
out.push_str(&draw.report.to_json()); out.push_str(&draw.report.to_json());
@@ -926,7 +966,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
} }
const USAGE: &str = const USAGE: &str =
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]"; "usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
fn main() { fn main() {
// Collect argv and match the whole vector: every accepted form is exhaustive, // Collect argv and match the whole vector: every accepted form is exhaustive,
@@ -950,12 +990,15 @@ fn main() {
["chart", name] => emit_chart(name, ChartMode::Overlay), ["chart", name] => emit_chart(name, ChartMode::Overlay),
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels), ["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
["graph"] => print!("{}", render::render_html(&sample_blueprint())), ["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["sweep"] => run_sweep("sweep"), ["sweep"] => run_sweep("sweep", false),
["sweep", "--name", n] => run_sweep(n), ["sweep", "--name", n] => run_sweep(n, false),
["walkforward"] => run_walkforward("walkforward"), ["sweep", "--trace", n] => run_sweep(n, true),
["walkforward", "--name", n] => run_walkforward(n), ["walkforward"] => run_walkforward("walkforward", false),
["mc"] => run_mc("mc"), ["walkforward", "--name", n] => run_walkforward(n, false),
["mc", "--name", n] => run_mc(n), ["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),
["runs", "families"] => runs_families(), ["runs", "families"] => runs_families(),
["runs", "family", id] => runs_family(id, None), ["runs", "family", id] => runs_family(id, None),
["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)), ["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)),
@@ -1142,16 +1185,16 @@ mod tests {
// a fresh temp store (the run_* fns themselves bind `default_registry()`): // a fresh temp store (the run_* fns themselves bind `default_registry()`):
// engine family -> per-kind extractor -> append_family. // engine family -> per-kind extractor -> append_family.
let sid = reg let sid = reg
.append_family("sweep", FamilyKind::Sweep, &sweep_member_reports(&sweep_family())) .append_family("sweep", FamilyKind::Sweep, &sweep_member_reports(&sweep_family(None)))
.expect("sweep family"); .expect("sweep family");
let mid = reg let mid = reg
.append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family())) .append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None)))
.expect("mc family"); .expect("mc family");
let wid = reg let wid = reg
.append_family( .append_family(
"walkforward", "walkforward",
FamilyKind::WalkForward, FamilyKind::WalkForward,
&walkforward_member_reports(&walkforward_family()), &walkforward_member_reports(&walkforward_family(None)),
) )
.expect("walkforward family"); .expect("walkforward family");
assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0")); assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0"));
@@ -1360,4 +1403,26 @@ mod tests {
assert!(!r1.manifest.commit.is_empty()); assert!(!r1.manifest.commit.is_empty());
assert_eq!(r1.manifest.commit, r2.manifest.commit); assert_eq!(r1.manifest.commit, r2.manifest.commit);
} }
#[test]
fn sweep_member_key_maps_the_two_named_axes() {
// The happy path the doc comment promises: the two varying axes drive
// the `fNsM` key, so distinct grid points yield distinct keys.
let named = vec![
("signals.trend.fast.length".to_string(), Scalar::i64(2)),
("signals.trend.slow.length".to_string(), Scalar::i64(5)),
];
assert_eq!(sweep_member_key(&named), "f2s5");
}
#[test]
#[should_panic(expected = "signals.trend.slow.length")]
fn sweep_member_key_fails_loudly_on_a_missing_axis() {
// Property: the key is collision-free over the grid, so a key-driving
// axis that is absent (a future grid/key drift) must fail loudly, not
// silently collapse to a shared `f…s0` dir that overwrites a sibling's
// trace. The panic names the offending axis.
let drifted = vec![("signals.trend.fast.length".to_string(), Scalar::i64(2))];
let _ = sweep_member_key(&drifted);
}
} }
+154
View File
@@ -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"); let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); 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)");
}