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/// 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}