use crate::error::{SparseError, SparseResult};
use crate::host_csr::{HostCsr, dense_solve};
pub struct AmgLevel {
pub a: HostCsr,
pub p: HostCsr,
pub r: HostCsr,
pub diag: Vec<f64>,
}
pub struct AmgHierarchy {
pub levels: Vec<AmgLevel>,
pub coarse: HostCsr,
pub pre_sweeps: usize,
pub post_sweeps: usize,
}
pub struct AmgOptions {
pub strength_threshold: f64,
pub max_levels: usize,
pub max_coarse: usize,
pub smooth_omega: f64,
pub pre_sweeps: usize,
pub post_sweeps: usize,
}
impl Default for AmgOptions {
fn default() -> Self {
Self {
strength_threshold: 0.08,
max_levels: 25,
max_coarse: 16,
smooth_omega: 4.0 / 3.0,
pre_sweeps: 2,
post_sweeps: 2,
}
}
}
pub fn amg_setup(a: &HostCsr, opts: &AmgOptions) -> SparseResult<AmgHierarchy> {
if a.nrows != a.ncols {
return Err(SparseError::DimensionMismatch(format!(
"AMG requires a square matrix, got {}x{}",
a.nrows, a.ncols
)));
}
if a.nrows == 0 {
return Err(SparseError::InvalidArgument(
"cannot build a hierarchy for an empty matrix".to_string(),
));
}
if opts.max_levels == 0 {
return Err(SparseError::InvalidArgument(
"max_levels must be >= 1".to_string(),
));
}
let mut levels: Vec<AmgLevel> = Vec::new();
let mut current = a.clone();
loop {
let n = current.nrows;
if n <= opts.max_coarse || levels.len() + 1 >= opts.max_levels {
break;
}
let strength = strength_of_connection(¤t, opts.strength_threshold);
let (aggregates, num_agg) = aggregate(¤t, &strength);
if num_agg == 0 || num_agg >= n {
break;
}
let p0 = tentative_prolongator(&aggregates, num_agg, n);
let diag = current.diagonal();
for &d in &diag {
if d == 0.0 {
return Err(SparseError::SingularMatrix);
}
}
let rho = spectral_radius_estimate(¤t, &diag);
let omega = opts.smooth_omega / rho.max(1e-30);
let p = smooth_prolongator(¤t, &diag, &p0, omega)?;
let r = p.transpose();
let ap = current.matmul(&p)?;
let a_coarse = r.matmul(&ap)?;
let level = AmgLevel {
a: current.clone(),
p,
r,
diag,
};
levels.push(level);
current = a_coarse;
}
Ok(AmgHierarchy {
levels,
coarse: current,
pre_sweeps: opts.pre_sweeps.max(1),
post_sweeps: opts.post_sweeps.max(1),
})
}
pub fn amg_v_cycle(h: &AmgHierarchy, b: &[f64], x: &mut [f64]) {
v_cycle_recursive(h, 0, b, x);
}
fn v_cycle_recursive(h: &AmgHierarchy, level: usize, b: &[f64], x: &mut [f64]) {
if level >= h.levels.len() {
let n = h.coarse.nrows;
if n == 0 {
return;
}
let dense = h.coarse.to_dense();
match dense_solve(&dense, b, n) {
Ok(sol) => {
x[..n].copy_from_slice(&sol[..n]);
}
Err(_) => {
let diag = h.coarse.diagonal();
jacobi_sweeps(&h.coarse, &diag, b, x, 30, 2.0 / 3.0);
}
}
return;
}
let lvl = &h.levels[level];
let omega = 2.0 / 3.0;
jacobi_sweeps(&lvl.a, &lvl.diag, b, x, h.pre_sweeps, omega);
let ax = lvl.a.matvec(x);
let mut residual = vec![0.0f64; b.len()];
for i in 0..b.len() {
residual[i] = b[i] - ax[i];
}
let r_coarse = lvl.r.matvec(&residual);
let coarse_n = r_coarse.len();
let mut e_coarse = vec![0.0f64; coarse_n];
v_cycle_recursive(h, level + 1, &r_coarse, &mut e_coarse);
let correction = lvl.p.matvec(&e_coarse);
for i in 0..x.len().min(correction.len()) {
x[i] += correction[i];
}
jacobi_sweeps(&lvl.a, &lvl.diag, b, x, h.post_sweeps, omega);
}
pub fn amg_solve(
a: &HostCsr,
b: &[f64],
opts: &AmgOptions,
max_iter: usize,
tol: f64,
) -> SparseResult<(Vec<f64>, usize, f64)> {
if b.len() != a.nrows {
return Err(SparseError::DimensionMismatch(format!(
"rhs length {} must equal matrix order {}",
b.len(),
a.nrows
)));
}
let hierarchy = amg_setup(a, opts)?;
let b_norm = norm(b);
if b_norm == 0.0 {
return Ok((vec![0.0; a.nrows], 0, 0.0));
}
let mut x = vec![0.0f64; a.nrows];
let mut final_rel = 1.0;
let mut iters = 0;
for it in 0..max_iter {
iters = it + 1;
amg_v_cycle(&hierarchy, b, &mut x);
let ax = a.matvec(&x);
let mut res = vec![0.0f64; b.len()];
for i in 0..b.len() {
res[i] = b[i] - ax[i];
}
final_rel = norm(&res) / b_norm;
if final_rel < tol {
break;
}
}
Ok((x, iters, final_rel))
}
type Strength = Vec<Vec<usize>>;
fn strength_of_connection(a: &HostCsr, theta: f64) -> Strength {
let n = a.nrows;
let diag = a.diagonal();
let mut strength: Strength = vec![Vec::new(); n];
for i in 0..n {
let start = a.row_ptr[i];
let end = a.row_ptr[i + 1];
let dii = diag[i].abs();
for kk in start..end {
let j = a.col_indices[kk];
if j == i {
continue;
}
let aij = a.values[kk].abs();
let djj = diag[j].abs();
let bound = theta * (dii * djj).sqrt();
if aij >= bound && aij > 0.0 {
strength[i].push(j);
}
}
}
strength
}
fn aggregate(a: &HostCsr, strength: &Strength) -> (Vec<usize>, usize) {
let n = a.nrows;
const UNASSIGNED: usize = usize::MAX;
let mut agg = vec![UNASSIGNED; n];
let mut num_agg = 0usize;
for i in 0..n {
if agg[i] != UNASSIGNED {
continue;
}
let all_free = strength[i].iter().all(|&j| agg[j] == UNASSIGNED);
if !all_free {
continue;
}
let g = num_agg;
num_agg += 1;
agg[i] = g;
for &j in &strength[i] {
agg[j] = g;
}
}
for i in 0..n {
if agg[i] != UNASSIGNED {
continue;
}
let mut best: Option<usize> = None;
let mut best_mag = 0.0f64;
let start = a.row_ptr[i];
let end = a.row_ptr[i + 1];
for &j in &strength[i] {
if agg[j] != UNASSIGNED {
let mut mag = 0.0;
for kk in start..end {
if a.col_indices[kk] == j {
mag = a.values[kk].abs();
break;
}
}
if best.is_none() || mag > best_mag {
best = Some(agg[j]);
best_mag = mag;
}
}
}
match best {
Some(g) => agg[i] = g,
None => {
let g = num_agg;
num_agg += 1;
agg[i] = g;
}
}
}
(agg, num_agg)
}
fn tentative_prolongator(agg: &[usize], num_agg: usize, n: usize) -> HostCsr {
let mut sizes = vec![0usize; num_agg];
for &g in agg {
sizes[g] += 1;
}
let mut row_ptr = vec![0usize; n + 1];
let mut col_indices = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
for i in 0..n {
let g = agg[i];
let norm = (sizes[g] as f64).sqrt();
col_indices.push(g);
values.push(1.0 / norm);
row_ptr[i + 1] = col_indices.len();
}
HostCsr {
nrows: n,
ncols: num_agg,
row_ptr,
col_indices,
values,
}
}
fn smooth_prolongator(
a: &HostCsr,
diag: &[f64],
p0: &HostCsr,
omega: f64,
) -> SparseResult<HostCsr> {
let ap0 = a.matmul(p0)?;
let n = a.nrows;
let ncols = p0.ncols;
let mut row_ptr = vec![0usize; n + 1];
let mut col_indices: Vec<usize> = Vec::new();
let mut values: Vec<f64> = Vec::new();
let mut accum = vec![0.0f64; ncols];
let mut touched: Vec<usize> = Vec::new();
let mut is_touched = vec![false; ncols];
for i in 0..n {
let dinv = 1.0 / diag[i];
let p0_s = p0.row_ptr[i];
let p0_e = p0.row_ptr[i + 1];
for kk in p0_s..p0_e {
let c = p0.col_indices[kk];
if !is_touched[c] {
is_touched[c] = true;
touched.push(c);
}
accum[c] += p0.values[kk];
}
let ap_s = ap0.row_ptr[i];
let ap_e = ap0.row_ptr[i + 1];
for kk in ap_s..ap_e {
let c = ap0.col_indices[kk];
if !is_touched[c] {
is_touched[c] = true;
touched.push(c);
}
accum[c] -= omega * dinv * ap0.values[kk];
}
touched.sort_unstable();
for &c in &touched {
let v = accum[c];
if v != 0.0 {
col_indices.push(c);
values.push(v);
}
accum[c] = 0.0;
is_touched[c] = false;
}
touched.clear();
row_ptr[i + 1] = col_indices.len();
}
Ok(HostCsr {
nrows: n,
ncols,
row_ptr,
col_indices,
values,
})
}
fn spectral_radius_estimate(a: &HostCsr, diag: &[f64]) -> f64 {
let n = a.nrows;
if n == 0 {
return 1.0;
}
let mut v: Vec<f64> = (0..n).map(|i| 1.0 + (i % 7) as f64 * 0.3).collect();
normalize(&mut v);
let mut lambda = 1.0;
for _ in 0..15 {
let av = a.matvec(&v);
let mut w = vec![0.0f64; n];
for i in 0..n {
w[i] = av[i] / diag[i];
}
let nrm = norm(&w);
if nrm < 1e-300 {
break;
}
lambda = dot(&v, &w);
for i in 0..n {
v[i] = w[i] / nrm;
}
}
lambda.abs().max(1.0)
}
fn jacobi_sweeps(a: &HostCsr, diag: &[f64], b: &[f64], x: &mut [f64], sweeps: usize, omega: f64) {
let n = a.nrows;
for _ in 0..sweeps {
let ax = a.matvec(x);
for i in 0..n {
if diag[i] != 0.0 {
x[i] += omega * (b[i] - ax[i]) / diag[i];
}
}
}
}
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
}
fn norm(a: &[f64]) -> f64 {
dot(a, a).sqrt()
}
fn normalize(v: &mut [f64]) {
let nrm = norm(v);
if nrm > 1e-300 {
let inv = 1.0 / nrm;
for x in v.iter_mut() {
*x *= inv;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_csr::{laplacian_1d, laplacian_2d};
#[test]
fn poisson_1d_converges() {
let n = 64;
let a = laplacian_1d(n);
let x_star: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.1).sin() + 1.0).collect();
let b = a.matvec(&x_star);
let opts = AmgOptions::default();
let (x, iters, rel) = amg_solve(&a, &b, &opts, 60, 1e-8).expect("solve");
assert!(rel < 1e-8, "did not converge: rel = {rel}");
assert!(iters < 20, "took too many V-cycles: {iters}");
for i in 0..n {
assert!(
(x[i] - x_star[i]).abs() < 1e-6,
"solution mismatch at {i}: {} vs {}",
x[i],
x_star[i]
);
}
}
#[test]
fn poisson_2d_geometric_residual_drop() {
let g = 16;
let a = laplacian_2d(g, g);
let n = g * g;
let x_star: Vec<f64> = (0..n).map(|i| 1.0 + (i % 5) as f64).collect();
let b = a.matvec(&x_star);
let opts = AmgOptions::default();
let hierarchy = amg_setup(&a, &opts).expect("setup");
let b_norm = norm(&b);
let mut x = vec![0.0f64; n];
let mut residuals = Vec::new();
for _ in 0..6 {
amg_v_cycle(&hierarchy, &b, &mut x);
let ax = a.matvec(&x);
let mut res = vec![0.0; n];
for i in 0..n {
res[i] = b[i] - ax[i];
}
residuals.push(norm(&res) / b_norm);
}
for w in residuals.windows(2) {
if w[0] > 1e-12 {
assert!(
w[1] < 0.7 * w[0],
"residual did not drop geometrically: {} -> {}",
w[0],
w[1]
);
}
}
}
#[test]
fn mesh_independence() {
let opts = AmgOptions::default();
let tol = 1e-8;
let a64 = laplacian_1d(64);
let xs64: Vec<f64> = (0..64).map(|i| 1.0 + (i % 3) as f64).collect();
let b64 = a64.matvec(&xs64);
let (_x, iters64, _r) = amg_solve(&a64, &b64, &opts, 50, tol).expect("solve64");
let g = 16;
let a256 = laplacian_2d(g, g);
let xs256: Vec<f64> = (0..256).map(|i| 1.0 + (i % 4) as f64).collect();
let b256 = a256.matvec(&xs256);
let (_x2, iters256, _r2) = amg_solve(&a256, &b256, &opts, 50, tol).expect("solve256");
assert!(iters64 < 25, "n=64 took {iters64} cycles");
assert!(iters256 < 25, "n=256 took {iters256} cycles");
}
#[test]
fn galerkin_coarse_is_symmetric() {
let a = laplacian_2d(8, 8);
let opts = AmgOptions::default();
let h = amg_setup(&a, &opts).expect("setup");
for lvl in &h.levels {
let ac = if lvl.a.nrows == a.nrows {
let ap = lvl.a.matmul(&lvl.p).expect("ap");
lvl.r.matmul(&ap).expect("rap")
} else {
lvl.a.clone()
};
for i in 0..ac.nrows {
for j in 0..ac.ncols {
let aij = ac.get(i, j).unwrap_or(0.0);
let aji = ac.get(j, i).unwrap_or(0.0);
assert!(
(aij - aji).abs() < 1e-9,
"coarse operator not symmetric at ({i},{j}): {aij} vs {aji}"
);
}
}
}
let ac = &h.coarse;
for i in 0..ac.nrows {
for j in 0..ac.ncols {
assert!((ac.get(i, j).unwrap_or(0.0) - ac.get(j, i).unwrap_or(0.0)).abs() < 1e-9);
}
}
}
#[test]
fn tentative_prolongator_preserves_constant() {
let a = laplacian_2d(6, 6);
let opts = AmgOptions::default();
let strength = strength_of_connection(&a, opts.strength_threshold);
let (agg, num_agg) = aggregate(&a, &strength);
let p0 = tentative_prolongator(&agg, num_agg, a.nrows);
let mut sizes = vec![0usize; num_agg];
for &g in &agg {
sizes[g] += 1;
}
let one_coarse: Vec<f64> = sizes.iter().map(|&s| (s as f64).sqrt()).collect();
let one_fine = p0.matvec(&one_coarse);
for &v in &one_fine {
assert!(
(v - 1.0).abs() < 1e-12,
"P0 does not preserve constant: {v}"
);
}
}
#[test]
fn smoothed_prolongator_approximates_constant() {
let a = laplacian_1d(32);
let opts = AmgOptions::default();
let strength = strength_of_connection(&a, opts.strength_threshold);
let (agg, num_agg) = aggregate(&a, &strength);
let p0 = tentative_prolongator(&agg, num_agg, a.nrows);
let diag = a.diagonal();
let rho = spectral_radius_estimate(&a, &diag);
let omega = opts.smooth_omega / rho;
let p = smooth_prolongator(&a, &diag, &p0, omega).expect("smooth");
let mut sizes = vec![0usize; num_agg];
for &g in &agg {
sizes[g] += 1;
}
let one_coarse: Vec<f64> = sizes.iter().map(|&s| (s as f64).sqrt()).collect();
let one_fine = p.matvec(&one_coarse);
let mut interior_ok = 0;
for (i, &v) in one_fine.iter().enumerate() {
if i > 2 && i < a.nrows - 3 {
assert!(
(v - 1.0).abs() < 1e-9,
"smoothed P does not preserve constant in interior at {i}: {v}"
);
interior_ok += 1;
}
}
assert!(interior_ok > 0);
}
#[test]
fn rejects_non_square() {
let a = HostCsr::new(2, 3, vec![0, 1, 2], vec![0, 1], vec![1.0, 1.0]).expect("valid");
let opts = AmgOptions::default();
assert!(matches!(
amg_setup(&a, &opts),
Err(SparseError::DimensionMismatch(_))
));
}
#[test]
fn strength_threshold_behaviour() {
let a = laplacian_1d(10);
let high = strength_of_connection(&a, 0.9);
assert!(high.iter().all(|nbrs| nbrs.is_empty()));
let low = strength_of_connection(&a, 0.4);
assert!(low.iter().any(|nbrs| !nbrs.is_empty()));
}
#[test]
fn aggregation_covers_all_nodes() {
let a = laplacian_2d(7, 7);
let opts = AmgOptions::default();
let strength = strength_of_connection(&a, opts.strength_threshold);
let (agg, num_agg) = aggregate(&a, &strength);
assert_eq!(agg.len(), a.nrows);
assert!(num_agg > 0 && num_agg < a.nrows);
for &g in &agg {
assert!(g < num_agg);
}
}
#[test]
fn empty_rhs_zero_solution() {
let a = laplacian_1d(16);
let b = vec![0.0; 16];
let opts = AmgOptions::default();
let (x, iters, rel) = amg_solve(&a, &b, &opts, 10, 1e-8).expect("solve");
assert_eq!(iters, 0);
assert_eq!(rel, 0.0);
assert!(x.iter().all(|&v| v == 0.0));
}
#[test]
fn cross_feature_poisson_consistency() {
use crate::eig::lobpcg::lobpcg;
use crate::preconditioner::ick::ic_k;
use std::f64::consts::PI;
let n = 48;
let a = laplacian_1d(n);
let eig = lobpcg(&a, 1, 300, 1e-7, None).expect("lobpcg");
let lambda1 = 2.0 - 2.0 * (PI / ((n + 1) as f64)).cos();
assert!(
(eig.eigenvalues[0] - lambda1).abs() < 1e-6,
"LOBPCG smallest eig {} vs analytic {}",
eig.eigenvalues[0],
lambda1
);
let x_star: Vec<f64> = (0..n).map(|i| 1.0 + ((i * 7) % 5) as f64).collect();
let b = a.matvec(&x_star);
let fac = ic_k(&a, n + 1).expect("complete cholesky");
let x_ic = fac.apply(&b);
for i in 0..n {
assert!(
(x_ic[i] - x_star[i]).abs() < 1e-7,
"IC complete solve mismatch at {i}: {} vs {}",
x_ic[i],
x_star[i]
);
}
let opts = AmgOptions::default();
let (x_amg, iters, rel) = amg_solve(&a, &b, &opts, 50, 1e-9).expect("amg solve");
assert!(rel < 1e-9, "AMG did not converge: rel = {rel}");
assert!(iters < 25, "AMG took too many cycles: {iters}");
for i in 0..n {
assert!(
(x_amg[i] - x_ic[i]).abs() < 1e-6,
"AMG and IC solutions disagree at {i}: {} vs {}",
x_amg[i],
x_ic[i]
);
}
}
}