//! Phase-0 golden snapshot regression test. //! //! Solves three reference R134a cycles and compares the converged state against //! committed snapshots. The comparison uses a SHA-256 hash of a canonical JSON //! representation as the primary gate, plus a floating-point tolerant diff on //! the fluid state vector for actionable diagnostics when the hash drifts. //! //! Run with `ENTROPYK_BLESS=1` to regenerate snapshots after an intentional //! physics or solver change. use std::collections::BTreeMap; use std::env; use std::fs; use std::path::PathBuf; use approx::relative_eq; use serde_json::Value; use sha2::{Digest, Sha256}; mod common; const BLESS_VAR: &str = "ENTROPYK_BLESS"; const SNAPSHOT_DIR: &str = "tests/snapshots"; struct Snapshot { value: Value, hash: String, } fn snapshot_path(name: &str) -> PathBuf { PathBuf::from(SNAPSHOT_DIR).join(format!("golden_{}.json", name)) } fn load_snapshot(name: &str) -> Option { let path = snapshot_path(name); if !path.exists() { return None; } let content = fs::read_to_string(&path).expect("failed to read snapshot"); let value: Value = serde_json::from_str(&content).expect("invalid snapshot JSON"); let hash = sha256_hex(&content); Some(Snapshot { value, hash }) } fn sha256_hex(data: &str) -> String { let mut hasher = Sha256::new(); hasher.update(data.as_bytes()); format!("{:x}", hasher.finalize()) } /// Recursively sorts object keys so the JSON representation is canonical and /// hash-stable for the golden snapshot. fn canonicalize(value: Value) -> Value { match value { Value::Object(map) => { let mut sorted = BTreeMap::new(); for (k, v) in map { sorted.insert(k, canonicalize(v)); } Value::Object(sorted.into_iter().collect()) } Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize).collect()), other => other, } } fn canonical_json(value: &Value) -> String { let canonical = canonicalize(value.clone()); canonical.to_string() } fn extract_fluid_state(value: &Value) -> Option<&Vec> { value .get("fluidState") .and_then(|v| v.as_array()) .or_else(|| value.get("fluid_state").and_then(|v| v.as_array())) } fn format_state_diff(expected: &[Value], actual: &[Value]) -> String { let mut lines = vec!["Fluid-state diff (index | expected | actual | rel_diff):".to_string()]; let len = expected.len().max(actual.len()); for i in 0..len { let e = expected.get(i).and_then(|v| v.as_f64()); let a = actual.get(i).and_then(|v| v.as_f64()); match (e, a) { (Some(e), Some(a)) => { let rel = if e.abs() > 0.0 { ((a - e) / e).abs() } else { a.abs() }; lines.push(format!(" [{:02}] {:>18.6} {:>18.6} {:.6e}", i, e, a, rel)); } (Some(e), None) => { lines.push(format!(" [{:02}] {:>18.6} {:>18} missing", i, e, "-")); } (None, Some(a)) => { lines.push(format!(" [{:02}] {:>18} {:>18.6} extra", i, "-", a)); } (None, None) => { lines.push(format!(" [{:02}] {:>18} {:>18} ???", i, "-", "-")); } } } lines.join("\n") } fn assert_state_matches(name: &str, expected_value: &Value, actual_value: &Value) { let expected_state = extract_fluid_state(expected_value).expect("snapshot missing fluidState"); let actual_state = extract_fluid_state(actual_value).expect("current solve missing fluidState"); assert_eq!( expected_state.len(), actual_state.len(), "{}: fluid state length mismatch (expected {}, got {})", name, expected_state.len(), actual_state.len() ); let mut mismatches = Vec::new(); for (i, (e, a)) in expected_state.iter().zip(actual_state.iter()).enumerate() { let e = e.as_f64().expect("snapshot state value is not a number"); let a = a.as_f64().expect("current state value is not a number"); if !relative_eq!(e, a, epsilon = 1e-3, max_relative = 1e-5) { mismatches.push((i, e, a)); } } if !mismatches.is_empty() { let mut msg = format!( "{}: {} fluid-state values exceed tolerance\n", name, mismatches.len() ); msg.push_str(&format_state_diff(expected_state, actual_state)); panic!("{}", msg); } } fn run_cycle_snapshot(name: &str, build_system: fn() -> entropyk_solver::system::System) { let mut system = build_system(); let converged = common::solve_reference_system_with_state(&mut system); // The solver's returned state vector is the authoritative converged state. // System::to_json_string currently reads component ports, which are not // updated with the converged vector in this code path, so we merge the // solver state into the snapshot JSON manually. let json_str = system.to_json_string().expect("failed to serialize system"); let mut actual_value: Value = serde_json::from_str(&json_str).expect("invalid system JSON"); if let Some(Value::Object(obj)) = actual_value.get_mut("fluidState") { obj.insert("data".to_string(), converged.state.clone().into()); obj.insert("edgeCount".to_string(), (converged.state.len() / 2).into()); } let canonical = canonical_json(&actual_value); let actual_hash = sha256_hex(&canonical); let bless = env::var(BLESS_VAR).map(|v| v == "1").unwrap_or(false); if bless { fs::create_dir_all(SNAPSHOT_DIR).expect("failed to create snapshot directory"); let path = snapshot_path(name); fs::write(&path, canonical).expect("failed to write snapshot"); println!("📸 Blessed snapshot for {} -> {}", name, path.display()); return; } let snapshot = load_snapshot(name).unwrap_or_else(|| { panic!( "No golden snapshot for {}. Run with {}=1 to create it.", name, BLESS_VAR ) }); if snapshot.hash != actual_hash { // Hash drifted; run tolerant comparison on fluid state for diagnostics. assert_state_matches(name, &snapshot.value, &actual_value); // If the fluid state is within tolerance but the hash changed, the drift // is in non-physics fields (topology, parameters, metadata). That is // still a regression for a golden snapshot. panic!( "{}: snapshot hash mismatch\n expected: {}\n actual: {}\n \ The fluid state is within tolerance, but non-state fields changed. \ If this is intentional, re-run with {}=1.", name, snapshot.hash, actual_hash, BLESS_VAR ); } println!("✅ {} snapshot hash matches ({})", name, actual_hash); } #[test] fn golden_reference_cycle_a() { run_cycle_snapshot("cycle_a", common::build_reference_cycle_a); } #[test] fn golden_reference_cycle_b() { run_cycle_snapshot("cycle_b", common::build_reference_cycle_b); } #[test] fn golden_reference_cycle_c() { run_cycle_snapshot("cycle_c", common::build_reference_cycle_c); }