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}