bixverse_methods/graph/graph_abstraction.rs
1//! Partition-based graph abstraction, the connectivity statistic behind PAGA.
2//!
3//! Coarse-grains a graph over a partition of its nodes and scores each pair of
4//! partitions by how many edges actually run between them versus how many a
5//! random null model predicts. The result is a small symmetric graph whose edge
6//! weights read as confidence in a connection, capped at one.
7//!
8//! The input adjacency is read as **directed and binarised**: only the sparsity
9//! pattern matters, stored values are ignored entirely. That is what the
10//! reference does (it sets the kNN distance matrix's data to ones and builds a
11//! directed igraph from it), and it lets callers hand in a
12//! `CompressedSparseData2<u8>` and pay a byte per edge.
13//!
14//! Counting is `O(nnz)` into a dense `n_partitions²` matrix, so the whole thing
15//! is one pass over the graph plus arithmetic on something the size of a
16//! clustering.
17//!
18//! ### References
19//!
20//! Wolf, et al., Genome Biol., 2019. "PAGA: graph abstraction reconciles
21//! clustering with trajectory inference through a topology preserving map of
22//! single cells."
23
24use rayon::prelude::*;
25
26use crate::prelude::*;
27use bixverse_core::core::math::sparse::{undirected_edges_to_csr, validate_square_csr};
28
29////////////
30// Consts //
31////////////
32
33/// Partition count above which edge counting drops to a single accumulator.
34///
35/// The parallel path keeps one dense `n_partitions²` `u64` accumulator per
36/// rayon chunk.
37const PARALLEL_PARTITION_LIMIT: usize = 512;
38
39/// Edges needed before the per-thread accumulators pay for themselves.
40///
41/// Each rayon chunk allocates, zeroes and then merges its own dense `k²` `u64`
42/// accumulator, so the fixed cost is `k² × 8 × num_threads` bytes touched twice
43/// no matter how few edges there are. Requiring one edge per accumulator slot
44/// per thread keeps the parallel arm to cases where the scan is the cost.
45const PARALLEL_EDGES_PER_SLOT: usize = 1;
46
47/// Largest partition count the dense coarsening accepts.
48const MAX_PARTITIONS: usize = 4096;
49
50/////////////
51// Structs //
52/////////////
53
54/// The abstracted graph produced by [partition_connectivities].
55pub struct PartitionConnectivity<T>
56where
57 T: Clone,
58{
59 /// Connectivities between partitions, `n_partitions` square, symmetric CSR
60 /// with a zero diagonal. Stored values lie in `(0, 1]`.
61 pub connectivities: CompressedSparseData2<T>,
62 /// Nodes per partition, indexed by partition id. Partitions declared
63 /// through `n_partitions` but never used keep a size of zero.
64 pub sizes: Vec<usize>,
65}
66
67/////////////
68// Helpers //
69/////////////
70
71/// Decide whether the per-thread accumulators are worth allocating.
72///
73/// Two conditions, and both matter. The partition count bounds a single
74/// accumulator's size, and the edge count decides whether there is enough work
75/// to amortise one per thread. See [PARALLEL_PARTITION_LIMIT] and
76/// [PARALLEL_EDGES_PER_SLOT].
77///
78/// ### Params
79///
80/// * `k` - Partition count.
81/// * `nnz` - Stored edges in the adjacency.
82/// * `n_threads` - Threads rayon would spread the scan over.
83///
84/// ### Returns
85///
86/// `true` when the fold-reduce arm should run.
87fn use_parallel_counting(k: usize, nnz: usize, n_threads: usize) -> bool {
88 if k > PARALLEL_PARTITION_LIMIT {
89 return false;
90 }
91 let slots = k.saturating_mul(k).saturating_mul(n_threads.max(1));
92 nnz >= slots.saturating_mul(PARALLEL_EDGES_PER_SLOT)
93}
94
95/// Count directed edges between every ordered pair of partitions.
96///
97/// The accumulator is dense `k * k` in row-major order, with
98/// `counts[a * k + b]` holding the number of stored edges from a node in `a` to
99/// a node in `b`. The diagonal therefore holds the within-partition count.
100///
101/// On the parallel arm rows are split into one chunk per thread and each chunk
102/// folds into its own accumulator, merged in the reduce. On the sequential arm
103/// a single accumulator is filled in one pass. [use_parallel_counting] picks.
104///
105/// ### Params
106///
107/// * `graph` - Square CSR adjacency, values ignored.
108/// * `partitions` - Partition label per node.
109/// * `k` - Partition count, at least one.
110/// * `parallel` - Take the fold-reduce arm.
111///
112/// ### Returns
113///
114/// The dense `k * k` count matrix, row-major. Both arms produce the same matrix.
115fn count_partition_edges<U>(
116 graph: &CompressedSparseData2<U>,
117 partitions: &[usize],
118 k: usize,
119 parallel: bool,
120) -> Vec<u64>
121where
122 U: Clone + Sync,
123{
124 let n = partitions.len();
125
126 if !parallel {
127 let mut counts = vec![0u64; k * k];
128 for i in 0..n {
129 let row = partitions[i] * k;
130 for idx in graph.indptr[i] as usize..graph.indptr[i + 1] as usize {
131 counts[row + partitions[graph.indices[idx] as usize]] += 1;
132 }
133 }
134 return counts;
135 }
136
137 let chunk_len = n.div_ceil(rayon::current_num_threads().max(1)).max(1);
138
139 (0..n)
140 .into_par_iter()
141 .with_min_len(chunk_len)
142 .fold(
143 || vec![0u64; k * k],
144 |mut counts, i| {
145 let row = partitions[i] * k;
146 for idx in graph.indptr[i] as usize..graph.indptr[i + 1] as usize {
147 counts[row + partitions[graph.indices[idx] as usize]] += 1;
148 }
149 counts
150 },
151 )
152 .reduce(
153 || vec![0u64; k * k],
154 |mut a, b| {
155 for (x, y) in a.iter_mut().zip(b) {
156 *x += y;
157 }
158 a
159 },
160 )
161}
162
163/// Turn the edge counts into the upper-triangular connectivity edge list.
164///
165/// The diagonal is skipped rather than zeroed: only pairs with `a < b` are
166/// visited, so the within-partition counts never reach the output. Pairs with
167/// no edges between them are left out of the sparsity pattern entirely.
168///
169/// ### Params
170///
171/// * `counts` - Dense `k * k` directed edge counts, row-major.
172/// * `out_edges` - Edges leaving each partition, the row sums of `counts`.
173/// * `sizes` - Nodes per partition.
174/// * `n` - Total node count.
175/// * `k` - Partition count.
176///
177/// ### Returns
178///
179/// `(a, b, connectivity)` triples with `a < b`, ascending, ready for
180/// [undirected_edges_to_csr].
181fn connectivity_edges<T>(
182 counts: &[u64],
183 out_edges: &[f64],
184 sizes: &[usize],
185 n: usize,
186 k: usize,
187) -> Vec<(u32, u32, T)>
188where
189 T: BixverseFloat,
190{
191 // The null divides by `n - 1`. Below two nodes there are no cross-partition
192 // edges to score anyway, so bail before the division rather than guarding
193 // inside the loop.
194 if n < 2 {
195 return Vec::new();
196 }
197
198 let denominator = (n - 1) as f64;
199 let mut edges = Vec::new();
200
201 for a in 0..k {
202 for b in (a + 1)..k {
203 let observed = counts[a * k + b] + counts[b * k + a];
204 if observed == 0 {
205 continue;
206 }
207
208 let expected =
209 (out_edges[a] * sizes[b] as f64 + out_edges[b] * sizes[a] as f64) / denominator;
210 // Unreachable: `observed > 0` forces both partition sizes and at
211 // least one out-edge count positive, so `expected` cannot be zero
212 // from a consistent count matrix. Kept because the reference carries
213 // the same guard, equally unreachable, and matching it makes the two
214 // easy to read side by side.
215 let connectivity = if expected != 0.0 {
216 (observed as f64 / expected).min(1.0)
217 } else {
218 1.0
219 };
220
221 edges.push((a as u32, b as u32, T::from_f64(connectivity).unwrap()));
222 }
223 }
224
225 edges
226}
227
228//////////
229// Main //
230//////////
231
232/// Connectivity of a graph's partitions under a random-connection null model.
233///
234/// For partitions `a` and `b`, with `e[a]` the number of edges leaving `a`
235/// (within-partition edges included), `s[a]` its node count and `n` the total
236/// node count:
237///
238/// ```text
239/// observed[a][b] = edges(a -> b) + edges(b -> a)
240/// expected[a][b] = (e[a] * s[b] + e[b] * s[a]) / (n - 1)
241/// conn[a][b] = min(observed / expected, 1)
242/// ```
243///
244/// The cap matters: the null over-estimates what "connected" means in real
245/// data, so ratios above one are common and are not more informative than one.
246/// See the reference's discussion.
247///
248/// Counting runs into a dense `n_partitions²` matrix whose **diagonal holds the
249/// within-partition edge count**, which makes `e[a]` a plain row sum. The
250/// diagonal is then dropped, mirroring the self-loop removal the reference gets
251/// from igraph's `simplify`.
252///
253/// Every accumulation and the ratio itself run in `f64` before the cast to `T`.
254/// Edge counts reach `10^8` on a million-node graph and the expected value is a
255/// sum of two large products, so `f32` would lose the low bits of both.
256///
257/// ### Params
258///
259/// * `graph` - Square CSR adjacency, read as directed. **Stored values are
260/// ignored**; only the sparsity pattern counts. Self loops are counted onto
261/// the diagonal and then discarded, so they influence nothing but `e[a]`.
262/// * `partitions` - Partition label per node, one per row of `graph`.
263/// * `n_partitions` - Declared partition count. `None` derives it as
264/// `max(label) + 1`, which drops trailing empty partitions; pass it
265/// explicitly to keep unused levels of a factor.
266///
267/// ### Returns
268///
269/// The abstracted graph and the partition sizes, or an error when `graph` is
270/// not a square CSR, the label count does not match the node count, a label
271/// sits outside `n_partitions`, or `n_partitions` exceeds `MAX_PARTITIONS`.
272///
273/// ### References
274///
275/// Wolf, et al., Genome Biol., 2019.
276pub fn partition_connectivities<T, U>(
277 graph: &CompressedSparseData2<U>,
278 partitions: &[usize],
279 n_partitions: Option<usize>,
280) -> Result<PartitionConnectivity<T>, MethodsErrors>
281where
282 T: BixverseFloat + BixverseNumeric,
283 U: Clone + Sync,
284{
285 let n = validate_square_csr(graph)?;
286 if partitions.len() != n {
287 return Err(MethodsErrors::CommunityAssignmentMismatch);
288 }
289
290 let k = match n_partitions {
291 Some(k) => k,
292 None => match partitions.iter().copied().max() {
293 // `m + 1` overflows on a `usize::MAX` label, which panics in debug
294 // and wraps to zero in release. Same input, two behaviours.
295 Some(m) => m
296 .checked_add(1)
297 .ok_or(MethodsErrors::PartitionLabelOutOfRange {
298 label: m,
299 n_partitions: MAX_PARTITIONS,
300 })?,
301 None => 0,
302 },
303 };
304 if k > MAX_PARTITIONS {
305 return Err(MethodsErrors::TooManyPartitions {
306 n_partitions: k,
307 max: MAX_PARTITIONS,
308 });
309 }
310 if let Some(&label) = partitions.iter().find(|&&p| p >= k) {
311 return Err(MethodsErrors::PartitionLabelOutOfRange {
312 label,
313 n_partitions: k,
314 });
315 }
316
317 let mut sizes = vec![0usize; k];
318 for &p in partitions {
319 sizes[p] += 1;
320 }
321
322 if k == 0 {
323 return Ok(PartitionConnectivity {
324 connectivities: undirected_edges_to_csr::<T>(0, &[]),
325 sizes,
326 });
327 }
328
329 let counts = count_partition_edges(
330 graph,
331 partitions,
332 k,
333 // `indices.len()` rather than `get_nnz()`: the latter needs the full
334 // numeric bound on `U`, which this reads as a pure sparsity pattern.
335 use_parallel_counting(k, graph.indices.len(), rayon::current_num_threads()),
336 );
337
338 // Row sums include the diagonal, which is exactly the reference's
339 // `es_inner_cluster + inter_es.sum(axis=1)`.
340 let out_edges: Vec<f64> = (0..k)
341 .map(|a| counts[a * k..(a + 1) * k].iter().sum::<u64>() as f64)
342 .collect();
343
344 let edges = connectivity_edges::<T>(&counts, &out_edges, &sizes, n, k);
345
346 Ok(PartitionConnectivity {
347 connectivities: undirected_edges_to_csr(k, &edges),
348 sizes,
349 })
350}
351
352///////////
353// Tests //
354///////////
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use approx::assert_relative_eq;
360
361 /// Build a directed binary CSR over `n` nodes storing exactly the given
362 /// `(from, to)` edges.
363 fn directed_csr(n: usize, edges: &[(usize, usize)]) -> CompressedSparseData2<u8> {
364 let mut rows: Vec<Vec<u32>> = vec![Vec::new(); n];
365 for &(a, b) in edges {
366 rows[a].push(b as u32);
367 }
368
369 let mut indptr: Vec<u32> = vec![0];
370 let mut indices: Vec<u32> = Vec::new();
371 let mut data: Vec<u8> = Vec::new();
372 for row in rows.iter_mut() {
373 row.sort_unstable();
374 for &j in row.iter() {
375 indices.push(j);
376 data.push(1);
377 }
378 indptr.push(indices.len() as u32);
379 }
380
381 CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n))
382 }
383
384 /// Densify a connectivity CSR into a `k` by `k` `[row][col]` copy.
385 fn densify(csr: &CompressedSparseData2<f64>) -> Vec<Vec<f64>> {
386 let k = csr.nrows();
387 let mut out = vec![vec![0.0; k]; k];
388 for (i, row) in out.iter_mut().enumerate() {
389 for idx in csr.indptr[i] as usize..csr.indptr[i + 1] as usize {
390 row[csr.indices[idx] as usize] = csr.data[idx];
391 }
392 }
393 out
394 }
395
396 /// A ring of directed edges over `members`, so every node has out-degree
397 /// one and the partition is internally connected.
398 fn ring(members: &[usize]) -> Vec<(usize, usize)> {
399 (0..members.len())
400 .map(|i| (members[i], members[(i + 1) % members.len()]))
401 .collect()
402 }
403
404 /// The connectivity of a single bridge between two dense blocks matches the
405 /// hand-computed null expectation.
406 #[test]
407 fn test_two_blocks_bridged_by_one_edge() {
408 // Two rings of 4, one directed edge 3 -> 4 between them.
409 let mut edges = ring(&[0, 1, 2, 3]);
410 edges.extend(ring(&[4, 5, 6, 7]));
411 edges.push((3, 4));
412 let graph = directed_csr(8, &edges);
413 let partitions = vec![0, 0, 0, 0, 1, 1, 1, 1];
414
415 let res: PartitionConnectivity<f64> =
416 partition_connectivities(&graph, &partitions, None).unwrap();
417
418 assert_eq!(res.sizes, vec![4, 4]);
419
420 // e[0] = 5 (four ring edges plus the bridge), e[1] = 4, s = (4, 4),
421 // n = 8, so expected = (5 * 4 + 4 * 4) / 7 = 36 / 7 and observed = 1.
422 let dense = densify(&res.connectivities);
423 let want = 1.0 / (36.0 / 7.0);
424 assert_relative_eq!(dense[0][1], want, epsilon = 1e-12);
425 assert_relative_eq!(dense[1][0], want, epsilon = 1e-12);
426 assert_eq!(dense[0][0], 0.0);
427 assert_eq!(dense[1][1], 0.0);
428 assert!(want < 1.0);
429 }
430
431 /// Connectivity is not transitive: unlinked partitions stay at zero.
432 #[test]
433 fn test_chain_of_three_partitions_leaves_the_ends_unconnected() {
434 let mut edges = ring(&[0, 1]);
435 edges.extend(ring(&[2, 3]));
436 edges.extend(ring(&[4, 5]));
437 edges.push((1, 2));
438 edges.push((3, 4));
439 let graph = directed_csr(6, &edges);
440 let partitions = vec![0, 0, 1, 1, 2, 2];
441
442 let res: PartitionConnectivity<f64> =
443 partition_connectivities(&graph, &partitions, None).unwrap();
444
445 let dense = densify(&res.connectivities);
446 assert!(dense[0][1] > 0.0);
447 assert!(dense[1][2] > 0.0);
448 assert_eq!(dense[0][2], 0.0);
449 assert_eq!(dense[2][0], 0.0);
450 }
451
452 /// An observation landing exactly on the null gives one, unclamped.
453 #[test]
454 fn test_connectivity_of_exactly_one_is_left_alone() {
455 // Two singleton partitions joined in both directions. e = (1, 1),
456 // s = (1, 1), n = 2, so expected = (1 + 1) / 1 = 2 while observed is
457 // also 2. The ratio is exactly one, so the clamp is a no-op here; this
458 // pins the boundary, not the clamp. `test_connectivity_is_capped_at_one`
459 // is the one that exercises the clamp.
460 let graph = directed_csr(2, &[(0, 1), (1, 0)]);
461 let partitions = vec![0, 1];
462
463 let res: PartitionConnectivity<f64> =
464 partition_connectivities(&graph, &partitions, None).unwrap();
465
466 assert_relative_eq!(densify(&res.connectivities)[0][1], 1.0, epsilon = 1e-12);
467 }
468
469 /// A ratio above the null is clamped to one rather than reported raw.
470 #[test]
471 fn test_connectivity_is_capped_at_one() {
472 // The observation has to genuinely exceed the null or the clamp is
473 // never asked to do anything. Four nodes, two per partition, fully
474 // cross-connected and nothing internal: e = (4, 4), s = (2, 2), n = 4,
475 // expected = (8 + 8) / 3 = 16 / 3 = 5.33 against an observed 8, so the
476 // raw ratio is 1.5.
477 let cross = directed_csr(
478 4,
479 &[
480 (0, 2),
481 (0, 3),
482 (1, 2),
483 (1, 3),
484 (2, 0),
485 (2, 1),
486 (3, 0),
487 (3, 1),
488 ],
489 );
490 let res: PartitionConnectivity<f64> =
491 partition_connectivities(&cross, &[0, 0, 1, 1], None).unwrap();
492
493 assert_relative_eq!(densify(&res.connectivities)[0][1], 1.0, epsilon = 1e-12);
494 }
495
496 /// Self loops count towards the within-partition total but never emit an
497 /// off-diagonal or diagonal entry of their own.
498 #[test]
499 fn test_self_loops_only_move_the_expected_value() {
500 // A self loop is a within-partition edge: it lands on the diagonal, so
501 // it raises e[a] and therefore lowers the connectivity, but it never
502 // becomes an entry of its own.
503 let plain = directed_csr(2, &[(0, 1), (1, 0)]);
504 let looped = directed_csr(2, &[(0, 0), (0, 1), (1, 0)]);
505
506 let a: PartitionConnectivity<f64> =
507 partition_connectivities(&plain, &[0, 1], None).unwrap();
508 let b: PartitionConnectivity<f64> =
509 partition_connectivities(&looped, &[0, 1], None).unwrap();
510
511 let (da, db) = (densify(&a.connectivities), densify(&b.connectivities));
512 assert!(db[0][1] < da[0][1]);
513 assert_eq!(db[0][0], 0.0);
514 assert_eq!(db[1][1], 0.0);
515 }
516
517 /// One partition yields a 1x1 empty matrix, not a self-connectivity.
518 #[test]
519 fn test_single_partition_has_no_connectivities() {
520 let graph = directed_csr(4, &ring(&[0, 1, 2, 3]));
521
522 let res: PartitionConnectivity<f64> =
523 partition_connectivities(&graph, &[0, 0, 0, 0], None).unwrap();
524
525 assert_eq!(res.sizes, vec![4]);
526 assert_eq!(res.connectivities.shape, (1, 1));
527 assert_eq!(res.connectivities.get_nnz(), 0);
528 }
529
530 /// A caller-declared partition count keeps unoccupied labels in the output
531 /// shape rather than compacting them away.
532 #[test]
533 fn test_declared_empty_partition_is_kept() {
534 let graph = directed_csr(2, &[(0, 1), (1, 0)]);
535
536 let res: PartitionConnectivity<f64> =
537 partition_connectivities(&graph, &[0, 1], Some(4)).unwrap();
538
539 assert_eq!(res.sizes, vec![1, 1, 0, 0]);
540 assert_eq!(res.connectivities.shape, (4, 4));
541 assert_eq!(res.connectivities.get_nnz(), 2);
542 }
543
544 /// The sequential and parallel edge-counting arms give identical counts.
545 #[test]
546 fn test_both_counting_arms_agree() {
547 // Both arms driven directly on the same graph and the same `k`. Forcing
548 // the arms apart by declaring more partitions changes the output shape
549 // as well as the code path, which is not the thing under test.
550 let mut edges = ring(&[0, 1, 2, 3]);
551 edges.extend(ring(&[4, 5, 6, 7]));
552 edges.push((3, 4));
553 edges.push((7, 0));
554 edges.push((0, 0));
555 let graph = directed_csr(8, &edges);
556 let partitions = vec![0, 0, 0, 1, 1, 2, 2, 2];
557
558 let sequential = count_partition_edges(&graph, &partitions, 3, false);
559 let parallel = count_partition_edges(&graph, &partitions, 3, true);
560
561 assert_eq!(sequential, parallel);
562 assert_eq!(sequential.iter().sum::<u64>(), graph.indices.len() as u64);
563 }
564
565 /// Regression: the parallel arm was chosen on partition count alone, so it
566 /// fired on graphs too small to pay for the per-thread accumulators.
567 #[test]
568 fn test_parallel_counting_needs_edges_as_well_as_few_partitions() {
569 // Keying the decision on `k` alone is what makes this go wrong: at
570 // k = 500 on 64 cores the parallel arm allocates and zeroes 128 MB of
571 // accumulators to perform 750k increments.
572 assert!(!use_parallel_counting(500, 750_000, 64));
573 assert!(!use_parallel_counting(MAX_PARTITIONS, 10_000_000, 8));
574 // A real single-cell graph at a real cluster count still takes it.
575 assert!(use_parallel_counting(30, 30_000_000, 16));
576 // And the partition ceiling still binds on its own.
577 assert!(!use_parallel_counting(
578 PARALLEL_PARTITION_LIMIT + 1,
579 usize::MAX,
580 1
581 ));
582 }
583
584 /// Regression: a non-monotonic `indptr` silently skipped rows instead of
585 /// erroring.
586 #[test]
587 fn test_rejects_a_non_monotonic_indptr() {
588 // `indptr = [0, 5, 2, 7]` makes the Rust range `5..2` empty, so row 1 is
589 // skipped and the answer comes back computed on a subset of the graph
590 // with nothing said about it.
591 let data = vec![1u8; 7];
592 let indices = vec![1u32, 2, 0, 2, 0, 0, 1];
593 let indptr = vec![0u32, 5, 2, 7];
594 let graph = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (3, 3));
595
596 assert!(matches!(
597 partition_connectivities::<f64, _>(&graph, &[0, 0, 1], None),
598 Err(MethodsErrors::Core(CoreErrors::MalformedCsr(_)))
599 ));
600 }
601
602 /// Regression: a column index beyond the node count panicked on the
603 /// `partitions` lookup rather than erroring.
604 #[test]
605 fn test_rejects_an_out_of_range_column_index() {
606 // Indexing `partitions[9]` on a three-node graph panics through the
607 // extendr boundary rather than erroring.
608 let data = vec![1u8, 1];
609 let indices = vec![1u32, 9];
610 let indptr = vec![0u32, 1, 2, 2];
611 let graph = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (3, 3));
612
613 assert!(matches!(
614 partition_connectivities::<f64, _>(&graph, &[0, 0, 1], None),
615 Err(MethodsErrors::Core(CoreErrors::MalformedCsr(_)))
616 ));
617 }
618
619 /// Fewer labels than nodes is refused rather than silently truncated.
620 #[test]
621 fn test_rejects_label_count_mismatch() {
622 let graph = directed_csr(3, &[(0, 1)]);
623
624 let res = partition_connectivities::<f64, _>(&graph, &[0, 1], None);
625
626 assert!(matches!(
627 res,
628 Err(MethodsErrors::CommunityAssignmentMismatch)
629 ));
630 }
631
632 /// A label past the declared partition count is refused, with both the
633 /// offending label and the ceiling reported.
634 #[test]
635 fn test_rejects_out_of_range_label() {
636 let graph = directed_csr(2, &[(0, 1)]);
637
638 let res = partition_connectivities::<f64, _>(&graph, &[0, 5], Some(2));
639
640 assert!(matches!(
641 res,
642 Err(MethodsErrors::PartitionLabelOutOfRange {
643 label: 5,
644 n_partitions: 2
645 })
646 ));
647 }
648
649 /// `MAX_PARTITIONS` binds, so the dense accumulator cannot be sized
650 /// arbitrarily by the caller.
651 #[test]
652 fn test_rejects_too_many_partitions() {
653 let graph = directed_csr(2, &[(0, 1)]);
654
655 let res = partition_connectivities::<f64, _>(&graph, &[0, 1], Some(MAX_PARTITIONS + 1));
656
657 assert!(matches!(res, Err(MethodsErrors::TooManyPartitions { .. })));
658 }
659
660 /// A CSC matrix is refused rather than read as if it were CSR.
661 #[test]
662 fn test_rejects_csc_input() {
663 let graph = directed_csr(2, &[(0, 1)]).transform();
664
665 let res = partition_connectivities::<f64, _>(&graph, &[0, 1], None);
666
667 assert!(matches!(
668 res,
669 Err(MethodsErrors::Core(CoreErrors::SparseMatrixMustBeCsr))
670 ));
671 }
672}