diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index ce930dd..c84f0ef 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 /// 3-step union-spine alignment — no tap privileged, no point dropped: /// (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), /// bootstraps it under the point vector, runs it, and folds the drained sinks to /// 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 space = bp.param_space(); bp.axis("signals.trend.fast.length", [2, 3]) @@ -465,12 +486,17 @@ fn sweep_family() -> SweepFamily { vec![Box::new(VecSource::new(showcase_prices()))]; let window = window_of(&sources).expect("non-empty showcase stream"); h.run(sources); - let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); - let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - RunReport { - manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, SYNTHETIC_PIP_SIZE), - metrics: summarize(&equity, &exposure), + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + 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") } @@ -480,7 +506,7 @@ fn sweep_family() -> SweepFamily { #[cfg(test)] fn sweep_report() -> String { 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('\n'); } @@ -494,12 +520,13 @@ fn default_registry() -> Registry { Registry::open("runs/runs.jsonl") } -/// `aura sweep [--name ]`: run the built-in sweep, persist it as a *family* -/// (related records sharing one `family_id`, C18/C21) via `append_family`, and -/// print each point's record line carrying the assigned id. -fn run_sweep(name: &str) { +/// `aura sweep [--name |--trace ]`: run the built-in sweep, persist it as a +/// *family* (related records sharing one `family_id`, C18/C21) via `append_family`, +/// and print each point's record line carrying the assigned id. With `--trace`, +/// also persist each member's streams under `runs/traces///` (opt-in). +fn run_sweep(name: &str, persist: bool) { 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)) { Ok(id) => id, Err(e) => { @@ -512,16 +539,17 @@ fn run_sweep(name: &str) { } } -/// `aura walkforward [--name ]`: run a built-in rolling walk-forward over the -/// sample blueprint + a synthetic windowed source. Per window: sweep the built-in -/// grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3, +/// `aura walkforward [--name |--trace ]`: run a built-in rolling walk-forward +/// over the sample blueprint + a synthetic windowed source. Per window: sweep the +/// 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. /// 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. -/// Deterministic (C1). -fn run_walkforward(name: &str) { +/// print each carrying the assigned id, then the stitched summary line. With +/// `--trace`, also persist each OOS member's streams under +/// `runs/traces//oos/` (opt-in). Deterministic (C1). +fn run_walkforward(name: &str, persist: bool) { let reg = default_registry(); - let result = walkforward_family(); + let result = walkforward_family(persist.then_some(name)); let id = 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 /// windows. Each window sweeps the built-in grid in-sample, optimizes by /// 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> = vec![Box::new(VecSource::new(walkforward_prices()))]; let span = window_of(&sources).expect("non-empty synthetic stream"); @@ -551,7 +579,7 @@ 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); + let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace); WindowRun { // The tag-free sweep winner is the chosen point; its kinds live on // 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 /// 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 space = bp.param_space(); 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))]; let window = window_of(&sources).expect("non-empty out-of-sample window"); h.run(sources); - let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); - let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - let report = RunReport { - manifest: sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE), - metrics: summarize(&equity, &exposure), - }; + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + 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) } @@ -659,7 +695,7 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { /// helper (mirrors `sweep_report`). #[cfg(test)] fn walkforward_report() -> String { - let result = walkforward_family(); + let result = walkforward_family(None); let mut out = String::new(); for w in &result.windows { 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`, /// varying the *seed* rather than a tuning param. The seed -> `Source` /// 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 = Vec::new(); monte_carlo(&base_point, &[1, 2, 3], |seed, _base| { let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); @@ -683,21 +719,24 @@ fn mc_family() -> McFamily { let sources: Vec> = vec![Box::new(spec.source(seed))]; let window = window_of(&sources).expect("non-empty synthetic stream"); h.run(sources); - let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); - let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - RunReport { - 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, - ), - metrics: summarize(&equity, &exposure), + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + 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) } }) } @@ -715,12 +754,13 @@ fn mc_aggregate_json(agg: &McAggregate) -> String { .to_string() } -/// `aura mc [--name ]`: run the built-in Monte-Carlo family, persist it to the -/// family store via `append_family` (C18/C21), print each draw's record line -/// (carrying the assigned `family_id`) plus the aggregate line. -fn run_mc(name: &str) { +/// `aura mc [--name |--trace ]`: run the built-in Monte-Carlo family, persist +/// it to the family store via `append_family` (C18/C21), print each draw's record +/// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`, +/// also persist each draw's streams under `runs/traces//seed/` (opt-in). +fn run_mc(name: &str, persist: bool) { 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)) { Ok(id) => id, Err(e) => { @@ -744,7 +784,7 @@ fn run_mc(name: &str) { /// computation, separate from the store-dependent id. #[cfg(test)] fn mc_report() -> String { - let family = mc_family(); + let family = mc_family(None); let mut out = String::new(); for draw in &family.draws { out.push_str(&draw.report.to_json()); @@ -926,7 +966,7 @@ fn run_macd(trace: Option<&str>) -> RunReport { } const USAGE: &str = - "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--panels] | aura graph | aura sweep [--name ] | aura mc [--name ] | aura walkforward [--name ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--panels] | aura graph | aura sweep [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--name |--trace ] | aura runs families | aura runs family [rank ]"; fn main() { // 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, "--panels"] => emit_chart(name, ChartMode::Panels), ["graph"] => print!("{}", render::render_html(&sample_blueprint())), - ["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), + ["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), ["runs", "families"] => runs_families(), ["runs", "family", id] => runs_family(id, None), ["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()`): // engine family -> per-kind extractor -> append_family. 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"); 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"); let wid = reg .append_family( "walkforward", FamilyKind::WalkForward, - &walkforward_member_reports(&walkforward_family()), + &walkforward_member_reports(&walkforward_family(None)), ) .expect("walkforward family"); 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_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); + } } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 1c0319b..39fe116 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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 ` 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 ` 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 ` persists one standalone, +/// round-tripping run-dir per OOS window (the built-in roll = 3 windows), each +/// keyed `oos`; 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 = 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: {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 { + 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)"); +}