Skip to main content

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}