//! 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::{ 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>, /// Right-hand side b: Vec, /// Number of equations n: usize, } impl LinearSystem { fn new(a: Vec>, b: Vec) -> 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> { // 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: &StateSlice, 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: &StateSlice, 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: &StateSlice, 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: &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) -> 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 = 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 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: &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)); 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()); }