Skip to main content

bixverse_methods/graph/
spanning_tree.rs

1//! Minimum and maximum spanning forests over CSR adjacencies.
2//!
3//! Kruskal with union-find. The adjacency is read as **undirected**: a stored
4//! entry connects its endpoints whichever direction it sits in, so a directed
5//! CSR needs no transpose. Where a pair is stored in both directions with
6//! different weights, the weight that favours the objective wins (smaller for
7//! the minimum forest, larger for the maximum one).
8//!
9//! Disconnected input yields a forest rather than an error, matching
10//! `scipy.sparse.csgraph.minimum_spanning_tree`. Two deliberate divergences
11//! from that routine: explicitly stored zero weights are kept as real edges
12//! (scipy treats a zero as an absent edge), and ties resolve by `(weight, lo,
13//! hi)` so the output does not depend on traversal order.
14
15use rayon::prelude::*;
16
17use crate::graph::graph_structures::UnionFind;
18use crate::prelude::*;
19use bixverse_core::core::math::sparse::{undirected_edges_to_csr, validate_square_csr};
20
21/////////////
22// Helpers //
23/////////////
24
25/// Shared Kruskal core behind both public entry points.
26///
27/// One sort over the candidate edges, then the union-find pass. A pair stored in
28/// both directions needs no separate collapse: the objective-favouring copy
29/// sorts first under the Kruskal ordering, so union accepts it and every later
30/// copy is rejected as a cycle. The result is identical to deduplicating first
31/// and half a sort cheaper.
32///
33/// The sort is the whole cost, so it is the only part that runs in parallel.
34/// Collecting the candidates is linear and the union-find pass is inherently
35/// sequential. Rayon leaves short slices on the sequential path, so this stays
36/// cheap on the tiny graphs that graph abstraction feeds it.
37///
38/// ### Params
39///
40/// * `graph` - Square CSR adjacency, read as undirected.
41/// * `maximise` - Prefer the heaviest edges rather than the lightest.
42///
43/// ### Returns
44///
45/// The forest as a symmetric CSR, or an error when `graph` is not a structurally
46/// sound square CSR.
47fn spanning_forest<T>(
48    graph: &CompressedSparseData2<T>,
49    maximise: bool,
50) -> Result<CompressedSparseData2<T>, MethodsErrors>
51where
52    T: BixverseFloat + BixverseNumeric,
53{
54    let n = validate_square_csr(graph)?;
55
56    let mut edges = candidate_edges(graph, n);
57
58    edges.par_sort_unstable_by(|a, b| {
59        weight_order(a.2, b.2, maximise).then_with(|| (a.0, a.1).cmp(&(b.0, b.1)))
60    });
61
62    let mut uf = UnionFind::new(n);
63    let mut kept: Vec<(u32, u32, T)> = Vec::with_capacity(n.saturating_sub(1));
64    for &(lo, hi, w) in &edges {
65        if uf.union(lo, hi) {
66            kept.push((lo, hi, w));
67        }
68    }
69
70    kept.sort_unstable_by_key(|e| (e.0, e.1));
71
72    Ok(undirected_edges_to_csr(n, &kept))
73}
74
75/// Collect the undirected candidate edges of a CSR adjacency.
76///
77/// Self loops and `NaN` weights are dropped: a self loop can never belong to a
78/// spanning forest, and a `NaN` would poison the comparator and silently
79/// reorder the whole Kruskal sequence.
80///
81/// Infinities are kept. `total_cmp` orders them correctly, scipy treats them as
82/// ordinary large weights, and for [maximum_spanning_forest] a `+inf` edge is
83/// the *most* desirable one in the graph. Dropping it would hand the caller an
84/// extra component whenever it was the only bridge between two of them.
85///
86/// ### Params
87///
88/// * `graph` - Square CSR adjacency.
89/// * `n` - Node count.
90///
91/// ### Returns
92///
93/// `(lo, hi, weight)` triples with `lo < hi`, still holding both directions of
94/// any pair stored twice.
95fn candidate_edges<T>(graph: &CompressedSparseData2<T>, n: usize) -> Vec<(u32, u32, T)>
96where
97    T: BixverseFloat + BixverseNumeric,
98{
99    let mut edges: Vec<(u32, u32, T)> = Vec::with_capacity(graph.get_nnz());
100
101    for i in 0..n {
102        let row = i as u32;
103        for idx in graph.indptr[i] as usize..graph.indptr[i + 1] as usize {
104            let j = graph.indices[idx];
105            let w = graph.data[idx];
106            if j == row || w.is_nan() {
107                continue;
108            }
109            edges.push((row.min(j), row.max(j), w));
110        }
111    }
112
113    edges
114}
115
116/// Order two weights so that the objective-favouring one compares as less.
117///
118/// ### Params
119///
120/// * `a` - First weight.
121/// * `b` - Second weight.
122/// * `maximise` - Heaviest-first rather than lightest-first.
123///
124/// ### Returns
125///
126/// The ordering to sort by. Uses `total_cmp`, which is a total order over the
127/// infinities [candidate_edges] lets through.
128#[inline]
129fn weight_order<T>(a: T, b: T, maximise: bool) -> std::cmp::Ordering
130where
131    T: BixverseFloat,
132{
133    if maximise {
134        b.total_cmp(&a)
135    } else {
136        a.total_cmp(&b)
137    }
138}
139
140//////////
141// Main //
142//////////
143
144/// Minimum spanning forest of a CSR adjacency.
145///
146/// ### Params
147///
148/// * `graph` - Square CSR adjacency, read as undirected. Self loops and `NaN`
149///   weights are skipped; infinities are ordinary weights.
150///
151/// ### Returns
152///
153/// A symmetric CSR of the same shape holding the retained edges at their
154/// original weights, or an error when `graph` is not a structurally sound square
155/// CSR.
156pub fn minimum_spanning_forest<T>(
157    graph: &CompressedSparseData2<T>,
158) -> Result<CompressedSparseData2<T>, MethodsErrors>
159where
160    T: BixverseFloat + BixverseNumeric,
161{
162    spanning_forest(graph, false)
163}
164
165/// Maximum spanning forest of a CSR adjacency.
166///
167/// The same routine with the weight ordering flipped. Preferring the heaviest
168/// edges is what PAGA's `connectivities_tree` wants, and it avoids the
169/// reference implementation's detour through the reciprocals of the weights.
170///
171/// ### Params
172///
173/// * `graph` - Square CSR adjacency, read as undirected. Self loops and `NaN`
174///   weights are skipped; infinities are ordinary weights.
175///
176/// ### Returns
177///
178/// A symmetric CSR of the same shape holding the retained edges at their
179/// original weights, or an error when `graph` is not a structurally sound square
180/// CSR.
181pub fn maximum_spanning_forest<T>(
182    graph: &CompressedSparseData2<T>,
183) -> Result<CompressedSparseData2<T>, MethodsErrors>
184where
185    T: BixverseFloat + BixverseNumeric,
186{
187    spanning_forest(graph, true)
188}
189
190///////////
191// Tests //
192///////////
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    /// Build a symmetric CSR over `n` nodes from `(a, b, weight)` edges, storing
199    /// both directions with each row sorted by column.
200    fn undirected_csr(n: usize, edges: &[(usize, usize, f64)]) -> CompressedSparseData2<f64> {
201        let mut rows: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
202        for &(a, b, w) in edges {
203            rows[a].push((b as u32, w));
204            rows[b].push((a as u32, w));
205        }
206
207        let mut indptr: Vec<u32> = vec![0];
208        let mut indices: Vec<u32> = Vec::new();
209        let mut data: Vec<f64> = Vec::new();
210        for row in rows.iter_mut() {
211            row.sort_unstable_by_key(|e| e.0);
212            for &(j, w) in row.iter() {
213                indices.push(j);
214                data.push(w);
215            }
216            indptr.push(indices.len() as u32);
217        }
218
219        CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n))
220    }
221
222    /// Build a symmetric CSR whose rows keep the insertion order of the edge
223    /// list rather than being sorted by column.
224    ///
225    /// A valid CSR for this crate wants ascending rows, so this is only for
226    /// probing order dependence in a routine that reads the rows as an unordered
227    /// bag of edges.
228    fn undirected_csr_unsorted(
229        n: usize,
230        edges: &[(usize, usize, f64)],
231    ) -> CompressedSparseData2<f64> {
232        let mut rows: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
233        for &(a, b, w) in edges {
234            rows[a].push((b as u32, w));
235            rows[b].push((a as u32, w));
236        }
237
238        let mut indptr: Vec<u32> = vec![0];
239        let mut indices: Vec<u32> = Vec::new();
240        let mut data: Vec<f64> = Vec::new();
241        for row in rows.iter() {
242            for &(j, w) in row.iter() {
243                indices.push(j);
244                data.push(w);
245            }
246            indptr.push(indices.len() as u32);
247        }
248
249        CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n))
250    }
251
252    /// Read a CSR back as deduplicated undirected `(lo, hi, weight)` edges,
253    /// ascending by `(lo, hi)`.
254    fn edge_list(csr: &CompressedSparseData2<f64>) -> Vec<(usize, usize, f64)> {
255        let n = csr.nrows();
256        let mut out = Vec::new();
257        for i in 0..n {
258            for idx in csr.indptr[i] as usize..csr.indptr[i + 1] as usize {
259                let j = csr.indices[idx] as usize;
260                if i < j {
261                    out.push((i, j, csr.data[idx]));
262                }
263            }
264        }
265        out.sort_unstable_by_key(|e| (e.0, e.1));
266        out
267    }
268
269    /// The two objectives really do run Kruskal in opposite orders and land on
270    /// different edge sets.
271    #[test]
272    fn test_minimum_and_maximum_forests_pick_different_edges() {
273        // A 4-cycle with one chord. Cheapest three edges are 0-1, 1-2, 2-3.
274        // Going the other way, 0-2 and 0-3 are taken first, which leaves 2-3
275        // closing a cycle, so node 1 gets attached through 1-2 instead.
276        let graph = undirected_csr(
277            4,
278            &[
279                (0, 1, 1.0),
280                (1, 2, 2.0),
281                (2, 3, 3.0),
282                (0, 3, 9.0),
283                (0, 2, 10.0),
284            ],
285        );
286
287        let min_tree = edge_list(&minimum_spanning_forest(&graph).unwrap());
288        assert_eq!(min_tree, vec![(0, 1, 1.0), (1, 2, 2.0), (2, 3, 3.0)]);
289
290        let max_tree = edge_list(&maximum_spanning_forest(&graph).unwrap());
291        assert_eq!(max_tree, vec![(0, 2, 10.0), (0, 3, 9.0), (1, 2, 2.0)]);
292    }
293
294    /// A disconnected graph gives a forest, not an attempt at a single tree.
295    #[test]
296    fn test_forest_spans_each_component_separately() {
297        // Two triangles with no edge between them: 4 kept edges, not 5.
298        let graph = undirected_csr(
299            6,
300            &[
301                (0, 1, 1.0),
302                (1, 2, 2.0),
303                (0, 2, 3.0),
304                (3, 4, 1.0),
305                (4, 5, 2.0),
306                (3, 5, 3.0),
307            ],
308        );
309
310        let forest = edge_list(&minimum_spanning_forest(&graph).unwrap());
311
312        assert_eq!(forest.len(), 4);
313        assert_eq!(
314            forest,
315            vec![(0, 1, 1.0), (1, 2, 2.0), (3, 4, 1.0), (4, 5, 2.0)]
316        );
317    }
318
319    /// An isolated node keeps its row in the output shape while adding no edge.
320    #[test]
321    fn test_isolated_node_contributes_no_edges() {
322        let graph = undirected_csr(3, &[(0, 1, 1.0)]);
323
324        let forest = minimum_spanning_forest(&graph).unwrap();
325
326        assert_eq!(edge_list(&forest), vec![(0, 1, 1.0)]);
327        assert_eq!(forest.shape, (3, 3));
328        assert_eq!(forest.indptr[3], 2);
329    }
330
331    /// When the two stored directions disagree the objective picks the weight,
332    /// rather than whichever direction happens to be read first.
333    #[test]
334    fn test_asymmetric_pair_resolves_towards_the_objective() {
335        // 0->1 stored at 5.0, 1->0 at 1.0. The minimum forest must take 1.0
336        // and the maximum forest 5.0.
337        let data = vec![5.0f64, 1.0];
338        let indices = vec![1u32, 0];
339        let indptr = vec![0u32, 1, 2];
340        let graph = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (2, 2));
341
342        assert_eq!(
343            edge_list(&minimum_spanning_forest(&graph).unwrap()),
344            vec![(0, 1, 1.0)]
345        );
346        assert_eq!(
347            edge_list(&maximum_spanning_forest(&graph).unwrap()),
348            vec![(0, 1, 5.0)]
349        );
350    }
351
352    /// Ties resolve on `(lo, hi)`, so storage order cannot change the answer.
353    #[test]
354    fn test_all_equal_weights_break_ties_deterministically() {
355        // Every edge of K4 at the same weight: the tie-break is (lo, hi), so
356        // the star at node 0 wins. Building the same graph twice in a *permuted
357        // storage order* is the point. Running the same pure function on the
358        // same input twice would pass for an order-dependent implementation too.
359        let mut edges = Vec::new();
360        for i in 0..4 {
361            for j in (i + 1)..4 {
362                edges.push((i, j, 1.0));
363            }
364        }
365        let graph = undirected_csr(4, &edges);
366
367        let mut permuted = edges.clone();
368        permuted.reverse();
369        let permuted = undirected_csr_unsorted(4, &permuted);
370
371        let first = edge_list(&minimum_spanning_forest(&graph).unwrap());
372        let second = edge_list(&minimum_spanning_forest(&permuted).unwrap());
373
374        assert_eq!(first, second);
375        assert_eq!(first, vec![(0, 1, 1.0), (0, 2, 1.0), (0, 3, 1.0)]);
376    }
377
378    /// Self loops never enter the forest, and a NaN weight is filtered out.
379    #[test]
380    fn test_self_loops_and_nan_weights_are_dropped() {
381        let data = vec![f64::NAN, 7.0, 1.0, 1.0];
382        let indices = vec![0u32, 1, 0, 0];
383        let indptr = vec![0u32, 2, 3, 4];
384        // Row 0: self loop at NaN plus 0-1 at 7.0. Row 1: 1-0 at 1.0. Row 2:
385        // 2-0 at 1.0. The NaN self loop must not survive, and 0-1 resolves to
386        // the smaller stored weight.
387        let graph = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (3, 3));
388
389        assert_eq!(
390            edge_list(&minimum_spanning_forest(&graph).unwrap()),
391            vec![(0, 1, 1.0), (0, 2, 1.0)]
392        );
393    }
394
395    /// Regression: a NaN on a genuine off-diagonal edge reached `total_cmp` and
396    /// reordered the whole Kruskal sequence.
397    #[test]
398    fn test_a_nan_on_a_real_edge_is_dropped() {
399        // The self-loop case above short-circuits on `j == row`, so it passes
400        // with the NaN filter deleted. This one cannot: a NaN reaching
401        // `total_cmp` sorts above every finite weight for the maximum forest and
402        // silently reorders the whole Kruskal sequence.
403        let graph = undirected_csr(3, &[(0, 1, f64::NAN), (0, 2, 5.0), (1, 2, 6.0)]);
404
405        assert_eq!(
406            edge_list(&maximum_spanning_forest(&graph).unwrap()),
407            vec![(0, 2, 5.0), (1, 2, 6.0)]
408        );
409        assert_eq!(
410            edge_list(&minimum_spanning_forest(&graph).unwrap()),
411            vec![(0, 2, 5.0), (1, 2, 6.0)]
412        );
413    }
414
415    /// Infinities are weights, not sentinels: the NaN filter must not take them.
416    #[test]
417    fn test_infinite_weights_are_ordinary_edges() {
418        // scipy treats infinities as very large weights and so does this.
419        // Filtering them out alongside NaN discards the single most desirable
420        // edge of a maximum forest, and hands the caller an extra component
421        // whenever it was the only bridge between two of them.
422        let graph = undirected_csr(4, &[(0, 1, 1.0), (2, 3, 1.0), (1, 2, f64::INFINITY)]);
423
424        let max_forest = edge_list(&maximum_spanning_forest(&graph).unwrap());
425        assert_eq!(max_forest.len(), 3);
426        assert!(max_forest.iter().any(|e| (e.0, e.1) == (1, 2)));
427
428        // The minimum forest takes it last but still has to take it: nothing
429        // else connects the two halves.
430        let min_forest = edge_list(&minimum_spanning_forest(&graph).unwrap());
431        assert_eq!(min_forest.len(), 3);
432        assert!(min_forest.iter().any(|e| (e.0, e.1) == (1, 2)));
433    }
434
435    /// A stored zero is a real edge here, not an absent one.
436    #[test]
437    fn test_zero_weight_edges_are_kept() {
438        // scipy's csgraph would treat the stored zero as an absent edge. Here
439        // it is the cheapest edge in the graph and must win.
440        let graph = undirected_csr(3, &[(0, 1, 0.0), (1, 2, 1.0), (0, 2, 2.0)]);
441
442        assert_eq!(
443            edge_list(&minimum_spanning_forest(&graph).unwrap()),
444            vec![(0, 1, 0.0), (1, 2, 1.0)]
445        );
446    }
447
448    /// A zero-node graph returns an empty forest rather than panicking.
449    #[test]
450    fn test_empty_graph_returns_empty_forest() {
451        let graph = CompressedSparseData2::<f64>::new_csr(&[], &[], &[0], None, (0, 0));
452
453        let forest = minimum_spanning_forest(&graph).unwrap();
454
455        assert_eq!(forest.shape, (0, 0));
456        assert_eq!(forest.get_nnz(), 0);
457    }
458
459    /// A CSC matrix is refused rather than read as if it were CSR.
460    #[test]
461    fn test_rejects_csc_input() {
462        let graph = undirected_csr(3, &[(0, 1, 1.0)]).transform();
463
464        assert!(minimum_spanning_forest(&graph).is_err());
465    }
466
467    /// A non-square adjacency is not a graph and must not be walked.
468    #[test]
469    fn test_rejects_non_square_input() {
470        let data = vec![1.0f64];
471        let indices = vec![1u32];
472        let indptr = vec![0u32, 1, 1];
473        let graph = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (2, 3));
474
475        assert!(maximum_spanning_forest(&graph).is_err());
476    }
477}