swactor/crates/simulation/tests/parity-bar/t_equivariance.rs

225 lines
6.8 KiB
Rust
Raw Normal View History

//! 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<PathBuf, String>,
) -> BTreeMap<PathBuf, String> {
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<PathBuf, String>,
rename: &BTreeMap<&str, &str>,
) -> BTreeMap<PathBuf, String> {
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<Vec<&str>> = blocks
.iter()
.map(|(s, e)| lines[*s..*e].to_vec())
.collect();
block_contents.reverse();
let mut out_lines: Vec<String> = 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
}