Files
wifi-densepose/crates/ruvector-solver/tests/test_cg.rs
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

250 lines
8.0 KiB
Rust

//! Integration tests for the Conjugate Gradient (CG) solver.
//!
//! Tests cover correctness on SPD systems, Laplacian solves, preconditioning
//! benefits, known-solution verification, and tolerance scaling.
mod helpers;
use approx::assert_relative_eq;
use ruvector_solver::cg::ConjugateGradientSolver;
use ruvector_solver::traits::SolverEngine;
use ruvector_solver::types::{Algorithm, ComputeBudget, CsrMatrix};
use helpers::{
compute_residual, dense_solve, f32_to_f64, l2_norm, random_laplacian_csr, random_spd_csr,
random_vector, relative_error,
};
// ---------------------------------------------------------------------------
// Helper: default compute budget
// ---------------------------------------------------------------------------
fn default_budget() -> ComputeBudget {
ComputeBudget {
max_time: std::time::Duration::from_secs(30),
max_iterations: 10_000,
tolerance: 1e-12,
}
}
// ---------------------------------------------------------------------------
// SPD system: solve and verify convergence
// ---------------------------------------------------------------------------
#[test]
fn test_cg_spd_system() {
let n = 15;
let matrix = random_spd_csr(n, 0.4, 42);
let rhs = random_vector(n, 43);
let budget = default_budget();
let solver = ConjugateGradientSolver::new(1e-8, 500, false);
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
assert_eq!(result.algorithm, Algorithm::CG);
assert!(
result.residual_norm < 1e-4,
"residual too large: {}",
result.residual_norm
);
// Independent residual check.
let x = f32_to_f64(&result.solution);
let residual = compute_residual(&matrix, &x, &rhs);
let resid_norm = l2_norm(&residual);
assert!(
resid_norm < 1e-3,
"independent residual check: {}",
resid_norm
);
// Compare with dense solve.
let exact = dense_solve(&matrix, &rhs);
let rel_err = relative_error(&x, &exact);
assert!(rel_err < 1e-2, "relative error vs dense solve: {}", rel_err);
}
// ---------------------------------------------------------------------------
// Graph Laplacian system
// ---------------------------------------------------------------------------
#[test]
fn test_cg_laplacian() {
let n = 12;
let laplacian = random_laplacian_csr(n, 0.3, 44);
// Laplacians are singular (L * ones = 0), so we add a small regulariser
// to make it SPD: A = L + epsilon * I.
let epsilon = 0.01;
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
for i in 0..n {
let start = laplacian.row_ptr[i];
let end = laplacian.row_ptr[i + 1];
for idx in start..end {
let j = laplacian.col_indices[idx];
let mut v = laplacian.values[idx];
if i == j {
v += epsilon;
}
entries.push((i, j, v));
}
}
let reg_laplacian = CsrMatrix::<f64>::from_coo(n, n, entries);
let rhs = random_vector(n, 45);
let budget = default_budget();
let solver = ConjugateGradientSolver::new(1e-8, 1000, false);
let result = solver.solve(&reg_laplacian, &rhs, &budget).unwrap();
assert!(
result.residual_norm < 1e-4,
"laplacian solve residual: {}",
result.residual_norm
);
// Verify Ax = b.
let x = f32_to_f64(&result.solution);
let residual = compute_residual(&reg_laplacian, &x, &rhs);
let resid_norm = l2_norm(&residual);
assert!(
resid_norm < 1e-3,
"laplacian residual check: {}",
resid_norm
);
}
// ---------------------------------------------------------------------------
// Preconditioned CG reduces iterations
// ---------------------------------------------------------------------------
#[test]
fn test_cg_preconditioned() {
let n = 30;
let matrix = random_spd_csr(n, 0.3, 46);
let rhs = random_vector(n, 47);
let budget = default_budget();
let unprecond = ConjugateGradientSolver::new(1e-8, 1000, false);
let precond = ConjugateGradientSolver::new(1e-8, 1000, true);
let result_no = unprecond.solve(&matrix, &rhs, &budget).unwrap();
let result_yes = precond.solve(&matrix, &rhs, &budget).unwrap();
// Both should converge.
assert!(
result_no.residual_norm < 1e-4,
"unpreconditioned residual: {}",
result_no.residual_norm
);
assert!(
result_yes.residual_norm < 1e-4,
"preconditioned residual: {}",
result_yes.residual_norm
);
// Preconditioner should take <= iterations (it won't always be strictly
// fewer on well-conditioned systems, but should not take more).
assert!(
result_yes.iterations <= result_no.iterations + 2,
"preconditioned ({}) should not take much more than unpreconditioned ({})",
result_yes.iterations,
result_no.iterations
);
}
// ---------------------------------------------------------------------------
// Known solution verification
// ---------------------------------------------------------------------------
#[test]
fn test_cg_known_solution() {
// Diagonal system D*x = b => x_i = b_i / d_i
let diag_vals = vec![2.0, 5.0, 10.0, 1.0];
let n = diag_vals.len();
let entries: Vec<(usize, usize, f64)> = diag_vals
.iter()
.enumerate()
.map(|(i, &d)| (i, i, d))
.collect();
let matrix = CsrMatrix::<f64>::from_coo(n, n, entries);
let rhs = vec![4.0, 15.0, 30.0, 7.0];
let expected = vec![2.0, 3.0, 3.0, 7.0]; // b_i / d_i
let budget = default_budget();
let solver = ConjugateGradientSolver::new(1e-10, 100, false);
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
let x = f32_to_f64(&result.solution);
for i in 0..n {
assert_relative_eq!(x[i], expected[i], epsilon = 1e-4);
}
// Also test a tridiagonal system with known answer.
// A = [4 -1 0] b = [3] => solve manually:
// [-1 4 -1] [2] x0 = (3 + x1)/4
// [0 -1 4] [3] x2 = (3 + x1)/4 => x0 = x2 (by symmetry)
// x1 = (2 + x0 + x2)/4 = (2 + 2*x0)/4
// From row 0: x0 = (3 + x1)/4
// From row 1: x1 = (2 + 2*x0)/4 = (1 + x0)/2
// Sub: x0 = (3 + (1+x0)/2)/4 = (3.5 + x0/2)/4 = 7/8 + x0/8
// => 7x0/8 = 7/8 => x0 = 1, x1 = 1, x2 = 1
let tri = CsrMatrix::<f64>::from_coo(
3,
3,
vec![
(0, 0, 4.0),
(0, 1, -1.0),
(1, 0, -1.0),
(1, 1, 4.0),
(1, 2, -1.0),
(2, 1, -1.0),
(2, 2, 4.0),
],
);
let rhs_tri = vec![3.0, 2.0, 3.0];
let result_tri = solver.solve(&tri, &rhs_tri, &budget).unwrap();
let x_tri = f32_to_f64(&result_tri.solution);
for i in 0..3 {
assert_relative_eq!(x_tri[i], 1.0, epsilon = 1e-4);
}
}
// ---------------------------------------------------------------------------
// Tolerance levels: accuracy scales with epsilon
// ---------------------------------------------------------------------------
#[test]
fn test_cg_tolerance_levels() {
let n = 20;
let matrix = random_spd_csr(n, 0.3, 48);
let rhs = random_vector(n, 49);
let exact = dense_solve(&matrix, &rhs);
let budget = default_budget();
let tolerances = [1e-4, 1e-6, 1e-8, 1e-10];
let mut prev_error = f64::INFINITY;
for &tol in &tolerances {
let solver = ConjugateGradientSolver::new(tol, 5000, false);
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
let x = f32_to_f64(&result.solution);
let rel_err = relative_error(&x, &exact);
// Error should generally decrease with tighter tolerance.
// Allow some slack for f32 precision limits at very tight tolerances.
assert!(
rel_err < tol.sqrt() * 100.0 || rel_err < prev_error * 10.0,
"tol={:.0e}: relative error {:.2e} is too large (prev={:.2e})",
tol,
rel_err,
prev_error
);
prev_error = rel_err;
}
}