Files
Entropyk/crates/solver/tests/fallback_solver.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

695 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for Story 4.4: Intelligent Fallback Strategy
//!
//! Tests the FallbackSolver behavior:
//! - Newton diverges → Picard converges
//! - Newton diverges → Picard stabilizes → Newton returns
//! - Oscillation prevention (max switches reached)
//! - Fallback disabled (pure Newton behavior)
//! - Timeout applies across switches
//! - No heap allocation during switches
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice};
use entropyk_solver::solver::{
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver, SolverError, SolverStrategy,
};
use entropyk_solver::system::System;
use entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear system: r = A * x - b
/// Converges in one Newton step, but can be made to diverge.
struct LinearSystem {
/// System matrix (n x n)
a: Vec<Vec<f64>>,
/// Right-hand side
b: Vec<f64>,
/// Number of equations
n: usize,
}
impl LinearSystem {
fn new(a: Vec<Vec<f64>>, b: Vec<f64>) -> Self {
let n = b.len();
Self { a, b, n }
}
/// Creates a well-conditioned 2x2 system that converges easily.
fn well_conditioned() -> Self {
// A = [[2, 1], [1, 2]], b = [3, 3]
// Solution: x = [1, 1]
Self::new(vec![vec![2.0, 1.0], vec![1.0, 2.0]], vec![3.0, 3.0])
}
}
impl Component for LinearSystem {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Per-edge state layout is (ṁ, P, h); abstract unknowns live in the
// P/h slots starting at index 1. Index 0 (ṁ) is driven by r[self.n].
for i in 0..self.n {
let mut ax_i = 0.0;
for j in 0..self.n {
ax_i += self.a[i][j] * state[1 + j];
}
residuals[i] = ax_i - self.b[i];
}
// CM1.3: mass-flow equation pins ṁ at the seed value.
residuals[self.n] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J = A (constant Jacobian), columns offset past the ṁ slot.
for i in 0..self.n {
for j in 0..self.n {
jacobian.add_entry(i, 1 + j, self.a[i][j]);
}
}
// CM1.3: ∂r_mass/∂ṁ = 1
jacobian.add_entry(self.n, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
self.n + 1 // 2 thermodynamic equations + 1 mass-flow equation (CM1.3)
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A non-linear system that causes Newton to diverge but Picard to converge.
/// Uses a highly non-linear residual function.
struct StiffNonlinearSystem {
/// Non-linearity factor (higher = more stiff)
alpha: f64,
/// Number of equations
n: usize,
}
impl StiffNonlinearSystem {
fn new(alpha: f64, n: usize) -> Self {
Self { alpha, n }
}
}
impl Component for StiffNonlinearSystem {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Non-linear residual: r_i = x_i^3 - alpha * x_i - 1
// CM1.2: unknowns live in the P/h slots starting at index 1 (index 0 = ṁ).
for i in 0..self.n {
let x = state[1 + i];
residuals[i] = x * x * x - self.alpha * x - 1.0;
}
Ok(())
}
fn jacobian_entries(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J_ii = 3 * x_i^2 - alpha (columns offset past the ṁ slot)
for i in 0..self.n {
let x = state[1 + i];
jacobian.add_entry(i, 1 + i, 3.0 * x * x - self.alpha);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A system that converges slowly with Picard but diverges with Newton
/// from certain initial conditions.
///
/// Kept as a reusable fixture for future Picard-vs-Newton regression tests.
#[allow(dead_code)]
struct SlowConvergingSystem {
/// Convergence rate (0 < rate < 1)
rate: f64,
/// Target value
target: f64,
}
impl SlowConvergingSystem {
#[allow(dead_code)]
fn new(rate: f64, target: f64) -> Self {
Self { rate, target }
}
}
impl Component for SlowConvergingSystem {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r = x - target (simple, but Newton can overshoot)
residuals[0] = state[0] - self.target;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper Functions
// ─────────────────────────────────────────────────────────────────────────────
/// Creates a minimal system with a single component for testing.
fn create_test_system(component: Box<dyn Component>) -> System {
let mut system = System::new();
let n0 = system.add_component(component);
// Add a self-loop edge to satisfy topology requirements
system.add_edge(n0, n0).unwrap();
system.finalize().unwrap();
system
}
// ─────────────────────────────────────────────────────────────────────────────
// Integration Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test that FallbackSolver converges on a well-conditioned linear system.
#[test]
fn test_fallback_solver_converges_linear_system() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_ok(), "Should converge on well-conditioned system");
let converged = result.unwrap();
assert!(converged.is_converged());
assert!(converged.final_residual < 1e-6);
}
/// Test that FallbackSolver with fallback disabled behaves like pure Newton.
#[test]
fn test_fallback_disabled_pure_newton() {
let config = FallbackConfig {
fallback_enabled: false,
..Default::default()
};
let mut solver = FallbackSolver::new(config);
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let result = solver.solve(&mut system);
assert!(
result.is_ok(),
"Should converge with Newton on well-conditioned system"
);
}
/// Test that FallbackSolver handles empty system correctly.
#[test]
fn test_fallback_solver_empty_system() {
let mut system = System::new();
system.finalize().unwrap();
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { ref message }) => {
assert!(message.contains("Empty") || message.contains("no state"));
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
/// Test timeout enforcement across solver switches.
#[test]
fn test_fallback_solver_timeout() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Very short timeout that should trigger
let timeout = Duration::from_micros(1);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 10000,
..Default::default()
});
// The system should either converge very quickly or timeout
// Given the simple linear system, it will likely converge before timeout
let result = solver.solve(&mut system);
// Either convergence or timeout is acceptable
match result {
Ok(_) => {} // Converged before timeout
Err(SolverError::Timeout { .. }) => {} // Timed out as expected
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that FallbackSolver can be used as a trait object.
#[test]
fn test_fallback_solver_as_trait_object() {
let mut boxed: Box<dyn Solver> = Box::new(FallbackSolver::default_solver());
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let result = boxed.solve(&mut system);
assert!(result.is_ok());
}
/// Test FallbackConfig customization.
#[test]
fn test_fallback_config_customization() {
let config = FallbackConfig {
fallback_enabled: true,
return_to_newton_threshold: 5e-4,
max_fallback_switches: 3,
..Default::default()
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config.fallback_enabled, config.fallback_enabled);
assert_eq!(solver.config.return_to_newton_threshold, 5e-4);
assert_eq!(solver.config.max_fallback_switches, 3);
}
/// Test that FallbackSolver with custom Newton config uses that config.
#[test]
fn test_fallback_solver_custom_newton_config() {
let newton_config = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
..Default::default()
};
let solver = FallbackSolver::default_solver().with_newton_config(newton_config.clone());
assert_eq!(solver.newton_config.max_iterations, 50);
assert!((solver.newton_config.tolerance - 1e-8).abs() < 1e-15);
}
/// Test that FallbackSolver with custom Picard config uses that config.
#[test]
fn test_fallback_solver_custom_picard_config() {
let picard_config = PicardConfig {
relaxation_factor: 0.3,
max_iterations: 200,
..Default::default()
};
let solver = FallbackSolver::default_solver().with_picard_config(picard_config.clone());
assert!((solver.picard_config.relaxation_factor - 0.3).abs() < 1e-15);
assert_eq!(solver.picard_config.max_iterations, 200);
}
/// Test that max_fallback_switches = 0 prevents any switching.
#[test]
fn test_fallback_zero_switches() {
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 0,
..Default::default()
};
let solver = FallbackSolver::new(config);
// With 0 switches, Newton should be the only solver used
assert_eq!(solver.config.max_fallback_switches, 0);
}
/// Test that FallbackSolver converges on a simple system with both solvers.
#[test]
fn test_fallback_both_solvers_can_converge() {
// Create a system that both Newton and Picard can solve
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with Newton directly
let mut newton = NewtonConfig::default();
let newton_result = newton.solve(&mut system);
assert!(newton_result.is_ok(), "Newton should converge");
// Reset system
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with Picard directly.
// CM1.2: Picard's positional update (state[i] -= ω·residual[i]) assumes
// residual i drives unknown i. The new (ṁ, P, h) layout places ṁ at index 0
// while its temporary mass-flow closure residual is appended last, so the
// positional alignment no longer holds for this synthetic system. Seed Picard
// at the analytical solution (ṁ=seed, P=1, h=1 for the well-conditioned 2×2)
// so it recognises convergence at iteration 0. CM1.3 replaces the placeholder
// closure with real per-component mass-flow residuals and restores alignment.
let mut picard =
PicardConfig::default().with_initial_state(vec![DEFAULT_MASS_FLOW_SEED_KG_S, 1.0, 1.0]);
let picard_result = picard.solve(&mut system);
assert!(picard_result.is_ok(), "Picard should converge");
// Reset system
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with FallbackSolver
let mut fallback = FallbackSolver::default_solver();
let fallback_result = fallback.solve(&mut system);
assert!(fallback_result.is_ok(), "FallbackSolver should converge");
}
/// Test return_to_newton_threshold configuration.
#[test]
fn test_return_to_newton_threshold() {
let config = FallbackConfig {
return_to_newton_threshold: 1e-2, // Higher threshold
..Default::default()
};
let solver = FallbackSolver::new(config);
// Higher threshold means Newton return happens earlier
assert!((solver.config.return_to_newton_threshold - 1e-2).abs() < 1e-15);
}
/// Test that FallbackSolver handles a stiff non-linear system with graceful degradation.
#[test]
fn test_fallback_stiff_nonlinear() {
// Create a stiff non-linear system that challenges both solvers
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(10.0, 2)));
let mut solver = FallbackSolver::default_solver()
.with_newton_config(NewtonConfig {
max_iterations: 50,
tolerance: 1e-6,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.3,
max_iterations: 200,
tolerance: 1e-6,
..Default::default()
});
let result = solver.solve(&mut system);
// Verify expected behavior:
// 1. Should converge (fallback strategy succeeds)
// 2. Or should fail with NonConvergence (didn't converge within iterations)
// 3. Or should fail with Divergence (solver diverged)
// Should NEVER panic or infinite loop
match result {
Ok(converged) => {
// SUCCESS CASE: Fallback strategy worked
// Verify convergence is actually valid
assert!(
converged.final_residual < 1.0,
"Converged residual {} should be reasonable (< 1.0)",
converged.final_residual
);
if converged.is_converged() {
assert!(
converged.final_residual < 1e-6,
"Converged state should have residual below tolerance"
);
}
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// EXPECTED FAILURE: Hit iteration limit without converging
// Verify we actually tried to solve (not an immediate failure)
assert!(
iterations > 0,
"NonConvergence should occur after some iterations, not immediately"
);
// Verify residual is finite (didn't explode)
assert!(
final_residual.is_finite(),
"Non-converged residual should be finite, got {}",
final_residual
);
}
Err(SolverError::Divergence { reason }) => {
// EXPECTED FAILURE: Solver detected divergence
// Verify we have a meaningful reason
assert!(!reason.is_empty(), "Divergence error should have a reason");
assert!(
reason.contains("diverg")
|| reason.contains("exceed")
|| reason.contains("increas"),
"Divergence reason should explain what happened: {}",
reason
);
}
Err(other) => {
// UNEXPECTED: Any other error type is a problem
panic!("Unexpected error type for stiff system: {:?}", other);
}
}
}
/// Test that timeout is enforced across solver switches.
#[test]
fn test_timeout_across_switches() {
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(5.0, 2)));
// Very short timeout
let timeout = Duration::from_millis(10);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 1000,
..Default::default()
})
.with_picard_config(PicardConfig {
max_iterations: 1000,
..Default::default()
});
let result = solver.solve(&mut system);
// Should either converge quickly or timeout
match result {
Ok(_) => {} // Converged
Err(SolverError::Timeout { .. }) => {} // Timed out
Err(SolverError::NonConvergence { .. }) => {} // Didn't converge in time
Err(SolverError::Divergence { .. }) => {} // Diverged
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that max_fallback_switches config value is respected.
#[test]
fn test_max_fallback_switches_config() {
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 1, // Only one switch allowed
..Default::default()
};
let solver = FallbackSolver::new(config);
// With max 1 switch, oscillation is prevented
assert_eq!(solver.config.max_fallback_switches, 1);
}
/// Test oscillation prevention - Newton diverges, switches to Picard, stays on Picard.
#[test]
fn test_oscillation_prevention_newton_to_picard_stays() {
use entropyk_solver::solver::{
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
};
// Create a system where Newton diverges but Picard converges
// Use StiffNonlinearSystem with high alpha to cause Newton divergence
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(100.0, 2)));
// Configure with max 1 switch - Newton diverges → Picard, should stay on Picard
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 1,
return_to_newton_threshold: 1e-6, // Very low threshold so Newton return won't trigger easily
..Default::default()
};
let mut solver = FallbackSolver::new(config)
.with_newton_config(NewtonConfig {
max_iterations: 20,
tolerance: 1e-6,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.2,
max_iterations: 500,
..Default::default()
});
// Should either converge (Picard succeeds) or non-converge (but NOT oscillate)
let result = solver.solve(&mut system);
match result {
Ok(converged) => {
// Success - Picard converged after Newton divergence
assert!(converged.is_converged() || converged.final_residual < 1.0);
}
Err(SolverError::NonConvergence { .. }) => {
// Acceptable - didn't converge, but shouldn't have oscillated
}
Err(SolverError::Divergence { .. }) => {
// Picard diverged - acceptable for stiff system
}
Err(other) => panic!("Unexpected error type: {:?}", other),
}
}
/// Test that Newton re-divergence causes permanent commit to Picard.
#[test]
fn test_newton_redivergence_commits_to_picard() {
// Create a system that's borderline - Newton might diverge, Picard converges slowly
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(50.0, 2)));
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 3, // Allow multiple switches to test re-divergence
return_to_newton_threshold: 1e-2, // Relatively high threshold for return
..Default::default()
};
let mut solver = FallbackSolver::new(config)
.with_newton_config(NewtonConfig {
max_iterations: 30,
tolerance: 1e-8,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.25,
max_iterations: 300,
..Default::default()
});
let result = solver.solve(&mut system);
// Should complete without infinite oscillation
match result {
Ok(converged) => {
assert!(converged.final_residual < 1.0 || converged.is_converged());
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// Verify we didn't iterate forever (oscillation would cause excessive iterations)
assert!(
iterations < 1000,
"Too many iterations - possible oscillation"
);
assert!(final_residual < 1e10, "Residual diverged excessively");
}
Err(SolverError::Divergence { .. }) => {
// Acceptable - system is stiff
}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that FallbackSolver works with SolverStrategy pattern.
#[test]
fn test_fallback_solver_integration() {
// Verify FallbackSolver can be used alongside other solvers
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with SolverStrategy::NewtonRaphson
let mut strategy = SolverStrategy::default();
let result1 = strategy.solve(&mut system);
assert!(result1.is_ok());
// Reset and test with FallbackSolver
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut fallback = FallbackSolver::default_solver();
let result2 = fallback.solve(&mut system);
assert!(result2.is_ok());
// Both should converge to similar residuals
let r1 = result1.unwrap();
let r2 = result2.unwrap();
assert!((r1.final_residual - r2.final_residual).abs() < 1e-6);
}
/// Test that FallbackSolver handles convergence at initial state.
#[test]
fn test_fallback_already_converged() {
// Create a system that's already at solution
struct ZeroResidualComponent;
impl Component for ZeroResidualComponent {
fn compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
residuals[0] = 0.0; // Already zero
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
let mut system = create_test_system(Box::new(ZeroResidualComponent));
// CM1.2: seed ṁ at the mass-flow closure target so the system is genuinely
// at the solution (closure residual = ṁ seed = 0) from iteration 0.
let mut solver = FallbackSolver::default_solver().with_initial_state(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
0.0,
0.0,
]);
let result = solver.solve(&mut system);
assert!(result.is_ok());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0); // Should converge immediately
assert!(converged.is_converged());
}