Some checks failed
CI / check (push) Has been cancelled
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>
209 lines
7.1 KiB
Rust
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);
|
|
}
|