use crate::error::{Result, SolverError};
use crate::matrix::Matrix;
use crate::types::Precision;
pub const FULLY_COHERENT_MARGIN: Precision = 1.0;
pub fn coherence_score(matrix: &dyn Matrix) -> Precision {
let n = matrix.rows();
if n == 0 {
return FULLY_COHERENT_MARGIN;
}
let mut worst: Precision = Precision::INFINITY;
for i in 0..n {
let diag = matrix.get(i, i).unwrap_or(0.0).abs();
if diag <= 1e-300 {
return Precision::NEG_INFINITY;
}
let mut off_diag_sum: Precision = 0.0;
for j in 0..matrix.cols() {
if i != j {
off_diag_sum += matrix.get(i, j).unwrap_or(0.0).abs();
}
}
let row_score = (diag - off_diag_sum) / diag;
if row_score < worst {
worst = row_score;
}
}
worst
}
pub fn delta_inf_bound(matrix: &dyn Matrix, delta_values: &[Precision]) -> Option<Precision> {
let c = coherence_score(matrix);
if !c.is_finite() || c <= 0.0 {
return None;
}
let min_diag = (0..matrix.rows())
.map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
.filter(|x| *x > 0.0)
.fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
if !min_diag.is_finite() || min_diag <= 0.0 {
return None;
}
let delta_inf = delta_values
.iter()
.map(|v| v.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });
Some(delta_inf / (min_diag * c))
}
pub fn delta_below_solve_threshold(
coherence: Precision,
min_diag: Precision,
delta_values: &[Precision],
tolerance: Precision,
) -> bool {
if tolerance <= 0.0 {
return false;
}
if !coherence.is_finite() || coherence <= 0.0 {
return false;
}
if !min_diag.is_finite() || min_diag <= 0.0 {
return false;
}
let delta_inf = delta_values
.iter()
.map(|v| v.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });
let bound = delta_inf / (min_diag * coherence);
bound < tolerance
}
#[derive(Debug, Clone)]
pub struct CoherenceCache {
per_row_margin: alloc::vec::Vec<Precision>,
min_margin: Precision,
min_row: usize,
}
impl CoherenceCache {
pub fn build(matrix: &dyn Matrix) -> Self {
let n = matrix.rows();
let mut per_row_margin = alloc::vec::Vec::with_capacity(n);
let mut min_margin = Precision::INFINITY;
let mut min_row = 0usize;
for i in 0..n {
let m = Self::row_margin(matrix, i);
if m < min_margin {
min_margin = m;
min_row = i;
}
per_row_margin.push(m);
}
Self {
per_row_margin,
min_margin,
min_row,
}
}
pub fn update(&mut self, matrix: &dyn Matrix, dirty_rows: &[usize]) {
let n = self.per_row_margin.len();
if dirty_rows.is_empty() || n == 0 {
return;
}
let mut prev_min_dirty = false;
for &row in dirty_rows {
if row >= n {
continue;
}
let new_margin = Self::row_margin(matrix, row);
let old_margin = self.per_row_margin[row];
self.per_row_margin[row] = new_margin;
if row == self.min_row {
prev_min_dirty = true;
}
if !prev_min_dirty || new_margin < self.min_margin {
if new_margin < self.min_margin {
self.min_margin = new_margin;
self.min_row = row;
}
let _ = old_margin; }
}
if prev_min_dirty && self.per_row_margin[self.min_row] > self.min_margin {
let mut new_min = Precision::INFINITY;
let mut new_min_row = 0usize;
for (i, &m) in self.per_row_margin.iter().enumerate() {
if m < new_min {
new_min = m;
new_min_row = i;
}
}
self.min_margin = new_min;
self.min_row = new_min_row;
}
}
pub fn score(&self) -> Precision {
self.min_margin
}
pub fn min_row(&self) -> usize {
self.min_row
}
fn row_margin(matrix: &dyn Matrix, i: usize) -> Precision {
let diag = matrix.get(i, i).unwrap_or(0.0).abs();
if diag <= 1e-300 {
return Precision::NEG_INFINITY;
}
let mut off_diag_sum: Precision = 0.0;
let cols = matrix.cols();
for j in 0..cols {
if i != j {
off_diag_sum += matrix.get(i, j).unwrap_or(0.0).abs();
}
}
(diag - off_diag_sum) / diag
}
}
pub fn approximate_spectral_radius(matrix: &dyn Matrix, num_iters: usize) -> Option<Precision> {
let n = matrix.rows();
if n == 0 || num_iters == 0 {
return None;
}
let mut diag_inv: alloc::vec::Vec<Precision> = alloc::vec::Vec::with_capacity(n);
for i in 0..n {
let d = matrix.get(i, i).unwrap_or(0.0);
if d.abs() <= 1e-300 {
return None;
}
diag_inv.push(1.0 / d.abs());
}
let mut v: alloc::vec::Vec<Precision> = (0..n)
.map(|i| (i as Precision + 1.0) / (n as Precision))
.collect();
let mut next: alloc::vec::Vec<Precision> = alloc::vec![0.0; n];
let mut rho: Precision = 0.0;
for _iter in 0..num_iters {
for entry in next.iter_mut() {
*entry = 0.0;
}
for i in 0..n {
let mut sum: Precision = 0.0;
for (col_idx, a_ij) in matrix.row_iter(i) {
let j = col_idx as usize;
if j == i {
continue;
}
if let Some(&vj) = v.get(j) {
sum += a_ij * vj;
}
}
next[i] = -sum * diag_inv[i];
}
let next_inf = next
.iter()
.map(|x| x.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });
let v_inf = v
.iter()
.map(|x| x.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });
if v_inf <= 1e-300 {
return None;
}
rho = next_inf / v_inf;
if next_inf <= 1e-300 {
return Some(0.0);
}
let scale = 1.0 / next_inf;
for (vi, ni) in v.iter_mut().zip(next.iter()) {
*vi = ni * scale;
}
}
Some(rho)
}
pub fn optimal_neumann_terms_with_rho(
rho: Precision,
b_inf_norm: Precision,
min_diag: Precision,
tolerance: Precision,
) -> Option<usize> {
if !rho.is_finite() || rho <= 0.0 || rho >= 1.0 {
return None;
}
if !min_diag.is_finite() || min_diag <= 0.0 {
return None;
}
if !tolerance.is_finite() || tolerance <= 0.0 {
return None;
}
if b_inf_norm < 0.0 || !b_inf_norm.is_finite() {
return None;
}
if b_inf_norm == 0.0 {
return Some(1);
}
let one_over_rho_log = (1.0 / rho).ln();
if !one_over_rho_log.is_finite() || one_over_rho_log <= 0.0 {
return Some(1);
}
let y0 = b_inf_norm / min_diag;
let ratio = y0 / tolerance;
if ratio <= 1.0 {
return Some(1);
}
let k_float = ratio.ln() / one_over_rho_log;
if !k_float.is_finite() || k_float <= 0.0 {
return Some(1);
}
let k = k_float.ceil() as usize;
Some(k.clamp(1, 64))
}
pub fn optimal_neumann_terms(
coherence: Precision,
b_inf_norm: Precision,
min_diag: Precision,
tolerance: Precision,
) -> Option<usize> {
if !coherence.is_finite() || coherence <= 0.0 || coherence >= 1.0 {
return None;
}
if !min_diag.is_finite() || min_diag <= 0.0 {
return None;
}
if !tolerance.is_finite() || tolerance <= 0.0 {
return None;
}
if b_inf_norm < 0.0 || !b_inf_norm.is_finite() {
return None;
}
if b_inf_norm == 0.0 {
return Some(1);
}
let rho = 1.0 - coherence;
let one_over_rho_log = (1.0 / rho).ln();
if !one_over_rho_log.is_finite() || one_over_rho_log <= 0.0 {
return Some(1);
}
let y0 = b_inf_norm / min_diag; let ratio = y0 / tolerance;
if ratio <= 1.0 {
return Some(1);
}
let k_float = ratio.ln() / one_over_rho_log;
if !k_float.is_finite() || k_float <= 0.0 {
return Some(1);
}
let k = k_float.ceil() as usize;
Some(k.clamp(1, 64))
}
pub fn check_coherence_or_reject(matrix: &dyn Matrix, threshold: Precision) -> Result<Precision> {
if threshold <= 0.0 {
return Ok(coherence_score(matrix));
}
let coherence = coherence_score(matrix);
if !coherence.is_finite() || coherence < threshold {
return Err(SolverError::Incoherent {
coherence,
threshold,
});
}
Ok(coherence)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matrix::SparseMatrix;
fn build(triplets: Vec<(usize, usize, Precision)>, n: usize) -> SparseMatrix {
SparseMatrix::from_triplets(triplets, n, n).unwrap()
}
#[test]
fn perfectly_diagonal_is_score_one() {
let m = build(vec![(0, 0, 5.0), (1, 1, 5.0), (2, 2, 5.0)], 3);
let s = coherence_score(&m);
assert!((s - 1.0).abs() < 1e-12, "expected 1.0, got {s}");
}
#[test]
fn moderately_dominant_scores_between_zero_and_one() {
let m = build(
vec![
(0, 0, 5.0),
(0, 1, 1.0),
(0, 2, 1.0),
(1, 0, 1.0),
(1, 1, 5.0),
(1, 2, 1.0),
(2, 0, 1.0),
(2, 1, 1.0),
(2, 2, 5.0),
],
3,
);
let s = coherence_score(&m);
assert!((s - 0.6).abs() < 1e-12, "expected 0.6, got {s}");
}
#[test]
fn boundary_case_scores_zero() {
let m = build(
vec![
(0, 0, 2.0),
(0, 1, 1.0),
(0, 2, 1.0),
(1, 0, 1.0),
(1, 1, 2.0),
(1, 2, 1.0),
(2, 0, 1.0),
(2, 1, 1.0),
(2, 2, 2.0),
],
3,
);
let s = coherence_score(&m);
assert!(s.abs() < 1e-12, "expected ~0, got {s}");
}
#[test]
fn non_dominant_scores_negative() {
let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
let s = coherence_score(&m);
assert!(s < 0.0, "expected negative, got {s}");
}
#[test]
fn zero_diagonal_scores_neg_infinity() {
let m = build(vec![(0, 0, 1.0), (1, 0, 1.0)], 2); let s = coherence_score(&m);
assert!(s.is_infinite() && s.is_sign_negative(), "got {s}");
}
#[test]
fn check_with_disabled_threshold_returns_ok() {
let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
let r = check_coherence_or_reject(&m, 0.0);
assert!(r.is_ok(), "disabled gate should never reject");
}
#[test]
fn check_with_enabled_threshold_rejects_incoherent_matrix() {
let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
let r = check_coherence_or_reject(&m, 0.05);
match r {
Err(SolverError::Incoherent {
coherence,
threshold,
}) => {
assert_eq!(threshold, 0.05);
assert!(coherence < threshold);
}
other => panic!("expected Err(Incoherent), got {other:?}"),
}
}
#[test]
fn check_with_enabled_threshold_passes_dominant_matrix() {
let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
let r = check_coherence_or_reject(&m, 0.05);
assert!(r.is_ok(), "5/1 dominant matrix should pass 0.05 threshold");
let score = r.unwrap();
assert!((score - 0.8).abs() < 1e-12, "expected 0.8, got {score}");
}
#[test]
fn delta_bound_on_strict_dd_matrix_is_finite() {
let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
let bound = delta_inf_bound(&m, &[0.1, 0.0]).unwrap();
assert!((bound - 0.025).abs() < 1e-12, "expected 0.025, got {bound}");
}
#[test]
fn delta_bound_on_non_dd_matrix_is_none() {
let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
assert!(delta_inf_bound(&m, &[1.0, 1.0]).is_none());
}
#[test]
fn delta_below_threshold_skips_tiny_delta() {
assert!(delta_below_solve_threshold(
0.8,
5.0,
&[1e-9, 0.0],
1e-8,
));
}
#[test]
fn delta_above_threshold_does_not_skip() {
assert!(!delta_below_solve_threshold(0.8, 5.0, &[1.0, 0.0], 1e-8));
}
#[test]
fn delta_below_threshold_with_disabled_tolerance_never_skips() {
assert!(!delta_below_solve_threshold(0.8, 5.0, &[1e-12, 0.0], 0.0));
assert!(!delta_below_solve_threshold(0.8, 5.0, &[1e-12, 0.0], -1.0));
}
#[test]
fn delta_below_threshold_refuses_to_skip_on_non_dd_input() {
assert!(!delta_below_solve_threshold(-0.1, 5.0, &[1e-12], 1e-8));
assert!(!delta_below_solve_threshold(0.0, 5.0, &[1e-12], 1e-8));
}
#[test]
fn delta_below_threshold_on_empty_delta_skips() {
assert!(delta_below_solve_threshold(0.8, 5.0, &[], 1e-8));
}
#[test]
fn optimal_terms_basic_case() {
let k = optimal_neumann_terms(0.5, 10.0, 5.0, 1e-8).unwrap();
assert!(k >= 27 && k <= 29, "expected ~28, got {k}");
}
#[test]
fn optimal_terms_high_coherence_needs_few_terms() {
let k = optimal_neumann_terms(0.95, 10.0, 5.0, 1e-6).unwrap();
assert!(k <= 10, "high coherence should converge fast; got {k}");
}
#[test]
fn optimal_terms_low_coherence_needs_many_terms() {
let k = optimal_neumann_terms(0.01, 10.0, 5.0, 1e-6).unwrap();
assert_eq!(k, 64, "tiny coherence should saturate at the cap; got {k}");
}
#[test]
fn optimal_terms_rejects_non_dd() {
assert!(optimal_neumann_terms(0.0, 1.0, 1.0, 1e-6).is_none());
assert!(optimal_neumann_terms(-0.1, 1.0, 1.0, 1e-6).is_none());
assert!(optimal_neumann_terms(1.0, 1.0, 1.0, 1e-6).is_none());
assert!(optimal_neumann_terms(1.5, 1.0, 1.0, 1e-6).is_none());
}
#[test]
fn optimal_terms_rejects_bad_min_diag() {
assert!(optimal_neumann_terms(0.5, 1.0, 0.0, 1e-6).is_none());
assert!(optimal_neumann_terms(0.5, 1.0, -1.0, 1e-6).is_none());
}
#[test]
fn optimal_terms_rejects_bad_tolerance() {
assert!(optimal_neumann_terms(0.5, 1.0, 1.0, 0.0).is_none());
assert!(optimal_neumann_terms(0.5, 1.0, 1.0, -1e-6).is_none());
}
#[test]
fn optimal_terms_zero_b_returns_one() {
assert_eq!(optimal_neumann_terms(0.5, 0.0, 1.0, 1e-8).unwrap(), 1);
}
#[test]
fn optimal_terms_loose_tolerance_returns_one() {
assert_eq!(optimal_neumann_terms(0.5, 10.0, 5.0, 10.0).unwrap(), 1);
}
#[test]
fn spectral_radius_on_diagonal_matrix_is_zero() {
let m = build(vec![(0, 0, 5.0), (1, 1, 5.0), (2, 2, 5.0)], 3);
let rho = approximate_spectral_radius(&m, 10).unwrap();
assert!(rho < 1e-10, "diagonal matrix should give rho ~0, got {rho}");
}
#[test]
fn spectral_radius_estimate_below_loose_bound() {
let n = 8;
let mut t = Vec::new();
for i in 0..n {
t.push((i, i, 10.0));
t.push((i, (i + 1) % n, 0.5));
t.push((i, (i + n - 1) % n, -0.5));
}
let m = build(t, n);
let coh = coherence_score(&m);
let loose = 1.0 - coh;
let rho = approximate_spectral_radius(&m, 20).unwrap();
assert!(rho <= loose + 1e-9, "rho={rho} should be ≤ loose={loose}");
assert!(rho > 0.0);
}
#[test]
fn spectral_radius_zero_iters_returns_none() {
let m = build(vec![(0, 0, 5.0), (1, 1, 5.0)], 2);
assert!(approximate_spectral_radius(&m, 0).is_none());
}
#[test]
fn spectral_radius_zero_diagonal_returns_none() {
let m = build(vec![(0, 0, 5.0), (1, 0, 1.0)], 2);
assert!(approximate_spectral_radius(&m, 10).is_none());
}
#[test]
fn optimal_terms_with_rho_beats_coherence_path_on_strong_matrix() {
let n = 8;
let mut t = Vec::new();
for i in 0..n {
t.push((i, i, 10.0));
t.push((i, (i + 1) % n, 0.5));
t.push((i, (i + n - 1) % n, -0.5));
}
let m = build(t, n);
let coh = coherence_score(&m);
let rho = approximate_spectral_radius(&m, 30).unwrap();
let b_inf = 10.0;
let min_diag = 10.0;
let tol = 1e-8;
let k_loose = optimal_neumann_terms(coh, b_inf, min_diag, tol).unwrap();
let k_tight = optimal_neumann_terms_with_rho(rho, b_inf, min_diag, tol).unwrap();
assert!(
k_tight <= k_loose,
"tight (rho={rho}) k={k_tight} should be ≤ loose (1-c={}) k={k_loose}",
1.0 - coh
);
}
#[test]
fn optimal_terms_with_rho_rejects_invalid_inputs() {
assert!(optimal_neumann_terms_with_rho(0.0, 1.0, 1.0, 1e-6).is_none());
assert!(optimal_neumann_terms_with_rho(-0.1, 1.0, 1.0, 1e-6).is_none());
assert!(optimal_neumann_terms_with_rho(1.0, 1.0, 1.0, 1e-6).is_none());
assert!(optimal_neumann_terms_with_rho(0.5, 1.0, 0.0, 1e-6).is_none());
assert!(optimal_neumann_terms_with_rho(0.5, 1.0, 1.0, 0.0).is_none());
}
#[test]
fn cache_build_matches_coherence_score() {
let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
let cache = CoherenceCache::build(&m);
let expected = coherence_score(&m);
assert!((cache.score() - expected).abs() < 1e-12);
}
#[test]
fn cache_update_with_empty_dirty_is_noop() {
let m = build(vec![(0, 0, 5.0), (1, 1, 5.0)], 2);
let mut cache = CoherenceCache::build(&m);
let before = cache.score();
cache.update(&m, &[]);
assert_eq!(cache.score(), before);
}
#[test]
fn cache_update_drops_score_when_dirty_row_loses_dominance() {
let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
let mut cache = CoherenceCache::build(&m);
assert!((cache.score() - 0.8).abs() < 1e-12);
let m2 = build(vec![(0, 0, 5.0), (0, 1, 3.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
cache.update(&m2, &[0]);
assert!((cache.score() - 0.4).abs() < 1e-12);
assert_eq!(cache.min_row(), 0);
}
#[test]
fn cache_update_recovers_score_after_min_row_improves() {
let m = build(vec![(0, 0, 5.0), (0, 1, 3.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
let mut cache = CoherenceCache::build(&m);
assert!((cache.score() - 0.4).abs() < 1e-12);
assert_eq!(cache.min_row(), 0);
let m2 = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
cache.update(&m2, &[0]);
assert!((cache.score() - 0.8).abs() < 1e-12);
}
#[test]
fn cache_update_drops_out_of_bound_dirty_rows() {
let m = build(vec![(0, 0, 5.0), (1, 1, 5.0)], 2);
let mut cache = CoherenceCache::build(&m);
let before = cache.score();
cache.update(&m, &[99, 100]); assert_eq!(cache.score(), before);
}
#[test]
fn cache_matches_full_score_after_arbitrary_updates() {
let m = build(
vec![
(0, 0, 5.0),
(0, 1, 2.0),
(1, 0, 1.0),
(1, 1, 5.0),
(1, 2, 1.0),
(2, 1, 1.0),
(2, 2, 5.0),
],
3,
);
let mut cache = CoherenceCache::build(&m);
cache.update(&m, &[1]);
assert!((cache.score() - coherence_score(&m)).abs() < 1e-12);
let m2 = build(
vec![
(0, 0, 5.0),
(0, 1, 0.5),
(1, 0, 0.5),
(1, 1, 5.0),
(1, 2, 0.5),
(2, 1, 0.5),
(2, 2, 5.0),
],
3,
);
cache.update(&m2, &[0, 1, 2]);
assert!((cache.score() - coherence_score(&m2)).abs() < 1e-12);
}
}