Skip to main content

scirs2_graph/
parallel_algorithms.rs

1//! Parallel graph algorithms for large-scale graph analytics
2//!
3//! This module provides parallel implementations of fundamental graph algorithms,
4//! designed for performance on multi-core systems. All algorithms operate on the
5//! [`CsrGraph`] format for cache-friendly, contiguous memory access.
6//!
7//! # Algorithms
8//!
9//! - **Parallel BFS**: Level-synchronous breadth-first search
10//! - **Parallel Connected Components**: Union-find with path compression
11//! - **Parallel PageRank**: Power iteration with parallel SpMV
12//! - **Parallel Triangle Counting**: Intersection-based counting
13//!
14//! All algorithms are feature-gated behind `#[cfg(feature = "parallel")]`.
15
16#[cfg(feature = "parallel")]
17use scirs2_core::parallel_ops::*;
18
19use crate::compressed::CsrGraph;
20use crate::error::{GraphError, Result};
21
22// ────────────────────────────────────────────────────────────────────────────
23// Parallel BFS (Level-Synchronous)
24// ────────────────────────────────────────────────────────────────────────────
25
26/// Result of a BFS traversal.
27#[derive(Debug, Clone)]
28pub struct BfsResult {
29    /// Distance from source to each node. `usize::MAX` means unreachable.
30    pub distances: Vec<usize>,
31    /// Parent of each node in the BFS tree. `usize::MAX` means no parent.
32    pub parents: Vec<usize>,
33    /// Number of nodes visited.
34    pub num_visited: usize,
35    /// Number of BFS levels (depth of BFS tree).
36    pub num_levels: usize,
37}
38
39/// Perform a level-synchronous parallel BFS from a source node.
40///
41/// At each level, the current frontier is processed in parallel: each node's
42/// neighbors are checked, and unvisited neighbors are added to the next frontier.
43///
44/// # Arguments
45/// * `graph` - The CSR graph to traverse
46/// * `source` - The source node for BFS
47///
48/// # Returns
49/// A [`BfsResult`] containing distances, parents, and traversal statistics.
50///
51/// # Complexity
52/// - Time: O((V + E) / P) with P threads, plus O(L) synchronization barriers
53///   where L is the BFS depth
54/// - Space: O(V) for distances, parents, and frontier arrays
55#[cfg(feature = "parallel")]
56pub fn parallel_bfs(graph: &CsrGraph, source: usize) -> Result<BfsResult> {
57    let n = graph.num_nodes();
58    if source >= n {
59        return Err(GraphError::node_not_found_with_context(
60            source,
61            n,
62            "parallel_bfs source",
63        ));
64    }
65
66    let not_visited = usize::MAX;
67    let mut distances = vec![not_visited; n];
68    let mut parents = vec![not_visited; n];
69
70    distances[source] = 0;
71
72    let mut frontier = vec![source];
73    let mut num_visited = 1;
74    let mut max_level = 0;
75
76    while !frontier.is_empty() {
77        let next_level = max_level + 1;
78
79        // Process current frontier in parallel
80        // Each thread produces a local list of newly discovered nodes
81        let next_frontiers: Vec<Vec<(usize, usize)>> = frontier
82            .par_iter()
83            .map(|&node| {
84                let mut local_discovered = Vec::new();
85                for (neighbor, _weight) in graph.neighbors(node) {
86                    // We use a relaxed check here; final dedup happens below
87                    if distances[neighbor] == not_visited {
88                        local_discovered.push((neighbor, node));
89                    }
90                }
91                local_discovered
92            })
93            .collect();
94
95        // Merge and deduplicate: only the first discovery wins
96        let mut next_frontier = Vec::new();
97        for discovered in next_frontiers {
98            for (neighbor, parent) in discovered {
99                if distances[neighbor] == not_visited {
100                    distances[neighbor] = next_level;
101                    parents[neighbor] = parent;
102                    next_frontier.push(neighbor);
103                    num_visited += 1;
104                }
105            }
106        }
107
108        if !next_frontier.is_empty() {
109            max_level = next_level;
110        }
111        frontier = next_frontier;
112    }
113
114    Ok(BfsResult {
115        distances,
116        parents,
117        num_visited,
118        num_levels: max_level,
119    })
120}
121
122/// Sequential BFS for comparison and non-parallel builds.
123pub fn bfs(graph: &CsrGraph, source: usize) -> Result<BfsResult> {
124    let n = graph.num_nodes();
125    if source >= n {
126        return Err(GraphError::node_not_found_with_context(
127            source,
128            n,
129            "bfs source",
130        ));
131    }
132
133    let not_visited = usize::MAX;
134    let mut distances = vec![not_visited; n];
135    let mut parents = vec![not_visited; n];
136
137    distances[source] = 0;
138
139    let mut frontier = vec![source];
140    let mut num_visited = 1;
141    let mut max_level = 0;
142
143    while !frontier.is_empty() {
144        let next_level = max_level + 1;
145        let mut next_frontier = Vec::new();
146        for &node in &frontier {
147            for (neighbor, _weight) in graph.neighbors(node) {
148                if distances[neighbor] == not_visited {
149                    distances[neighbor] = next_level;
150                    parents[neighbor] = node;
151                    next_frontier.push(neighbor);
152                    num_visited += 1;
153                }
154            }
155        }
156        if !next_frontier.is_empty() {
157            max_level = next_level;
158        }
159        frontier = next_frontier;
160    }
161
162    Ok(BfsResult {
163        distances,
164        parents,
165        num_visited,
166        num_levels: max_level,
167    })
168}
169
170// ────────────────────────────────────────────────────────────────────────────
171// Parallel Connected Components (Union-Find)
172// ────────────────────────────────────────────────────────────────────────────
173
174/// Result of connected components computation.
175#[derive(Debug, Clone)]
176pub struct ComponentsResult {
177    /// Component label for each node. Nodes in the same component share a label.
178    pub labels: Vec<usize>,
179    /// Number of connected components.
180    pub num_components: usize,
181    /// Size of each component (indexed by component ID after relabeling).
182    pub component_sizes: Vec<usize>,
183}
184
185/// Thread-safe union-find structure for parallel connected components.
186struct UnionFind {
187    parent: Vec<std::sync::atomic::AtomicUsize>,
188}
189
190impl UnionFind {
191    fn new(n: usize) -> Self {
192        let parent: Vec<_> = (0..n).map(std::sync::atomic::AtomicUsize::new).collect();
193        Self { parent }
194    }
195
196    fn find(&self, mut x: usize) -> usize {
197        loop {
198            let p = self.parent[x].load(std::sync::atomic::Ordering::Relaxed);
199            if p == x {
200                return x;
201            }
202            let gp = self.parent[p].load(std::sync::atomic::Ordering::Relaxed);
203            // Path compression: point x to grandparent
204            let _ = self.parent[x].compare_exchange_weak(
205                p,
206                gp,
207                std::sync::atomic::Ordering::Relaxed,
208                std::sync::atomic::Ordering::Relaxed,
209            );
210            x = gp;
211        }
212    }
213
214    fn union(&self, x: usize, y: usize) {
215        loop {
216            let rx = self.find(x);
217            let ry = self.find(y);
218            if rx == ry {
219                return;
220            }
221            // Always make the smaller root the parent (deterministic)
222            let (small, large) = if rx < ry { (rx, ry) } else { (ry, rx) };
223            match self.parent[large].compare_exchange_weak(
224                large,
225                small,
226                std::sync::atomic::Ordering::Relaxed,
227                std::sync::atomic::Ordering::Relaxed,
228            ) {
229                Ok(_) => return,
230                Err(_) => continue,
231            }
232        }
233    }
234}
235
236/// Find connected components using parallel label propagation with union-find.
237///
238/// Uses a lock-free union-find data structure where each edge `(u, v)` triggers
239/// a union operation. Edges are processed in parallel.
240///
241/// # Arguments
242/// * `graph` - An undirected CSR graph
243///
244/// # Returns
245/// A [`ComponentsResult`] with component labels, count, and sizes.
246///
247/// # Note
248/// For directed graphs, this finds weakly connected components (treating edges
249/// as undirected).
250#[cfg(feature = "parallel")]
251pub fn parallel_connected_components(graph: &CsrGraph) -> ComponentsResult {
252    let n = graph.num_nodes();
253    if n == 0 {
254        return ComponentsResult {
255            labels: vec![],
256            num_components: 0,
257            component_sizes: vec![],
258        };
259    }
260
261    let uf = UnionFind::new(n);
262
263    // Process all edges in parallel
264    (0..n).into_par_iter().for_each(|node| {
265        for (neighbor, _) in graph.neighbors(node) {
266            uf.union(node, neighbor);
267        }
268    });
269
270    // Finalize: find root for each node
271    let labels: Vec<usize> = (0..n).into_par_iter().map(|i| uf.find(i)).collect();
272
273    // Relabel to contiguous IDs and compute sizes
274    relabel_components(&labels)
275}
276
277/// Sequential connected components (for non-parallel builds).
278pub fn connected_components(graph: &CsrGraph) -> ComponentsResult {
279    let n = graph.num_nodes();
280    if n == 0 {
281        return ComponentsResult {
282            labels: vec![],
283            num_components: 0,
284            component_sizes: vec![],
285        };
286    }
287
288    let not_visited = usize::MAX;
289    let mut labels = vec![not_visited; n];
290    let mut component_id = 0;
291
292    for start in 0..n {
293        if labels[start] != not_visited {
294            continue;
295        }
296        // BFS from start
297        let mut queue = vec![start];
298        labels[start] = component_id;
299        let mut head = 0;
300        while head < queue.len() {
301            let node = queue[head];
302            head += 1;
303            for (neighbor, _) in graph.neighbors(node) {
304                if labels[neighbor] == not_visited {
305                    labels[neighbor] = component_id;
306                    queue.push(neighbor);
307                }
308            }
309        }
310        component_id += 1;
311    }
312
313    let num_components = component_id;
314    let mut component_sizes = vec![0usize; num_components];
315    for &label in &labels {
316        if label < num_components {
317            component_sizes[label] += 1;
318        }
319    }
320
321    ComponentsResult {
322        labels,
323        num_components,
324        component_sizes,
325    }
326}
327
328/// Relabel component roots to contiguous IDs (0, 1, 2, ...) and compute sizes.
329fn relabel_components(raw_labels: &[usize]) -> ComponentsResult {
330    let n = raw_labels.len();
331    let mut root_to_id = std::collections::HashMap::new();
332    let mut next_id = 0usize;
333    let mut labels = vec![0usize; n];
334
335    for (i, &root) in raw_labels.iter().enumerate() {
336        let id = root_to_id.entry(root).or_insert_with(|| {
337            let id = next_id;
338            next_id += 1;
339            id
340        });
341        labels[i] = *id;
342    }
343
344    let num_components = next_id;
345    let mut component_sizes = vec![0usize; num_components];
346    for &label in &labels {
347        component_sizes[label] += 1;
348    }
349
350    ComponentsResult {
351        labels,
352        num_components,
353        component_sizes,
354    }
355}
356
357// ────────────────────────────────────────────────────────────────────────────
358// Parallel PageRank
359// ────────────────────────────────────────────────────────────────────────────
360
361/// Configuration for PageRank computation.
362#[derive(Debug, Clone)]
363pub struct PageRankConfig {
364    /// Damping factor (default: 0.85)
365    pub damping: f64,
366    /// Maximum iterations (default: 100)
367    pub max_iterations: usize,
368    /// Convergence tolerance on L1 norm (default: 1e-6)
369    pub tolerance: f64,
370}
371
372impl Default for PageRankConfig {
373    fn default() -> Self {
374        Self {
375            damping: 0.85,
376            max_iterations: 100,
377            tolerance: 1e-6,
378        }
379    }
380}
381
382/// Result of PageRank computation.
383#[derive(Debug, Clone)]
384pub struct PageRankResult {
385    /// PageRank score for each node.
386    pub scores: Vec<f64>,
387    /// Number of iterations performed.
388    pub iterations: usize,
389    /// Final L1 residual.
390    pub residual: f64,
391    /// Whether the algorithm converged.
392    pub converged: bool,
393}
394
395/// Compute PageRank using parallel power iteration.
396///
397/// At each iteration:
398/// 1. Compute the stochastic matrix column contributions in parallel
399/// 2. Apply the PageRank formula: `r = (1-d)/n + d * A_norm * r_old`
400/// 3. Check L1 convergence
401///
402/// Dangling nodes (out-degree 0) distribute their rank equally to all nodes.
403///
404/// # Arguments
405/// * `graph` - A directed CSR graph (or undirected treated as bidirectional)
406/// * `config` - PageRank parameters
407///
408/// # Returns
409/// A [`PageRankResult`] with scores and convergence information.
410#[cfg(feature = "parallel")]
411pub fn parallel_pagerank(graph: &CsrGraph, config: &PageRankConfig) -> Result<PageRankResult> {
412    let n = graph.num_nodes();
413    if n == 0 {
414        return Ok(PageRankResult {
415            scores: vec![],
416            iterations: 0,
417            residual: 0.0,
418            converged: true,
419        });
420    }
421
422    let d = config.damping;
423    let inv_n = 1.0 / n as f64;
424
425    // Compute out-degrees
426    let out_degree: Vec<usize> = (0..n).into_par_iter().map(|i| graph.degree(i)).collect();
427
428    // Initialize uniform scores
429    let mut scores = vec![inv_n; n];
430    let mut new_scores = vec![0.0f64; n];
431    let mut iterations = 0;
432    let mut residual = f64::MAX;
433
434    while iterations < config.max_iterations && residual > config.tolerance {
435        iterations += 1;
436
437        // Compute dangling node contribution (nodes with out-degree 0)
438        let dangling_sum: f64 = (0..n)
439            .into_par_iter()
440            .filter(|&i| out_degree[i] == 0)
441            .map(|i| scores[i])
442            .sum();
443
444        let teleport = (1.0 - d) * inv_n + d * dangling_sum * inv_n;
445
446        // Parallel SpMV-style computation
447        // For each node v, sum scores[u] / out_degree[u] for all in-neighbors u
448        // We compute this by iterating outgoing edges: each node u
449        // contributes scores[u] / out_degree[u] to each of its neighbors
450
451        // First zero out new_scores
452        new_scores.iter_mut().for_each(|x| *x = 0.0);
453
454        // Sequential accumulation (parallel reading, sequential writing)
455        // For large graphs, we use a transpose approach:
456        // Build contributions per-source, then scatter
457        for src in 0..n {
458            if out_degree[src] == 0 {
459                continue;
460            }
461            let contrib = scores[src] / out_degree[src] as f64;
462            for (dst, _) in graph.neighbors(src) {
463                new_scores[dst] += contrib;
464            }
465        }
466
467        // Apply damping and teleport
468        let final_scores: Vec<f64> = new_scores
469            .par_iter()
470            .map(|&incoming| teleport + d * incoming)
471            .collect();
472
473        // Compute L1 residual in parallel
474        residual = scores
475            .par_iter()
476            .zip(final_scores.par_iter())
477            .map(|(&old, &new)| (old - new).abs())
478            .sum();
479
480        scores = final_scores;
481    }
482
483    Ok(PageRankResult {
484        scores,
485        iterations,
486        residual,
487        converged: residual <= config.tolerance,
488    })
489}
490
491/// Sequential PageRank for non-parallel builds.
492pub fn pagerank(graph: &CsrGraph, config: &PageRankConfig) -> Result<PageRankResult> {
493    let n = graph.num_nodes();
494    if n == 0 {
495        return Ok(PageRankResult {
496            scores: vec![],
497            iterations: 0,
498            residual: 0.0,
499            converged: true,
500        });
501    }
502
503    let d = config.damping;
504    let inv_n = 1.0 / n as f64;
505
506    let out_degree: Vec<usize> = (0..n).map(|i| graph.degree(i)).collect();
507
508    let mut scores = vec![inv_n; n];
509    let mut new_scores = vec![0.0f64; n];
510    let mut iterations = 0;
511    let mut residual = f64::MAX;
512
513    while iterations < config.max_iterations && residual > config.tolerance {
514        iterations += 1;
515
516        let dangling_sum: f64 = (0..n)
517            .filter(|&i| out_degree[i] == 0)
518            .map(|i| scores[i])
519            .sum();
520
521        let teleport = (1.0 - d) * inv_n + d * dangling_sum * inv_n;
522
523        new_scores.iter_mut().for_each(|x| *x = 0.0);
524
525        for src in 0..n {
526            if out_degree[src] == 0 {
527                continue;
528            }
529            let contrib = scores[src] / out_degree[src] as f64;
530            for (dst, _) in graph.neighbors(src) {
531                new_scores[dst] += contrib;
532            }
533        }
534
535        residual = 0.0;
536        for i in 0..n {
537            let new_val = teleport + d * new_scores[i];
538            residual += (scores[i] - new_val).abs();
539            scores[i] = new_val;
540        }
541    }
542
543    Ok(PageRankResult {
544        scores,
545        iterations,
546        residual,
547        converged: residual <= config.tolerance,
548    })
549}
550
551// ────────────────────────────────────────────────────────────────────────────
552// Parallel Triangle Counting
553// ────────────────────────────────────────────────────────────────────────────
554
555/// Result of triangle counting.
556#[derive(Debug, Clone)]
557pub struct TriangleCountResult {
558    /// Total number of triangles in the graph.
559    pub total_triangles: usize,
560    /// Number of triangles each node participates in.
561    pub per_node_triangles: Vec<usize>,
562}
563
564/// Count triangles in an undirected graph using parallel intersection.
565///
566/// For each edge (u, v) where u < v, count the number of common neighbors w
567/// where w > v. This avoids triple-counting.
568///
569/// For directed graphs, edges are treated as undirected.
570///
571/// # Complexity
572/// - Time: O(m * d_max / P) where m is edges, d_max is max degree, P is threads
573/// - Space: O(V) for per-node triangle counts
574#[cfg(feature = "parallel")]
575pub fn parallel_triangle_count(graph: &CsrGraph) -> TriangleCountResult {
576    let n = graph.num_nodes();
577    if n < 3 {
578        return TriangleCountResult {
579            total_triangles: 0,
580            per_node_triangles: vec![0; n],
581        };
582    }
583
584    // For each node, count triangles it participates in
585    // We use the approach: for each node u, for each neighbor v > u,
586    // count |N(u) intersect N(v)| where neighbors > v
587    let per_node: Vec<usize> = (0..n)
588        .into_par_iter()
589        .map(|u| {
590            let mut count = 0;
591            let neighbors_u: Vec<usize> = graph.neighbors(u).map(|(v, _)| v).collect();
592
593            for &v in &neighbors_u {
594                if v <= u {
595                    continue;
596                }
597                // Count common neighbors w > v
598                let neighbors_v: Vec<usize> = graph.neighbors(v).map(|(w, _)| w).collect();
599
600                // Sorted intersection counting
601                let mut iu = 0;
602                let mut iv = 0;
603                while iu < neighbors_u.len() && iv < neighbors_v.len() {
604                    let nu = neighbors_u[iu];
605                    let nv = neighbors_v[iv];
606                    if nu <= v {
607                        iu += 1;
608                    } else if nv <= v {
609                        iv += 1;
610                    } else if nu == nv {
611                        count += 1;
612                        iu += 1;
613                        iv += 1;
614                    } else if nu < nv {
615                        iu += 1;
616                    } else {
617                        iv += 1;
618                    }
619                }
620            }
621            count
622        })
623        .collect();
624
625    let total: usize = per_node.iter().sum();
626
627    // Each triangle is counted once per node that "owns" it (the smallest node u)
628    // But per_node counts for each u the triangles where u is the smallest vertex
629    // Total triangles = sum of per_node counts
630    // For per_node_triangles, each node participates in triangles discovered by itself
631    // AND by smaller nodes. We need a separate pass for per-node participation.
632
633    // Build proper per-node participation counts
634    let mut per_node_triangles = vec![0usize; n];
635
636    // Recount with participation tracking
637    for u in 0..n {
638        let neighbors_u: Vec<usize> = graph.neighbors(u).map(|(v, _)| v).collect();
639        for &v in &neighbors_u {
640            if v <= u {
641                continue;
642            }
643            let neighbors_v: Vec<usize> = graph.neighbors(v).map(|(w, _)| w).collect();
644            let mut iu = 0;
645            let mut iv = 0;
646            while iu < neighbors_u.len() && iv < neighbors_v.len() {
647                let nu = neighbors_u[iu];
648                let nv = neighbors_v[iv];
649                if nu <= v {
650                    iu += 1;
651                } else if nv <= v {
652                    iv += 1;
653                } else if nu == nv {
654                    // Triangle: u, v, nu
655                    per_node_triangles[u] += 1;
656                    per_node_triangles[v] += 1;
657                    per_node_triangles[nu] += 1;
658                    iu += 1;
659                    iv += 1;
660                } else if nu < nv {
661                    iu += 1;
662                } else {
663                    iv += 1;
664                }
665            }
666        }
667    }
668
669    TriangleCountResult {
670        total_triangles: total,
671        per_node_triangles,
672    }
673}
674
675/// Sequential triangle counting.
676pub fn triangle_count(graph: &CsrGraph) -> TriangleCountResult {
677    let n = graph.num_nodes();
678    if n < 3 {
679        return TriangleCountResult {
680            total_triangles: 0,
681            per_node_triangles: vec![0; n],
682        };
683    }
684
685    let mut total = 0usize;
686    let mut per_node_triangles = vec![0usize; n];
687
688    for u in 0..n {
689        let neighbors_u: Vec<usize> = graph.neighbors(u).map(|(v, _)| v).collect();
690        for &v in &neighbors_u {
691            if v <= u {
692                continue;
693            }
694            let neighbors_v: Vec<usize> = graph.neighbors(v).map(|(w, _)| w).collect();
695
696            // Sorted intersection for w > v
697            let mut iu = 0;
698            let mut iv = 0;
699            while iu < neighbors_u.len() && iv < neighbors_v.len() {
700                let nu = neighbors_u[iu];
701                let nv = neighbors_v[iv];
702                if nu <= v {
703                    iu += 1;
704                } else if nv <= v {
705                    iv += 1;
706                } else if nu == nv {
707                    total += 1;
708                    per_node_triangles[u] += 1;
709                    per_node_triangles[v] += 1;
710                    per_node_triangles[nu] += 1;
711                    iu += 1;
712                    iv += 1;
713                } else if nu < nv {
714                    iu += 1;
715                } else {
716                    iv += 1;
717                }
718            }
719        }
720    }
721
722    TriangleCountResult {
723        total_triangles: total,
724        per_node_triangles,
725    }
726}
727
728// ────────────────────────────────────────────────────────────────────────────
729// Tests
730// ────────────────────────────────────────────────────────────────────────────
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    fn make_path_graph(n: usize) -> CsrGraph {
737        let edges: Vec<(usize, usize, f64)> = (0..n - 1).map(|i| (i, i + 1, 1.0)).collect();
738        CsrGraph::from_edges(n, edges, false).expect("build path")
739    }
740
741    fn make_cycle_graph(n: usize) -> CsrGraph {
742        let mut edges: Vec<(usize, usize, f64)> = (0..n - 1).map(|i| (i, i + 1, 1.0)).collect();
743        edges.push((n - 1, 0, 1.0));
744        CsrGraph::from_edges(n, edges, false).expect("build cycle")
745    }
746
747    fn make_complete_graph(n: usize) -> CsrGraph {
748        let mut edges = Vec::new();
749        for i in 0..n {
750            for j in i + 1..n {
751                edges.push((i, j, 1.0));
752            }
753        }
754        CsrGraph::from_edges(n, edges, false).expect("build complete")
755    }
756
757    fn make_disconnected_graph() -> CsrGraph {
758        // Two components: {0,1,2} and {3,4}
759        let edges = vec![(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0), (3, 4, 1.0)];
760        CsrGraph::from_edges(5, edges, false).expect("build disconnected")
761    }
762
763    // ── BFS Tests ──
764
765    #[test]
766    fn test_bfs_path() {
767        let g = make_path_graph(5);
768        let result = bfs(&g, 0).expect("bfs");
769
770        assert_eq!(result.distances[0], 0);
771        assert_eq!(result.distances[1], 1);
772        assert_eq!(result.distances[4], 4);
773        assert_eq!(result.num_visited, 5);
774        assert_eq!(result.num_levels, 4);
775    }
776
777    #[test]
778    fn test_bfs_cycle() {
779        let g = make_cycle_graph(6);
780        let result = bfs(&g, 0).expect("bfs");
781
782        assert_eq!(result.distances[0], 0);
783        assert_eq!(result.distances[1], 1);
784        assert_eq!(result.distances[3], 3); // 0-1-2-3 or 0-5-4-3
785        assert_eq!(result.num_visited, 6);
786    }
787
788    #[test]
789    fn test_bfs_disconnected() {
790        let g = make_disconnected_graph();
791        let result = bfs(&g, 0).expect("bfs");
792
793        assert_eq!(result.distances[0], 0);
794        assert_eq!(result.distances[1], 1);
795        assert_eq!(result.distances[2], 1);
796        assert_eq!(result.distances[3], usize::MAX); // unreachable
797        assert_eq!(result.distances[4], usize::MAX);
798        assert_eq!(result.num_visited, 3);
799    }
800
801    #[test]
802    fn test_bfs_invalid_source() {
803        let g = make_path_graph(3);
804        assert!(bfs(&g, 10).is_err());
805    }
806
807    #[test]
808    fn test_bfs_single_node() {
809        let g = CsrGraph::from_edges(1, vec![], false).expect("build");
810        let result = bfs(&g, 0).expect("bfs");
811        assert_eq!(result.distances[0], 0);
812        assert_eq!(result.num_visited, 1);
813        assert_eq!(result.num_levels, 0);
814    }
815
816    #[cfg(feature = "parallel")]
817    #[test]
818    fn test_parallel_bfs_path() {
819        let g = make_path_graph(10);
820        let result = parallel_bfs(&g, 0).expect("parallel bfs");
821
822        assert_eq!(result.distances[0], 0);
823        assert_eq!(result.distances[9], 9);
824        assert_eq!(result.num_visited, 10);
825    }
826
827    #[cfg(feature = "parallel")]
828    #[test]
829    fn test_parallel_bfs_complete() {
830        let g = make_complete_graph(5);
831        let result = parallel_bfs(&g, 0).expect("parallel bfs");
832
833        // In a complete graph, all nodes are at distance 1 from source
834        for i in 1..5 {
835            assert_eq!(result.distances[i], 1);
836        }
837        assert_eq!(result.num_visited, 5);
838    }
839
840    // ── Connected Components Tests ──
841
842    #[test]
843    fn test_cc_connected() {
844        let g = make_path_graph(5);
845        let result = connected_components(&g);
846
847        assert_eq!(result.num_components, 1);
848        // All nodes should have the same label
849        let label = result.labels[0];
850        for &l in &result.labels {
851            assert_eq!(l, label);
852        }
853        assert_eq!(result.component_sizes[0], 5);
854    }
855
856    #[test]
857    fn test_cc_disconnected() {
858        let g = make_disconnected_graph();
859        let result = connected_components(&g);
860
861        assert_eq!(result.num_components, 2);
862        // Nodes 0,1,2 in one component, 3,4 in another
863        assert_eq!(result.labels[0], result.labels[1]);
864        assert_eq!(result.labels[0], result.labels[2]);
865        assert_eq!(result.labels[3], result.labels[4]);
866        assert_ne!(result.labels[0], result.labels[3]);
867    }
868
869    #[test]
870    fn test_cc_isolated_nodes() {
871        let g = CsrGraph::from_edges(5, vec![], false).expect("build");
872        let result = connected_components(&g);
873        assert_eq!(result.num_components, 5);
874        for &size in &result.component_sizes {
875            assert_eq!(size, 1);
876        }
877    }
878
879    #[test]
880    fn test_cc_empty() {
881        let g = CsrGraph::from_edges(0, vec![], false).expect("build");
882        let result = connected_components(&g);
883        assert_eq!(result.num_components, 0);
884    }
885
886    #[cfg(feature = "parallel")]
887    #[test]
888    fn test_parallel_cc_disconnected() {
889        let g = make_disconnected_graph();
890        let result = parallel_connected_components(&g);
891
892        assert_eq!(result.num_components, 2);
893        assert_eq!(result.labels[0], result.labels[1]);
894        assert_eq!(result.labels[0], result.labels[2]);
895        assert_eq!(result.labels[3], result.labels[4]);
896        assert_ne!(result.labels[0], result.labels[3]);
897    }
898
899    #[cfg(feature = "parallel")]
900    #[test]
901    fn test_parallel_cc_connected() {
902        let g = make_complete_graph(10);
903        let result = parallel_connected_components(&g);
904        assert_eq!(result.num_components, 1);
905        assert_eq!(result.component_sizes[0], 10);
906    }
907
908    // ── PageRank Tests ──
909
910    #[test]
911    fn test_pagerank_simple() {
912        // Simple directed graph: 0->1, 1->2, 2->0
913        let g = CsrGraph::from_edges(3, vec![(0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0)], true)
914            .expect("build");
915
916        let config = PageRankConfig {
917            damping: 0.85,
918            max_iterations: 100,
919            tolerance: 1e-8,
920        };
921        let result = pagerank(&g, &config).expect("pagerank");
922
923        // Symmetric cycle: all scores should be equal (~1/3)
924        let expected = 1.0 / 3.0;
925        for &score in &result.scores {
926            assert!(
927                (score - expected).abs() < 1e-4,
928                "score {score} != expected {expected}"
929            );
930        }
931        assert!(result.converged);
932    }
933
934    #[test]
935    fn test_pagerank_star() {
936        // Star graph: center node 0 should have highest rank
937        let edges: Vec<(usize, usize, f64)> = (1..5).map(|i| (i, 0, 1.0)).collect();
938        let g = CsrGraph::from_edges(5, edges, true).expect("build");
939
940        let result = pagerank(&g, &PageRankConfig::default()).expect("pagerank");
941        // Node 0 should have the highest score (all point to it)
942        let max_node = result
943            .scores
944            .iter()
945            .enumerate()
946            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
947            .map(|(i, _)| i)
948            .unwrap_or(0);
949        assert_eq!(max_node, 0);
950    }
951
952    #[test]
953    fn test_pagerank_empty() {
954        let g = CsrGraph::from_edges(0, vec![], true).expect("build");
955        let result = pagerank(&g, &PageRankConfig::default()).expect("pagerank");
956        assert!(result.scores.is_empty());
957        assert!(result.converged);
958    }
959
960    #[test]
961    fn test_pagerank_dangling() {
962        // Node 2 has no outgoing edges (dangling)
963        let g = CsrGraph::from_edges(3, vec![(0, 1, 1.0), (1, 2, 1.0)], true).expect("build");
964        let result = pagerank(&g, &PageRankConfig::default()).expect("pagerank");
965        // Should still produce valid probabilities summing to ~1
966        let total: f64 = result.scores.iter().sum();
967        assert!(
968            (total - 1.0).abs() < 0.01,
969            "scores should sum to ~1.0, got {total}"
970        );
971    }
972
973    #[cfg(feature = "parallel")]
974    #[test]
975    fn test_parallel_pagerank_cycle() {
976        let g = CsrGraph::from_edges(
977            4,
978            vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 0, 1.0)],
979            true,
980        )
981        .expect("build");
982
983        let result = parallel_pagerank(&g, &PageRankConfig::default()).expect("pagerank");
984
985        let expected = 0.25;
986        for &score in &result.scores {
987            assert!((score - expected).abs() < 1e-4);
988        }
989        assert!(result.converged);
990    }
991
992    // ── Triangle Counting Tests ──
993
994    #[test]
995    fn test_triangle_count_k3() {
996        let g = make_complete_graph(3);
997        let result = triangle_count(&g);
998        assert_eq!(result.total_triangles, 1);
999        for &count in &result.per_node_triangles {
1000            assert_eq!(count, 1);
1001        }
1002    }
1003
1004    #[test]
1005    fn test_triangle_count_k4() {
1006        let g = make_complete_graph(4);
1007        let result = triangle_count(&g);
1008        assert_eq!(result.total_triangles, 4); // C(4,3) = 4
1009        for &count in &result.per_node_triangles {
1010            assert_eq!(count, 3); // each node in 3 triangles
1011        }
1012    }
1013
1014    #[test]
1015    fn test_triangle_count_k5() {
1016        let g = make_complete_graph(5);
1017        let result = triangle_count(&g);
1018        assert_eq!(result.total_triangles, 10); // C(5,3) = 10
1019    }
1020
1021    #[test]
1022    fn test_triangle_count_path() {
1023        let g = make_path_graph(5);
1024        let result = triangle_count(&g);
1025        assert_eq!(result.total_triangles, 0); // No triangles in a path
1026    }
1027
1028    #[test]
1029    fn test_triangle_count_single_triangle() {
1030        let g = CsrGraph::from_edges(
1031            4,
1032            vec![(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0), (2, 3, 1.0)],
1033            false,
1034        )
1035        .expect("build");
1036        let result = triangle_count(&g);
1037        assert_eq!(result.total_triangles, 1);
1038        assert_eq!(result.per_node_triangles[0], 1);
1039        assert_eq!(result.per_node_triangles[1], 1);
1040        assert_eq!(result.per_node_triangles[2], 1);
1041        assert_eq!(result.per_node_triangles[3], 0); // not in any triangle
1042    }
1043
1044    #[test]
1045    fn test_triangle_count_empty() {
1046        let g = CsrGraph::from_edges(2, vec![], false).expect("build");
1047        let result = triangle_count(&g);
1048        assert_eq!(result.total_triangles, 0);
1049    }
1050
1051    #[cfg(feature = "parallel")]
1052    #[test]
1053    fn test_parallel_triangle_count_k4() {
1054        let g = make_complete_graph(4);
1055        let result = parallel_triangle_count(&g);
1056        assert_eq!(result.total_triangles, 4);
1057    }
1058
1059    #[cfg(feature = "parallel")]
1060    #[test]
1061    fn test_parallel_triangle_count_k5() {
1062        let g = make_complete_graph(5);
1063        let result = parallel_triangle_count(&g);
1064        assert_eq!(result.total_triangles, 10);
1065    }
1066
1067    // ── Consistency Tests ──
1068
1069    #[test]
1070    fn test_bfs_parent_tree() {
1071        let g = make_path_graph(5);
1072        let result = bfs(&g, 0).expect("bfs");
1073
1074        // Verify parent tree structure
1075        assert_eq!(result.parents[0], usize::MAX); // source has no parent
1076        for i in 1..5 {
1077            let parent = result.parents[i];
1078            assert!(parent < 5);
1079            assert_eq!(result.distances[i], result.distances[parent] + 1);
1080        }
1081    }
1082
1083    #[test]
1084    fn test_cc_label_consistency() {
1085        let g = make_disconnected_graph();
1086        let result = connected_components(&g);
1087
1088        // Verify: nodes connected by an edge share a label
1089        for node in 0..g.num_nodes() {
1090            for (neighbor, _) in g.neighbors(node) {
1091                assert_eq!(
1092                    result.labels[node], result.labels[neighbor],
1093                    "nodes {node} and {neighbor} should be in same component"
1094                );
1095            }
1096        }
1097    }
1098
1099    #[test]
1100    fn test_pagerank_scores_sum_to_one() {
1101        let g = make_complete_graph(5);
1102        // Complete graph treated as directed (both directions stored)
1103        let config = PageRankConfig {
1104            max_iterations: 200,
1105            tolerance: 1e-10,
1106            ..Default::default()
1107        };
1108        let result = pagerank(&g, &config).expect("pagerank");
1109        let total: f64 = result.scores.iter().sum();
1110        assert!(
1111            (total - 1.0).abs() < 0.01,
1112            "PageRank scores should sum to ~1.0, got {total}"
1113        );
1114    }
1115}