Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
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>
This commit is contained in:
208
crates/solver/tests/golden_snapshot.rs
Normal file
208
crates/solver/tests/golden_snapshot.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in New Issue
Block a user