Entropyk/crates/solver/tests/fallback_solver.rs

673 lines
22 KiB
Rust

//! 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, SystemState,
};
use entropyk_solver::solver::{
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
SolverError, SolverStrategy,
};
use entropyk_solver::system::System;
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: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r = A * x - b
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[j];
}
residuals[i] = ax_i - self.b[i];
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J = A (constant Jacobian)
for i in 0..self.n {
for j in 0..self.n {
jacobian.add_entry(i, j, self.a[i][j]);
}
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
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: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Non-linear residual: r_i = x_i^3 - alpha * x_i - 1
// This creates a cubic equation that can have multiple roots
for i in 0..self.n {
let x = state[i];
residuals[i] = x * x * x - self.alpha * x - 1.0;
}
Ok(())
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J_ii = 3 * x_i^2 - alpha
for i in 0..self.n {
let x = state[i];
jacobian.add_entry(i, 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.
struct SlowConvergingSystem {
/// Convergence rate (0 < rate < 1)
rate: f64,
/// Target value
target: f64,
}
impl SlowConvergingSystem {
fn new(rate: f64, target: f64) -> Self {
Self { rate, target }
}
}
impl Component for SlowConvergingSystem {
fn compute_residuals(
&self,
state: &SystemState,
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: &SystemState,
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,
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config, config);
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
let mut picard = PicardConfig::default();
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: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
residuals[0] = 0.0; // Already zero
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
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));
let mut solver = FallbackSolver::default_solver();
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());
}