//! Comprehensive integration tests for Newton-Raphson solver (Story 4.2). //! //! Tests cover all Acceptance Criteria: //! - AC #1: Quadratic convergence near solution //! - AC #2: Line search prevents overshooting //! - AC #3: Analytical and numerical Jacobian support //! - AC #4: Timeout enforcement //! - AC #5: Divergence detection //! - AC #6: Pre-allocated buffers use approx::assert_relative_eq; use entropyk_solver::{ ConvergenceStatus, JacobianMatrix, NewtonConfig, Solver, SolverError, System, }; use std::time::Duration; // ───────────────────────────────────────────────────────────────────────────── // AC #1: Quadratic Convergence Near Solution // ───────────────────────────────────────────────────────────────────────────── /// Test that Newton-Raphson exhibits quadratic convergence on a simple system. /// /// For a well-conditioned system near the solution, the residual norm should /// decrease quadratically (roughly square each iteration). #[test] fn test_quadratic_convergence_simple_system() { // We'll test the Jacobian solve directly since we need a mock system // For J = [[2, 0], [0, 3]] and r = [2, 3], solution is x = [-1, -1] let entries = vec![(0, 0, 2.0), (1, 1, 3.0)]; let jacobian = JacobianMatrix::from_builder(&entries, 2, 2); let residuals = vec![2.0, 3.0]; let delta = jacobian.solve(&residuals).expect("non-singular"); // J·Δx = -r => Δx = -J^{-1}·r assert_relative_eq!(delta[0], -1.0, epsilon = 1e-10); assert_relative_eq!(delta[1], -1.0, epsilon = 1e-10); } /// Test convergence on a 2x2 linear system. #[test] fn test_solve_2x2_linear_system() { // J = [[4, 1], [1, 3]], r = [1, 2] // Solution: Δx = -J^{-1}·r let entries = vec![(0, 0, 4.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)]; let jacobian = JacobianMatrix::from_builder(&entries, 2, 2); let residuals = vec![1.0, 2.0]; let delta = jacobian.solve(&residuals).expect("non-singular"); // Verify: J·Δx = -r let j00 = 4.0; let j01 = 1.0; let j10 = 1.0; let j11 = 3.0; let computed_r0 = j00 * delta[0] + j01 * delta[1]; let computed_r1 = j10 * delta[0] + j11 * delta[1]; assert_relative_eq!(computed_r0, -1.0, epsilon = 1e-10); assert_relative_eq!(computed_r1, -2.0, epsilon = 1e-10); } /// Test that a diagonal system converges in one Newton iteration. #[test] fn test_diagonal_system_one_iteration() { // For a diagonal Jacobian, Newton should converge in 1 iteration // J = [[a, 0], [0, b]], r = [c, d] // Δx = [-c/a, -d/b] let entries = vec![(0, 0, 5.0), (1, 1, 7.0)]; let jacobian = JacobianMatrix::from_builder(&entries, 2, 2); let residuals = vec![10.0, 21.0]; let delta = jacobian.solve(&residuals).expect("non-singular"); assert_relative_eq!(delta[0], -2.0, epsilon = 1e-10); assert_relative_eq!(delta[1], -3.0, epsilon = 1e-10); } // ───────────────────────────────────────────────────────────────────────────── // AC #2: Line Search Prevents Overshooting // ───────────────────────────────────────────────────────────────────────────── /// Test that line search is configured correctly. #[test] fn test_line_search_configuration() { let cfg = NewtonConfig { line_search: true, line_search_armijo_c: 1e-4, line_search_max_backtracks: 20, ..Default::default() }; assert!(cfg.line_search); assert_relative_eq!(cfg.line_search_armijo_c, 1e-4); assert_eq!(cfg.line_search_max_backtracks, 20); } /// Test that line search can be disabled. #[test] fn test_line_search_disabled_by_default() { let cfg = NewtonConfig::default(); assert!(!cfg.line_search); } /// Test Armijo condition constants are sensible. #[test] fn test_armijo_constant_range() { let cfg = NewtonConfig::default(); // Armijo constant should be in (0, 0.5) for typical line search assert!(cfg.line_search_armijo_c > 0.0); assert!(cfg.line_search_armijo_c < 0.5); } // ───────────────────────────────────────────────────────────────────────────── // AC #3: Analytical and Numerical Jacobian Support // ───────────────────────────────────────────────────────────────────────────── /// Test that numerical Jacobian can be enabled. #[test] fn test_numerical_jacobian_configuration() { let cfg = NewtonConfig { use_numerical_jacobian: true, ..Default::default() }; assert!(cfg.use_numerical_jacobian); } /// Test that analytical Jacobian is the default. #[test] fn test_analytical_jacobian_default() { let cfg = NewtonConfig::default(); assert!(!cfg.use_numerical_jacobian); } /// Test numerical Jacobian computation matches analytical for linear function. #[test] fn test_numerical_jacobian_linear_function() { // r[0] = 2*x0 + 3*x1 // r[1] = x0 - 2*x1 // J = [[2, 3], [1, -2]] let state = vec![1.0, 2.0]; let residuals = vec![2.0 * state[0] + 3.0 * state[1], state[0] - 2.0 * state[1]]; let compute_residuals = |s: &[f64], r: &mut [f64]| { r[0] = 2.0 * s[0] + 3.0 * s[1]; r[1] = s[0] - 2.0 * s[1]; Ok(()) }; let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap(); // Check against analytical Jacobian assert_relative_eq!(j_num.get(0, 0).unwrap(), 2.0, epsilon = 1e-5); assert_relative_eq!(j_num.get(0, 1).unwrap(), 3.0, epsilon = 1e-5); assert_relative_eq!(j_num.get(1, 0).unwrap(), 1.0, epsilon = 1e-5); assert_relative_eq!(j_num.get(1, 1).unwrap(), -2.0, epsilon = 1e-5); } /// Test numerical Jacobian for non-linear function. #[test] fn test_numerical_jacobian_nonlinear_function() { // r[0] = x0^2 + x1 // r[1] = sin(x0) + cos(x1) // J = [[2*x0, 1], [cos(x0), -sin(x1)]] let state = vec![0.5_f64, 1.0_f64]; let residuals = vec![state[0].powi(2) + state[1], state[0].sin() + state[1].cos()]; let compute_residuals = |s: &[f64], r: &mut [f64]| { r[0] = s[0].powi(2) + s[1]; r[1] = s[0].sin() + s[1].cos(); Ok(()) }; let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap(); // Analytical values let j00 = 2.0 * state[0]; // 1.0 let j01 = 1.0; let j10 = state[0].cos(); let j11 = -state[1].sin(); assert_relative_eq!(j_num.get(0, 0).unwrap(), j00, epsilon = 1e-5); assert_relative_eq!(j_num.get(0, 1).unwrap(), j01, epsilon = 1e-5); assert_relative_eq!(j_num.get(1, 0).unwrap(), j10, epsilon = 1e-5); assert_relative_eq!(j_num.get(1, 1).unwrap(), j11, epsilon = 1e-5); } // ───────────────────────────────────────────────────────────────────────────── // AC #4: Timeout Enforcement // ───────────────────────────────────────────────────────────────────────────── /// Test timeout configuration. #[test] fn test_timeout_configuration() { let timeout = Duration::from_millis(500); let cfg = NewtonConfig::default().with_timeout(timeout); assert_eq!(cfg.timeout, Some(timeout)); } /// Test timeout is None by default. #[test] fn test_no_timeout_by_default() { let cfg = NewtonConfig::default(); assert!(cfg.timeout.is_none()); } /// Test timeout error contains correct duration. #[test] fn test_timeout_error_contains_duration() { let err = SolverError::Timeout { timeout_ms: 1234 }; let msg = err.to_string(); assert!(msg.contains("1234")); } // ───────────────────────────────────────────────────────────────────────────── // AC #5: Divergence Detection // ───────────────────────────────────────────────────────────────────────────── /// Test divergence threshold configuration. #[test] fn test_divergence_threshold_configuration() { let cfg = NewtonConfig { divergence_threshold: 1e8, ..Default::default() }; assert_relative_eq!(cfg.divergence_threshold, 1e8); } /// Test default divergence threshold. #[test] fn test_default_divergence_threshold() { let cfg = NewtonConfig::default(); assert_relative_eq!(cfg.divergence_threshold, 1e10); } /// Test divergence error contains reason. #[test] fn test_divergence_error_contains_reason() { let err = SolverError::Divergence { reason: "Residual increased for 3 consecutive iterations".to_string(), }; let msg = err.to_string(); assert!(msg.contains("Residual increased")); assert!(msg.contains("3 consecutive")); } /// Test divergence error for threshold exceeded. #[test] fn test_divergence_error_threshold_exceeded() { let err = SolverError::Divergence { reason: "Residual norm 1e12 exceeds threshold 1e10".to_string(), }; let msg = err.to_string(); assert!(msg.contains("exceeds threshold")); } // ───────────────────────────────────────────────────────────────────────────── // AC #6: Pre-Allocated Buffers // ───────────────────────────────────────────────────────────────────────────── /// Test that solver handles empty system gracefully (pre-allocated buffers work). #[test] fn test_preallocated_buffers_empty_system() { let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = NewtonConfig::default(); let result = solver.solve(&mut sys); // Should return error without panic assert!(result.is_err()); } /// Test that solver handles configuration variations without panic. #[test] fn test_preallocated_buffers_all_configs() { let mut sys = System::new(); sys.finalize().unwrap(); // Test with all features enabled let mut solver = NewtonConfig { max_iterations: 50, tolerance: 1e-8, line_search: true, timeout: Some(Duration::from_millis(100)), use_numerical_jacobian: true, line_search_armijo_c: 1e-3, line_search_max_backtracks: 10, divergence_threshold: 1e8, ..Default::default() }; let result = solver.solve(&mut sys); assert!(result.is_err()); // Empty system, but no panic } // ───────────────────────────────────────────────────────────────────────────── // Jacobian Matrix Tests // ───────────────────────────────────────────────────────────────────────────── /// Test singular Jacobian returns None. #[test] fn test_singular_jacobian_returns_none() { // Singular matrix: [[1, 1], [1, 1]] let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0)]; let jacobian = JacobianMatrix::from_builder(&entries, 2, 2); let residuals = vec![1.0, 2.0]; let result = jacobian.solve(&residuals); assert!(result.is_none(), "Singular matrix should return None"); } /// Test zero Jacobian returns None. #[test] fn test_zero_jacobian_returns_none() { let jacobian = JacobianMatrix::zeros(2, 2); let residuals = vec![1.0, 2.0]; let result = jacobian.solve(&residuals); assert!(result.is_none(), "Zero matrix should return None"); } /// Test Jacobian condition number for well-conditioned matrix. #[test] fn test_jacobian_condition_number_well_conditioned() { let entries = vec![(0, 0, 1.0), (1, 1, 1.0)]; let jacobian = JacobianMatrix::from_builder(&entries, 2, 2); let cond = jacobian.condition_number().unwrap(); assert_relative_eq!(cond, 1.0, epsilon = 1e-10); } /// Test Jacobian condition number for ill-conditioned matrix. #[test] fn test_jacobian_condition_number_ill_conditioned() { // Nearly singular matrix let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0 + 1e-12)]; let jacobian = JacobianMatrix::from_builder(&entries, 2, 2); let cond = jacobian.condition_number(); assert!(cond.unwrap() > 1e10, "Should be ill-conditioned"); } /// Test Jacobian for non-square (overdetermined) system uses least-squares. #[test] fn test_jacobian_non_square_overdetermined() { // 3 equations, 2 unknowns (overdetermined) let entries = vec![ (0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 2.0), (2, 0, 1.0), (2, 1, 3.0), ]; let jacobian = JacobianMatrix::from_builder(&entries, 3, 2); let residuals = vec![1.0, 2.0, 3.0]; let result = jacobian.solve(&residuals); // Should return a least-squares solution assert!( result.is_some(), "Non-square system should return least-squares solution" ); } // ───────────────────────────────────────────────────────────────────────────── // ConvergenceStatus Tests // ───────────────────────────────────────────────────────────────────────────── /// Test ConvergenceStatus::Converged. #[test] fn test_convergence_status_converged() { use entropyk_solver::ConvergedState; let state = ConvergedState::new(vec![1.0, 2.0], 10, 1e-8, ConvergenceStatus::Converged, entropyk_solver::SimulationMetadata::new("".to_string())); assert!(state.is_converged()); assert_eq!(state.status, ConvergenceStatus::Converged); } /// Test ConvergenceStatus::TimedOutWithBestState. #[test] fn test_convergence_status_timed_out() { use entropyk_solver::ConvergedState; let state = ConvergedState::new( vec![1.0], 50, 1e-3, ConvergenceStatus::TimedOutWithBestState, entropyk_solver::SimulationMetadata::new("".to_string()), ); assert!(!state.is_converged()); assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState); } // ───────────────────────────────────────────────────────────────────────────── // Error Display Tests // ───────────────────────────────────────────────────────────────────────────── /// Test NonConvergence error display. #[test] fn test_non_convergence_display() { let err = SolverError::NonConvergence { iterations: 100, final_residual: 1.23e-4, }; let msg = err.to_string(); assert!(msg.contains("100")); assert!(msg.contains("1.23")); } /// Test InvalidSystem error display. #[test] fn test_invalid_system_display() { let err = SolverError::InvalidSystem { message: "Empty system has no equations".to_string(), }; let msg = err.to_string(); assert!(msg.contains("Empty system")); } // ───────────────────────────────────────────────────────────────────────────── // Configuration Validation Tests // ───────────────────────────────────────────────────────────────────────────── /// Test that max_iterations must be positive. #[test] fn test_max_iterations_positive() { let cfg = NewtonConfig::default(); assert!(cfg.max_iterations > 0); } /// Test that tolerance must be positive. #[test] fn test_tolerance_positive() { let cfg = NewtonConfig::default(); assert!(cfg.tolerance > 0.0); } /// Test that relaxation factor for Picard is in valid range. #[test] fn test_picard_relaxation_factor_range() { use entropyk_solver::PicardConfig; let cfg = PicardConfig::default(); assert!(cfg.relaxation_factor > 0.0); assert!(cfg.relaxation_factor <= 1.0); } /// Test line search max backtracks is reasonable. #[test] fn test_line_search_max_backtracks_reasonable() { let cfg = NewtonConfig::default(); assert!(cfg.line_search_max_backtracks > 0); assert!(cfg.line_search_max_backtracks <= 100); }