//! TESTING_SPEC §9 — Equivariance. //! //! Three rewrites of the reference scenario that should be //! indistinguishable to the engine: node-id rename, host-listing //! permutation, and same-tick mutation reordering. All three must //! produce byte-identical bundles after applying the appropriate //! rename map. #[path = "common.rs"] mod common; use common::{reference_scenario_text, run_engine, sha256_tree}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; /// Drop the `sim/` subtree from a hash tree before comparison. /// SPEC §6.4: "The sim/ subtree is sim-only and is ignored by the /// parity diff." The rename / ordering checks land below the same /// exclusion — sim/spec.toml necessarily encodes the *renamed* host /// names so comparing it under rename would force the engine to /// rename strings it has no way to recognise. fn drop_sim_subtree( tree: BTreeMap, ) -> BTreeMap { tree.into_iter() .filter(|(p, _)| !p.starts_with(Path::new("sim"))) .collect() } // ── §9.1 — Node-id rename invariance ─────────────────────────────── #[test] fn rename_invariance() { let base_spec = reference_scenario_text(); let rename = BTreeMap::from([ ("orchestrator", "x"), ("stage-0", "alpha"), ("stage-1", "beta"), ("stage-2", "gamma"), ("collector", "z"), ]); let renamed_spec = apply_text_rename(&base_spec, &rename); let b_base = run_engine(&base_spec, 42); let b_renamed = run_engine(&renamed_spec, 42); let base_tree = drop_sim_subtree(sha256_tree(&b_base.root)); let renamed_tree = drop_sim_subtree(sha256_tree(&b_renamed.root)); let base_aligned = apply_path_rename(base_tree, &rename); assert_eq!( base_aligned, renamed_tree, "rename-equivariance: bundles diverged after path rename" ); } // ── §9.2 — Spec ordering invariance ──────────────────────────────── #[test] fn spec_ordering_invariance() { let base = reference_scenario_text(); let permuted = reverse_host_blocks(&base); assert_ne!( base.trim(), permuted.trim(), "test harness bug: permutation should change the spec text" ); let b1 = run_engine(&base, 42); let b2 = run_engine(&permuted, 42); assert_eq!( drop_sim_subtree(sha256_tree(&b1.root)), drop_sim_subtree(sha256_tree(&b2.root)), "spec ordering must not affect the bundle" ); } // ── §9.3 — Same-tick mutation reordering ─────────────────────────── #[test] fn same_tick_mutation_reorder() { let order_a = same_tick_scenario(&[("partition", true), ("partition", false)]); let order_b = same_tick_scenario(&[("partition", false), ("partition", true)]); let b1 = run_engine(&order_a, 11); let b2 = run_engine(&order_b, 11); assert_eq!( drop_sim_subtree(sha256_tree(&b1.root)), drop_sim_subtree(sha256_tree(&b2.root)), "same-tick mutations declared in different orders must produce \ byte-identical bundles" ); } // ── Helpers ──────────────────────────────────────────────────────── fn apply_text_rename(text: &str, rename: &BTreeMap<&str, &str>) -> String { let mut out = text.to_string(); // Use distinctive placeholders to avoid renaming "stage-0" inside // the result of a previous rename to "stage-00". for (i, (from, _)) in rename.iter().enumerate() { let placeholder = format!("__RENAME_{i}__"); out = out.replace(from, &placeholder); } for (i, (_, to)) in rename.iter().enumerate() { let placeholder = format!("__RENAME_{i}__"); out = out.replace(&placeholder, to); } out } fn apply_path_rename( tree: BTreeMap, rename: &BTreeMap<&str, &str>, ) -> BTreeMap { let mut out = BTreeMap::new(); for (rel, hash) in tree { let s = rel.to_string_lossy().to_string(); let mut renamed = s.clone(); for (i, (from, _)) in rename.iter().enumerate() { let placeholder = format!("__RENAME_{i}__"); renamed = renamed.replace(from, &placeholder); } for (i, (_, to)) in rename.iter().enumerate() { let placeholder = format!("__RENAME_{i}__"); renamed = renamed.replace(&placeholder, to); } out.insert(PathBuf::from(renamed), hash); } out } fn reverse_host_blocks(text: &str) -> String { // Naively flip the order of `[[hosts]]` blocks in the TOML. // Other blocks (`[host_defaults]`, `[[links]]`, `[[mutations]]`, // top-level keys) are emitted unchanged. let mut lines: Vec<&str> = text.lines().collect(); let mut blocks: Vec<(usize, usize)> = Vec::new(); let mut i = 0; while i < lines.len() { if lines[i].trim() == "[[hosts]]" { let start = i; let mut j = i + 1; while j < lines.len() && !lines[j].trim_start().starts_with('[') { j += 1; } blocks.push((start, j)); i = j; } else { i += 1; } } // Reverse the host blocks in place. let mut block_contents: Vec> = blocks .iter() .map(|(s, e)| lines[*s..*e].to_vec()) .collect(); block_contents.reverse(); let mut out_lines: Vec = Vec::new(); let mut block_iter = block_contents.into_iter(); let mut consumed_until = 0; for (s, e) in &blocks { for raw in &lines[consumed_until..*s] { out_lines.push((*raw).to_string()); } let block = block_iter.next().expect("blocks aligned"); for raw in block { out_lines.push(raw.to_string()); } consumed_until = *e; } for raw in &lines[consumed_until..] { out_lines.push((*raw).to_string()); } out_lines.join("\n") + "\n" } fn same_tick_scenario(mutations: &[(&str, bool)]) -> String { let mut s = String::from( r#" run_id = "equivariance-001" seed = 11 duration_ms = 3000 [[hosts]] name = "alpha" role = "stage" stage_index = 0 start_at_ms = 0 stop_at_ms = 3000 [[hosts]] name = "beta" role = "stage" stage_index = 1 start_at_ms = 0 stop_at_ms = 3000 [[hosts]] name = "gamma" role = "stage" stage_index = 2 start_at_ms = 0 stop_at_ms = 3000 "#, ); for (kind, first_pair) in mutations { let (a, b) = if *first_pair { ("alpha", "beta") } else { ("beta", "gamma") }; s.push_str(&format!( r#" [[mutations]] kind = "{kind}" at_ms = 1500 edges = [["{a}", "{b}"]] "# )); } s }