Files
Entropyk/crates/solver/tests/golden_snapshot.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

209 lines
7.1 KiB
Rust

//! 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<Snapshot> {
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>> {
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);
}