Skip to main content

sublinear_solver/
coherence.rs

1//! Coherence gate — refuse to spend polynomial-time work on a near-singular
2//! system whose residual signal-to-noise ratio is too low to produce a
3//! useful answer.
4//!
5//! Implements roadmap item #3 from
6//! [ADR-001: Complexity as Architecture](../docs/adr/ADR-001-complexity-as-architecture.md):
7//!
8//! > Before any solve, the system checks coherence: `coherence(A, b) =
9//! > min_i |diag(A)[i]| / Σ_{j≠i} |A[i,j]|` (the diagonal-dominance
10//! > margin). If coherence drops below a configurable threshold (default
11//! > 0.05), the solver refuses and returns `Err(SolverError::Incoherent {
12//! > coherence, threshold })`.
13//!
14//! Why this matters in the ADR's stack:
15//!
16//! - **Cognitum reflex loops** running on a Pi Zero 2W have a joules-per-
17//!   decision budget. Spending 50 ms on a near-singular system to produce
18//!   an ε-quality answer the agent will discard anyway is *strictly worse*
19//!   than refusing in <1 µs.
20//! - **RuView change detection** wants to know fast whether a system is
21//!   degenerate; the coherence score itself is a useful diagnostic before
22//!   any solve runs.
23//! - **Ruflo bounded planning** can fall back to a cached / heuristic
24//!   answer on incoherent inputs without burning a J/decision quota.
25//!
26//! The check is *opt-in* — `SolverOptions::coherence_threshold` defaults
27//! to `0.0`, which means "never reject for incoherence". Setting it to
28//! `0.05` enables the gate. This keeps the change wire-compatible with
29//! every existing caller.
30
31use crate::error::{Result, SolverError};
32use crate::matrix::Matrix;
33use crate::types::Precision;
34
35/// Minimum diagonal-dominance margin we report as "perfectly coherent".
36/// Used by `coherence_score` to normalise the result into `[0, 1]`.
37pub const FULLY_COHERENT_MARGIN: Precision = 1.0;
38
39/// Compute the diagonal-dominance margin of a sparse matrix.
40///
41/// For each row `i`, computes `|diag[i]| - Σ_{j≠i} |A[i,j]|` (the
42/// "diagonal-dominance excess") and divides by `|diag[i]|` to get a
43/// dimensionless score. The matrix's coherence is the *minimum* of these
44/// per-row scores: the worst row dominates the bound.
45///
46/// Returns a value in `[-∞, 1]`:
47///
48/// - `1.0` — perfectly diagonal (every off-diagonal is zero).
49/// - `(0, 1)` — strictly diagonally dominant; the larger the value, the
50///   more coherent. Neumann series convergence is guaranteed iff > 0.
51/// - `0.0` — exactly on the diagonal-dominance boundary.
52/// - negative — *not* diagonally dominant; iterative solvers may diverge.
53///
54/// Cost: one pass through the matrix's row iterator. `O(nnz(A))` —
55/// matches `Linear` complexity class per
56/// [ADR-001](../docs/adr/ADR-001-complexity-as-architecture.md).
57pub fn coherence_score(matrix: &dyn Matrix) -> Precision {
58    let n = matrix.rows();
59    if n == 0 {
60        // Empty matrix is vacuously coherent.
61        return FULLY_COHERENT_MARGIN;
62    }
63
64    let mut worst: Precision = Precision::INFINITY;
65    for i in 0..n {
66        let diag = matrix.get(i, i).unwrap_or(0.0).abs();
67        if diag <= 1e-300 {
68            // A zero (or near-zero) diagonal is the worst kind of incoherence;
69            // the solver cannot even Jacobi-iterate. Score is -∞ in spirit,
70            // but we report a large negative so callers can still compare.
71            return Precision::NEG_INFINITY;
72        }
73        let mut off_diag_sum: Precision = 0.0;
74        for j in 0..matrix.cols() {
75            if i != j {
76                off_diag_sum += matrix.get(i, j).unwrap_or(0.0).abs();
77            }
78        }
79
80        // Per-row score: positive iff |diag| > Σ |off|.
81        // Normalised by |diag| so the score is dimensionless.
82        let row_score = (diag - off_diag_sum) / diag;
83        if row_score < worst {
84            worst = row_score;
85        }
86    }
87
88    worst
89}
90
91/// Verify that a matrix's coherence meets or exceeds the configured
92/// threshold; otherwise return `SolverError::Incoherent`.
93///
94/// If `threshold <= 0.0` the gate is disabled — this is the default for
95/// `SolverOptions`, preserving wire compatibility with every existing
96/// caller. Setting `threshold = 0.05` enables the gate.
97///
98/// Cost: one `coherence_score` call. Linear in the matrix's nonzeros.
99pub fn check_coherence_or_reject(
100    matrix: &dyn Matrix,
101    threshold: Precision,
102) -> Result<Precision> {
103    if threshold <= 0.0 {
104        // Gate disabled.
105        return Ok(coherence_score(matrix));
106    }
107    let coherence = coherence_score(matrix);
108    if !coherence.is_finite() || coherence < threshold {
109        return Err(SolverError::Incoherent {
110            coherence,
111            threshold,
112        });
113    }
114    Ok(coherence)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::matrix::SparseMatrix;
121
122    fn build(triplets: Vec<(usize, usize, Precision)>, n: usize) -> SparseMatrix {
123        SparseMatrix::from_triplets(triplets, n, n).unwrap()
124    }
125
126    #[test]
127    fn perfectly_diagonal_is_score_one() {
128        let m = build(vec![(0, 0, 5.0), (1, 1, 5.0), (2, 2, 5.0)], 3);
129        let s = coherence_score(&m);
130        assert!((s - 1.0).abs() < 1e-12, "expected 1.0, got {s}");
131    }
132
133    #[test]
134    fn moderately_dominant_scores_between_zero_and_one() {
135        // Diagonal 5, off-diagonals summing to 2 per row → score 0.6.
136        let m = build(
137            vec![
138                (0, 0, 5.0), (0, 1, 1.0), (0, 2, 1.0),
139                (1, 0, 1.0), (1, 1, 5.0), (1, 2, 1.0),
140                (2, 0, 1.0), (2, 1, 1.0), (2, 2, 5.0),
141            ],
142            3,
143        );
144        let s = coherence_score(&m);
145        assert!((s - 0.6).abs() < 1e-12, "expected 0.6, got {s}");
146    }
147
148    #[test]
149    fn boundary_case_scores_zero() {
150        // Diagonal == off-diagonal sum → score exactly 0.
151        let m = build(
152            vec![
153                (0, 0, 2.0), (0, 1, 1.0), (0, 2, 1.0),
154                (1, 0, 1.0), (1, 1, 2.0), (1, 2, 1.0),
155                (2, 0, 1.0), (2, 1, 1.0), (2, 2, 2.0),
156            ],
157            3,
158        );
159        let s = coherence_score(&m);
160        assert!(s.abs() < 1e-12, "expected ~0, got {s}");
161    }
162
163    #[test]
164    fn non_dominant_scores_negative() {
165        // Off-diagonals dominate the diagonal → score negative.
166        let m = build(
167            vec![
168                (0, 0, 1.0), (0, 1, 2.0),
169                (1, 0, 2.0), (1, 1, 1.0),
170            ],
171            2,
172        );
173        let s = coherence_score(&m);
174        assert!(s < 0.0, "expected negative, got {s}");
175    }
176
177    #[test]
178    fn zero_diagonal_scores_neg_infinity() {
179        let m = build(vec![(0, 0, 1.0), (1, 0, 1.0)], 2); // row 1 has no diag
180        let s = coherence_score(&m);
181        assert!(s.is_infinite() && s.is_sign_negative(), "got {s}");
182    }
183
184    #[test]
185    fn check_with_disabled_threshold_returns_ok() {
186        let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
187        // threshold = 0 → gate off
188        let r = check_coherence_or_reject(&m, 0.0);
189        assert!(r.is_ok(), "disabled gate should never reject");
190    }
191
192    #[test]
193    fn check_with_enabled_threshold_rejects_incoherent_matrix() {
194        let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
195        let r = check_coherence_or_reject(&m, 0.05);
196        match r {
197            Err(SolverError::Incoherent { coherence, threshold }) => {
198                assert_eq!(threshold, 0.05);
199                assert!(coherence < threshold);
200            }
201            other => panic!("expected Err(Incoherent), got {other:?}"),
202        }
203    }
204
205    #[test]
206    fn check_with_enabled_threshold_passes_dominant_matrix() {
207        let m = build(
208            vec![
209                (0, 0, 5.0), (0, 1, 1.0),
210                (1, 0, 1.0), (1, 1, 5.0),
211            ],
212            2,
213        );
214        let r = check_coherence_or_reject(&m, 0.05);
215        assert!(r.is_ok(), "5/1 dominant matrix should pass 0.05 threshold");
216        // Score is (5-1)/5 = 0.8
217        let score = r.unwrap();
218        assert!((score - 0.8).abs() < 1e-12, "expected 0.8, got {score}");
219    }
220}