sublinear_solver/entry.rs
1//! Single-entry sublinear Neumann solver.
2//!
3//! ADR-001 roadmap item #6 (phase-2B): compute `x[i] = e_iᵀ A⁻¹ b` for a
4//! diagonally-dominant `A`, **without materialising the full `x`**. This
5//! is the missing inner primitive for
6//! [`crate::contrastive::contrastive_solve_on_change`] to drop end-to-end
7//! to `SubLinear` in `n`.
8//!
9//! ## Math
10//!
11//! For `A = D - O` with `D` diagonal and `ρ(D⁻¹O) < 1` (the strict-DD
12//! regime), the Neumann series expansion of `A⁻¹` gives
13//!
14//! ```text
15//! A⁻¹ = Σ_{k≥0} (D⁻¹O)^k D⁻¹
16//! x = A⁻¹ b = Σ_{k≥0} y_k, y_0 = D⁻¹b, y_{k+1} = D⁻¹O y_k
17//! ```
18//!
19//! For a *single* target entry `x[i]`, we only need entries of `y_k`
20//! restricted to the rows reachable from `i` in `≤k` hops through `A`'s
21//! row-graph — exactly the [`crate::closure::closure_indices`] primitive
22//! at depth `k`. So the per-iteration work is bounded by
23//! `O(|closure| · branching)`, independent of `n` when the closure is
24//! small (sparse `A`, bounded depth).
25//!
26//! ## Complexity
27//!
28//! - **Time:** `O(max_terms · |closure| · branching)`, where `|closure|`
29//! is the closure of `{target}` at depth `max_terms`. For sparse DD
30//! matrices with bounded degree this is `o(n)`.
31//! - **Space:** `O(|closure|)` for the sparse `y` map.
32//! - **ComplexityClass:** [`crate::complexity::ComplexityClass::SubLinear`]
33//! when `max_terms · branching ≪ n`; widens to `Linear` if the closure
34//! spans the whole graph.
35//!
36//! ## Correctness contract
37//!
38//! On a strict-DD matrix with `max_terms ≥ ⌈log_{1/ρ}(‖b‖∞ / ε)⌉` the
39//! returned estimate is within `ε` of the true `x[i]`. Caller picks
40//! `max_terms` from a spectral-radius bound; if it's too small the
41//! tolerance check exits early when iterates fall below `tolerance`.
42
43use crate::closure::closure_indices;
44use crate::complexity::{Complexity, ComplexityClass};
45use crate::error::{Result, SolverError};
46use crate::matrix::Matrix;
47use crate::types::Precision;
48use alloc::collections::BTreeMap;
49use alloc::vec::Vec;
50
51/// Op marker for [`solve_single_entry_neumann`]. Declares `SubLinear`
52/// so callers can budget against this class without running the op.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct SolveSingleEntryNeumannOp;
55
56impl Complexity for SolveSingleEntryNeumannOp {
57 const CLASS: ComplexityClass = ComplexityClass::SubLinear;
58 const DETAIL: &'static str =
59 "Single-entry Neumann: O(max_terms · |closure| · branching). Independent of n for \
60 sparse DD matrices with bounded degree + bounded max_terms. Widens to Linear when \
61 the closure spans the whole graph.";
62}
63
64/// Compute `x[i] = e_iᵀ A⁻¹ b` to within `tolerance` using closure-restricted
65/// Neumann iteration, without materialising the full solution vector.
66///
67/// # Parameters
68///
69/// - `matrix`: a square, strictly diagonally dominant matrix `A` (with a
70/// non-zero diagonal at row `target`).
71/// - `b`: the right-hand side. `b.len()` must equal `matrix.rows()`.
72/// - `target`: the row index `i` whose solution entry to compute.
73/// - `max_terms`: maximum number of Neumann terms. Picks the depth of the
74/// row-graph closure; pick `⌈log_{1/ρ}(‖b‖∞ / tolerance)⌉` for a strict
75/// ε bound, where `ρ` is the spectral radius of `D⁻¹O`.
76/// - `tolerance`: early-exit threshold on per-term contribution to `x[i]`.
77///
78/// # Returns
79///
80/// `Ok(x_i_estimate)` — the truncated Neumann approximation to `x[target]`.
81///
82/// # Errors
83///
84/// - [`SolverError::IndexOutOfBounds`] if `target >= matrix.rows()`.
85/// - [`SolverError::DimensionMismatch`] if `b.len() != matrix.rows()`.
86/// - [`SolverError::InvalidInput`] if `A[target, target] == 0` or `A` has
87/// a zero diagonal at any reachable row (Neumann series diverges).
88///
89/// # Examples
90///
91/// ```rust,no_run
92/// # use sublinear_solver::{Matrix, SparseMatrix};
93/// # use sublinear_solver::entry::solve_single_entry_neumann;
94/// # fn demo(a: &SparseMatrix) {
95/// let b = vec![1.0, 2.0, 3.0, 4.0];
96/// // Compute only x[2], skipping x[0], x[1], x[3].
97/// let x2 = solve_single_entry_neumann(a, &b, 2, /*max_terms=*/ 16, /*tol=*/ 1e-10).unwrap();
98/// # }
99/// ```
100pub fn solve_single_entry_neumann(
101 matrix: &dyn Matrix,
102 b: &[Precision],
103 target: usize,
104 max_terms: usize,
105 tolerance: Precision,
106) -> Result<Precision> {
107 let n = matrix.rows();
108 if target >= n {
109 return Err(SolverError::IndexOutOfBounds {
110 index: target,
111 max_index: n.saturating_sub(1),
112 context: alloc::string::String::from("solve_single_entry_neumann::target"),
113 });
114 }
115 if b.len() != n {
116 return Err(SolverError::DimensionMismatch {
117 expected: n,
118 actual: b.len(),
119 operation: alloc::string::String::from("solve_single_entry_neumann::b.len()"),
120 });
121 }
122
123 // Closure of {target} at depth = max_terms. This is the set of rows
124 // whose y_0 value can propagate into y_{max_terms}[target] via at
125 // most `max_terms` Neumann iterations.
126 let closure_set = closure_indices(matrix, &[target], max_terms);
127 if closure_set.is_empty() {
128 // Degenerate: matrix is 0×0 or target row has no diagonal entry.
129 return Ok(0.0);
130 }
131
132 // Membership probe — used to skip neighbours that fall outside the
133 // closure mid-iteration.
134 let in_closure = |idx: usize| closure_set.binary_search(&idx).is_ok();
135
136 // y[j] = current Neumann iterate at row j. Sparse map over the closure.
137 let mut y: BTreeMap<usize, Precision> = BTreeMap::new();
138 for &j in &closure_set {
139 let a_jj = matrix.get(j, j).unwrap_or(0.0);
140 if a_jj == 0.0 {
141 return Err(SolverError::InvalidInput {
142 message: alloc::format!(
143 "solve_single_entry_neumann: zero diagonal at row {} in closure of target {}",
144 j,
145 target,
146 ),
147 parameter: Some(alloc::string::String::from("matrix")),
148 });
149 }
150 // y_0[j] = b[j] / A[j,j]
151 y.insert(j, b[j] / a_jj);
152 }
153
154 // Accumulated estimate: starts at y_0[target].
155 let mut x_target = *y.get(&target).unwrap_or(&0.0);
156
157 // Neumann iteration y_{k+1} = D⁻¹ O y_k, restricted to the closure.
158 // Each iteration shrinks the effective support by one hop; rather than
159 // tracking that explicitly, we just zero-pad outside the closure
160 // (`in_closure` skip) and let the closure-depth bound do its job.
161 let mut y_next: BTreeMap<usize, Precision> = BTreeMap::new();
162 for _term in 1..=max_terms {
163 y_next.clear();
164 for &j in &closure_set {
165 let a_jj = matrix.get(j, j).unwrap_or(0.0);
166 // a_jj zero was rejected at init; can't be zero here.
167 debug_assert!(a_jj != 0.0);
168
169 // sum = Σ_{m ≠ j, m ∈ closure} A[j,m] · y[m]
170 let mut sum: Precision = 0.0;
171 for (m_idx, a_jm) in matrix.row_iter(j) {
172 let m = m_idx as usize;
173 if m == j {
174 continue;
175 }
176 if !in_closure(m) {
177 continue;
178 }
179 if let Some(&y_m) = y.get(&m) {
180 sum += a_jm * y_m;
181 }
182 }
183 // O[j,m] = -A[j,m] for m ≠ j, so
184 // y_{k+1}[j] = (D⁻¹ O y_k)[j] = -(1 / A[j,j]) · Σ_{m≠j} A[j,m] · y_k[m]
185 let val = -sum / a_jj;
186 if val != 0.0 {
187 y_next.insert(j, val);
188 }
189 }
190
191 let delta = y_next.get(&target).copied().unwrap_or(0.0);
192 x_target += delta;
193 if delta.abs() < tolerance {
194 break;
195 }
196 core::mem::swap(&mut y, &mut y_next);
197 }
198
199 Ok(x_target)
200}
201
202/// Batched variant — compute `x[i]` for every `i` in `targets`, sharing
203/// no per-target state. The orchestrator
204/// [`crate::contrastive::contrastive_solve_on_change`] is the natural
205/// caller: pass `targets = closure_indices(matrix, &delta.indices, depth)`
206/// and you get the candidate-set solution map for top-k anomaly ranking.
207///
208/// # Returns
209///
210/// `Vec<(usize, Precision)>` sorted ascending by index, suitable for
211/// feeding straight into [`crate::contrastive::find_anomalous_rows_in_subset`]
212/// after fanning out via a dense-vector materialisation step.
213///
214/// # Complexity
215///
216/// `O(|targets| · max_terms · |closure_max| · branching)`. Still
217/// `SubLinear` in `n` when each per-target closure is bounded.
218pub fn solve_single_entries_neumann(
219 matrix: &dyn Matrix,
220 b: &[Precision],
221 targets: &[usize],
222 max_terms: usize,
223 tolerance: Precision,
224) -> Result<Vec<(usize, Precision)>> {
225 let mut out: Vec<(usize, Precision)> = Vec::with_capacity(targets.len());
226 for &t in targets {
227 let val = solve_single_entry_neumann(matrix, b, t, max_terms, tolerance)?;
228 out.push((t, val));
229 }
230 // Sorted ascending so the output is canonical.
231 out.sort_by_key(|(i, _)| *i);
232 Ok(out)
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::matrix::SparseMatrix;
239 use crate::solver::neumann::NeumannSolver;
240 use crate::solver::{SolverAlgorithm, SolverOptions};
241
242 /// Build a strictly-DD 8×8 with `a[i,i]=4`, off-diagonals `-1` at
243 /// neighbours `i-1` and `i+1` (chain graph). Spectral radius ≈ 0.5.
244 fn build_chain(n: usize) -> SparseMatrix {
245 let mut triplets = Vec::new();
246 for i in 0..n {
247 triplets.push((i, i, 4.0));
248 if i + 1 < n {
249 triplets.push((i, i + 1, -1.0));
250 triplets.push((i + 1, i, -1.0));
251 }
252 }
253 SparseMatrix::from_triplets(triplets, n, n).unwrap()
254 }
255
256 /// Single-entry estimate must agree with a full Neumann solve at
257 /// every target index, within a strict tolerance.
258 #[test]
259 fn matches_full_solve_on_chain() {
260 let n = 8;
261 let a = build_chain(n);
262 let b: Vec<Precision> = (1..=n).map(|i| i as Precision).collect();
263
264 let full_solver = NeumannSolver::new(64, 1e-12);
265 let opts = SolverOptions::default();
266 let full = full_solver.solve(&a, &b, &opts).unwrap();
267
268 for target in 0..n {
269 let est = solve_single_entry_neumann(
270 &a, &b, target, /*max_terms=*/ 32, /*tol=*/ 1e-10,
271 )
272 .unwrap();
273 let diff = (est - full.solution[target]).abs();
274 assert!(
275 diff < 1e-6,
276 "single-entry estimate diverged at row {}: est={}, full={}, diff={}",
277 target,
278 est,
279 full.solution[target],
280 diff
281 );
282 }
283 }
284
285 /// Diagonal matrix: single-entry estimate equals `b[i] / a_ii`
286 /// exactly after zero Neumann iterations.
287 #[test]
288 fn diagonal_matrix_returns_b_over_diag() {
289 let n = 4;
290 let triplets: Vec<_> = (0..n).map(|i| (i, i, 2.0)).collect();
291 let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
292 let b = alloc::vec![3.0, 6.0, 9.0, 12.0];
293 for i in 0..n {
294 let est = solve_single_entry_neumann(&a, &b, i, 0, 1e-12).unwrap();
295 assert!((est - b[i] / 2.0).abs() < 1e-12);
296 }
297 }
298
299 /// max_terms = 0 returns the zeroth Neumann term only (the
300 /// `D⁻¹ b` approximation). Useful sanity check on the loop bounds.
301 #[test]
302 fn max_terms_zero_returns_zeroth_neumann_term() {
303 let n = 4;
304 let a = build_chain(n);
305 let b = alloc::vec![1.0, 2.0, 3.0, 4.0];
306 for i in 0..n {
307 let est = solve_single_entry_neumann(&a, &b, i, 0, 1e-12).unwrap();
308 assert!((est - b[i] / 4.0).abs() < 1e-12);
309 }
310 }
311
312 /// Out-of-bound target rejects with the right error variant.
313 #[test]
314 fn target_out_of_bounds_errors() {
315 let n = 4;
316 let a = build_chain(n);
317 let b = alloc::vec![1.0; n];
318 let err = solve_single_entry_neumann(&a, &b, 99, 8, 1e-10).unwrap_err();
319 assert!(matches!(err, SolverError::IndexOutOfBounds { .. }));
320 }
321
322 /// Wrong-sized RHS rejects with `DimensionMismatch`.
323 #[test]
324 fn b_length_mismatch_errors() {
325 let n = 4;
326 let a = build_chain(n);
327 let b = alloc::vec![1.0; n + 3];
328 let err = solve_single_entry_neumann(&a, &b, 0, 8, 1e-10).unwrap_err();
329 assert!(matches!(err, SolverError::DimensionMismatch { .. }));
330 }
331
332 /// Zero-diagonal at the target row rejects with `InvalidInput`.
333 #[test]
334 fn zero_diagonal_at_target_errors() {
335 // 2×2 with a[0,0] = 0, a[1,1] = 1.
336 let triplets = alloc::vec![(0, 1, 1.0), (1, 1, 1.0)];
337 let a = SparseMatrix::from_triplets(triplets, 2, 2).unwrap();
338 let b = alloc::vec![1.0, 1.0];
339 let err = solve_single_entry_neumann(&a, &b, 0, 4, 1e-10).unwrap_err();
340 assert!(matches!(err, SolverError::InvalidInput { .. }));
341 }
342
343 /// Batched API: returns sorted (index, value) pairs matching the
344 /// per-target single-entry result.
345 #[test]
346 fn batched_matches_per_target() {
347 let n = 6;
348 let a = build_chain(n);
349 let b = alloc::vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
350 let targets = alloc::vec![4usize, 1, 2]; // intentionally unsorted
351
352 let batched = solve_single_entries_neumann(&a, &b, &targets, 32, 1e-10).unwrap();
353 assert_eq!(batched.len(), 3);
354 // Sorted ascending.
355 assert_eq!(batched[0].0, 1);
356 assert_eq!(batched[1].0, 2);
357 assert_eq!(batched[2].0, 4);
358
359 for &(idx, val) in &batched {
360 let scalar = solve_single_entry_neumann(&a, &b, idx, 32, 1e-10).unwrap();
361 assert!((val - scalar).abs() < 1e-12);
362 }
363 }
364
365 /// Op marker has the right complexity class.
366 #[test]
367 fn op_complexity_class_is_sublinear() {
368 assert_eq!(
369 <SolveSingleEntryNeumannOp as Complexity>::CLASS,
370 ComplexityClass::SubLinear
371 );
372 }
373
374 /// Compile-time check that the op declares SubLinear so callers can
375 /// match on it at compile time (the ADR-001 contract).
376 #[test]
377 fn op_compile_time_bound() {
378 const _: () = assert!(matches!(
379 <SolveSingleEntryNeumannOp as Complexity>::CLASS,
380 ComplexityClass::SubLinear
381 ));
382 }
383}