Skip to main content

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}