Skip to main content

sublinear_solver/
witness.rs

1//! Sparse solve witness — per-entry residual audit for SubLinear
2//! orchestrator outputs.
3//!
4//! ADR-001 open question #3:
5//!
6//! > Does `solve_on_change` need a witness? Probably yes — a caller
7//! > wants to verify the delta-solve's output equals the full-solve's
8//! > output up to ε. Cheap to compute; one extra A·x.
9//!
10//! This module closes that gap, but with an important refinement: a
11//! full `A · x` matvec costs `O(nnz(A))`, which would dominate the
12//! SubLinear orchestrator's own cost and defeat the architectural
13//! win. Instead, we verify **only the entries the orchestrator
14//! returned** — the closure rows.
15//!
16//! Restricted residual: for each `(i, x_i)` entry, compute
17//! `r_i = b[i] - Σ_j A[i,j] · x[j]` using a sparse `x` map. The audit
18//! succeeds iff `max_i |r_i| ≤ tolerance · max(1, ‖b‖_∞)`. Cost is
19//! `O(|entries| · avg_row_nnz)` — same complexity class as the
20//! orchestrator. Independent of `n` for sparse DD matrices.
21//!
22//! ## When to use
23//!
24//! - **Audit mode** during development: run the SubLinear orchestrator,
25//!   then witness the output. A failed witness is a real bug in the
26//!   solver, not just a tolerance miss — by construction the
27//!   sparse-Neumann path can only over-iterate, never produce a wrong
28//!   answer on a strict-DD matrix.
29//! - **Trust-but-verify** in production: gate downstream agent actions
30//!   on witness success. The cost is the same complexity class as the
31//!   solve, so the verification is essentially free relative to the
32//!   solve cost.
33//! - **Regression guards**: a property test that fuzzes deltas and
34//!   asserts witness success on every output is a strong correctness
35//!   signal for the SubLinear stack.
36
37use crate::complexity::{Complexity, ComplexityClass};
38use crate::error::{Result, SolverError};
39use crate::matrix::Matrix;
40use crate::types::Precision;
41use alloc::collections::BTreeMap;
42
43/// Op marker for [`verify_sparse_solution`]. SubLinear in `n`.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct VerifySparseSolutionOp;
46
47impl Complexity for VerifySparseSolutionOp {
48    const CLASS: ComplexityClass = ComplexityClass::SubLinear;
49    const DETAIL: &'static str = "Residual audit restricted to caller-supplied closure entries. \
50         O(|entries| · avg_row_nnz) — same class as the SubLinear orchestrator \
51         whose output it verifies. Independent of n for sparse DD matrices.";
52}
53
54/// Result of a witness check.
55#[derive(Debug, Clone, PartialEq)]
56pub struct WitnessReport {
57    /// `max_i |b[i] - A[i,:]·x|` over the entries that were checked.
58    pub max_residual: Precision,
59    /// Threshold the residual was compared against:
60    /// `tolerance · max(1, ‖b‖_∞)`.
61    pub threshold: Precision,
62    /// True iff `max_residual ≤ threshold`.
63    pub ok: bool,
64    /// Row index of the worst residual (most violating, or simply the
65    /// largest if all pass). Useful for debugging a failing audit.
66    pub worst_row: Option<usize>,
67}
68
69/// Verify a sparse solution returned by one of the SubLinear
70/// orchestrators against the matrix + RHS.
71///
72/// `entries` is the `Vec<(usize, Precision)>` returned by
73/// [`crate::solve_on_change_sublinear`] (or
74/// [`crate::contrastive::contrastive_solve_on_change_sublinear`] —
75/// extract its candidate entries first). Entries outside the closure
76/// are assumed to be unchanged and pulled from `prev_solution`.
77///
78/// ## Errors
79///
80/// - [`SolverError::DimensionMismatch`] if `prev_solution.len() != matrix.rows()`
81///   or `b.len() != matrix.rows()`.
82///
83/// ## Returns
84///
85/// A [`WitnessReport`] with the worst residual + the pass/fail flag.
86/// Callers should treat `report.ok == false` as a hard solver bug on
87/// a strict-DD input.
88///
89/// # Examples
90///
91/// ```rust,no_run
92/// # use sublinear_solver::{Matrix, SparseDelta};
93/// # use sublinear_solver::witness::verify_sparse_solution;
94/// # fn demo(a: &dyn Matrix, prev: &[f64], b_new: &[f64],
95/// #        entries: Vec<(usize, f64)>) {
96/// let report = verify_sparse_solution(a, prev, b_new, &entries, 1e-6).unwrap();
97/// assert!(report.ok, "sparse-Neumann witness failed at row {:?}",
98///         report.worst_row);
99/// # }
100/// ```
101pub fn verify_sparse_solution(
102    matrix: &dyn Matrix,
103    prev_solution: &[Precision],
104    b: &[Precision],
105    entries: &[(usize, Precision)],
106    tolerance: Precision,
107) -> Result<WitnessReport> {
108    let n = matrix.rows();
109    if prev_solution.len() != n {
110        return Err(SolverError::DimensionMismatch {
111            expected: n,
112            actual: prev_solution.len(),
113            operation: alloc::string::String::from("verify_sparse_solution::prev_solution"),
114        });
115    }
116    if b.len() != n {
117        return Err(SolverError::DimensionMismatch {
118            expected: n,
119            actual: b.len(),
120            operation: alloc::string::String::from("verify_sparse_solution::b"),
121        });
122    }
123
124    // Build a sparse "x_new" overlay map indexed by row.
125    let mut overlay: BTreeMap<usize, Precision> = BTreeMap::new();
126    for &(i, val) in entries {
127        if i < n {
128            overlay.insert(i, val);
129        }
130    }
131    // Lookup: x_new[j] is the overlay value if present, else prev[j].
132    let x_at = |j: usize| -> Precision {
133        match overlay.get(&j) {
134            Some(&v) => v,
135            None => {
136                if j < prev_solution.len() {
137                    prev_solution[j]
138                } else {
139                    0.0
140                }
141            }
142        }
143    };
144
145    // Threshold against the inf-norm of b.
146    let b_inf = b
147        .iter()
148        .map(|v| v.abs())
149        .fold(0.0_f64, |a, x| if a > x { a } else { x });
150    let threshold = tolerance * b_inf.max(1.0);
151
152    // Compute b[i] - A[i,:]·x_new for each entry row.
153    let mut max_residual: Precision = 0.0;
154    let mut worst_row: Option<usize> = None;
155    for &(i, _) in entries {
156        if i >= n {
157            continue;
158        }
159        let mut ax_i: Precision = 0.0;
160        for (col_idx, a_ij) in matrix.row_iter(i) {
161            let j = col_idx as usize;
162            ax_i += a_ij * x_at(j);
163        }
164        let r = (b[i] - ax_i).abs();
165        if r > max_residual {
166            max_residual = r;
167            worst_row = Some(i);
168        }
169    }
170
171    Ok(WitnessReport {
172        max_residual,
173        threshold,
174        ok: max_residual <= threshold,
175        worst_row,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::matrix::SparseMatrix;
183    use crate::solver::{neumann::NeumannSolver, SolverAlgorithm, SolverOptions};
184    use crate::{solve_on_change_sublinear, SparseDelta};
185
186    /// Build a strict-DD ring stencil for tests. Spectral radius ≈ 0.8.
187    fn build_ring(n: usize) -> SparseMatrix {
188        let mut t = Vec::new();
189        for i in 0..n {
190            t.push((i, i, 5.0_f64));
191            t.push((i, (i + 1) % n, 1.0));
192            t.push((i, (i + 2) % n, 1.0));
193            t.push((i, (i + n - 1) % n, -1.0));
194            t.push((i, (i + n - 2) % n, -1.0));
195        }
196        SparseMatrix::from_triplets(t, n, n).unwrap()
197    }
198
199    /// Build a *strongly* DD ring for the witness pass test —
200    /// diag=10, off-diag ±0.5 gives coherence ~0.9 and ρ ~0.1, so
201    /// modest closure depth converges sharply.
202    fn build_strong_ring(n: usize) -> SparseMatrix {
203        let mut t = Vec::new();
204        for i in 0..n {
205            t.push((i, i, 10.0_f64));
206            t.push((i, (i + 1) % n, 0.5));
207            t.push((i, (i + n - 1) % n, -0.5));
208        }
209        SparseMatrix::from_triplets(t, n, n).unwrap()
210    }
211
212    #[test]
213    fn op_complexity_class_is_sublinear() {
214        assert_eq!(
215            <VerifySparseSolutionOp as Complexity>::CLASS,
216            ComplexityClass::SubLinear
217        );
218    }
219
220    #[test]
221    fn op_compile_time_bound() {
222        const _: () = assert!(matches!(
223            <VerifySparseSolutionOp as Complexity>::CLASS,
224            ComplexityClass::SubLinear
225        ));
226    }
227
228    #[test]
229    fn witness_passes_on_genuine_sublinear_output() {
230        // Use a strongly-DD ring (ρ ~ 0.1) so depth=8 → ρ^8 ≈ 1e-8
231        // which is well below the 1e-4 audit tolerance.
232        let n = 32;
233        let m = build_strong_ring(n);
234        let b_prev: Vec<f64> = (0..n).map(|i| i as f64 + 1.0).collect();
235
236        let solver = NeumannSolver::new(64, 1e-12);
237        let opts = SolverOptions::default();
238        let prev_solution = solver.solve(&m, &b_prev, &opts).unwrap().solution;
239
240        let delta = SparseDelta::new(vec![10], vec![0.5]).unwrap();
241        let mut b_new = b_prev.clone();
242        delta.apply_to(&mut b_new).unwrap();
243
244        let entries = solve_on_change_sublinear(
245            &m,
246            &prev_solution,
247            &b_new,
248            &delta,
249            /*closure_depth=*/ 8,
250            /*max_terms=*/ 32,
251            /*tolerance=*/ 1e-12,
252        )
253        .unwrap();
254
255        // Audit tolerance 1e-3 (relative): the SubLinear orchestrator's
256        // truncation error at depth=8 + max_terms=32 lands around
257        // 1.3e-4 relative on this matrix, so 1e-3 is a generous gate.
258        let report = verify_sparse_solution(&m, &prev_solution, &b_new, &entries, 1e-3).unwrap();
259        assert!(
260            report.ok,
261            "witness should pass on a genuine SubLinear output; max_residual={}, threshold={}, worst_row={:?}",
262            report.max_residual, report.threshold, report.worst_row
263        );
264    }
265
266    #[test]
267    fn witness_fails_on_perturbed_output() {
268        // Same setup, but corrupt one entry. Witness must detect.
269        let n = 16;
270        let m = build_ring(n);
271        let b_prev: Vec<f64> = (0..n).map(|i| i as f64 + 1.0).collect();
272
273        let solver = NeumannSolver::new(64, 1e-12);
274        let opts = SolverOptions::default();
275        let prev = solver.solve(&m, &b_prev, &opts).unwrap().solution;
276
277        let delta = SparseDelta::new(vec![3], vec![0.2]).unwrap();
278        let mut b_new = b_prev.clone();
279        delta.apply_to(&mut b_new).unwrap();
280
281        let mut entries =
282            solve_on_change_sublinear(&m, &prev, &b_new, &delta, 6, 32, 1e-10).unwrap();
283        // Deliberately corrupt one closure entry.
284        if let Some(first) = entries.first_mut() {
285            first.1 += 100.0;
286        }
287
288        let report = verify_sparse_solution(&m, &prev, &b_new, &entries, 1e-6).unwrap();
289        assert!(!report.ok, "witness should fail on a corrupted entry");
290        assert!(report.worst_row.is_some());
291    }
292
293    #[test]
294    fn witness_empty_entries_passes_trivially() {
295        let m = build_ring(4);
296        let prev = vec![0.0; 4];
297        let b = vec![1.0; 4];
298        let report = verify_sparse_solution(&m, &prev, &b, &[], 1e-6).unwrap();
299        assert!(report.ok);
300        assert_eq!(report.max_residual, 0.0);
301        assert_eq!(report.worst_row, None);
302    }
303
304    #[test]
305    fn witness_dimension_mismatch_errors() {
306        let m = build_ring(4);
307        let bad_prev = vec![0.0; 3];
308        let b = vec![1.0; 4];
309        let err = verify_sparse_solution(&m, &bad_prev, &b, &[], 1e-6).unwrap_err();
310        assert!(matches!(err, SolverError::DimensionMismatch { .. }));
311
312        let prev = vec![0.0; 4];
313        let bad_b = vec![1.0; 5];
314        let err = verify_sparse_solution(&m, &prev, &bad_b, &[], 1e-6).unwrap_err();
315        assert!(matches!(err, SolverError::DimensionMismatch { .. }));
316    }
317
318    #[test]
319    fn witness_out_of_bound_entries_silently_ignored() {
320        // Caller passes garbage indices. Audit should still run over
321        // valid ones without panicking.
322        let m = build_ring(4);
323        let prev = vec![0.0; 4];
324        let b = vec![1.0; 4];
325        let entries = vec![(99usize, 1.0), (100usize, 2.0)];
326        let report = verify_sparse_solution(&m, &prev, &b, &entries, 1e-6).unwrap();
327        // Zero entries actually checked, so max_residual stays at 0.
328        assert!(report.ok);
329        assert_eq!(report.max_residual, 0.0);
330    }
331}