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}