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/// Upper bound on `‖A⁻¹ · δ‖_∞`, derived from the coherence margin.
92///
93/// For a strictly diagonally dominant matrix `A = D - O` with coherence
94/// margin `c = min_i (|A[i,i]| - Σ_{j≠i}|A[i,j]|) / |A[i,i]|`, we have
95/// `‖A⁻¹ δ‖_∞ ≤ ‖δ‖_∞ / (min_i |A[i,i]| · c)`. This is a Neumann-series
96/// envelope bound — never tight, but always safe.
97///
98/// Returns `None` if the matrix is not strictly DD (`coherence_score
99/// <= 0`); the caller must fall back to an actual solve in that case.
100///
101/// Cost: one `coherence_score` pass + one min-diagonal pass — Linear in
102/// `nnz(A)`. **But the *point* of this primitive is to amortise the
103/// score across many event-handling cycles**: callers cache the
104/// `(coherence, min_diag)` pair once at matrix-build time, then ask
105/// this function `Option<Precision>` on every event for an `O(|δ|)`
106/// envelope check.
107///
108/// Use [`delta_below_solve_threshold`] for the cached-input fast path.
109pub fn delta_inf_bound(matrix: &dyn Matrix, delta_values: &[Precision]) -> Option<Precision> {
110 let c = coherence_score(matrix);
111 if !c.is_finite() || c <= 0.0 {
112 return None;
113 }
114 let min_diag = (0..matrix.rows())
115 .map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
116 .filter(|x| *x > 0.0)
117 .fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
118 if !min_diag.is_finite() || min_diag <= 0.0 {
119 return None;
120 }
121 let delta_inf = delta_values
122 .iter()
123 .map(|v| v.abs())
124 .fold(0.0_f64, |a, b| if a > b { a } else { b });
125 Some(delta_inf / (min_diag * c))
126}
127
128/// Fast-path coherence-gated event filter. Returns `true` iff the
129/// supplied `delta` is small enough that, given the matrix's
130/// `(coherence, min_diag)` pair, the induced change in `x` is
131/// guaranteed below `tolerance` — so a downstream solve can safely
132/// be skipped.
133///
134/// **This is the "no event, no work" gate from the ADR-001 thesis.**
135/// Cost is `O(|δ|)` — independent of `n`, independent of `nnz(A)`. The
136/// `(coherence, min_diag)` pair is computed once per matrix at build
137/// time and reused across every event.
138///
139/// Returns `false` when:
140/// - `tolerance <= 0` (gate disabled)
141/// - `coherence <= 0` (not strict-DD — bound doesn't hold; can't skip)
142/// - `min_diag <= 0`
143/// - the bound `‖δ‖_∞ / (min_diag · coherence)` exceeds `tolerance`
144/// (meaningful change may have happened — don't skip)
145///
146/// # Examples
147///
148/// ```rust,no_run
149/// # use sublinear_solver::{Matrix, coherence::{coherence_score, delta_below_solve_threshold}};
150/// # fn demo<M: Matrix>(a: &M, deltas: impl Iterator<Item = Vec<f64>>) {
151/// // Cache once.
152/// let c = coherence_score(a);
153/// let min_diag = (0..a.rows())
154/// .map(|i| a.get(i, i).unwrap_or(0.0).abs())
155/// .filter(|x| *x > 0.0)
156/// .fold(f64::INFINITY, |a, b| a.min(b));
157///
158/// // O(|delta|) check per event.
159/// let tolerance = 1e-6;
160/// for delta in deltas {
161/// if delta_below_solve_threshold(c, min_diag, &delta, tolerance) {
162/// // Skip the solve; the world didn't meaningfully change.
163/// continue;
164/// }
165/// // Otherwise: dispatch to solve_on_change_sublinear / contrastive / …
166/// }
167/// # }
168/// ```
169pub fn delta_below_solve_threshold(
170 coherence: Precision,
171 min_diag: Precision,
172 delta_values: &[Precision],
173 tolerance: Precision,
174) -> bool {
175 if tolerance <= 0.0 {
176 return false;
177 }
178 if !coherence.is_finite() || coherence <= 0.0 {
179 return false;
180 }
181 if !min_diag.is_finite() || min_diag <= 0.0 {
182 return false;
183 }
184 let delta_inf = delta_values
185 .iter()
186 .map(|v| v.abs())
187 .fold(0.0_f64, |a, b| if a > b { a } else { b });
188 let bound = delta_inf / (min_diag * coherence);
189 bound < tolerance
190}
191
192/// Incremental coherence cache for streaming matrix-update workloads.
193///
194/// [`coherence_score`] is `O(nnz(A))` — a full scan of the matrix.
195/// Streaming workloads (Cognitum reflex loops over a learning system,
196/// RuView agents tracking sensor-network state, anything that mutates
197/// rows over time) re-pay that cost on every event even when only a
198/// handful of rows actually changed.
199///
200/// This cache stores the per-row diagonal-dominance margin and the
201/// current global minimum. Initial [`CoherenceCache::build`] is the
202/// same O(nnz) one-shot cost. Each subsequent [`update`] only
203/// re-scans the dirty rows: `O(|dirty| · avg_row_nnz)`. The cached
204/// [`score`] is an O(1) read.
205///
206/// ## Math
207///
208/// Per-row margin at row `i`:
209/// `(|A[i,i]| - Σ_{j≠i}|A[i,j]|) / |A[i,i]|`
210///
211/// Global coherence = `min_i row_margin[i]`. When row `i`'s margin
212/// changes, the global min either becomes the new value (if `i` was
213/// the previous min row, or the new value is lower) or stays as
214/// before (otherwise). We track the min row index so we can detect
215/// when re-computing it across all clean rows is unavoidable —
216/// happens iff the dirty update increases the previous-min row's
217/// margin. The unavoidable case is still bounded by `O(n)` (just a
218/// vec scan), no matrix touches.
219///
220/// ## Complexity
221///
222/// * `build`: `O(nnz(A))` — same as `coherence_score`.
223/// * `update`: `O(|dirty| · avg_row_nnz)` typical case.
224/// The rare unavoidable global recompute is `O(n)`
225/// vec scan (no matrix touches) — still cheaper than
226/// a full `coherence_score` on a sparse matrix.
227/// * `score`: `O(1)`.
228///
229/// The "amortised SubLinear-per-event" guarantee: as long as the
230/// previous-min row stays among the dirty set or the new minimum
231/// lands among the dirty rows, the cache never has to do the global
232/// scan.
233#[derive(Debug, Clone)]
234pub struct CoherenceCache {
235 /// Per-row diagonal-dominance margin. Length = matrix.rows() at build time.
236 per_row_margin: alloc::vec::Vec<Precision>,
237 /// Current global minimum margin (cached).
238 min_margin: Precision,
239 /// Row index of the current min margin (used to detect when a
240 /// dirty update on that row forces a global recompute).
241 min_row: usize,
242}
243
244impl CoherenceCache {
245 /// Compute per-row margins for every row and cache the global
246 /// minimum. One-shot O(nnz(A)) work, matches [`coherence_score`].
247 pub fn build(matrix: &dyn Matrix) -> Self {
248 let n = matrix.rows();
249 let mut per_row_margin = alloc::vec::Vec::with_capacity(n);
250 let mut min_margin = Precision::INFINITY;
251 let mut min_row = 0usize;
252 for i in 0..n {
253 let m = Self::row_margin(matrix, i);
254 if m < min_margin {
255 min_margin = m;
256 min_row = i;
257 }
258 per_row_margin.push(m);
259 }
260 Self {
261 per_row_margin,
262 min_margin,
263 min_row,
264 }
265 }
266
267 /// Re-scan only the listed dirty rows. `O(|dirty| · row_nnz)`
268 /// typical case; up to `O(n)` vec scan if the previous-min row
269 /// got dirtier and we have to find the new global minimum.
270 ///
271 /// `dirty_rows` need not be sorted or unique; duplicates are
272 /// handled idempotently. Out-of-bound indices are silently
273 /// dropped (matches `coherence_score`'s tolerant handling).
274 pub fn update(&mut self, matrix: &dyn Matrix, dirty_rows: &[usize]) {
275 let n = self.per_row_margin.len();
276 if dirty_rows.is_empty() || n == 0 {
277 return;
278 }
279
280 // Re-compute margins for every dirty row.
281 let mut prev_min_dirty = false;
282 for &row in dirty_rows {
283 if row >= n {
284 continue;
285 }
286 let new_margin = Self::row_margin(matrix, row);
287 let old_margin = self.per_row_margin[row];
288 self.per_row_margin[row] = new_margin;
289 if row == self.min_row {
290 prev_min_dirty = true;
291 }
292 // Fast path: dirty row dropped below the cached min →
293 // we can update the cached min in O(1).
294 if !prev_min_dirty || new_margin < self.min_margin {
295 if new_margin < self.min_margin {
296 self.min_margin = new_margin;
297 self.min_row = row;
298 }
299 let _ = old_margin; // silence unused
300 }
301 }
302
303 // Unavoidable case: the previous-min row's margin increased
304 // (got more coherent) and we don't know which other row is
305 // now the min. O(n) vec scan, no matrix touches.
306 if prev_min_dirty && self.per_row_margin[self.min_row] > self.min_margin {
307 // Rare. Re-scan the cached margins to find the new min.
308 let mut new_min = Precision::INFINITY;
309 let mut new_min_row = 0usize;
310 for (i, &m) in self.per_row_margin.iter().enumerate() {
311 if m < new_min {
312 new_min = m;
313 new_min_row = i;
314 }
315 }
316 self.min_margin = new_min;
317 self.min_row = new_min_row;
318 }
319 }
320
321 /// Cached global minimum margin. `O(1)`.
322 pub fn score(&self) -> Precision {
323 self.min_margin
324 }
325
326 /// Row index of the current global minimum margin.
327 pub fn min_row(&self) -> usize {
328 self.min_row
329 }
330
331 /// Compute the diagonal-dominance margin for a single row.
332 ///
333 /// `(|diag| - Σ_{j≠i}|A[i,j]|) / |diag|`. Returns `NEG_INFINITY`
334 /// for a zero-diagonal row (matches `coherence_score`).
335 fn row_margin(matrix: &dyn Matrix, i: usize) -> Precision {
336 let diag = matrix.get(i, i).unwrap_or(0.0).abs();
337 if diag <= 1e-300 {
338 return Precision::NEG_INFINITY;
339 }
340 let mut off_diag_sum: Precision = 0.0;
341 let cols = matrix.cols();
342 for j in 0..cols {
343 if i != j {
344 off_diag_sum += matrix.get(i, j).unwrap_or(0.0).abs();
345 }
346 }
347 (diag - off_diag_sum) / diag
348 }
349}
350
351/// Estimate the spectral radius of `D⁻¹O` (the Neumann iteration matrix)
352/// via power iteration. Tighter than the `1 - coherence` upper bound that
353/// [`optimal_neumann_terms`] uses by default.
354///
355/// ## Why
356///
357/// For DD `A = D - O` with coherence `c`, `‖D⁻¹O‖_∞ ≤ 1 - c`. That's a
358/// safe upper bound on `ρ(D⁻¹O)` but often loose — the actual spectral
359/// radius can be much smaller, especially on matrices with non-uniform
360/// row weights. A tight `ρ` estimate lets [`optimal_neumann_terms_with_rho`]
361/// pick a much smaller `max_terms`:
362///
363/// ```text
364/// k ≥ log(b_inf / (min_diag · tolerance)) / log(1 / ρ)
365/// ```
366///
367/// Smaller `ρ` → larger `log(1/ρ)` → smaller `k`. The auto-tuned closure
368/// shrinks proportionally, and per-event SubLinear cost drops.
369///
370/// ## Algorithm
371///
372/// Standard power iteration on `M = D⁻¹O = I - D⁻¹A`:
373///
374/// 1. Start with a uniform unit vector `v_0`.
375/// 2. For each iteration, compute `v_{k+1} = M v_k` and the
376/// Rayleigh-style ratio `‖M v_k‖_∞ / ‖v_k‖_∞`.
377/// 3. Renormalise `v_{k+1}` to unit infinity norm.
378/// 4. Return the last ratio (the dominant-eigenvalue estimate).
379///
380/// Cost: `O(num_iters · nnz(A))`. Convergence is geometric in
381/// `λ_2 / λ_1` — typically 10–20 iterations suffice on well-conditioned
382/// DD matrices. Run once at matrix-build time; amortised across all
383/// subsequent events.
384///
385/// ## Returns
386///
387/// - `Some(rho)` on success. `0.0 ≤ rho < 1.0` for strict-DD matrices.
388/// - `None` if `num_iters == 0`, the matrix is empty, or any diagonal
389/// row has zero entry.
390///
391/// # Examples
392///
393/// ```rust,no_run
394/// # use sublinear_solver::Matrix;
395/// # use sublinear_solver::coherence::{approximate_spectral_radius, optimal_neumann_terms_with_rho};
396/// # fn demo(a: &dyn Matrix) {
397/// let rho = approximate_spectral_radius(a, /*num_iters=*/ 20).unwrap_or(0.99);
398/// // Use the tight rho instead of the loose (1 - coherence) bound.
399/// let k = optimal_neumann_terms_with_rho(rho, /*b_inf=*/ 10.0, /*min_diag=*/ 5.0, /*tol=*/ 1e-8);
400/// # }
401/// ```
402pub fn approximate_spectral_radius(matrix: &dyn Matrix, num_iters: usize) -> Option<Precision> {
403 let n = matrix.rows();
404 if n == 0 || num_iters == 0 {
405 return None;
406 }
407 // Cache the diagonals; reject if any is zero (M = D⁻¹O ill-defined).
408 let mut diag_inv: alloc::vec::Vec<Precision> = alloc::vec::Vec::with_capacity(n);
409 for i in 0..n {
410 let d = matrix.get(i, i).unwrap_or(0.0);
411 if d.abs() <= 1e-300 {
412 return None;
413 }
414 diag_inv.push(1.0 / d.abs());
415 }
416 // Start with a *non-symmetric* deterministic vector. A uniform
417 // start is convenient but lies in the null-space of M = D⁻¹O for
418 // symmetric stencils (ring, Laplacian, …) — the iteration would
419 // collapse to zero. v[i] = (i + 1) / n breaks any reasonable
420 // row-symmetry of A while staying deterministic.
421 let mut v: alloc::vec::Vec<Precision> = (0..n)
422 .map(|i| (i as Precision + 1.0) / (n as Precision))
423 .collect();
424 let mut next: alloc::vec::Vec<Precision> = alloc::vec![0.0; n];
425 let mut rho: Precision = 0.0;
426
427 for _iter in 0..num_iters {
428 // next[i] = (D⁻¹ O v)[i] = -(1/|A[i,i]|) · Σ_{j ≠ i} A[i,j] · v[j]
429 for entry in next.iter_mut() {
430 *entry = 0.0;
431 }
432 for i in 0..n {
433 let mut sum: Precision = 0.0;
434 for (col_idx, a_ij) in matrix.row_iter(i) {
435 let j = col_idx as usize;
436 if j == i {
437 continue;
438 }
439 if let Some(&vj) = v.get(j) {
440 sum += a_ij * vj;
441 }
442 }
443 next[i] = -sum * diag_inv[i];
444 }
445 // Infinity norm of next.
446 let next_inf = next
447 .iter()
448 .map(|x| x.abs())
449 .fold(0.0_f64, |a, b| if a > b { a } else { b });
450 let v_inf = v
451 .iter()
452 .map(|x| x.abs())
453 .fold(0.0_f64, |a, b| if a > b { a } else { b });
454 if v_inf <= 1e-300 {
455 return None;
456 }
457 rho = next_inf / v_inf;
458 if next_inf <= 1e-300 {
459 // Converged to zero — radius is effectively 0.
460 return Some(0.0);
461 }
462 // Renormalise to unit ∞-norm.
463 let scale = 1.0 / next_inf;
464 for (vi, ni) in v.iter_mut().zip(next.iter()) {
465 *vi = ni * scale;
466 }
467 }
468 Some(rho)
469}
470
471/// Variant of [`optimal_neumann_terms`] that takes a caller-supplied
472/// spectral-radius `rho` directly instead of inferring it from coherence.
473/// Use this with [`approximate_spectral_radius`] for tight auto-tuning
474/// on matrices where the `1 - coherence` bound is loose.
475///
476/// All other semantics match [`optimal_neumann_terms`] (`[1, 64]` clamp,
477/// zero-RHS short-circuit, etc).
478pub fn optimal_neumann_terms_with_rho(
479 rho: Precision,
480 b_inf_norm: Precision,
481 min_diag: Precision,
482 tolerance: Precision,
483) -> Option<usize> {
484 if !rho.is_finite() || rho <= 0.0 || rho >= 1.0 {
485 return None;
486 }
487 if !min_diag.is_finite() || min_diag <= 0.0 {
488 return None;
489 }
490 if !tolerance.is_finite() || tolerance <= 0.0 {
491 return None;
492 }
493 if b_inf_norm < 0.0 || !b_inf_norm.is_finite() {
494 return None;
495 }
496 if b_inf_norm == 0.0 {
497 return Some(1);
498 }
499 let one_over_rho_log = (1.0 / rho).ln();
500 if !one_over_rho_log.is_finite() || one_over_rho_log <= 0.0 {
501 return Some(1);
502 }
503 let y0 = b_inf_norm / min_diag;
504 let ratio = y0 / tolerance;
505 if ratio <= 1.0 {
506 return Some(1);
507 }
508 let k_float = ratio.ln() / one_over_rho_log;
509 if !k_float.is_finite() || k_float <= 0.0 {
510 return Some(1);
511 }
512 let k = k_float.ceil() as usize;
513 Some(k.clamp(1, 64))
514}
515
516/// Minimum number of Neumann terms required to hit `tolerance` on a
517/// single-entry solve, given the matrix's `coherence` margin and the
518/// magnitudes of the RHS and any perturbation.
519///
520/// ## Math
521///
522/// For strictly DD `A = D - O` with coherence `c`, the Neumann series
523/// `A⁻¹ b = Σ_k y_k` satisfies `‖y_k‖_∞ ≤ ρ^k · ‖y_0‖_∞` where the
524/// spectral radius of `D⁻¹O` is bounded by `ρ ≤ 1 - c`. To get
525/// per-entry error below `tolerance` we need
526///
527/// ```text
528/// k ≥ log(‖y_0‖_∞ / tolerance) / log(1 / ρ)
529/// = log(‖b‖_∞ / (min_diag · tolerance)) / log(1 / (1 - c))
530/// ```
531///
532/// This function picks the smallest such `k` (rounded up). Saturates at
533/// `64` so a callers-provided pathological pair can't blow up the
534/// iteration count.
535///
536/// ## Returns
537///
538/// - `Some(k)` — recommended Neumann term count, clamped to `[1, 64]`.
539/// - `None` — non-strict-DD input (`coherence <= 0`), or non-positive
540/// `tolerance`, or non-positive `min_diag`. Caller should fall back
541/// to a hand-picked `max_terms` or to a full solve.
542///
543/// ## Edge cases
544///
545/// - `b_inf_norm == 0` → returns `Some(1)` (we still want at least one
546/// term to confirm the zero answer).
547/// - Inputs that produce `k > 64` are clamped (the bound is conservative;
548/// real matrices rarely need that many).
549///
550/// # Examples
551///
552/// ```rust,no_run
553/// # use sublinear_solver::coherence::optimal_neumann_terms;
554/// // For coherence 0.5, ‖b‖ = 10, min_diag = 5, tolerance = 1e-8:
555/// // k ≥ log(10 / (5 · 1e-8)) / log(2) = log2(2e8) ≈ 27.6 → k = 28
556/// let k = optimal_neumann_terms(
557/// /*coherence=*/ 0.5,
558/// /*b_inf_norm=*/ 10.0,
559/// /*min_diag=*/ 5.0,
560/// /*tolerance=*/ 1e-8,
561/// ).unwrap();
562/// assert!(k >= 28 && k <= 30);
563/// ```
564pub fn optimal_neumann_terms(
565 coherence: Precision,
566 b_inf_norm: Precision,
567 min_diag: Precision,
568 tolerance: Precision,
569) -> Option<usize> {
570 if !coherence.is_finite() || coherence <= 0.0 || coherence >= 1.0 {
571 return None;
572 }
573 if !min_diag.is_finite() || min_diag <= 0.0 {
574 return None;
575 }
576 if !tolerance.is_finite() || tolerance <= 0.0 {
577 return None;
578 }
579 if b_inf_norm < 0.0 || !b_inf_norm.is_finite() {
580 return None;
581 }
582 // Zero RHS → one term is enough (it's the zero answer).
583 if b_inf_norm == 0.0 {
584 return Some(1);
585 }
586 // ρ ≤ 1 - coherence is the conservative spectral-radius bound.
587 let rho = 1.0 - coherence;
588 // 1/ρ > 1 because coherence > 0 ⇒ rho < 1.
589 let one_over_rho_log = (1.0 / rho).ln();
590 if !one_over_rho_log.is_finite() || one_over_rho_log <= 0.0 {
591 // Numerical floor — extreme coherence near 1.0 collapses the log.
592 return Some(1);
593 }
594 let y0 = b_inf_norm / min_diag; // ‖D⁻¹ b‖_∞ upper bound
595 let ratio = y0 / tolerance;
596 if ratio <= 1.0 {
597 // Already at tolerance from term 0 alone.
598 return Some(1);
599 }
600 let k_float = ratio.ln() / one_over_rho_log;
601 if !k_float.is_finite() || k_float <= 0.0 {
602 return Some(1);
603 }
604 let k = k_float.ceil() as usize;
605 Some(k.clamp(1, 64))
606}
607
608/// Verify that a matrix's coherence meets or exceeds the configured
609/// threshold; otherwise return `SolverError::Incoherent`.
610///
611/// If `threshold <= 0.0` the gate is disabled — this is the default for
612/// `SolverOptions`, preserving wire compatibility with every existing
613/// caller. Setting `threshold = 0.05` enables the gate.
614///
615/// Cost: one `coherence_score` call. Linear in the matrix's nonzeros.
616pub fn check_coherence_or_reject(matrix: &dyn Matrix, threshold: Precision) -> Result<Precision> {
617 if threshold <= 0.0 {
618 // Gate disabled.
619 return Ok(coherence_score(matrix));
620 }
621 let coherence = coherence_score(matrix);
622 if !coherence.is_finite() || coherence < threshold {
623 return Err(SolverError::Incoherent {
624 coherence,
625 threshold,
626 });
627 }
628 Ok(coherence)
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::matrix::SparseMatrix;
635
636 fn build(triplets: Vec<(usize, usize, Precision)>, n: usize) -> SparseMatrix {
637 SparseMatrix::from_triplets(triplets, n, n).unwrap()
638 }
639
640 #[test]
641 fn perfectly_diagonal_is_score_one() {
642 let m = build(vec![(0, 0, 5.0), (1, 1, 5.0), (2, 2, 5.0)], 3);
643 let s = coherence_score(&m);
644 assert!((s - 1.0).abs() < 1e-12, "expected 1.0, got {s}");
645 }
646
647 #[test]
648 fn moderately_dominant_scores_between_zero_and_one() {
649 // Diagonal 5, off-diagonals summing to 2 per row → score 0.6.
650 let m = build(
651 vec![
652 (0, 0, 5.0),
653 (0, 1, 1.0),
654 (0, 2, 1.0),
655 (1, 0, 1.0),
656 (1, 1, 5.0),
657 (1, 2, 1.0),
658 (2, 0, 1.0),
659 (2, 1, 1.0),
660 (2, 2, 5.0),
661 ],
662 3,
663 );
664 let s = coherence_score(&m);
665 assert!((s - 0.6).abs() < 1e-12, "expected 0.6, got {s}");
666 }
667
668 #[test]
669 fn boundary_case_scores_zero() {
670 // Diagonal == off-diagonal sum → score exactly 0.
671 let m = build(
672 vec![
673 (0, 0, 2.0),
674 (0, 1, 1.0),
675 (0, 2, 1.0),
676 (1, 0, 1.0),
677 (1, 1, 2.0),
678 (1, 2, 1.0),
679 (2, 0, 1.0),
680 (2, 1, 1.0),
681 (2, 2, 2.0),
682 ],
683 3,
684 );
685 let s = coherence_score(&m);
686 assert!(s.abs() < 1e-12, "expected ~0, got {s}");
687 }
688
689 #[test]
690 fn non_dominant_scores_negative() {
691 // Off-diagonals dominate the diagonal → score negative.
692 let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
693 let s = coherence_score(&m);
694 assert!(s < 0.0, "expected negative, got {s}");
695 }
696
697 #[test]
698 fn zero_diagonal_scores_neg_infinity() {
699 let m = build(vec![(0, 0, 1.0), (1, 0, 1.0)], 2); // row 1 has no diag
700 let s = coherence_score(&m);
701 assert!(s.is_infinite() && s.is_sign_negative(), "got {s}");
702 }
703
704 #[test]
705 fn check_with_disabled_threshold_returns_ok() {
706 let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
707 // threshold = 0 → gate off
708 let r = check_coherence_or_reject(&m, 0.0);
709 assert!(r.is_ok(), "disabled gate should never reject");
710 }
711
712 #[test]
713 fn check_with_enabled_threshold_rejects_incoherent_matrix() {
714 let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
715 let r = check_coherence_or_reject(&m, 0.05);
716 match r {
717 Err(SolverError::Incoherent {
718 coherence,
719 threshold,
720 }) => {
721 assert_eq!(threshold, 0.05);
722 assert!(coherence < threshold);
723 }
724 other => panic!("expected Err(Incoherent), got {other:?}"),
725 }
726 }
727
728 #[test]
729 fn check_with_enabled_threshold_passes_dominant_matrix() {
730 let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
731 let r = check_coherence_or_reject(&m, 0.05);
732 assert!(r.is_ok(), "5/1 dominant matrix should pass 0.05 threshold");
733 // Score is (5-1)/5 = 0.8
734 let score = r.unwrap();
735 assert!((score - 0.8).abs() < 1e-12, "expected 0.8, got {score}");
736 }
737
738 // ── delta_inf_bound / delta_below_solve_threshold tests ────────────
739
740 #[test]
741 fn delta_bound_on_strict_dd_matrix_is_finite() {
742 // 5/1 dominant: coherence = 0.8, min_diag = 5.
743 // delta_inf = 0.1 → bound = 0.1 / (5 · 0.8) = 0.025.
744 let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
745 let bound = delta_inf_bound(&m, &[0.1, 0.0]).unwrap();
746 assert!((bound - 0.025).abs() < 1e-12, "expected 0.025, got {bound}");
747 }
748
749 #[test]
750 fn delta_bound_on_non_dd_matrix_is_none() {
751 // Non-DD: bound doesn't hold. Caller must fall back to a solve.
752 let m = build(vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)], 2);
753 assert!(delta_inf_bound(&m, &[1.0, 1.0]).is_none());
754 }
755
756 #[test]
757 fn delta_below_threshold_skips_tiny_delta() {
758 // coherence = 0.8, min_diag = 5, delta = 1e-9.
759 // bound = 1e-9 / (5 · 0.8) = 2.5e-10 < tolerance = 1e-8 → skip.
760 assert!(delta_below_solve_threshold(
761 /*coherence=*/ 0.8,
762 /*min_diag=*/ 5.0,
763 /*delta=*/ &[1e-9, 0.0],
764 /*tolerance=*/ 1e-8,
765 ));
766 }
767
768 #[test]
769 fn delta_above_threshold_does_not_skip() {
770 // coherence = 0.8, min_diag = 5, delta = 1.0.
771 // bound = 1.0 / 4.0 = 0.25 > tolerance = 1e-8 → must solve.
772 assert!(!delta_below_solve_threshold(0.8, 5.0, &[1.0, 0.0], 1e-8));
773 }
774
775 #[test]
776 fn delta_below_threshold_with_disabled_tolerance_never_skips() {
777 // tolerance <= 0 disables the gate.
778 assert!(!delta_below_solve_threshold(0.8, 5.0, &[1e-12, 0.0], 0.0));
779 assert!(!delta_below_solve_threshold(0.8, 5.0, &[1e-12, 0.0], -1.0));
780 }
781
782 #[test]
783 fn delta_below_threshold_refuses_to_skip_on_non_dd_input() {
784 // coherence <= 0 means the bound doesn't hold. Refuse to skip
785 // regardless of how small the delta is — safety first.
786 assert!(!delta_below_solve_threshold(-0.1, 5.0, &[1e-12], 1e-8));
787 assert!(!delta_below_solve_threshold(0.0, 5.0, &[1e-12], 1e-8));
788 }
789
790 #[test]
791 fn delta_below_threshold_on_empty_delta_skips() {
792 // Empty delta has inf-norm 0, which is below any positive tolerance.
793 assert!(delta_below_solve_threshold(0.8, 5.0, &[], 1e-8));
794 }
795
796 // ── optimal_neumann_terms tests ────────────────────────────────────
797
798 #[test]
799 fn optimal_terms_basic_case() {
800 // coherence=0.5, ‖b‖=10, min_diag=5, tol=1e-8.
801 // y0 = 2, ratio = 2 / 1e-8 = 2e8, log2(2e8) ≈ 27.6 → 28
802 // rho = 0.5, log(2) ≈ 0.693, log(2e8) ≈ 19.11
803 // k ≥ 19.11 / 0.693 ≈ 27.6 → 28
804 let k = optimal_neumann_terms(0.5, 10.0, 5.0, 1e-8).unwrap();
805 assert!(k >= 27 && k <= 29, "expected ~28, got {k}");
806 }
807
808 #[test]
809 fn optimal_terms_high_coherence_needs_few_terms() {
810 // coherence near 1: 1/rho is huge, log is huge, so k is tiny.
811 let k = optimal_neumann_terms(0.95, 10.0, 5.0, 1e-6).unwrap();
812 assert!(k <= 10, "high coherence should converge fast; got {k}");
813 }
814
815 #[test]
816 fn optimal_terms_low_coherence_needs_many_terms() {
817 // coherence near 0: 1/rho ≈ 1, log near 0, so k saturates.
818 let k = optimal_neumann_terms(0.01, 10.0, 5.0, 1e-6).unwrap();
819 assert_eq!(k, 64, "tiny coherence should saturate at the cap; got {k}");
820 }
821
822 #[test]
823 fn optimal_terms_rejects_non_dd() {
824 assert!(optimal_neumann_terms(0.0, 1.0, 1.0, 1e-6).is_none());
825 assert!(optimal_neumann_terms(-0.1, 1.0, 1.0, 1e-6).is_none());
826 assert!(optimal_neumann_terms(1.0, 1.0, 1.0, 1e-6).is_none());
827 assert!(optimal_neumann_terms(1.5, 1.0, 1.0, 1e-6).is_none());
828 }
829
830 #[test]
831 fn optimal_terms_rejects_bad_min_diag() {
832 assert!(optimal_neumann_terms(0.5, 1.0, 0.0, 1e-6).is_none());
833 assert!(optimal_neumann_terms(0.5, 1.0, -1.0, 1e-6).is_none());
834 }
835
836 #[test]
837 fn optimal_terms_rejects_bad_tolerance() {
838 assert!(optimal_neumann_terms(0.5, 1.0, 1.0, 0.0).is_none());
839 assert!(optimal_neumann_terms(0.5, 1.0, 1.0, -1e-6).is_none());
840 }
841
842 #[test]
843 fn optimal_terms_zero_b_returns_one() {
844 // Zero RHS — one term confirms the zero answer.
845 assert_eq!(optimal_neumann_terms(0.5, 0.0, 1.0, 1e-8).unwrap(), 1);
846 }
847
848 #[test]
849 fn optimal_terms_loose_tolerance_returns_one() {
850 // y0 = 2, tolerance = 10 → ratio < 1, no iteration needed beyond term 0.
851 assert_eq!(optimal_neumann_terms(0.5, 10.0, 5.0, 10.0).unwrap(), 1);
852 }
853
854 // ── approximate_spectral_radius + optimal_neumann_terms_with_rho ──
855
856 #[test]
857 fn spectral_radius_on_diagonal_matrix_is_zero() {
858 // A = D → O = 0 → ρ(D⁻¹O) = 0.
859 let m = build(vec![(0, 0, 5.0), (1, 1, 5.0), (2, 2, 5.0)], 3);
860 let rho = approximate_spectral_radius(&m, 10).unwrap();
861 assert!(rho < 1e-10, "diagonal matrix should give rho ~0, got {rho}");
862 }
863
864 #[test]
865 fn spectral_radius_estimate_below_loose_bound() {
866 // Strong-DD ring: diag=10, off ±0.5. Coherence = 0.9 → loose
867 // bound 1 - 0.9 = 0.1. Actual ρ should be at or below that.
868 let n = 8;
869 let mut t = Vec::new();
870 for i in 0..n {
871 t.push((i, i, 10.0));
872 t.push((i, (i + 1) % n, 0.5));
873 t.push((i, (i + n - 1) % n, -0.5));
874 }
875 let m = build(t, n);
876 let coh = coherence_score(&m);
877 let loose = 1.0 - coh;
878 let rho = approximate_spectral_radius(&m, 20).unwrap();
879 assert!(rho <= loose + 1e-9, "rho={rho} should be ≤ loose={loose}");
880 assert!(rho > 0.0);
881 }
882
883 #[test]
884 fn spectral_radius_zero_iters_returns_none() {
885 let m = build(vec![(0, 0, 5.0), (1, 1, 5.0)], 2);
886 assert!(approximate_spectral_radius(&m, 0).is_none());
887 }
888
889 #[test]
890 fn spectral_radius_zero_diagonal_returns_none() {
891 // Row 1 has no diagonal entry.
892 let m = build(vec![(0, 0, 5.0), (1, 0, 1.0)], 2);
893 assert!(approximate_spectral_radius(&m, 10).is_none());
894 }
895
896 #[test]
897 fn optimal_terms_with_rho_beats_coherence_path_on_strong_matrix() {
898 // On a strong-DD matrix, the tight ρ from power iteration
899 // should produce a STRICTLY SMALLER k than the loose
900 // (1-coherence)-based path.
901 let n = 8;
902 let mut t = Vec::new();
903 for i in 0..n {
904 t.push((i, i, 10.0));
905 t.push((i, (i + 1) % n, 0.5));
906 t.push((i, (i + n - 1) % n, -0.5));
907 }
908 let m = build(t, n);
909 let coh = coherence_score(&m);
910 let rho = approximate_spectral_radius(&m, 30).unwrap();
911
912 let b_inf = 10.0;
913 let min_diag = 10.0;
914 let tol = 1e-8;
915 let k_loose = optimal_neumann_terms(coh, b_inf, min_diag, tol).unwrap();
916 let k_tight = optimal_neumann_terms_with_rho(rho, b_inf, min_diag, tol).unwrap();
917
918 assert!(
919 k_tight <= k_loose,
920 "tight (rho={rho}) k={k_tight} should be ≤ loose (1-c={}) k={k_loose}",
921 1.0 - coh
922 );
923 }
924
925 #[test]
926 fn optimal_terms_with_rho_rejects_invalid_inputs() {
927 assert!(optimal_neumann_terms_with_rho(0.0, 1.0, 1.0, 1e-6).is_none());
928 assert!(optimal_neumann_terms_with_rho(-0.1, 1.0, 1.0, 1e-6).is_none());
929 assert!(optimal_neumann_terms_with_rho(1.0, 1.0, 1.0, 1e-6).is_none());
930 assert!(optimal_neumann_terms_with_rho(0.5, 1.0, 0.0, 1e-6).is_none());
931 assert!(optimal_neumann_terms_with_rho(0.5, 1.0, 1.0, 0.0).is_none());
932 }
933
934 // ── CoherenceCache tests ──────────────────────────────────────────
935
936 #[test]
937 fn cache_build_matches_coherence_score() {
938 let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
939 let cache = CoherenceCache::build(&m);
940 let expected = coherence_score(&m);
941 assert!((cache.score() - expected).abs() < 1e-12);
942 }
943
944 #[test]
945 fn cache_update_with_empty_dirty_is_noop() {
946 let m = build(vec![(0, 0, 5.0), (1, 1, 5.0)], 2);
947 let mut cache = CoherenceCache::build(&m);
948 let before = cache.score();
949 cache.update(&m, &[]);
950 assert_eq!(cache.score(), before);
951 }
952
953 #[test]
954 fn cache_update_drops_score_when_dirty_row_loses_dominance() {
955 // Initial: diag 5, off 1 → margin 0.8 on every row.
956 let m = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
957 let mut cache = CoherenceCache::build(&m);
958 assert!((cache.score() - 0.8).abs() < 1e-12);
959
960 // Mutate row 0 so its off-diagonal grows. New margin = (5-3)/5 = 0.4.
961 let m2 = build(vec![(0, 0, 5.0), (0, 1, 3.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
962 cache.update(&m2, &[0]);
963 assert!((cache.score() - 0.4).abs() < 1e-12);
964 assert_eq!(cache.min_row(), 0);
965 }
966
967 #[test]
968 fn cache_update_recovers_score_after_min_row_improves() {
969 // Row 0 starts as the worst (margin 0.4); row 1 has margin 0.8.
970 let m = build(vec![(0, 0, 5.0), (0, 1, 3.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
971 let mut cache = CoherenceCache::build(&m);
972 assert!((cache.score() - 0.4).abs() < 1e-12);
973 assert_eq!(cache.min_row(), 0);
974
975 // Heal row 0 so its margin matches row 1's (0.8). Global min
976 // must rise to 0.8 — exercises the rare full-rescan path.
977 let m2 = build(vec![(0, 0, 5.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 5.0)], 2);
978 cache.update(&m2, &[0]);
979 assert!((cache.score() - 0.8).abs() < 1e-12);
980 }
981
982 #[test]
983 fn cache_update_drops_out_of_bound_dirty_rows() {
984 let m = build(vec![(0, 0, 5.0), (1, 1, 5.0)], 2);
985 let mut cache = CoherenceCache::build(&m);
986 let before = cache.score();
987 cache.update(&m, &[99, 100]); // OOB, should be silently dropped
988 assert_eq!(cache.score(), before);
989 }
990
991 #[test]
992 fn cache_matches_full_score_after_arbitrary_updates() {
993 // Sanity property: after any sequence of dirty updates, the
994 // cached score equals what a fresh coherence_score would
995 // compute on the same matrix.
996 let m = build(
997 vec![
998 (0, 0, 5.0),
999 (0, 1, 2.0),
1000 (1, 0, 1.0),
1001 (1, 1, 5.0),
1002 (1, 2, 1.0),
1003 (2, 1, 1.0),
1004 (2, 2, 5.0),
1005 ],
1006 3,
1007 );
1008 let mut cache = CoherenceCache::build(&m);
1009
1010 // Imagine we mutated row 1 — re-pass the same matrix at row 1.
1011 // (No-op semantically, but exercises the update path.)
1012 cache.update(&m, &[1]);
1013 assert!((cache.score() - coherence_score(&m)).abs() < 1e-12);
1014
1015 // Mutate to an entirely different matrix and update every row.
1016 let m2 = build(
1017 vec![
1018 (0, 0, 5.0),
1019 (0, 1, 0.5),
1020 (1, 0, 0.5),
1021 (1, 1, 5.0),
1022 (1, 2, 0.5),
1023 (2, 1, 0.5),
1024 (2, 2, 5.0),
1025 ],
1026 3,
1027 );
1028 cache.update(&m2, &[0, 1, 2]);
1029 assert!((cache.score() - coherence_score(&m2)).abs() < 1e-12);
1030 }
1031}