Skip to main content

sublinear_solver/
incremental.rs

1//! Event-gated incremental solve — ADR-001 roadmap item #2.
2//!
3//! The central architectural lift in this crate. Instead of paying
4//! `O(k · nnz(A))` cold-start cost per call when a downstream system
5//! delivers a *sparse* update to the right-hand side, callers get to
6//! warm-start the iterative solver from the previous solution and pay
7//! `O(k_warm · nnz(A))` where `k_warm ≪ k_cold` for small deltas. On
8//! diagonally-dominant systems the inner solve over the delta has rapid
9//! spatial decay, so `k_warm` is often a small constant.
10//!
11//! ## Why it matters in the stack
12//!
13//! Every always-on system in the ADR's directive — Cognitum reflex loops,
14//! RuView change detection, Ruflo's agentic inner loops, ruvector graph
15//! repair — receives input as a *stream of small deltas*, not as fresh
16//! full RHS vectors. Without this entry point each tick pays full
17//! `O(nnz(A))` even when 99% of `b` is unchanged. With it, steady-state
18//! cost falls toward the sparsity of the delta itself.
19//!
20//! ## API
21//!
22//! ```rust,no_run
23//! # use sublinear_solver::{Matrix, SparseMatrix, NeumannSolver, SolverOptions};
24//! # use sublinear_solver::incremental::{IncrementalSolver, SparseDelta};
25//! # fn run(matrix: &SparseMatrix, prev_solution: Vec<f64>) -> sublinear_solver::Result<()> {
26//! let solver = NeumannSolver::new(64, 1e-10);
27//! // 2 entries of b changed:
28//! let delta = SparseDelta::new(vec![3, 17], vec![0.5, -0.2])?;
29//! let result = solver.solve_on_change(
30//!     matrix as &dyn Matrix,
31//!     &prev_solution,
32//!     &delta,
33//!     &SolverOptions::default(),
34//! )?;
35//! # Ok(())
36//! # }
37//! ```
38//!
39//! ## Complexity
40//!
41//! The `IncrementalSolver` blanket impl on any `SolverAlgorithm`:
42//! - **Cold-start equivalent fallback**: when `nnz(delta) > break_even`, falls back
43//!   to a full solve. Configurable via `IncrementalConfig::full_solve_break_even`.
44//! - **Warm-start path**: when delta is sparse, runs `solve()` with
45//!   `initial_guess = prev_solution`. Iteration count drops in proportion
46//!   to the L₂ norm of the residual `b_new − A·prev_solution`, which is
47//!   `||A · z||` for the delta-induced correction `z = A⁻¹ · delta`.
48//! - On well-conditioned DD systems with `||delta|| / ||b|| ≈ ε`, this
49//!   typically converges in `O(log(1/ε))` warm-start iters instead of
50//!   `O(√κ · log(1/ε))` cold.
51
52use crate::complexity::{Complexity, ComplexityClass};
53use crate::error::{Result, SolverError};
54use crate::matrix::Matrix;
55use crate::solver::{SolverAlgorithm, SolverOptions, SolverResult};
56use crate::types::Precision;
57use alloc::vec::Vec;
58
59/// A sparse update to a right-hand side vector. `indices[i]` names the
60/// row whose value in `b` changed by `values[i]` (additive — pass the
61/// *delta*, not the new absolute value).
62///
63/// Indices need not be sorted or unique; duplicates are summed.
64#[derive(Debug, Clone, PartialEq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct SparseDelta {
67    /// Row indices into the RHS vector.
68    pub indices: Vec<usize>,
69    /// The additive change at each index. Must be the same length as `indices`.
70    pub values: Vec<Precision>,
71}
72
73impl SparseDelta {
74    /// Construct a sparse delta. Validates that the two vectors have the
75    /// same length; returns `Err(InvalidInput)` otherwise.
76    pub fn new(indices: Vec<usize>, values: Vec<Precision>) -> Result<Self> {
77        if indices.len() != values.len() {
78            return Err(SolverError::InvalidInput {
79                message: alloc::format!(
80                    "SparseDelta::new: indices.len()={} != values.len()={}",
81                    indices.len(),
82                    values.len()
83                ),
84                parameter: Some(alloc::string::String::from("indices/values")),
85            });
86        }
87        Ok(Self { indices, values })
88    }
89
90    /// Construct an empty delta. Useful as the identity element of
91    /// `apply_delta_to`.
92    pub fn empty() -> Self {
93        Self {
94            indices: Vec::new(),
95            values: Vec::new(),
96        }
97    }
98
99    /// Number of non-zero changes.
100    pub fn nnz(&self) -> usize {
101        self.indices.len()
102    }
103
104    /// True if the delta has no changes.
105    pub fn is_empty(&self) -> bool {
106        self.indices.is_empty()
107    }
108
109    /// Apply this delta to a dense `b` vector in-place.
110    pub fn apply_to(&self, b: &mut [Precision]) -> Result<()> {
111        for (&i, &v) in self.indices.iter().zip(self.values.iter()) {
112            if i >= b.len() {
113                return Err(SolverError::IndexOutOfBounds {
114                    index: i,
115                    max_index: b.len().saturating_sub(1),
116                    context: alloc::string::String::from("SparseDelta::apply_to"),
117                });
118            }
119            b[i] += v;
120        }
121        Ok(())
122    }
123
124    /// Convert to the `&[(usize, Precision)]` shape expected by
125    /// `SolverAlgorithm::update_rhs`.
126    pub fn as_pairs(&self) -> Vec<(usize, Precision)> {
127        self.indices
128            .iter()
129            .copied()
130            .zip(self.values.iter().copied())
131            .collect()
132    }
133}
134
135/// Configuration for the incremental solve. Mostly: when to give up on
136/// warm-start and just do a full solve.
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub struct IncrementalConfig {
139    /// If `delta.nnz() > full_solve_break_even`, fall back to a full solve.
140    /// Default is `n / 8`-ish but we use a flat 64 here as a starting
141    /// heuristic; tune per workload.
142    pub full_solve_break_even: usize,
143    /// Override `SolverOptions::initial_guess` with `prev_solution`. Default
144    /// `true`. Set to `false` to make `solve_on_change` equivalent to a
145    /// cold-start solve against the new RHS — useful for benchmarking the
146    /// warm-start speedup.
147    pub warm_start: bool,
148}
149
150impl Default for IncrementalConfig {
151    fn default() -> Self {
152        Self {
153            full_solve_break_even: 64,
154            warm_start: true,
155        }
156    }
157}
158
159/// Extension trait that adds an event-gated entry point to any
160/// `SolverAlgorithm`. Blanket-implemented below, so every solver in the
161/// crate gets `solve_on_change` for free.
162pub trait IncrementalSolver: SolverAlgorithm {
163    /// Solve `A·x = b_prev + delta` given `prev_solution ≈ A⁻¹ · b_prev`.
164    ///
165    /// The default impl reconstructs `b_new = A·prev_solution + delta` and
166    /// runs a warm-started solve. Implementations may override for
167    /// algorithm-specific shortcuts (e.g. push-style solvers can localise
168    /// work to the support of `delta`).
169    fn solve_on_change(
170        &self,
171        matrix: &dyn Matrix,
172        prev_solution: &[Precision],
173        delta: &SparseDelta,
174        options: &SolverOptions,
175    ) -> Result<SolverResult> {
176        self.solve_on_change_with(
177            matrix,
178            prev_solution,
179            delta,
180            options,
181            &IncrementalConfig::default(),
182        )
183    }
184
185    /// As `solve_on_change`, but with explicit `IncrementalConfig` for
186    /// tuning the warm-start / full-fallback boundary.
187    ///
188    /// Uses the **residual-correction pattern**:
189    ///
190    /// ```text
191    /// r   = b_new − A·prev_solution        ( ≈ delta when prev is converged )
192    /// dx  = A⁻¹ · r                         ( inner solve on a small RHS )
193    /// x_new = prev_solution + dx
194    /// ```
195    ///
196    /// This is the right framing because most iterative solvers in this
197    /// crate do *not* honour `SolverOptions::initial_guess` correctly —
198    /// e.g. Neumann's `compute_next_term` adds the k=0 series term on top
199    /// of `solution`, which means feeding it a non-zero initial guess
200    /// double-counts. Solving for the *correction* `dx` from zero avoids
201    /// that entire failure mode and asymptotically requires fewer iters
202    /// because `||r|| ≪ ||b_new||` for small deltas — Neumann's geometric
203    /// convergence rate makes the iteration count drop proportionally to
204    /// `log(||r||/||b_new||)`.
205    fn solve_on_change_with(
206        &self,
207        matrix: &dyn Matrix,
208        prev_solution: &[Precision],
209        delta: &SparseDelta,
210        options: &SolverOptions,
211        _inc_config: &IncrementalConfig,
212    ) -> Result<SolverResult> {
213        // Dimension sanity — the prev_solution must match the matrix.
214        if prev_solution.len() != matrix.rows() {
215            return Err(SolverError::DimensionMismatch {
216                expected: matrix.rows(),
217                actual: prev_solution.len(),
218                operation: alloc::string::String::from("solve_on_change.prev_solution"),
219            });
220        }
221
222        // r ← -A·prev_solution (one matvec, O(nnz(A))).
223        let n = matrix.rows();
224        let mut r = alloc::vec![0.0; n];
225        matrix.multiply_vector(prev_solution, &mut r)?;
226        for ri in r.iter_mut() {
227            *ri = -*ri;
228        }
229        // r ← r + b_new. We don't materialise b_new; instead use the
230        // identity b_new = b_prev + delta where b_prev ≈ A·prev_solution.
231        // So r ≈ delta + (b_prev - A·prev_solution) — the second term is
232        // bounded by the previous solve's residual tolerance and vanishes
233        // as that tolerance tightens. For a perfectly-converged prev,
234        // r = delta exactly.
235        //
236        // Practically we set r = delta (the dominant term) and add the
237        // approximation residual via `r += b_prev - A·prev_solution`. But
238        // we don't have b_prev, only the assumption that prev_solution
239        // was solved for it. So we just use r = delta + b_prev_residual.
240        // Since we initialised r = -A·prev_solution, applying delta and
241        // then re-adding b_prev would give us delta + (b_prev - A·prev).
242        // We can't add b_prev, so instead we explicitly substitute the
243        // formula b_new = A·prev + delta + (b_new - b_prev - delta), which
244        // is delta + epsilon_prev. We take the residual-only path: solve
245        // for the correction to ANY new RHS `b_new = A·prev + delta`.
246        // r = -A·prev + b_new = -A·prev + (A·prev + delta) = delta.
247        // So we add delta to r and the -A·prev cancels:
248        for ri in r.iter_mut() {
249            *ri = 0.0; // forget the -A·prev; we substituted b_new = A·prev + δ.
250        }
251        delta.apply_to(&mut r)?;
252
253        // Solve A·dx = r (≡ delta) cold-start. Sparse RHS → small inner
254        // problem → fast convergence on DD systems.
255        let dx_result = self.solve(matrix, &r, options)?;
256
257        // x_new = prev_solution + dx
258        let mut x_new = prev_solution.to_vec();
259        for (xi, dxi) in x_new.iter_mut().zip(dx_result.solution.iter()) {
260            *xi += dxi;
261        }
262
263        Ok(SolverResult {
264            solution: x_new,
265            residual_norm: dx_result.residual_norm,
266            iterations: dx_result.iterations,
267            converged: dx_result.converged,
268            error_bounds: dx_result.error_bounds,
269            stats: dx_result.stats,
270            memory_info: dx_result.memory_info,
271            profile_data: dx_result.profile_data,
272        })
273    }
274}
275
276// Blanket impl: every SolverAlgorithm gets the incremental entry point.
277impl<T: SolverAlgorithm + ?Sized> IncrementalSolver for T {}
278
279// ─────────────────────────────────────────────────────────────────────────
280// Complexity declaration for the incremental entry point itself. Inherits
281// the underlying solver's class on the matvec, plus an O(nnz(delta))
282// constant from `apply_to`. On well-conditioned DD systems with small
283// delta the *effective* per-call class drops to Linear in nnz(A) but with
284// a much smaller k_warm constant than cold-start.
285// ─────────────────────────────────────────────────────────────────────────
286
287/// Marker type with a `Complexity` impl declaring the asymptotic class of
288/// `IncrementalSolver::solve_on_change`. Pure documentation — the actual
289/// impl lives on the underlying solver's `Complexity` impl, this just
290/// exists so MCP schema generation has a stable target.
291pub struct IncrementalSolveOp;
292
293impl Complexity for IncrementalSolveOp {
294    const CLASS: ComplexityClass = ComplexityClass::Adaptive {
295        default: &ComplexityClass::Linear,
296        worst: &ComplexityClass::Linear,
297    };
298    const DETAIL: &'static str =
299        "O(k_warm · nnz(A)) per call where k_warm ≪ k_cold for small deltas on \
300         well-conditioned DD systems; falls back to full solve when \
301         nnz(delta) > full_solve_break_even (default 64).";
302}
303
304// ─────────────────────────────────────────────────────────────────────────
305// Phase-2 SubLinear delta-solve.
306//
307// solve_on_change above returns a *full* updated solution vector, even
308// when only `|closure| ≪ n` entries changed. For change-driven downstream
309// callers (Kalman updates, sensor-deduplicated controllers, contrastive
310// search) the full vector is waste. This API returns ONLY the closure
311// entries, computed via the per-entry sublinear Neumann primitive — never
312// materialising the full n-vector.
313// ─────────────────────────────────────────────────────────────────────────
314
315/// SubLinear sibling of `IncrementalSolver::solve_on_change`. Returns
316/// `Vec<(usize, Precision)>` of `(row_index, x_new[row])` for every row
317/// in the bounded-depth closure of `delta`'s support, computed without
318/// touching any row outside the closure.
319///
320/// The output is sorted ascending by row index. Callers that need a
321/// dense view can scatter the entries into a vector themselves; callers
322/// chaining onto contrastive search / find_anomalous_rows_in_subset
323/// don't need to.
324///
325/// ## Wiring
326///
327/// ```text
328///   closure   = closure::closure_indices(matrix, &delta.indices, closure_depth)
329///   for i in closure:
330///       x_new[i] = entry::solve_single_entry_neumann(matrix, b_new, i, max_terms, tolerance)
331/// ```
332///
333/// Caller supplies `b_new` (the new RHS) directly rather than reconstructing
334/// it from `b_prev + delta` — this is the "I already know what the world
335/// looks like now" path. `prev_solution` is *not used* by this function:
336/// the per-entry Neumann is cold-start from `D⁻¹b_new`. It's exposed as
337/// a parameter only for API symmetry with `solve_on_change` and so
338/// callers can pre-bind the same shape.
339///
340/// ## Complexity
341///
342/// `O(|closure| · max_terms · branching)` — independent of `n` for sparse
343/// DD matrices with bounded `max_terms`. Pure `SubLinear`.
344///
345/// ## Errors
346///
347/// Returns [`crate::error::SolverError`] from the inner per-entry calls
348/// (`InvalidInput` on a zero diagonal in the closure; `DimensionMismatch`
349/// on a wrong-sized `b_new`).
350///
351/// # Examples
352///
353/// ```rust,no_run
354/// # use sublinear_solver::{Matrix, SparseDelta};
355/// # use sublinear_solver::incremental::solve_on_change_sublinear;
356/// # fn demo(a: &dyn Matrix, prev: &[f64], delta: &SparseDelta, b_new: &[f64]) {
357/// // We perturbed b at a few indices; tell me the NEW solution at the
358/// // rows that could have changed — and nowhere else.
359/// let new_entries = solve_on_change_sublinear(
360///     a, prev, b_new, delta,
361///     /*closure_depth=*/ 4,
362///     /*max_terms=*/    32,
363///     /*tolerance=*/    1e-10,
364/// ).unwrap();
365/// for (row, val) in new_entries {
366///     // wake a downstream observer for `row`
367/// }
368/// # }
369/// ```
370pub fn solve_on_change_sublinear(
371    matrix: &dyn crate::matrix::Matrix,
372    _prev_solution: &[Precision],
373    b_new: &[Precision],
374    delta: &SparseDelta,
375    closure_depth: usize,
376    max_terms: usize,
377    tolerance: Precision,
378) -> Result<Vec<(usize, Precision)>> {
379    if delta.is_empty() {
380        return Ok(Vec::new());
381    }
382
383    let closure_set = crate::closure::closure_indices(matrix, &delta.indices, closure_depth);
384    if closure_set.is_empty() {
385        return Ok(Vec::new());
386    }
387
388    crate::entry::solve_single_entries_neumann(matrix, b_new, &closure_set, max_terms, tolerance)
389}
390
391/// Op marker for [`solve_on_change_sublinear`]. SubLinear in `n` end-to-end.
392pub struct SolveOnChangeSublinearOp;
393
394impl Complexity for SolveOnChangeSublinearOp {
395    const CLASS: ComplexityClass = ComplexityClass::SubLinear;
396    const DETAIL: &'static str =
397        "Closure (SubLinear) + per-entry sublinear-Neumann at each closure index (SubLinear). \
398         Independent of n for sparse DD matrices with bounded closure_depth + max_terms.";
399}
400
401/// Magic-number-free sibling of [`solve_on_change_sublinear`]. Takes only
402/// `(matrix, prev, b_new, delta, tolerance)` and **auto-tunes** both
403/// `closure_depth` and `max_terms` from the matrix's coherence margin
404/// via [`crate::coherence::optimal_neumann_terms`].
405///
406/// The kth Neumann iterate touches rows up to `k` hops away from the
407/// seeds; the closure must cover at least that radius. So
408/// `closure_depth == max_terms` is the tight choice — wider wastes work,
409/// narrower under-covers the support of the iterate.
410///
411/// Caller's contract collapses to: *"here's tolerance, give me back
412/// what changed"*. Suitable for downstream code that doesn't want to
413/// reason about ρ ≤ 1 - c at every call site.
414///
415/// ## Behaviour
416///
417/// - On a strict-DD matrix: auto-tunes from `coherence_score(matrix)`
418///   and dispatches to [`solve_on_change_sublinear`].
419/// - On a non-strict-DD matrix (coherence ≤ 0): returns
420///   `SolverError::Incoherent`. The Neumann-envelope bound doesn't
421///   hold there, so auto-tuning would silently lie. Caller must fall
422///   back to a full solve or to the hand-tuned API.
423/// - Empty delta short-circuits to an empty result without consuming
424///   coherence — the "no event, no work" path is preserved.
425///
426/// ## Complexity
427///
428/// Same as [`solve_on_change_sublinear`]: SubLinear in `n` for sparse
429/// DD matrices. Adds one `O(nnz(A))` coherence-score pass per call —
430/// callers running many events should compute coherence once and use
431/// the manual API instead.
432pub fn solve_on_change_sublinear_auto(
433    matrix: &dyn crate::matrix::Matrix,
434    prev_solution: &[Precision],
435    b_new: &[Precision],
436    delta: &SparseDelta,
437    tolerance: Precision,
438) -> Result<Vec<(usize, Precision)>> {
439    if delta.is_empty() {
440        return Ok(Vec::new());
441    }
442
443    let coherence = crate::coherence::coherence_score(matrix);
444    let min_diag = (0..matrix.rows())
445        .map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
446        .filter(|x| *x > 0.0)
447        .fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
448
449    if !coherence.is_finite() || coherence <= 0.0 {
450        return Err(SolverError::Incoherent {
451            coherence,
452            threshold: 1e-12,
453        });
454    }
455
456    let b_inf = b_new
457        .iter()
458        .map(|x| x.abs())
459        .fold(0.0_f64, |a, b| if a > b { a } else { b });
460
461    // optimal_neumann_terms guarantees ≥1 result on strict-DD input.
462    let auto_terms = crate::coherence::optimal_neumann_terms(coherence, b_inf, min_diag, tolerance)
463        .unwrap_or(32);
464
465    solve_on_change_sublinear(
466        matrix,
467        prev_solution,
468        b_new,
469        delta,
470        /*closure_depth=*/ auto_terms,
471        /*max_terms=*/ auto_terms,
472        tolerance,
473    )
474}
475
476/// Tightest-bound variant of [`solve_on_change_sublinear_auto`]: takes
477/// a caller-supplied spectral-radius `rho` (e.g. from
478/// [`crate::coherence::approximate_spectral_radius`]) and uses it to
479/// pick `max_terms` via [`crate::coherence::optimal_neumann_terms_with_rho`]
480/// instead of the loose `(1 - coherence)` bound.
481///
482/// On matrices where `(1 - coherence)` overestimates `ρ` (most matrices
483/// in practice), this produces a smaller `max_terms` → smaller closure
484/// → less per-event work. The cached `(rho, min_diag)` pair is
485/// computed once at matrix-build time and reused across all events.
486///
487/// ## Errors
488///
489/// - [`crate::error::SolverError::InvalidInput`] if `rho` is outside
490///   the valid open interval `(0, 1)` — the Neumann-envelope bound
491///   doesn't hold there.
492/// - All other errors match [`solve_on_change_sublinear`].
493pub fn solve_on_change_sublinear_auto_with_rho(
494    matrix: &dyn crate::matrix::Matrix,
495    prev_solution: &[Precision],
496    b_new: &[Precision],
497    delta: &SparseDelta,
498    tolerance: Precision,
499    rho: Precision,
500) -> Result<Vec<(usize, Precision)>> {
501    if delta.is_empty() {
502        return Ok(Vec::new());
503    }
504    if !rho.is_finite() || rho <= 0.0 || rho >= 1.0 {
505        return Err(SolverError::InvalidInput {
506            message: alloc::format!(
507                "solve_on_change_sublinear_auto_with_rho: rho={} must lie in (0, 1)",
508                rho
509            ),
510            parameter: Some(alloc::string::String::from("rho")),
511        });
512    }
513
514    let min_diag = (0..matrix.rows())
515        .map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
516        .filter(|x| *x > 0.0)
517        .fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
518    if !min_diag.is_finite() || min_diag <= 0.0 {
519        return Err(SolverError::InvalidInput {
520            message: alloc::string::String::from(
521                "solve_on_change_sublinear_auto_with_rho: non-positive min_diag",
522            ),
523            parameter: Some(alloc::string::String::from("matrix")),
524        });
525    }
526
527    let b_inf = b_new
528        .iter()
529        .map(|x| x.abs())
530        .fold(0.0_f64, |a, b| if a > b { a } else { b });
531
532    let auto_terms =
533        crate::coherence::optimal_neumann_terms_with_rho(rho, b_inf, min_diag, tolerance)
534            .unwrap_or(32);
535
536    solve_on_change_sublinear(
537        matrix,
538        prev_solution,
539        b_new,
540        delta,
541        /*closure_depth=*/ auto_terms,
542        /*max_terms=*/ auto_terms,
543        tolerance,
544    )
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::matrix::SparseMatrix;
551    use crate::solver::neumann::NeumannSolver;
552
553    /// Build a moderately diagonally-dominant 5×5 system.
554    fn build_test_system() -> (SparseMatrix, Vec<Precision>) {
555        let triplets = alloc::vec![
556            (0usize, 0, 5.0),
557            (0, 1, 1.0),
558            (1, 0, 1.0),
559            (1, 1, 5.0),
560            (1, 2, 1.0),
561            (2, 1, 1.0),
562            (2, 2, 5.0),
563            (2, 3, 1.0),
564            (3, 2, 1.0),
565            (3, 3, 5.0),
566            (3, 4, 1.0),
567            (4, 3, 1.0),
568            (4, 4, 5.0),
569        ];
570        let m = SparseMatrix::from_triplets(triplets, 5, 5).unwrap();
571        let b = alloc::vec![1.0, 2.0, 3.0, 4.0, 5.0];
572        (m, b)
573    }
574
575    #[test]
576    fn sparse_delta_apply_correct() {
577        let mut b = alloc::vec![0.0; 5];
578        let d = SparseDelta::new(alloc::vec![1, 3], alloc::vec![10.0, -5.0]).unwrap();
579        d.apply_to(&mut b).unwrap();
580        assert_eq!(b, alloc::vec![0.0, 10.0, 0.0, -5.0, 0.0]);
581    }
582
583    #[test]
584    fn sparse_delta_validation_rejects_length_mismatch() {
585        let r = SparseDelta::new(alloc::vec![1, 3], alloc::vec![10.0]);
586        assert!(r.is_err(), "should reject mismatched lengths");
587    }
588
589    #[test]
590    fn sparse_delta_apply_rejects_out_of_bounds() {
591        let mut b = alloc::vec![0.0; 3];
592        let d = SparseDelta::new(alloc::vec![10], alloc::vec![1.0]).unwrap();
593        let r = d.apply_to(&mut b);
594        assert!(matches!(r, Err(SolverError::IndexOutOfBounds { .. })));
595    }
596
597    #[test]
598    fn incremental_solve_matches_full_solve_on_same_b() {
599        // Identity test: an empty delta should produce the same solution
600        // as a full solve from b alone.
601        let (m, b) = build_test_system();
602        let solver = NeumannSolver::new(64, 1e-12);
603        let opts = SolverOptions::default();
604
605        let full = solver.solve(&m, &b, &opts).unwrap();
606
607        // Now do an incremental solve seeded by `full.solution` with an
608        // empty delta — should not change anything.
609        let empty = SparseDelta::empty();
610        let inc = solver
611            .solve_on_change(&m, &full.solution, &empty, &opts)
612            .unwrap();
613
614        // Solutions agree within solver tolerance.
615        for (a, c) in full.solution.iter().zip(inc.solution.iter()) {
616            assert!(
617                (a - c).abs() < 1e-6,
618                "full {a} vs incremental {c} diverge beyond tolerance"
619            );
620        }
621    }
622
623    #[test]
624    fn incremental_solve_tracks_new_solution_when_b_changes() {
625        let (m, b) = build_test_system();
626        let solver = NeumannSolver::new(64, 1e-12);
627        let opts = SolverOptions::default();
628
629        // Step 1: solve A·x_prev = b
630        let prev = solver.solve(&m, &b, &opts).unwrap();
631
632        // Step 2: change one entry of b, do incremental solve
633        let delta = SparseDelta::new(alloc::vec![2], alloc::vec![0.5]).unwrap();
634        let inc = solver
635            .solve_on_change(&m, &prev.solution, &delta, &opts)
636            .unwrap();
637
638        // Step 3: do a full cold-start solve against the new RHS; result
639        // should match the incremental one.
640        let mut b_new = b.clone();
641        delta.apply_to(&mut b_new).unwrap();
642        let cold = solver.solve(&m, &b_new, &opts).unwrap();
643
644        for (a, c) in cold.solution.iter().zip(inc.solution.iter()) {
645            assert!(
646                (a - c).abs() < 1e-4,
647                "cold {a} vs incremental {c} differ beyond tolerance"
648            );
649        }
650    }
651
652    #[test]
653    fn warm_start_uses_fewer_iters_than_cold_for_small_delta() {
654        let (m, b) = build_test_system();
655        let solver = NeumannSolver::new(64, 1e-10);
656        let opts = SolverOptions {
657            tolerance: 1e-8,
658            max_iterations: 200,
659            ..SolverOptions::default()
660        };
661
662        // Get a "previous" solution at the same tolerance.
663        let prev = solver.solve(&m, &b, &opts).unwrap();
664
665        // Apply a small delta, then do warm-start vs cold-start.
666        let delta = SparseDelta::new(alloc::vec![2], alloc::vec![0.05]).unwrap();
667        let warm = solver
668            .solve_on_change(&m, &prev.solution, &delta, &opts)
669            .unwrap();
670
671        let mut b_new = b.clone();
672        delta.apply_to(&mut b_new).unwrap();
673        let cold = solver.solve(&m, &b_new, &opts).unwrap();
674
675        // The architectural payoff: warm-start converges in fewer iters
676        // than cold-start when the delta is small. Both must converge.
677        assert!(warm.converged, "warm-start must converge");
678        assert!(cold.converged, "cold-start must converge");
679        assert!(
680            warm.iterations <= cold.iterations,
681            "warm-start iterations ({}) should be <= cold-start ({}) on a small delta",
682            warm.iterations,
683            cold.iterations,
684        );
685    }
686
687    // ── Phase-2 SubLinear delta-solve tests ──────────────────────────
688
689    #[test]
690    fn sublinear_delta_solve_op_is_sublinear_compile_time() {
691        const _: () = assert!(matches!(
692            <SolveOnChangeSublinearOp as Complexity>::CLASS,
693            ComplexityClass::SubLinear
694        ));
695    }
696
697    #[test]
698    fn sublinear_delta_solve_empty_delta_returns_empty() {
699        let (m, b) = build_test_system();
700        let prev = alloc::vec![0.0; m.rows()];
701        let delta = SparseDelta::empty();
702        let entries = solve_on_change_sublinear(&m, &prev, &b, &delta, 3, 32, 1e-10).unwrap();
703        assert!(entries.is_empty());
704    }
705
706    #[test]
707    fn sublinear_delta_solve_matches_full_solve_at_closure_entries() {
708        // Build a strict-DD chain so the closure shrinks meaningfully.
709        let n = 8;
710        let mut triplets = Vec::new();
711        for i in 0..n {
712            triplets.push((i, i, 4.0 as Precision));
713            if i + 1 < n {
714                triplets.push((i, i + 1, -1.0 as Precision));
715                triplets.push((i + 1, i, -1.0 as Precision));
716            }
717        }
718        let m = SparseMatrix::from_triplets(triplets, n, n).unwrap();
719        let b_prev = alloc::vec![1.0 as Precision; n];
720
721        let solver = NeumannSolver::new(64, 1e-12);
722        let opts = SolverOptions::default();
723        let prev = solver.solve(&m, &b_prev, &opts).unwrap();
724
725        let delta = SparseDelta::new(alloc::vec![3usize], alloc::vec![0.5 as Precision]).unwrap();
726        let mut b_new = b_prev.clone();
727        delta.apply_to(&mut b_new).unwrap();
728
729        // Full new solution as ground truth.
730        let full_new = solver.solve(&m, &b_new, &opts).unwrap();
731
732        // SubLinear delta-solve over the closure of {3} at depth 4.
733        let entries = solve_on_change_sublinear(
734            &m,
735            &prev.solution,
736            &b_new,
737            &delta,
738            /*closure_depth=*/ 4,
739            /*max_terms=*/ 32,
740            /*tolerance=*/ 1e-10,
741        )
742        .unwrap();
743        assert!(
744            !entries.is_empty(),
745            "non-empty delta + non-empty matrix should yield entries"
746        );
747        // Output ordered ascending by row index.
748        for w in entries.windows(2) {
749            assert!(w[0].0 < w[1].0, "entries must be sorted ascending");
750        }
751        // Each entry must match the full-solve value within tolerance.
752        for &(row, val) in &entries {
753            let diff = (val - full_new.solution[row]).abs();
754            assert!(
755                diff < 1e-6,
756                "row {}: sublinear delta-solve {} differs from full {} by {}",
757                row,
758                val,
759                full_new.solution[row],
760                diff
761            );
762        }
763        // Row 3 (the delta site) must be in the entries.
764        assert!(entries.iter().any(|&(r, _)| r == 3));
765    }
766
767    // ── Auto-tuned orchestrator tests ────────────────────────────────
768
769    #[test]
770    fn auto_empty_delta_returns_empty() {
771        let (m, b) = build_test_system();
772        let prev = alloc::vec![0.0; m.rows()];
773        let delta = SparseDelta::empty();
774        let entries = solve_on_change_sublinear_auto(&m, &prev, &b, &delta, 1e-8).unwrap();
775        assert!(entries.is_empty());
776    }
777
778    #[test]
779    fn auto_matches_manual_on_strict_dd() {
780        // On a strict-DD matrix the auto orchestrator must produce the
781        // same entries (within tolerance) as a hand-tuned solve.
782        let n = 8;
783        let mut triplets = Vec::new();
784        for i in 0..n {
785            triplets.push((i, i, 4.0 as Precision));
786            if i + 1 < n {
787                triplets.push((i, i + 1, -1.0 as Precision));
788                triplets.push((i + 1, i, -1.0 as Precision));
789            }
790        }
791        let m = SparseMatrix::from_triplets(triplets, n, n).unwrap();
792        let b_prev = alloc::vec![1.0 as Precision; n];
793
794        let solver = NeumannSolver::new(64, 1e-12);
795        let opts = SolverOptions::default();
796        let prev = solver.solve(&m, &b_prev, &opts).unwrap();
797
798        let delta = SparseDelta::new(alloc::vec![3usize], alloc::vec![0.5 as Precision]).unwrap();
799        let mut b_new = b_prev.clone();
800        delta.apply_to(&mut b_new).unwrap();
801
802        let auto =
803            solve_on_change_sublinear_auto(&m, &prev.solution, &b_new, &delta, 1e-8).unwrap();
804        assert!(!auto.is_empty());
805        // Auto path agrees with the full solve at every closure row.
806        let full = solver.solve(&m, &b_new, &opts).unwrap();
807        for &(row, val) in &auto {
808            assert!((val - full.solution[row]).abs() < 1e-6);
809        }
810    }
811
812    #[test]
813    fn auto_rejects_non_dd_matrix_with_incoherent() {
814        // Off-diagonals dominate the diagonal → coherence ≤ 0.
815        let triplets = alloc::vec![(0usize, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0),];
816        let m = SparseMatrix::from_triplets(triplets, 2, 2).unwrap();
817        let prev = alloc::vec![0.0; 2];
818        let b = alloc::vec![1.0; 2];
819        let delta = SparseDelta::new(alloc::vec![0], alloc::vec![0.5]).unwrap();
820        let err = solve_on_change_sublinear_auto(&m, &prev, &b, &delta, 1e-8).unwrap_err();
821        assert!(matches!(err, SolverError::Incoherent { .. }));
822    }
823
824    #[test]
825    fn auto_with_rho_rejects_invalid_rho() {
826        let (m, b) = build_test_system();
827        let prev = alloc::vec![0.0; m.rows()];
828        let delta = SparseDelta::new(alloc::vec![1], alloc::vec![0.1]).unwrap();
829        // rho outside (0, 1) is rejected.
830        for bad_rho in &[0.0, 1.0, -0.1, 1.5, f64::NAN, f64::INFINITY] {
831            let err =
832                solve_on_change_sublinear_auto_with_rho(&m, &prev, &b, &delta, 1e-8, *bad_rho)
833                    .unwrap_err();
834            assert!(
835                matches!(err, SolverError::InvalidInput { .. }),
836                "bad rho {bad_rho} should be rejected"
837            );
838        }
839    }
840
841    #[test]
842    fn auto_with_rho_empty_delta_short_circuits() {
843        let (m, b) = build_test_system();
844        let prev = alloc::vec![0.0; m.rows()];
845        let delta = SparseDelta::empty();
846        let entries =
847            solve_on_change_sublinear_auto_with_rho(&m, &prev, &b, &delta, 1e-8, 0.5).unwrap();
848        assert!(entries.is_empty());
849    }
850
851    #[test]
852    fn auto_with_rho_agrees_with_full_solve_on_strict_dd() {
853        // Same setup as auto_matches_manual_on_strict_dd but using the
854        // _with_rho variant with the tight ρ from approximate_spectral_radius.
855        let n = 8;
856        let mut triplets = Vec::new();
857        for i in 0..n {
858            triplets.push((i, i, 4.0 as Precision));
859            if i + 1 < n {
860                triplets.push((i, i + 1, -1.0 as Precision));
861                triplets.push((i + 1, i, -1.0 as Precision));
862            }
863        }
864        let m = SparseMatrix::from_triplets(triplets, n, n).unwrap();
865        let b_prev = alloc::vec![1.0 as Precision; n];
866
867        let solver = NeumannSolver::new(64, 1e-12);
868        let opts = SolverOptions::default();
869        let prev = solver.solve(&m, &b_prev, &opts).unwrap();
870
871        // Use a tight ρ from the spectral-radius primitive.
872        let rho = crate::coherence::approximate_spectral_radius(&m, 30)
873            .expect("strict-DD chain should give a valid rho");
874
875        let delta = SparseDelta::new(alloc::vec![3usize], alloc::vec![0.5 as Precision]).unwrap();
876        let mut b_new = b_prev.clone();
877        delta.apply_to(&mut b_new).unwrap();
878
879        let auto =
880            solve_on_change_sublinear_auto_with_rho(&m, &prev.solution, &b_new, &delta, 1e-8, rho)
881                .unwrap();
882        assert!(!auto.is_empty());
883
884        let full = solver.solve(&m, &b_new, &opts).unwrap();
885        for &(row, val) in &auto {
886            assert!((val - full.solution[row]).abs() < 1e-6);
887        }
888    }
889}