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>
268 lines
9.7 KiB
Rust
268 lines
9.7 KiB
Rust
//! Integration tests for Story 1.2: Core Crate & Typed Convergence Taxonomy.
|
||
//!
|
||
//! Covers:
|
||
//! - AC #1: non-converged terminations are reported as `ConvergenceReason`
|
||
//! outcomes (via `SolverError::convergence_reason` and `Solver::solve_outcome`),
|
||
//! while hard errors (invalid system, validation) remain `Err`.
|
||
//! - AC #3: existing `SolverError` behavior (incl. `WithDiagnostics`) is
|
||
//! unchanged — the taxonomy is purely additive.
|
||
|
||
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice};
|
||
use entropyk_solver::solver::{NewtonConfig, Solver, SolverError};
|
||
use entropyk_solver::system::{System, DEFAULT_MASS_FLOW_SEED_KG_S, MIN_SOLVER_PRESSURE_PA};
|
||
use entropyk_solver::{ConvergenceDiagnostics, ConvergenceReason, SolveOutcome};
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Mock components (same fixture pattern as fallback_solver.rs)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// A well-conditioned linear system r = A·x − b: converges in one Newton step.
|
||
struct LinearSystem {
|
||
a: Vec<Vec<f64>>,
|
||
b: Vec<f64>,
|
||
n: usize,
|
||
}
|
||
|
||
impl LinearSystem {
|
||
fn well_conditioned() -> Self {
|
||
let (p, h) = (MIN_SOLVER_PRESSURE_PA, MIN_SOLVER_PRESSURE_PA);
|
||
Self {
|
||
a: vec![vec![2.0, 1.0], vec![1.0, 2.0]],
|
||
b: vec![2.0 * p + h, p + 2.0 * h],
|
||
n: 2,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Component for LinearSystem {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
for (i, residual) in residuals.iter_mut().enumerate().take(self.n) {
|
||
let mut ax_i = 0.0;
|
||
for j in 0..self.n {
|
||
ax_i += self.a[i][j] * state[1 + j];
|
||
}
|
||
*residual = ax_i - self.b[i];
|
||
}
|
||
residuals[self.n] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
for i in 0..self.n {
|
||
for j in 0..self.n {
|
||
jacobian.add_entry(i, 1 + j, self.a[i][j]);
|
||
}
|
||
}
|
||
jacobian.add_entry(self.n, 0, 1.0);
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
self.n + 1
|
||
}
|
||
|
||
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
|
||
&[]
|
||
}
|
||
}
|
||
|
||
/// A mildly non-linear system ((x − p₀)² = 1) parked at the pressure floor,
|
||
/// needing several Newton steps from an offset guess — deterministic
|
||
/// `MaxIters` generator when the iteration budget is 1.
|
||
struct QuadraticSystem;
|
||
|
||
impl Component for QuadraticSystem {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
// r0 = (x − p₀)² − 1 (root at p₀ + 1, on the clip floor like the
|
||
// established LinearSystem fixture; residual stays O(1) so neither
|
||
// the divergence threshold nor pressure clipping interferes).
|
||
let dx = state[1] - MIN_SOLVER_PRESSURE_PA;
|
||
residuals[0] = dx * dx - 1.0;
|
||
// r1 pins the second unknown.
|
||
residuals[1] = state[2] - MIN_SOLVER_PRESSURE_PA;
|
||
// Mass-flow row pins ṁ at the seed value.
|
||
residuals[2] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
jacobian.add_entry(0, 1, 2.0 * (state[1] - MIN_SOLVER_PRESSURE_PA));
|
||
jacobian.add_entry(1, 2, 1.0);
|
||
jacobian.add_entry(2, 0, 1.0);
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
3
|
||
}
|
||
|
||
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
|
||
&[]
|
||
}
|
||
}
|
||
|
||
fn create_test_system(component: Box<dyn Component>) -> System {
|
||
let mut system = System::new();
|
||
let n0 = system.add_component(component);
|
||
system.add_edge(n0, n0).unwrap();
|
||
system.finalize().unwrap();
|
||
system
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// AC #1: SolverError classification into ConvergenceReason
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn non_convergence_maps_to_max_iters() {
|
||
let err = SolverError::NonConvergence {
|
||
iterations: 42,
|
||
final_residual: 1.0e-3,
|
||
};
|
||
assert_eq!(err.convergence_reason(), Some(ConvergenceReason::MaxIters));
|
||
}
|
||
|
||
#[test]
|
||
fn timeout_maps_to_timed_out() {
|
||
let err = SolverError::Timeout { timeout_ms: 500 };
|
||
assert_eq!(err.convergence_reason(), Some(ConvergenceReason::TimedOut));
|
||
}
|
||
|
||
#[test]
|
||
fn divergence_maps_to_stalled() {
|
||
let err = SolverError::Divergence {
|
||
reason: "residual growing".to_string(),
|
||
};
|
||
assert_eq!(err.convergence_reason(), Some(ConvergenceReason::Stalled));
|
||
}
|
||
|
||
#[test]
|
||
fn with_diagnostics_delegates_to_inner_error() {
|
||
let base = SolverError::NonConvergence {
|
||
iterations: 10,
|
||
final_residual: 1.0e-4,
|
||
};
|
||
let wrapped = SolverError::WithDiagnostics {
|
||
error: Box::new(base),
|
||
diagnostics: Box::new(ConvergenceDiagnostics::new()),
|
||
};
|
||
assert_eq!(
|
||
wrapped.convergence_reason(),
|
||
Some(ConvergenceReason::MaxIters)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hard_errors_map_to_none() {
|
||
let invalid = SolverError::InvalidSystem {
|
||
message: "empty system".to_string(),
|
||
};
|
||
assert_eq!(invalid.convergence_reason(), None);
|
||
|
||
let validation = SolverError::Validation {
|
||
mass_error: 1.0,
|
||
energy_error: 2.0,
|
||
};
|
||
assert_eq!(validation.convergence_reason(), None);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// AC #1: solve_outcome reports outcomes as data
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn solve_outcome_reports_converged_as_data() {
|
||
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
|
||
let mut solver = NewtonConfig::default();
|
||
|
||
let outcome: SolveOutcome = solver
|
||
.solve_outcome(&mut system)
|
||
.expect("converged solve must not be a hard error");
|
||
|
||
assert_eq!(outcome.reason, ConvergenceReason::Converged);
|
||
assert!(outcome.is_converged());
|
||
assert!(outcome.final_residual < 1e-6);
|
||
assert!(outcome.state.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn solve_outcome_reports_max_iters_as_data_not_err() {
|
||
let mut system = create_test_system(Box::new(QuadraticSystem));
|
||
let mut solver = NewtonConfig {
|
||
max_iterations: 1,
|
||
tolerance: 1e-9,
|
||
initial_state: Some(vec![
|
||
DEFAULT_MASS_FLOW_SEED_KG_S,
|
||
MIN_SOLVER_PRESSURE_PA + 10.0,
|
||
MIN_SOLVER_PRESSURE_PA,
|
||
]),
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
let outcome = solver
|
||
.solve_outcome(&mut system)
|
||
.expect("MaxIters termination must be an outcome, not a hard Err");
|
||
|
||
assert_eq!(outcome.reason, ConvergenceReason::MaxIters);
|
||
assert!(!outcome.is_converged());
|
||
assert!(outcome.iterations >= 1);
|
||
assert!(outcome.final_residual.is_finite());
|
||
assert!(outcome.state.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn solve_outcome_keeps_hard_errors_as_err() {
|
||
// Empty system: InvalidSystem is a hard error and must remain `Err`.
|
||
let mut system = System::new();
|
||
system.finalize().unwrap();
|
||
let mut solver = NewtonConfig::default();
|
||
|
||
let result = solver.solve_outcome(&mut system);
|
||
|
||
match result {
|
||
Err(SolverError::InvalidSystem { .. }) => {}
|
||
other => panic!("expected Err(InvalidSystem), got {:?}", other),
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// AC #3: legacy behavior untouched
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn legacy_solve_still_returns_err_on_non_convergence() {
|
||
// The legacy `solve` contract is unchanged: NonConvergence stays an `Err`.
|
||
// Only the additive `solve_outcome` reports outcomes as data.
|
||
let mut system = create_test_system(Box::new(QuadraticSystem));
|
||
let mut solver = NewtonConfig {
|
||
max_iterations: 1,
|
||
tolerance: 1e-9,
|
||
initial_state: Some(vec![
|
||
DEFAULT_MASS_FLOW_SEED_KG_S,
|
||
MIN_SOLVER_PRESSURE_PA + 10.0,
|
||
MIN_SOLVER_PRESSURE_PA,
|
||
]),
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
let result = solver.solve(&mut system);
|
||
|
||
assert!(matches!(result, Err(SolverError::NonConvergence { .. })));
|
||
}
|