Skip to main content

bixverse_methods/graph/
graph_components.rs

1//! Connected components over CSR adjacency matrices.
2//!
3//! Components are **weak**: a stored edge connects its endpoints regardless of
4//! direction, matching `scipy.sparse.csgraph.connected_components` with
5//! `directed=False`. Union-find is what makes that cheap on a directed CSR — it
6//! unions each stored edge, so no transpose is needed to see the in-edges.
7//!
8//! Labels are assigned by scanning nodes in ascending order and numbering roots
9//! on first encounter, which reproduces scipy's labelling.
10
11use rustc_hash::FxHashMap;
12
13use crate::graph::graph_structures::UnionFind;
14use crate::prelude::*;
15
16//////////
17// Main //
18//////////
19
20/// Weakly connected components of a CSR adjacency.
21///
22/// ### Params
23///
24/// * `graph` - Square CSR adjacency. Edge direction is ignored.
25///
26/// ### Returns
27///
28/// `(n_components, labels)` where `labels[i]` is the zero-based component of
29/// node `i`. Labels are assigned in ascending node order.
30pub fn connected_components<T>(
31    graph: &CompressedSparseData2<T>,
32) -> Result<(usize, Vec<usize>), MethodsErrors>
33where
34    T: Clone,
35{
36    let n = validate_square_csr(graph)?;
37
38    let mut uf = UnionFind::new(n);
39    for i in 0..n {
40        for idx in graph.indptr[i] as usize..graph.indptr[i + 1] as usize {
41            uf.union(i as u32, graph.indices[idx]);
42        }
43    }
44
45    Ok(label_by_root(
46        &mut uf,
47        (0..n as u32).collect::<Vec<_>>().as_slice(),
48    ))
49}
50
51/// Weakly connected components of the subgraph induced by `members`.
52///
53/// Only edges with **both** endpoints in `members` are considered. Duplicated
54/// entries in `members` are tolerated and land in the same component.
55///
56/// ### Params
57///
58/// * `graph` - Square CSR adjacency. Edge direction is ignored.
59/// * `members` - Node indices spanning the induced subgraph.
60///
61/// ### Returns
62///
63/// `(n_components, labels)` where `labels[p]` is the component of
64/// `members[p]`. Labels are assigned in ascending `members` position.
65pub fn induced_components<T>(
66    graph: &CompressedSparseData2<T>,
67    members: &[usize],
68) -> Result<(usize, Vec<usize>), MethodsErrors>
69where
70    T: Clone,
71{
72    let n = validate_square_csr(graph)?;
73
74    let mut position: FxHashMap<usize, u32> = FxHashMap::default();
75    for (p, &node) in members.iter().enumerate() {
76        if node >= n {
77            return Err(CoreErrors::SliceIndexOutOfBounds {
78                index: node,
79                len: n,
80            }
81            .into());
82        }
83        position.entry(node).or_insert(p as u32);
84    }
85
86    let mut uf = UnionFind::new(members.len());
87
88    for (p, &node) in members.iter().enumerate() {
89        // A repeated node shares a component with its first occurrence.
90        if let Some(&first) = position.get(&node)
91            && first != p as u32
92        {
93            uf.union(first, p as u32);
94        }
95        for idx in graph.indptr[node] as usize..graph.indptr[node + 1] as usize {
96            let neighbour = graph.indices[idx] as usize;
97            if let Some(&q) = position.get(&neighbour) {
98                uf.union(p as u32, q);
99            }
100        }
101    }
102
103    Ok(label_by_root(
104        &mut uf,
105        (0..members.len() as u32).collect::<Vec<_>>().as_slice(),
106    ))
107}
108
109/////////////
110// Helpers //
111/////////////
112
113/// Turn a resolved union-find forest into dense component labels.
114///
115/// Roots are numbered on first encounter while scanning `elements` in order,
116/// which is what gives ascending-node label assignment.
117///
118/// ### Params
119///
120/// * `uf` - The forest, mutated by the path compression inside `find`.
121/// * `elements` - Elements to label, in the order the labels should follow.
122///
123/// ### Returns
124///
125/// `(n_components, labels)` aligned with `elements`.
126fn label_by_root(uf: &mut UnionFind, elements: &[u32]) -> (usize, Vec<usize>) {
127    let mut root_to_label: FxHashMap<u32, usize> = FxHashMap::default();
128    let mut labels = Vec::with_capacity(elements.len());
129
130    for &e in elements {
131        let root = uf.find(e);
132        let next = root_to_label.len();
133        let label = *root_to_label.entry(root).or_insert(next);
134        labels.push(label);
135    }
136
137    (root_to_label.len(), labels)
138}
139
140/// Check that a graph is a square CSR matrix and return its node count.
141///
142/// ### Params
143///
144/// * `graph` - The adjacency to validate.
145///
146/// ### Returns
147///
148/// The node count, or an error when the matrix is not CSR or not square.
149fn validate_square_csr<T>(graph: &CompressedSparseData2<T>) -> Result<usize, MethodsErrors>
150where
151    T: Clone,
152{
153    if !graph.cs_type.is_csr() {
154        return Err(CoreErrors::SparseMatrixMustBeCsr.into());
155    }
156    let (rows, cols) = graph.shape;
157    if rows != cols {
158        return Err(CoreErrors::ShapeMismatch {
159            expected: (rows, rows),
160            got: (rows, cols),
161        }
162        .into());
163    }
164    Ok(rows)
165}
166
167///////////
168// Tests //
169///////////
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// Build a CSR over `n` nodes storing exactly the given `(from, to)` edges,
176    /// all at weight one.
177    fn directed_csr(n: usize, edges: &[(usize, usize)]) -> CompressedSparseData2<f64> {
178        let mut rows: Vec<Vec<u32>> = vec![Vec::new(); n];
179        for &(a, b) in edges {
180            rows[a].push(b as u32);
181        }
182
183        let mut indptr: Vec<u32> = vec![0];
184        let mut indices: Vec<u32> = Vec::new();
185        let mut data: Vec<f64> = Vec::new();
186        for row in rows.iter_mut() {
187            row.sort_unstable();
188            for &j in row.iter() {
189                indices.push(j);
190                data.push(1.0);
191            }
192            indptr.push(indices.len() as u32);
193        }
194
195        CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n))
196    }
197
198    /// Disjoint cliques come back as separate components with dense labels.
199    #[test]
200    fn test_components_of_two_cliques() {
201        // {0,1,2} and {3,4} with no edge between them.
202        let edges = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 3)];
203        let graph = directed_csr(5, &edges);
204
205        let (n_comps, labels) = connected_components(&graph).unwrap();
206
207        assert_eq!(n_comps, 2);
208        assert_eq!(labels, vec![0, 0, 0, 1, 1]);
209    }
210
211    /// Edge direction is ignored: this is weak connectivity, not strong.
212    #[test]
213    fn test_components_are_weak_not_strong() {
214        // 0 -> 1 -> 2 with no return path: strongly this is three components,
215        // weakly it is one.
216        let graph = directed_csr(3, &[(0, 1), (1, 2)]);
217
218        let (n_comps, labels) = connected_components(&graph).unwrap();
219
220        assert_eq!(n_comps, 1);
221        assert_eq!(labels, vec![0, 0, 0]);
222    }
223
224    /// Nodes with no edges are components in their own right, not dropped.
225    #[test]
226    fn test_isolated_nodes_get_own_components() {
227        let graph = directed_csr(4, &[(0, 1)]);
228
229        let (n_comps, labels) = connected_components(&graph).unwrap();
230
231        assert_eq!(n_comps, 3);
232        assert_eq!(labels, vec![0, 0, 1, 2]);
233    }
234
235    /// The induced subgraph is walked on its own, so removing a cut vertex
236    /// splits it.
237    #[test]
238    fn test_induced_components_split_a_connected_graph() {
239        // A path 0-1-2-3-4. Dropping node 2 splits it into {0,1} and {3,4}.
240        let edges = [
241            (0, 1),
242            (1, 0),
243            (1, 2),
244            (2, 1),
245            (2, 3),
246            (3, 2),
247            (3, 4),
248            (4, 3),
249        ];
250        let graph = directed_csr(5, &edges);
251
252        let (n_comps, labels) = induced_components(&graph, &[0, 1, 3, 4]).unwrap();
253
254        assert_eq!(n_comps, 2);
255        assert_eq!(labels, vec![0, 0, 1, 1]);
256    }
257
258    /// Paths through non-members do not connect two members.
259    #[test]
260    fn test_induced_components_ignore_edges_leaving_the_subset() {
261        // 0 and 2 are only connected through 1, which is not a member.
262        let edges = [(0, 1), (1, 0), (1, 2), (2, 1)];
263        let graph = directed_csr(3, &edges);
264
265        let (n_comps, labels) = induced_components(&graph, &[0, 2]).unwrap();
266
267        assert_eq!(n_comps, 2);
268        assert_eq!(labels, vec![0, 1]);
269    }
270
271    /// Regression: a repeated member used to inflate the component count and
272    /// give its copies different labels.
273    #[test]
274    fn test_induced_components_put_duplicate_members_in_one_component() {
275        // Node 0 appears three times and node 2 twice, with no edge between the
276        // two. Every copy must land on its first occurrence's label, and the
277        // component count must not be inflated by the repeats.
278        let edges = [(0, 1), (1, 0), (1, 2), (2, 1)];
279        let graph = directed_csr(3, &edges);
280
281        let (n_comps, labels) = induced_components(&graph, &[0, 2, 0, 2, 0]).unwrap();
282
283        assert_eq!(n_comps, 2);
284        assert_eq!(labels, vec![0, 1, 0, 1, 0]);
285    }
286
287    /// Regression: a duplicated member on a path used to split one component
288    /// into two.
289    #[test]
290    fn test_induced_components_merge_through_a_duplicated_member() {
291        // A path 0-1-2-3. Members list node 1 twice; the second copy must not
292        // split the induced component in two.
293        let edges = [(0, 1), (1, 0), (1, 2), (2, 1), (2, 3), (3, 2)];
294        let graph = directed_csr(4, &edges);
295
296        let (n_comps, labels) = induced_components(&graph, &[0, 1, 2, 1]).unwrap();
297
298        assert_eq!(n_comps, 1);
299        assert_eq!(labels, vec![0, 0, 0, 0]);
300    }
301
302    /// A member index past the node count errors rather than panicking.
303    #[test]
304    fn test_induced_components_rejects_out_of_range_member() {
305        let graph = directed_csr(3, &[(0, 1)]);
306
307        assert!(induced_components(&graph, &[0, 9]).is_err());
308    }
309
310    /// A CSC matrix is refused rather than walked as if it were CSR.
311    #[test]
312    fn test_components_reject_csc_input() {
313        let graph = directed_csr(3, &[(0, 1)]).transform();
314
315        assert!(connected_components(&graph).is_err());
316    }
317}