480 lines
18 KiB
Rust
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);
|
|
} |