Skip to main content

sublinear_solver/
closure.rs

1//! Bounded-depth graph closure over a sparse matrix's adjacency.
2//!
3//! ADR-001 roadmap item #6 (phase-2A): the closure primitive that turns
4//! a sparse RHS delta into the **candidate set of rows** whose solution
5//! might have changed. Composes with [`crate::incremental::SparseDelta`]
6//! and [`crate::contrastive::find_anomalous_rows_in_subset`] to get a
7//! sub-linear contrastive solve when the delta is small and the matrix
8//! is sparse + diagonally dominant.
9//!
10//! ## What this does
11//!
12//! Given a sparse matrix `A` and a set of seed row indices `S`, returns
13//! the bounded-depth closure of `S` in the row-graph of `A`:
14//!
15//! ```text
16//!   closure_0 = S
17//!   closure_{d+1} = closure_d ∪ { j : ∃ i ∈ closure_d, A[i,j] ≠ 0 }
18//! ```
19//!
20//! For diagonally dominant `A`, the magnitude of `A⁻¹[i,j]` decays
21//! geometrically with hop distance between `i` and `j` in this graph
22//! (the **Neumann support shrinkage** observation from §2.2 of
23//! "Sublinear time approximation of weighted set cover", and the basis
24//! for the sublinear-Neumann solver in `crate::sublinear`). So the
25//! candidate set above conservatively over-covers the set of rows
26//! whose solution entries changed by more than `O(ρ^depth · ‖delta‖)`,
27//! where `ρ` is the spectral radius of `I − D⁻¹A` (strictly < 1 for DD).
28//!
29//! Practical implication: passing `depth = ⌈log_{1/ρ}(‖delta‖/ε)⌉` to
30//! [`closure_indices`] gives you the exact candidate set needed for an
31//! ε-accurate contrastive solve, and on a sparse matrix with bounded
32//! degree this set is `O(branch^depth)` — sub-linear in `n` when the
33//! delta is small.
34//!
35//! ## Complexity
36//!
37//! - **Time:** `O(depth · branch · |closure|)`, where `branch` is the
38//!   maximum out-degree across visited rows. Bounded by `O(nnz)` worst
39//!   case (depth ≥ diameter); sub-linear in `n` when `depth · branch ≪ n`.
40//! - **Space:** `O(|closure|)` for the visited bit-set + the output vec.
41//! - **ComplexityClass:** [`crate::complexity::ComplexityClass::SubLinear`]
42//!   under the standard "sparse-A + bounded-depth" regime; degrades to
43//!   `Linear` when `depth ≥ diameter`. The op-marker
44//!   [`ClosureIndicesOp`] declares `SubLinear` and is the contract the
45//!   caller's budget should match.
46
47use crate::complexity::{Complexity, ComplexityClass};
48use crate::matrix::Matrix;
49use alloc::vec::Vec;
50use bit_set::BitSet;
51
52/// Op marker for the bounded-depth closure operation. Implements
53/// [`Complexity`] so callers can budget against this class without
54/// running the op first.
55#[derive(Debug, Clone, Copy, Default)]
56pub struct ClosureIndicesOp;
57
58impl Complexity for ClosureIndicesOp {
59    /// The closure walk is `O(depth · branch · |closure|)`. For the
60    /// regime ADR-001 targets — sparse A + bounded delta + bounded
61    /// depth — this is `o(n)`, hence `SubLinear`. When `depth ≥
62    /// diameter(A)` it widens to `Linear`; callers in that regime
63    /// should explicitly accept the linear-cost fallback.
64    const CLASS: ComplexityClass = ComplexityClass::SubLinear;
65}
66
67/// Return the bounded-depth row-graph closure of `seeds` in `matrix`.
68///
69/// The output is **sorted ascending** and **deduplicated**, suitable as
70/// the `candidates` argument to
71/// [`crate::contrastive::find_anomalous_rows_in_subset`].
72///
73/// - `depth = 0` returns the seed set itself (deduped + sorted).
74/// - `depth = 1` returns seeds ∪ their direct row-graph neighbours.
75/// - Out-of-bound seed indices are silently dropped (they have no
76///   neighbours in `matrix`).
77///
78/// # Examples
79///
80/// ```rust,no_run
81/// # use sublinear_solver::{Matrix, closure::closure_indices};
82/// # fn demo(a: &dyn Matrix) {
83/// let seeds = [3usize, 17];
84/// let candidates = closure_indices(a, &seeds, 2);
85/// // candidates ⊇ {3, 17} and every row reachable in ≤2 hops via A's
86/// // non-zero pattern.
87/// # }
88/// ```
89pub fn closure_indices(matrix: &dyn Matrix, seeds: &[usize], depth: usize) -> Vec<usize> {
90    let n = matrix.rows();
91    if n == 0 || seeds.is_empty() {
92        return Vec::new();
93    }
94
95    let mut visited = BitSet::with_capacity(n);
96    let mut frontier: Vec<usize> = Vec::with_capacity(seeds.len());
97
98    // Seed the frontier with in-bounds, unique entries.
99    for &s in seeds {
100        if s < n && visited.insert(s) {
101            frontier.push(s);
102        }
103    }
104
105    // BFS-style expansion. Each layer pulls neighbours of the previous
106    // layer's frontier through `row_iter`; we deduplicate via the
107    // visited bit-set so cycles in the row-graph don't re-enqueue.
108    let mut next: Vec<usize> = Vec::new();
109    for _ in 0..depth {
110        if frontier.is_empty() {
111            break;
112        }
113        next.clear();
114        for &row in &frontier {
115            for (col, _value) in matrix.row_iter(row) {
116                let c = col as usize;
117                if c < n && visited.insert(c) {
118                    next.push(c);
119                }
120            }
121        }
122        core::mem::swap(&mut frontier, &mut next);
123    }
124
125    // Materialise the visited set as a sorted dedup vec. BitSet iterates
126    // ascending so the output is naturally sorted.
127    let mut out: Vec<usize> = Vec::with_capacity(visited.len());
128    for idx in visited.iter() {
129        out.push(idx);
130    }
131    out
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::matrix::SparseMatrix;
138
139    /// A diagonal-only matrix: closure should be just the seeds (no
140    /// off-diagonal neighbours to traverse).
141    #[test]
142    fn closure_on_diagonal_is_seeds_only() {
143        let n = 8;
144        let triplets: Vec<_> = (0..n).map(|i| (i, i, 1.0)).collect();
145        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
146
147        let result = closure_indices(&a, &[2, 5], 3);
148        assert_eq!(result, vec![2, 5], "diagonal matrix should not expand");
149    }
150
151    /// A bidiagonal matrix `a[i,i]=2, a[i,i+1]=-1`: depth-1 closure of
152    /// `{0}` should be `{0,1}`; depth-2 should be `{0,1,2}`; etc.
153    #[test]
154    fn closure_grows_with_depth_on_bidiagonal() {
155        let n = 10;
156        let mut triplets = Vec::new();
157        for i in 0..n {
158            triplets.push((i, i, 2.0));
159            if i + 1 < n {
160                triplets.push((i, i + 1, -1.0));
161            }
162        }
163        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
164
165        assert_eq!(closure_indices(&a, &[0], 0), vec![0]);
166        assert_eq!(closure_indices(&a, &[0], 1), vec![0, 1]);
167        assert_eq!(closure_indices(&a, &[0], 2), vec![0, 1, 2]);
168        assert_eq!(closure_indices(&a, &[0], 5), vec![0, 1, 2, 3, 4, 5]);
169    }
170
171    /// Out-of-bound seeds are silently dropped.
172    #[test]
173    fn closure_drops_out_of_bound_seeds() {
174        let n = 4;
175        let triplets: Vec<_> = (0..n).map(|i| (i, i, 1.0)).collect();
176        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
177
178        let result = closure_indices(&a, &[1, 99, 3], 5);
179        assert_eq!(result, vec![1, 3]);
180    }
181
182    /// Empty seeds produces empty output.
183    #[test]
184    fn closure_on_empty_seeds_is_empty() {
185        let a = SparseMatrix::from_triplets(Vec::new(), 4, 4).unwrap();
186        assert!(closure_indices(&a, &[], 5).is_empty());
187    }
188
189    /// Empty matrix produces empty output.
190    #[test]
191    fn closure_on_empty_matrix_is_empty() {
192        let a = SparseMatrix::from_triplets(Vec::new(), 0, 0).unwrap();
193        assert!(closure_indices(&a, &[0, 1, 2], 3).is_empty());
194    }
195
196    /// Op marker has the right complexity class.
197    #[test]
198    fn closure_op_complexity_class() {
199        assert_eq!(
200            <ClosureIndicesOp as Complexity>::CLASS,
201            ComplexityClass::SubLinear
202        );
203    }
204
205    /// Compile-time check that the op is SubLinear so callers can match
206    /// on it at compile time (the ADR-001 contract).
207    #[test]
208    fn closure_op_compile_time_bound() {
209        const _: () = assert!(matches!(
210            <ClosureIndicesOp as Complexity>::CLASS,
211            ComplexityClass::SubLinear
212        ));
213    }
214}