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}