use std::fmt;
use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
pub const GEOMETRY_EPS: f64 = 1.0e-12;
#[derive(Debug, Clone, PartialEq)]
pub enum GeometryError {
DimensionMismatch {
context: &'static str,
expected: usize,
got: usize,
},
InvalidPoint(&'static str),
Singular(&'static str),
Unsupported(&'static str),
}
impl fmt::Display for GeometryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DimensionMismatch {
context,
expected,
got,
} => write!(f, "{context} expected length {expected}, got {got}"),
Self::InvalidPoint(message) => write!(f, "invalid manifold point: {message}"),
Self::Singular(message) => write!(f, "singular geometry operation: {message}"),
Self::Unsupported(message) => write!(f, "unsupported geometry operation: {message}"),
}
}
}
impl std::error::Error for GeometryError {}
pub type GeometryResult<T> = Result<T, GeometryError>;
pub trait RiemannianManifold: Send + Sync {
fn dim(&self) -> usize;
fn ambient_dim(&self) -> usize {
self.dim()
}
fn tangent_basis(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>>;
fn exp_map(
&self,
point: ArrayView1<'_, f64>,
tangent_vec: ArrayView1<'_, f64>,
) -> GeometryResult<Array1<f64>>;
fn log_map(
&self,
p_from: ArrayView1<'_, f64>,
p_to: ArrayView1<'_, f64>,
) -> GeometryResult<Array1<f64>>;
fn parallel_transport(
&self,
point_along: ArrayView2<'_, f64>,
vec: ArrayView1<'_, f64>,
) -> GeometryResult<Array1<f64>>;
fn metric_tensor(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>>;
fn christoffel_symbols(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Vec<Array2<f64>>>;
fn sectional_curvature(
&self,
point: ArrayView1<'_, f64>,
tangent_pair: (ArrayView1<'_, f64>, ArrayView1<'_, f64>),
) -> GeometryResult<f64>;
fn project_tangent(
&self,
point: ArrayView1<'_, f64>,
vec: ArrayView1<'_, f64>,
) -> GeometryResult<Array1<f64>> {
let expected = self.ambient_dim();
if point.len() != expected {
return Err(GeometryError::DimensionMismatch {
context: "project_tangent point",
expected,
got: point.len(),
});
}
if vec.len() != expected {
return Err(GeometryError::DimensionMismatch {
context: "project_tangent vector",
expected,
got: vec.len(),
});
}
Ok(vec.to_owned())
}
fn riemannian_gradient(
&self,
point: ArrayView1<'_, f64>,
euclidean_grad: ArrayView1<'_, f64>,
) -> GeometryResult<Array1<f64>> {
let m = self.ambient_dim();
check_len("riemannian_gradient point", point.len(), m)?;
check_len(
"riemannian_gradient euclidean_grad",
euclidean_grad.len(),
m,
)?;
let b = self.tangent_basis(point)?; let g = self.metric_tensor(point)?; let bt = b.t();
let bte = bt.dot(&euclidean_grad.to_owned());
let gb = g.dot(&b);
let btgb = bt.dot(&gb);
if btgb.nrows() == 0 {
return Ok(Array1::<f64>::zeros(m));
}
let c = inverse(&btgb)?.dot(&bte);
Ok(b.dot(&c))
}
fn retract(
&self,
point: ArrayView1<'_, f64>,
tangent_vec: ArrayView1<'_, f64>,
) -> GeometryResult<Array1<f64>> {
self.exp_map(point, tangent_vec)
}
fn retraction_is_second_order(&self) -> bool {
true
}
fn exp_map_vjp(
&self,
point: ArrayView1<'_, f64>,
tangent_vec: ArrayView1<'_, f64>,
grad_output: ArrayView1<'_, f64>,
) -> GeometryResult<(Array1<f64>, Array1<f64>)> {
let m = self.ambient_dim();
check_len("exp_map_vjp point", point.len(), m)?;
check_len("exp_map_vjp tangent", tangent_vec.len(), m)?;
check_len("exp_map_vjp grad_output", grad_output.len(), m)?;
Ok((grad_output.to_owned(), grad_output.to_owned()))
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ManifoldSpec {
Euclidean(usize),
Circle,
Sphere { intrinsic_dim: usize },
Torus { dim: usize },
Grassmann { k: usize, n: usize },
Stiefel { k: usize, n: usize },
Spd { n: usize },
Product(Vec<ManifoldSpec>),
}
impl ManifoldSpec {
pub fn build(&self) -> GeometryResult<Box<dyn RiemannianManifold>> {
match self {
Self::Euclidean(dim) => Ok(Box::new(crate::geometry::EuclideanManifold::new(*dim))),
Self::Circle => Ok(Box::new(crate::geometry::CircleManifold::new())),
Self::Sphere { intrinsic_dim } => Ok(Box::new(crate::geometry::SphereManifold::new(
*intrinsic_dim,
))),
Self::Torus { dim } => Ok(Box::new(crate::geometry::TorusManifold::new(*dim))),
Self::Grassmann { k, n } => {
Ok(Box::new(crate::geometry::GrassmannManifold::new(*k, *n)?))
}
Self::Stiefel { k, n } => Ok(Box::new(crate::geometry::StiefelManifold::new(*k, *n)?)),
Self::Spd { n } => Ok(Box::new(crate::geometry::SpdManifold::new(*n))),
Self::Product(parts) => {
let mut built = Vec::with_capacity(parts.len());
for part in parts {
built.push(part.build()?);
}
Ok(Box::new(crate::geometry::ProductManifold::new(built)))
}
}
}
}
pub(crate) const fn check_len(
context: &'static str,
got: usize,
expected: usize,
) -> GeometryResult<()> {
if got == expected {
Ok(())
} else {
Err(GeometryError::DimensionMismatch {
context,
expected,
got,
})
}
}
pub(crate) fn dot(a: ArrayView1<'_, f64>, b: ArrayView1<'_, f64>) -> f64 {
assert_eq!(a.len(), b.len());
let mut out = 0.0;
for i in 0..a.len() {
out += a[i] * b[i];
}
out
}
pub(crate) fn fast_ab_rows_multi_gpu(
a: ArrayView2<'_, f64>,
b: ArrayView2<'_, f64>,
) -> Array2<f64> {
use crate::linalg::faer_ndarray::fast_ab;
let (m, k) = a.dim();
let (kb, n) = b.dim();
assert_eq!(k, kb, "fast_ab_rows_multi_gpu inner dimension mismatch");
let multi_gpu =
crate::gpu::runtime::GpuRuntime::global().is_some_and(|rt| rt.device_count() > 1);
const MIN_TILES: usize = 64;
const MIN_TILE_ROWS: usize = 4;
if multi_gpu && m >= MIN_TILES * MIN_TILE_ROWS && n > 0 {
let rows_per_tile = (m / MIN_TILES).max(MIN_TILE_ROWS);
let tiles = m / rows_per_tile;
let covered = tiles * rows_per_tile;
let a3 = a
.slice(ndarray::s![0..covered, ..])
.to_owned()
.into_shape_with_order((tiles, rows_per_tile, k));
if let Ok(a3) = a3 {
if let Some(result3) = crate::gpu::try_fast_ab_broadcast_b_batched(a3.view(), b.view())
{
let mut out = Array2::<f64>::zeros((m, n));
for t in 0..tiles {
let block = result3.index_axis(ndarray::Axis(0), t);
out.slice_mut(ndarray::s![t * rows_per_tile..(t + 1) * rows_per_tile, ..])
.assign(&block);
}
if covered < m {
let tail = fast_ab(&a.slice(ndarray::s![covered..m, ..]), &b);
out.slice_mut(ndarray::s![covered..m, ..]).assign(&tail);
}
return out;
}
}
}
fast_ab(&a, &b)
}
pub(crate) fn norm(a: ArrayView1<'_, f64>) -> f64 {
dot(a, a).sqrt()
}
pub(crate) fn quad_form(
g: ArrayView2<'_, f64>,
a: ArrayView1<'_, f64>,
b: ArrayView1<'_, f64>,
) -> f64 {
let n = a.len();
assert_eq!(g.nrows(), n);
assert_eq!(g.ncols(), b.len());
let gb = crate::linalg::faer_ndarray::fast_av(&g, &b);
dot(a, gb.view())
}
pub(crate) fn identity(n: usize) -> Array2<f64> {
let mut out = Array2::<f64>::zeros((n, n));
for i in 0..n {
out[[i, i]] = 1.0;
}
out
}
pub(crate) fn zero_christoffel(dim: usize) -> Vec<Array2<f64>> {
(0..dim).map(|_| Array2::<f64>::zeros((dim, dim))).collect()
}
pub(crate) fn wrap_angle(theta: f64) -> f64 {
let two_pi = std::f64::consts::PI * 2.0;
(theta + std::f64::consts::PI).rem_euclid(two_pi) - std::f64::consts::PI
}
pub(crate) fn sym(a: &Array2<f64>) -> Array2<f64> {
let mut out = a.clone();
for i in 0..a.nrows() {
for j in 0..a.ncols() {
out[[i, j]] = 0.5 * (a[[i, j]] + a[[j, i]]);
}
}
out
}
pub(crate) fn from_flat(
v: ArrayView1<'_, f64>,
rows: usize,
cols: usize,
) -> GeometryResult<Array2<f64>> {
check_len("flat matrix", v.len(), rows * cols)?;
let mut out = Array2::<f64>::zeros((rows, cols));
for i in 0..rows {
for j in 0..cols {
out[[i, j]] = v[i * cols + j];
}
}
Ok(out)
}
pub(crate) fn flatten(a: &Array2<f64>) -> Array1<f64> {
let mut out = Array1::<f64>::zeros(a.nrows() * a.ncols());
for i in 0..a.nrows() {
for j in 0..a.ncols() {
out[i * a.ncols() + j] = a[[i, j]];
}
}
out
}
pub(crate) fn projected_standard_basis_tangent<M: RiemannianManifold + ?Sized>(
m: &M,
point: ArrayView1<'_, f64>,
n: usize,
k: usize,
) -> GeometryResult<Array2<f64>> {
let mut columns: Vec<Array1<f64>> = Vec::with_capacity(m.dim());
for col in 0..k {
for row in 0..n {
let mut e = Array2::<f64>::zeros((n, k));
e[[row, col]] = 1.0;
let mut v = m.project_tangent(point, flatten(&e).view())?;
for q in &columns {
let proj = dot(q.view(), v.view());
v -= &(q * proj);
}
let nrm = dot(v.view(), v.view()).sqrt();
if nrm > 1.0e-10 {
columns.push(v / nrm);
}
if columns.len() == m.dim() {
let mut out = Array2::<f64>::zeros((m.ambient_dim(), m.dim()));
for j in 0..columns.len() {
for i in 0..m.ambient_dim() {
out[[i, j]] = columns[j][i];
}
}
return Ok(out);
}
}
}
Ok(Array2::<f64>::zeros((m.ambient_dim(), columns.len())))
}
pub(crate) fn tangent_basis_metric_orthonormal<M: RiemannianManifold + ?Sized>(
m: &M,
point: ArrayView1<'_, f64>,
n: usize,
k: usize,
) -> GeometryResult<Array2<f64>> {
let w = m.metric_tensor(point)?;
let mut columns: Vec<Array1<f64>> = Vec::with_capacity(m.dim());
for col in 0..k {
for row in 0..n {
let mut e = Array2::<f64>::zeros((n, k));
e[[row, col]] = 1.0;
let mut v = m.project_tangent(point, flatten(&e).view())?;
for q in &columns {
let proj = quad_form(w.view(), q.view(), v.view());
v -= &(q * proj);
}
let nrm = quad_form(w.view(), v.view(), v.view()).max(0.0).sqrt();
if nrm > 1.0e-10 {
columns.push(v / nrm);
}
if columns.len() == m.dim() {
let mut out = Array2::<f64>::zeros((m.ambient_dim(), m.dim()));
for j in 0..columns.len() {
for i in 0..m.ambient_dim() {
out[[i, j]] = columns[j][i];
}
}
return Ok(out);
}
}
}
Ok(Array2::<f64>::zeros((m.ambient_dim(), columns.len())))
}
pub(crate) fn qr_thin(a: &Array2<f64>) -> (Array2<f64>, Array2<f64>) {
let n = a.nrows();
let k = a.ncols();
let mut q = Array2::<f64>::zeros((n, k));
let mut r = Array2::<f64>::zeros((k, k));
for j in 0..k {
let mut v = a.column(j).to_owned();
for i in 0..j {
let qi = q.column(i);
let rij = dot(qi, v.view());
r[[i, j]] = rij;
for row in 0..n {
v[row] -= rij * q[[row, i]];
}
}
let nrm = norm(v.view());
if nrm > GEOMETRY_EPS {
r[[j, j]] = nrm;
for row in 0..n {
q[[row, j]] = v[row] / nrm;
}
} else {
r[[j, j]] = 0.0;
for axis in 0..n {
let mut f = Array1::<f64>::zeros(n);
f[axis] = 1.0;
for i in 0..j {
let qi = q.column(i);
let proj = dot(qi, f.view());
for row in 0..n {
f[row] -= proj * q[[row, i]];
}
}
let fnrm = norm(f.view());
if fnrm > GEOMETRY_EPS {
for row in 0..n {
q[[row, j]] = f[row] / fnrm;
}
break;
}
}
}
}
(q, r)
}
pub(crate) fn inverse(a: &Array2<f64>) -> GeometryResult<Array2<f64>> {
let n = a.nrows();
if n != a.ncols() {
return Err(GeometryError::Singular("inverse requires a square matrix"));
}
let mut aug = Array2::<f64>::zeros((n, 2 * n));
for i in 0..n {
for j in 0..n {
aug[[i, j]] = a[[i, j]];
}
aug[[i, n + i]] = 1.0;
}
for col in 0..n {
let mut pivot = col;
let mut best = aug[[col, col]].abs();
for row in col + 1..n {
let val = aug[[row, col]].abs();
if val > best {
best = val;
pivot = row;
}
}
if best < GEOMETRY_EPS {
return Err(GeometryError::Singular("matrix inverse pivot underflow"));
}
if pivot != col {
for j in 0..2 * n {
let tmp = aug[[col, j]];
aug[[col, j]] = aug[[pivot, j]];
aug[[pivot, j]] = tmp;
}
}
let scale = aug[[col, col]];
for j in 0..2 * n {
aug[[col, j]] /= scale;
}
for row in 0..n {
if row == col {
continue;
}
let factor = aug[[row, col]];
for j in 0..2 * n {
aug[[row, j]] -= factor * aug[[col, j]];
}
}
}
let mut out = Array2::<f64>::zeros((n, n));
for i in 0..n {
for j in 0..n {
out[[i, j]] = aug[[i, n + j]];
}
}
Ok(out)
}
const JACOBI_SWEEP_BUDGET: usize = 64;
const JACOBI_REL_TOL: f64 = 1.0e-13;
pub(crate) fn jacobi_symmetric(a: &Array2<f64>) -> GeometryResult<(Array1<f64>, Array2<f64>)> {
let n = a.nrows();
if n != a.ncols() {
return Err(GeometryError::InvalidPoint(
"Jacobi eigensolver requires square input",
));
}
let mut d = sym(a);
let mut v = identity(n);
let max_iter = JACOBI_SWEEP_BUDGET * n.max(1) * n.max(1);
let frob_norm = {
let mut acc = 0.0;
for i in 0..n {
for j in 0..n {
acc += d[[i, j]] * d[[i, j]];
}
}
acc.sqrt()
};
let threshold = JACOBI_REL_TOL * frob_norm;
let mut converged = false;
for _ in 0..max_iter {
let mut p = 0usize;
let mut q = 0usize;
let mut best = 0.0;
for i in 0..n {
for j in i + 1..n {
let val = d[[i, j]].abs();
if val > best {
best = val;
p = i;
q = j;
}
}
}
if best <= threshold {
converged = true;
break;
}
let tau = (d[[q, q]] - d[[p, p]]) / (2.0 * d[[p, q]]);
let t = tau.signum() / (tau.abs() + (1.0 + tau * tau).sqrt());
let c = 1.0 / (1.0 + t * t).sqrt();
let s = t * c;
for k in 0..n {
let dpk = d[[p, k]];
let dqk = d[[q, k]];
d[[p, k]] = c * dpk - s * dqk;
d[[q, k]] = s * dpk + c * dqk;
}
for k in 0..n {
let dkp = d[[k, p]];
let dkq = d[[k, q]];
d[[k, p]] = c * dkp - s * dkq;
d[[k, q]] = s * dkp + c * dkq;
}
for k in 0..n {
let vkp = v[[k, p]];
let vkq = v[[k, q]];
v[[k, p]] = c * vkp - s * vkq;
v[[k, q]] = s * vkp + c * vkq;
}
}
if !converged {
return Err(GeometryError::Singular(
"Jacobi eigensolver did not converge within max_iter (off-diagonal mass above 1e-13 * Frobenius norm)",
));
}
let mut evals = Array1::<f64>::zeros(n);
for i in 0..n {
evals[i] = d[[i, i]];
}
Ok((evals, v))
}
pub(crate) fn spectral_map_spd(
a: &Array2<f64>,
f: impl Fn(f64) -> GeometryResult<f64>,
) -> GeometryResult<Array2<f64>> {
let (evals, evecs) = jacobi_symmetric(a)?;
let n = a.nrows();
let mut diag = Array2::<f64>::zeros((n, n));
for i in 0..n {
if evals[i] <= 0.0 || !evals[i].is_finite() {
return Err(GeometryError::InvalidPoint(
"SPD eigenvalue is not positive",
));
}
diag[[i, i]] = f(evals[i])?;
}
use crate::linalg::faer_ndarray::{fast_ab, fast_abt};
Ok(fast_abt(&fast_ab(&evecs, &diag), &evecs))
}
pub(crate) fn spectral_map_symmetric(
a: &Array2<f64>,
f: impl Fn(f64) -> GeometryResult<f64>,
) -> GeometryResult<Array2<f64>> {
let (evals, evecs) = jacobi_symmetric(a)?;
let n = a.nrows();
let mut diag = Array2::<f64>::zeros((n, n));
for i in 0..n {
diag[[i, i]] = f(evals[i])?;
}
use crate::linalg::faer_ndarray::{fast_ab, fast_abt};
Ok(fast_abt(&fast_ab(&evecs, &diag), &evecs))
}
pub(crate) fn matrix_exp(a: &Array2<f64>) -> GeometryResult<Array2<f64>> {
let n = a.nrows();
if n != a.ncols() {
return Err(GeometryError::InvalidPoint(
"matrix exponential requires square input",
));
}
if !a.iter().all(|v| v.is_finite()) {
return Err(GeometryError::InvalidPoint(
"matrix exponential requires finite entries",
));
}
let mut frob = 0.0;
for v in a.iter() {
frob += v * v;
}
let frob = frob.sqrt();
let squarings = if frob > 0.25 {
(frob / 0.25).log2().ceil() as i32
} else {
0
};
let scale = 2.0_f64.powi(squarings);
let a_scaled = a / scale;
use crate::linalg::faer_ndarray::fast_ab;
let mut result = identity(n);
let mut term = identity(n);
for k in 1..=12 {
term = fast_ab(&term, &a_scaled) / (k as f64);
result = result + &term;
}
for _ in 0..squarings {
result = fast_ab(&result, &result);
}
Ok(result)
}
pub(crate) fn cholesky_spd(a: &Array2<f64>) -> GeometryResult<Array2<f64>> {
let n = a.nrows();
if n != a.ncols() {
return Err(GeometryError::InvalidPoint(
"Cholesky requires square input",
));
}
let mut trace = 0.0_f64;
for i in 0..n {
trace += a[[i, i]];
}
if !trace.is_finite() {
return Err(GeometryError::InvalidPoint(
"matrix is not positive definite",
));
}
let scale = (trace / n as f64).abs().max(f64::MIN_POSITIVE);
let scale_eps = GEOMETRY_EPS * scale;
let mut l = Array2::<f64>::zeros((n, n));
for i in 0..n {
for j in 0..=i {
let mut sum = a[[i, j]];
for k in 0..j {
sum -= l[[i, k]] * l[[j, k]];
}
if i == j {
if !sum.is_finite() || sum <= scale_eps {
return Err(GeometryError::InvalidPoint(
"matrix is not positive definite",
));
}
l[[i, j]] = sum.sqrt();
} else {
l[[i, j]] = sum / l[[j, j]];
}
}
}
Ok(l)
}
#[cfg(test)]
mod cholesky_tests {
use super::{GeometryError, cholesky_spd};
use ndarray::Array2;
#[test]
fn cholesky_accepts_tiny_spd() {
let mut a = Array2::<f64>::zeros((1, 1));
a[[0, 0]] = 1.0e-16;
let l = cholesky_spd(&a).expect("tiny positive 1x1 must be SPD");
assert!((l[[0, 0]] - 1.0e-8).abs() <= 1.0e-16);
}
#[test]
fn cholesky_accepts_well_scaled_spd() {
let mut a = Array2::<f64>::zeros((2, 2));
a[[0, 0]] = 4.0;
a[[0, 1]] = 2.0;
a[[1, 0]] = 2.0;
a[[1, 1]] = 3.0;
let l = cholesky_spd(&a).expect("well-scaled SPD must factor");
let recon = l.dot(&l.t());
for i in 0..2 {
for j in 0..2 {
assert!(
(recon[[i, j]] - a[[i, j]]).abs() <= 1.0e-12,
"L Lᵀ != A at ({i},{j})"
);
}
}
}
#[test]
fn cholesky_rejects_zero_and_indefinite() {
let zero = Array2::<f64>::zeros((1, 1));
match cholesky_spd(&zero) {
Err(GeometryError::InvalidPoint(_)) => {}
other => panic!("expected non-PD rejection of zero pivot, got {other:?}"),
}
let mut indef = Array2::<f64>::zeros((2, 2));
indef[[0, 0]] = 1.0;
indef[[0, 1]] = 2.0;
indef[[1, 0]] = 2.0;
indef[[1, 1]] = 1.0;
match cholesky_spd(&indef) {
Err(GeometryError::InvalidPoint(_)) => {}
other => panic!("expected non-PD rejection of indefinite matrix, got {other:?}"),
}
}
}
#[cfg(test)]
mod qr_thin_tests {
use super::qr_thin;
use ndarray::Array2;
#[test]
fn qr_thin_duplicated_columns_orthonormal() {
let mut a = Array2::<f64>::zeros((2, 2));
a[[0, 0]] = 1.0;
a[[1, 0]] = 1.0;
a[[0, 1]] = 1.0;
a[[1, 1]] = 1.0;
let (q, r) = qr_thin(&a);
assert!(
r[[1, 1]].abs() <= 1.0e-14,
"deficient column must set R[1,1]=0"
);
let gram = q.t().dot(&q);
for i in 0..2 {
for j in 0..2 {
let want = if i == j { 1.0 } else { 0.0 };
assert!(
(gram[[i, j]] - want).abs() <= 1.0e-12,
"QᵀQ != I at ({i},{j}): got {}",
gram[[i, j]]
);
}
}
}
#[test]
fn qr_thin_full_rank_reconstructs() {
let mut a = Array2::<f64>::zeros((3, 2));
a[[0, 0]] = 1.0;
a[[1, 0]] = 1.0;
a[[2, 0]] = 0.0;
a[[0, 1]] = 1.0;
a[[1, 1]] = 0.0;
a[[2, 1]] = 1.0;
let (q, r) = qr_thin(&a);
let gram = q.t().dot(&q);
for i in 0..2 {
for j in 0..2 {
let want = if i == j { 1.0 } else { 0.0 };
assert!(
(gram[[i, j]] - want).abs() <= 1.0e-12,
"QᵀQ != I at ({i},{j})"
);
}
}
let recon = q.dot(&r);
for i in 0..3 {
for j in 0..2 {
assert!(
(recon[[i, j]] - a[[i, j]]).abs() <= 1.0e-12,
"QR != A at ({i},{j})"
);
}
}
}
}
#[cfg(test)]
mod jacobi_tests {
use super::{GeometryError, jacobi_symmetric};
use ndarray::Array2;
#[test]
fn jacobi_converges_on_large_norm_spd() {
let theta = 0.7_f64;
let (c, s) = (theta.cos(), theta.sin());
let mut q = Array2::<f64>::eye(3);
q[[0, 0]] = c;
q[[0, 1]] = -s;
q[[1, 0]] = s;
q[[1, 1]] = c;
let lambda = [1.0e8_f64, 2.0e8, 3.0e8];
let mut diag = Array2::<f64>::zeros((3, 3));
for i in 0..3 {
diag[[i, i]] = lambda[i];
}
let a = q.dot(&diag).dot(&q.t());
let (evals, evecs) = jacobi_symmetric(&a).expect("large-norm SPD must converge");
let mut sorted: Vec<f64> = evals.to_vec();
sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
for (got, want) in sorted.iter().zip(lambda.iter()) {
assert!(
(got - want).abs() <= 1.0e-6 * want,
"eigenvalue mismatch: got {got}, want {want}"
);
}
let mut diag_e = Array2::<f64>::zeros((3, 3));
for i in 0..3 {
diag_e[[i, i]] = evals[i];
}
let recon = evecs.dot(&diag_e).dot(&evecs.t());
for i in 0..3 {
for j in 0..3 {
assert!(
(recon[[i, j]] - a[[i, j]]).abs() <= 1.0e-6 * 3.0e8,
"reconstruction mismatch at ({i},{j})"
);
}
}
}
#[test]
fn jacobi_handles_clustered_spectrum() {
let theta = 0.4_f64;
let (c, s) = (theta.cos(), theta.sin());
let mut q = Array2::<f64>::eye(3);
q[[0, 0]] = c;
q[[0, 2]] = -s;
q[[2, 0]] = s;
q[[2, 2]] = c;
let lambda = [5.0_f64, 5.0, 1.0];
let mut diag = Array2::<f64>::zeros((3, 3));
for i in 0..3 {
diag[[i, i]] = lambda[i];
}
let a = q.dot(&diag).dot(&q.t());
let (evals, evecs) = jacobi_symmetric(&a).expect("clustered SPD must converge");
let mut sorted: Vec<f64> = evals.to_vec();
sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
assert!((sorted[0] - 1.0).abs() <= 1.0e-12);
assert!((sorted[1] - 5.0).abs() <= 1.0e-12);
assert!((sorted[2] - 5.0).abs() <= 1.0e-12);
let gram = evecs.t().dot(&evecs);
for i in 0..3 {
for j in 0..3 {
let want = if i == j { 1.0 } else { 0.0 };
assert!(
(gram[[i, j]] - want).abs() <= 1.0e-12,
"eigenvectors not orthonormal at ({i},{j})"
);
}
}
}
#[test]
fn jacobi_errors_on_non_convergence() {
let mut a = Array2::<f64>::eye(3);
a[[0, 1]] = f64::NAN;
a[[1, 0]] = f64::NAN;
match jacobi_symmetric(&a) {
Err(GeometryError::Singular(_)) => {}
other => panic!("expected Singular non-convergence error, got {other:?}"),
}
}
}