Entropyk/crates/solver/tests/newton_convergence.rs

480 lines
18 KiB
Rust

//! 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 entropyk_solver::{ConvergenceStatus, JacobianMatrix, NewtonConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
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,
);
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,
);
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);
}