Skip to main content

aprender/graph/
mod.rs

1//! Graph construction and analysis with cache-optimized CSR representation.
2//!
3//! This module provides high-performance graph algorithms built on top of
4//! Compressed Sparse Row (CSR) format for maximum cache locality. Key features:
5//!
6//! - CSR representation (50-70% memory reduction vs `HashMap`)
7//! - Centrality measures (degree, betweenness, `PageRank`)
8//! - Parallel algorithms using Rayon
9//! - Numerical stability (Kahan summation in `PageRank`)
10//!
11//! # Examples
12//!
13//! ```
14//! use aprender::graph::Graph;
15//!
16//! let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], false);
17//!
18//! let dc = g.degree_centrality();
19//! assert_eq!(dc.len(), 3);
20//! ```
21
22#[cfg(feature = "parallel")]
23use rayon::prelude::*;
24use std::collections::{HashMap, VecDeque};
25
26/// Graph node identifier (contiguous integers for cache efficiency).
27pub type NodeId = usize;
28
29/// Graph edge with optional weight.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Edge {
32    pub source: NodeId,
33    pub target: NodeId,
34    pub weight: Option<f64>,
35}
36
37/// Graph structure using CSR (Compressed Sparse Row) for cache efficiency.
38///
39/// Memory layout inspired by Combinatorial BLAS (Buluc et al. 2009):
40/// - Adjacency stored as two flat vectors (CSR format)
41/// - Node labels stored separately (accessed rarely)
42/// - String→NodeId mapping via `HashMap` (build-time only)
43///
44/// # Performance
45/// - Memory: 50-70% reduction vs `HashMap` (no pointer overhead)
46/// - Cache misses: 3-5x fewer (sequential access pattern)
47/// - SIMD-friendly: Neighbor iteration can use vectorization
48#[derive(Debug)]
49pub struct Graph {
50    // CSR adjacency representation (cache-friendly)
51    row_ptr: Vec<usize>,      // Offset into col_indices (length = n_nodes + 1)
52    col_indices: Vec<NodeId>, // Flattened neighbor lists (length = n_edges)
53    edge_weights: Vec<f64>,   // Parallel to col_indices (empty if unweighted)
54
55    // Metadata (accessed less frequently)
56    #[allow(dead_code)]
57    node_labels: Vec<Option<String>>, // Indexed by NodeId
58    #[allow(dead_code)]
59    label_to_id: HashMap<String, NodeId>, // For label lookups
60
61    is_directed: bool,
62    n_nodes: usize,
63    n_edges: usize,
64}
65
66impl Graph {
67    /// Create empty graph.
68    ///
69    /// # Arguments
70    /// * `is_directed` - Whether the graph is directed
71    ///
72    /// # Examples
73    /// ```
74    /// use aprender::graph::Graph;
75    ///
76    /// let g = Graph::new(false); // undirected
77    /// assert_eq!(g.num_nodes(), 0);
78    /// ```
79    #[must_use]
80    pub fn new(is_directed: bool) -> Self {
81        Self {
82            row_ptr: vec![0],
83            col_indices: Vec::new(),
84            edge_weights: Vec::new(),
85            node_labels: Vec::new(),
86            label_to_id: HashMap::new(),
87            is_directed,
88            n_nodes: 0,
89            n_edges: 0,
90        }
91    }
92
93    /// Get number of nodes in graph.
94    #[must_use]
95    pub fn num_nodes(&self) -> usize {
96        self.n_nodes
97    }
98
99    /// Get number of edges in graph.
100    #[must_use]
101    pub fn num_edges(&self) -> usize {
102        self.n_edges
103    }
104
105    /// Check if graph is directed.
106    #[must_use]
107    pub fn is_directed(&self) -> bool {
108        self.is_directed
109    }
110
111    /// Get neighbors of node v in O(degree(v)) time with perfect cache locality.
112    ///
113    /// # Arguments
114    /// * `v` - Node ID
115    ///
116    /// # Returns
117    /// Slice of neighbor node IDs
118    ///
119    /// # Examples
120    /// ```
121    /// use aprender::graph::Graph;
122    ///
123    /// let g = Graph::from_edges(&[(0, 1), (1, 2)], false);
124    /// assert_eq!(g.neighbors(1), &[0, 2]);
125    /// ```
126    #[must_use]
127    pub fn neighbors(&self, v: NodeId) -> &[NodeId] {
128        if v >= self.n_nodes {
129            return &[];
130        }
131        let start = self.row_ptr[v];
132        let end = self.row_ptr[v + 1];
133        &self.col_indices[start..end]
134    }
135
136    /// Build graph from edge list.
137    ///
138    /// This is the primary construction method. Automatically detects
139    /// the number of nodes from the edge list and builds CSR representation.
140    ///
141    /// # Arguments
142    /// * `edges` - Slice of (source, target) tuples
143    /// * `is_directed` - Whether the graph is directed
144    ///
145    /// # Examples
146    /// ```
147    /// use aprender::graph::Graph;
148    ///
149    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], true);
150    /// assert_eq!(g.num_nodes(), 3);
151    /// assert_eq!(g.num_edges(), 3);
152    /// ```
153    #[must_use]
154    pub fn from_edges(edges: &[(NodeId, NodeId)], is_directed: bool) -> Self {
155        if edges.is_empty() {
156            return Self::new(is_directed);
157        }
158
159        // Find max node ID to determine number of nodes
160        let max_node = edges.iter().flat_map(|&(s, t)| [s, t]).max().unwrap_or(0);
161        let n_nodes = max_node + 1;
162
163        // Build adjacency list first (for sorting and deduplication)
164        let mut adj_list: Vec<Vec<NodeId>> = vec![Vec::new(); n_nodes];
165        for &(source, target) in edges {
166            adj_list[source].push(target);
167            if !is_directed && source != target {
168                // For undirected graphs, add reverse edge
169                adj_list[target].push(source);
170            }
171        }
172
173        // Sort and deduplicate neighbors for each node
174        for neighbors in &mut adj_list {
175            neighbors.sort_unstable();
176            neighbors.dedup();
177        }
178
179        // Build CSR representation
180        let mut row_ptr = Vec::with_capacity(n_nodes + 1);
181        let mut col_indices = Vec::new();
182
183        row_ptr.push(0);
184        for neighbors in &adj_list {
185            col_indices.extend_from_slice(neighbors);
186            row_ptr.push(col_indices.len());
187        }
188
189        let n_edges = if is_directed {
190            edges.len()
191        } else {
192            // For undirected, each edge is counted once in input
193            edges.len()
194        };
195
196        Self {
197            row_ptr,
198            col_indices,
199            edge_weights: Vec::new(),
200            node_labels: vec![None; n_nodes],
201            label_to_id: HashMap::new(),
202            is_directed,
203            n_nodes,
204            n_edges,
205        }
206    }
207
208    /// Build weighted graph from edge list with weights.
209    ///
210    /// # Arguments
211    /// * `edges` - Slice of (source, target, weight) tuples
212    /// * `is_directed` - Whether the graph is directed
213    ///
214    /// # Examples
215    /// ```
216    /// use aprender::graph::Graph;
217    ///
218    /// let g = Graph::from_weighted_edges(&[(0, 1, 1.0), (1, 2, 2.5)], false);
219    /// assert_eq!(g.num_nodes(), 3);
220    /// assert_eq!(g.num_edges(), 2);
221    /// ```
222    #[must_use]
223    pub fn from_weighted_edges(edges: &[(NodeId, NodeId, f64)], is_directed: bool) -> Self {
224        if edges.is_empty() {
225            return Self::new(is_directed);
226        }
227
228        // Find max node ID
229        let max_node = edges
230            .iter()
231            .flat_map(|&(s, t, _)| [s, t])
232            .max()
233            .unwrap_or(0);
234        let n_nodes = max_node + 1;
235
236        // Build adjacency list with weights
237        let mut adj_list: Vec<Vec<(NodeId, f64)>> = vec![Vec::new(); n_nodes];
238        for &(source, target, weight) in edges {
239            adj_list[source].push((target, weight));
240            if !is_directed && source != target {
241                adj_list[target].push((source, weight));
242            }
243        }
244
245        // Sort and deduplicate (keep first weight for duplicates)
246        for neighbors in &mut adj_list {
247            neighbors.sort_unstable_by_key(|&(id, _)| id);
248            neighbors.dedup_by_key(|&mut (id, _)| id);
249        }
250
251        // Build CSR representation
252        let mut row_ptr = Vec::with_capacity(n_nodes + 1);
253        let mut col_indices = Vec::new();
254        let mut edge_weights = Vec::new();
255
256        row_ptr.push(0);
257        for neighbors in &adj_list {
258            for &(neighbor, weight) in neighbors {
259                col_indices.push(neighbor);
260                edge_weights.push(weight);
261            }
262            row_ptr.push(col_indices.len());
263        }
264
265        let n_edges = edges.len();
266
267        Self {
268            row_ptr,
269            col_indices,
270            edge_weights,
271            node_labels: vec![None; n_nodes],
272            label_to_id: HashMap::new(),
273            is_directed,
274            n_nodes,
275            n_edges,
276        }
277    }
278
279    /// Get edge weight between two nodes.
280    ///
281    /// # Returns
282    /// * `Some(weight)` if edge exists
283    /// * `None` if no edge exists
284    #[allow(dead_code)]
285    fn edge_weight(&self, source: NodeId, target: NodeId) -> Option<f64> {
286        if source >= self.n_nodes {
287            return None;
288        }
289
290        let start = self.row_ptr[source];
291        let end = self.row_ptr[source + 1];
292        let neighbors = &self.col_indices[start..end];
293
294        // Binary search for target
295        let pos = neighbors.binary_search(&target).ok()?;
296
297        if self.edge_weights.is_empty() {
298            Some(1.0) // Unweighted graph
299        } else {
300            Some(self.edge_weights[start + pos])
301        }
302    }
303
304    /// Compute degree centrality for all nodes.
305    ///
306    /// Uses Freeman's normalization (1978): `C_D(v)` = deg(v) / (n - 1)
307    ///
308    /// # Returns
309    /// `HashMap` mapping `NodeId` to centrality score in [0, 1]
310    ///
311    /// # Performance
312    /// O(n + m) where n = nodes, m = edges
313    ///
314    /// # Examples
315    /// ```
316    /// use aprender::graph::Graph;
317    ///
318    /// // Star graph: center has degree 3, leaves have degree 1
319    /// let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false);
320    /// let dc = g.degree_centrality();
321    ///
322    /// assert_eq!(dc[&0], 1.0); // center connected to all others
323    /// assert!((dc[&1] - 0.333).abs() < 0.01); // leaves connected to 1 of 3
324    /// ```
325    #[must_use]
326    pub fn degree_centrality(&self) -> HashMap<NodeId, f64> {
327        let mut centrality = HashMap::with_capacity(self.n_nodes);
328
329        if self.n_nodes <= 1 {
330            // Single node or empty graph
331            for v in 0..self.n_nodes {
332                centrality.insert(v, 0.0);
333            }
334            return centrality;
335        }
336
337        let norm = (self.n_nodes - 1) as f64;
338
339        #[allow(clippy::needless_range_loop)]
340        for v in 0..self.n_nodes {
341            let degree = self.neighbors(v).len() as f64;
342            centrality.insert(v, degree / norm);
343        }
344
345        centrality
346    }
347
348    /// Compute `PageRank` using power iteration with Kahan summation.
349    ///
350    /// Uses the `PageRank` algorithm (Page et al. 1999) with numerically
351    /// stable Kahan summation (Higham 1993) to prevent floating-point
352    /// drift in large graphs (>10K nodes).
353    ///
354    /// # Arguments
355    /// * `damping` - Damping factor (typically 0.85)
356    /// * `max_iter` - Maximum iterations (default 100)
357    /// * `tol` - Convergence tolerance (default 1e-6)
358    ///
359    /// # Returns
360    /// Vector of `PageRank` scores (one per node)
361    ///
362    /// # Performance
363    /// O(k * m) where k = iterations, m = edges
364    ///
365    /// # Examples
366    /// ```
367    /// use aprender::graph::Graph;
368    ///
369    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], true);
370    /// let pr = g.pagerank(0.85, 100, 1e-6).expect("pagerank should converge for valid graph");
371    /// assert!((pr.iter().sum::<f64>() - 1.0).abs() < 1e-10); // Kahan ensures precision
372    /// ```
373    pub fn pagerank(&self, damping: f64, max_iter: usize, tol: f64) -> Result<Vec<f64>, String> {
374        if self.n_nodes == 0 {
375            return Ok(Vec::new());
376        }
377
378        let n = self.n_nodes;
379        let mut ranks = vec![1.0 / n as f64; n];
380        let mut new_ranks = vec![0.0; n];
381
382        for _ in 0..max_iter {
383            // Handle dangling nodes (nodes with no outgoing edges)
384            // Redistribute their rank uniformly to all nodes
385            let mut dangling_sum = 0.0;
386            #[allow(clippy::needless_range_loop)]
387            for v in 0..n {
388                if self.neighbors(v).is_empty() {
389                    dangling_sum += ranks[v];
390                }
391            }
392            let dangling_contribution = damping * dangling_sum / n as f64;
393
394            // Compute new ranks with Kahan summation
395            #[allow(clippy::needless_range_loop)]
396            for v in 0..n {
397                let incoming_neighbors = self.incoming_neighbors(v);
398
399                let mut sum = 0.0;
400                let mut c = 0.0; // Kahan compensation term
401
402                for u in &incoming_neighbors {
403                    let out_degree = self.neighbors(*u).len() as f64;
404                    if out_degree > 0.0 {
405                        let y = (ranks[*u] / out_degree) - c;
406                        let t = sum + y;
407                        c = (t - sum) - y; // Recover low-order bits
408                        sum = t;
409                    }
410                }
411
412                new_ranks[v] = (1.0 - damping) / n as f64 + damping * sum + dangling_contribution;
413            }
414
415            // Convergence check using Kahan for diff calculation
416            let diff = kahan_diff(&ranks, &new_ranks);
417            if diff < tol {
418                return Ok(new_ranks);
419            }
420
421            std::mem::swap(&mut ranks, &mut new_ranks);
422        }
423
424        Ok(ranks)
425    }
426
427    /// Get incoming neighbors for directed graphs (reverse edges).
428    ///
429    /// For undirected graphs, this is the same as `neighbors()`.
430    /// For directed graphs, we need to scan all nodes to find incoming edges.
431    fn incoming_neighbors(&self, v: NodeId) -> Vec<NodeId> {
432        if !self.is_directed {
433            // For undirected graphs, incoming == outgoing
434            return self.neighbors(v).to_vec();
435        }
436
437        // For directed graphs, scan all nodes to find incoming edges
438        let mut incoming = Vec::new();
439        for u in 0..self.n_nodes {
440            if self.neighbors(u).contains(&v) {
441                incoming.push(u);
442            }
443        }
444        incoming
445    }
446
447    /// Compute betweenness centrality using parallel Brandes' algorithm.
448    ///
449    /// Uses Brandes' algorithm (2001) with Rayon parallelization for the outer loop.
450    /// Each source's BFS is independent, making this "embarrassingly parallel" (Bader & Madduri 2006).
451    ///
452    /// # Performance
453    /// - Serial: O(nm) for unweighted graphs
454    /// - Parallel: O(nm/p) where p = number of CPU cores
455    /// - Expected speedup: ~8x on 8-core CPU for graphs with >1K nodes
456    ///
457    /// # Returns
458    /// Vector of betweenness centrality scores (one per node)
459    ///
460    /// # Examples
461    /// ```
462    /// use aprender::graph::Graph;
463    ///
464    /// // Path graph: 0 -- 1 -- 2
465    /// // Middle node has higher betweenness
466    /// let g = Graph::from_edges(&[(0, 1), (1, 2)], false);
467    /// let bc = g.betweenness_centrality();
468    /// assert!(bc[1] > bc[0]); // middle node has highest betweenness
469    /// assert!(bc[1] > bc[2]);
470    /// ```
471    #[must_use]
472    pub fn betweenness_centrality(&self) -> Vec<f64> {
473        if self.n_nodes == 0 {
474            return Vec::new();
475        }
476
477        // Compute partial betweenness from each source (parallel when available)
478        #[cfg(feature = "parallel")]
479        let partial_scores: Vec<Vec<f64>> = (0..self.n_nodes)
480            .into_par_iter()
481            .map(|source| self.brandes_bfs_from_source(source))
482            .collect();
483
484        #[cfg(not(feature = "parallel"))]
485        let partial_scores: Vec<Vec<f64>> = (0..self.n_nodes)
486            .map(|source| self.brandes_bfs_from_source(source))
487            .collect();
488
489        // Reduce partial scores (single-threaded, but fast)
490        let mut centrality = vec![0.0; self.n_nodes];
491        for partial in partial_scores {
492            for (i, &score) in partial.iter().enumerate() {
493                centrality[i] += score;
494            }
495        }
496
497        // Normalize for undirected graphs
498        if !self.is_directed {
499            for score in &mut centrality {
500                *score /= 2.0;
501            }
502        }
503
504        centrality
505    }
506
507    /// Brandes' BFS from a single source node.
508    ///
509    /// Computes the contribution to betweenness centrality from paths
510    /// starting at the given source node.
511    fn brandes_bfs_from_source(&self, source: NodeId) -> Vec<f64> {
512        let n = self.n_nodes;
513        let mut stack = Vec::new(); // Nodes in order of non-increasing distance
514        let mut paths = vec![0u64; n]; // Number of shortest paths from source to each node
515        let mut distance = vec![i32::MAX; n]; // Distance from source
516        let mut predecessors: Vec<Vec<NodeId>> = vec![Vec::new(); n]; // Predecessors on shortest paths
517        let mut dependency = vec![0.0; n]; // Dependency of source on each node
518
519        // BFS initialization
520        paths[source] = 1;
521        distance[source] = 0;
522        let mut queue = VecDeque::new();
523        queue.push_back(source);
524
525        // BFS to compute shortest paths
526        while let Some(v) = queue.pop_front() {
527            stack.push(v);
528            for &w in self.neighbors(v) {
529                // First time we see w?
530                if distance[w] == i32::MAX {
531                    distance[w] = distance[v] + 1;
532                    queue.push_back(w);
533                }
534                // Shortest path to w via v?
535                if distance[w] == distance[v] + 1 {
536                    paths[w] = paths[w].saturating_add(paths[v]);
537                    predecessors[w].push(v);
538                }
539            }
540        }
541
542        // Backward accumulation of dependencies
543        while let Some(w) = stack.pop() {
544            for &v in &predecessors[w] {
545                let coeff = (paths[v] as f64 / paths[w] as f64) * (1.0 + dependency[w]);
546                dependency[v] += coeff;
547            }
548        }
549
550        dependency
551    }
552
553    /// Compute modularity of a community partition.
554    ///
555    /// Modularity Q measures the density of edges within communities compared to
556    /// a random graph. Ranges from -0.5 to 1.0 (higher is better).
557    ///
558    /// Formula: Q = (1/2m) Σ[`A_ij` - `k_i`*`k_j/2m`] `δ(c_i`, `c_j`)
559    /// where:
560    /// - m = total edges
561    /// - `A_ij` = adjacency matrix
562    /// - `k_i` = degree of node i
563    /// - `δ(c_i`, `c_j`) = 1 if nodes i,j in same community, 0 otherwise
564    ///
565    /// # Arguments
566    /// * `communities` - Vector of communities, each community is a vector of node IDs
567    ///
568    /// # Returns
569    /// Modularity score Q ∈ [-0.5, 1.0]
570    #[must_use]
571    pub fn modularity(&self, communities: &[Vec<NodeId>]) -> f64 {
572        if self.n_nodes == 0 || communities.is_empty() {
573            return 0.0;
574        }
575
576        // Build community membership map
577        let mut community_map = vec![None; self.n_nodes];
578        for (comm_id, community) in communities.iter().enumerate() {
579            for &node in community {
580                community_map[node] = Some(comm_id);
581            }
582        }
583
584        // Total edges
585        let m = self.n_edges as f64;
586
587        if m == 0.0 {
588            return 0.0;
589        }
590
591        let two_m = 2.0 * m;
592
593        // Compute degrees
594        let degrees: Vec<f64> = (0..self.n_nodes)
595            .map(|i| self.neighbors(i).len() as f64)
596            .collect();
597
598        // Compute modularity: Q = (1/2m) Σ[A_ij - k_i*k_j/2m] δ(c_i, c_j)
599        let mut q = 0.0;
600
601        for i in 0..self.n_nodes {
602            for j in 0..self.n_nodes {
603                // Skip if nodes not in same community
604                match (community_map[i], community_map[j]) {
605                    (Some(ci), Some(cj)) if ci == cj => {
606                        // Check if edge exists
607                        let a_ij = if self.neighbors(i).contains(&j) {
608                            1.0
609                        } else {
610                            0.0
611                        };
612
613                        // Expected edges in random graph
614                        let expected = (degrees[i] * degrees[j]) / two_m;
615
616                        q += a_ij - expected;
617                    }
618                    _ => {}
619                }
620            }
621        }
622
623        q / two_m
624    }
625
626    /// Detect communities using the Louvain algorithm.
627    ///
628    /// The Louvain method is a greedy modularity optimization algorithm that:
629    /// 1. Starts with each node in its own community
630    /// 2. Iteratively moves nodes to communities that maximize modularity gain
631    /// 3. Aggregates the graph by treating communities as super-nodes
632    /// 4. Repeats until modularity converges
633    ///
634    /// # Performance
635    /// - Time: O(m·log n) typical, where m = edges, n = nodes
636    /// - Space: O(n + m)
637    ///
638    /// # Returns
639    /// Vector of communities, each community is a vector of node IDs
640    ///
641    /// # Examples
642    /// ```
643    /// use aprender::graph::Graph;
644    ///
645    /// // Two triangles connected by one edge
646    /// let g = Graph::from_edges(&[
647    ///     (0, 1), (1, 2), (2, 0),  // Triangle 1
648    ///     (3, 4), (4, 5), (5, 3),  // Triangle 2
649    ///     (2, 3),                   // Connection
650    /// ], false);
651    ///
652    /// let communities = g.louvain();
653    /// assert_eq!(communities.len(), 2);  // Two communities detected
654    /// ```
655    #[must_use]
656    pub fn louvain(&self) -> Vec<Vec<NodeId>> {
657        if self.n_nodes == 0 {
658            return Vec::new();
659        }
660
661        // Initialize: each node in its own community
662        let mut node_to_comm: Vec<usize> = (0..self.n_nodes).collect();
663        let mut improved = true;
664        let mut iteration = 0;
665        let max_iterations = 100;
666
667        // Phase 1: Iteratively move nodes to communities
668        while improved && iteration < max_iterations {
669            improved = false;
670            iteration += 1;
671
672            for node in 0..self.n_nodes {
673                let current_comm = node_to_comm[node];
674                let neighbors = self.neighbors(node);
675
676                if neighbors.is_empty() {
677                    continue;
678                }
679
680                // Find best community to move to
681                let mut best_comm = current_comm;
682                let mut best_gain = 0.0;
683
684                // Try moving to each neighbor's community
685                let mut tried_comms = std::collections::HashSet::new();
686                for &neighbor in neighbors {
687                    let neighbor_comm = node_to_comm[neighbor];
688
689                    if tried_comms.contains(&neighbor_comm) {
690                        continue;
691                    }
692                    tried_comms.insert(neighbor_comm);
693
694                    // Calculate modularity gain
695                    let gain =
696                        self.modularity_gain(node, current_comm, neighbor_comm, &node_to_comm);
697
698                    if gain > best_gain {
699                        best_gain = gain;
700                        best_comm = neighbor_comm;
701                    }
702                }
703
704                // Move node if improves modularity
705                if best_comm != current_comm && best_gain > 1e-10 {
706                    node_to_comm[node] = best_comm;
707                    improved = true;
708                }
709            }
710        }
711
712        // Convert node_to_comm map to community lists
713        let mut communities: HashMap<usize, Vec<NodeId>> = HashMap::new();
714
715        for (node, &comm) in node_to_comm.iter().enumerate() {
716            communities.entry(comm).or_default().push(node);
717        }
718
719        communities.into_values().collect()
720    }
721
722    /// Calculate modularity gain from moving a node to a different community.
723    fn modularity_gain(
724        &self,
725        node: NodeId,
726        from_comm: usize,
727        to_comm: usize,
728        node_to_comm: &[usize],
729    ) -> f64 {
730        if from_comm == to_comm {
731            return 0.0;
732        }
733
734        let m = self.n_edges as f64;
735        if m == 0.0 {
736            return 0.0;
737        }
738
739        let two_m = 2.0 * m;
740        let k_i = self.neighbors(node).len() as f64;
741
742        // Count edges from node to target community
743        let mut k_i_to = 0.0;
744        for &neighbor in self.neighbors(node) {
745            if node_to_comm[neighbor] == to_comm {
746                k_i_to += 1.0;
747            }
748        }
749
750        // Count edges from node to current community (excluding self)
751        let mut k_i_from = 0.0;
752        for &neighbor in self.neighbors(node) {
753            if node_to_comm[neighbor] == from_comm && neighbor != node {
754                k_i_from += 1.0;
755            }
756        }
757
758        // Total degree of target community (excluding node)
759        let sigma_tot_to: f64 = node_to_comm
760            .iter()
761            .enumerate()
762            .filter(|&(n, &comm)| comm == to_comm && n != node)
763            .map(|(n, _)| self.neighbors(n).len() as f64)
764            .sum();
765
766        // Total degree of current community (excluding node)
767        let sigma_tot_from: f64 = node_to_comm
768            .iter()
769            .enumerate()
770            .filter(|&(n, &comm)| comm == from_comm && n != node)
771            .map(|(n, _)| self.neighbors(n).len() as f64)
772            .sum();
773
774        // Modularity gain formula
775        (k_i_to - k_i_from) / m - k_i * (sigma_tot_to - sigma_tot_from) / (two_m * m)
776    }
777
778    /// Compute closeness centrality for all nodes.
779    ///
780    /// Closeness measures how close a node is to all other nodes in the graph.
781    /// Uses Wasserman & Faust (1994) normalization: `C_C(v)` = (n-1) / Σd(v,u)
782    ///
783    /// # Returns
784    /// Vector of closeness centrality scores (one per node)
785    /// For disconnected graphs, nodes unreachable from v are ignored in the sum.
786    ///
787    /// # Performance
788    /// O(n·(n + m)) where n = nodes, m = edges (BFS from each node)
789    ///
790    /// # Examples
791    /// ```
792    /// use aprender::graph::Graph;
793    ///
794    /// // Star graph: center is close to all nodes
795    /// let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false);
796    /// let cc = g.closeness_centrality();
797    /// assert!(cc[0] > cc[1]); // center has highest closeness
798    /// ```
799    #[must_use]
800    pub fn closeness_centrality(&self) -> Vec<f64> {
801        if self.n_nodes == 0 {
802            return Vec::new();
803        }
804
805        let mut centrality = vec![0.0; self.n_nodes];
806
807        #[allow(clippy::needless_range_loop)]
808        for v in 0..self.n_nodes {
809            let distances = self.bfs_distances(v);
810
811            // Sum of distances to all reachable nodes
812            let sum: usize = distances.iter().filter(|&&d| d != usize::MAX).sum();
813            let reachable = distances
814                .iter()
815                .filter(|&&d| d != usize::MAX && d > 0)
816                .count();
817
818            if reachable > 0 && sum > 0 {
819                // Normalized closeness: (n-1) / sum_of_distances
820                centrality[v] = reachable as f64 / sum as f64;
821            }
822        }
823
824        centrality
825    }
826
827    /// BFS to compute shortest path distances from a source node.
828    fn bfs_distances(&self, source: NodeId) -> Vec<usize> {
829        let mut distances = vec![usize::MAX; self.n_nodes];
830        distances[source] = 0;
831
832        let mut queue = VecDeque::new();
833        queue.push_back(source);
834
835        while let Some(v) = queue.pop_front() {
836            for &w in self.neighbors(v) {
837                if distances[w] == usize::MAX {
838                    distances[w] = distances[v] + 1;
839                    queue.push_back(w);
840                }
841            }
842        }
843
844        distances
845    }
846
847    /// Compute eigenvector centrality using power iteration.
848    ///
849    /// Eigenvector centrality measures node importance based on the importance
850    /// of its neighbors. Uses the dominant eigenvector of the adjacency matrix.
851    ///
852    /// # Arguments
853    /// * `max_iter` - Maximum power iterations (default 100)
854    /// * `tol` - Convergence tolerance (default 1e-6)
855    ///
856    /// # Returns
857    /// Vector of eigenvector centrality scores (one per node)
858    ///
859    /// # Performance
860    /// O(k·m) where k = iterations, m = edges
861    ///
862    /// # Examples
863    /// ```
864    /// use aprender::graph::Graph;
865    ///
866    /// // Path graph: middle nodes have higher eigenvector centrality
867    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false);
868    /// let ec = g.eigenvector_centrality(100, 1e-6).unwrap();
869    /// assert!(ec[1] > ec[0]); // middle nodes more central
870    /// ```
871    pub fn eigenvector_centrality(&self, max_iter: usize, tol: f64) -> Result<Vec<f64>, String> {
872        if self.n_nodes == 0 {
873            return Ok(Vec::new());
874        }
875
876        let n = self.n_nodes;
877        let mut x = vec![1.0 / (n as f64).sqrt(); n]; // Initial uniform vector
878        let mut x_new = vec![0.0; n];
879
880        for _ in 0..max_iter {
881            // Matrix-vector multiplication: x_new = A * x
882            #[allow(clippy::needless_range_loop)]
883            for v in 0..n {
884                x_new[v] = self.neighbors(v).iter().map(|&u| x[u]).sum();
885            }
886
887            // Normalize to unit vector (L2 norm)
888            let norm: f64 = x_new.iter().map(|&val| val * val).sum::<f64>().sqrt();
889
890            if norm < 1e-10 {
891                // Disconnected graph or no edges
892                return Ok(vec![0.0; n]);
893            }
894
895            for val in &mut x_new {
896                *val /= norm;
897            }
898
899            // Check convergence
900            let diff: f64 = x.iter().zip(&x_new).map(|(a, b)| (a - b).abs()).sum();
901
902            if diff < tol {
903                return Ok(x_new);
904            }
905
906            std::mem::swap(&mut x, &mut x_new);
907        }
908
909        Ok(x)
910    }
911
912    /// Compute Katz centrality with attenuation factor.
913    ///
914    /// Katz centrality generalizes eigenvector centrality by adding an attenuation
915    /// factor for long-range connections: `C_K` = Σ(α^k · A^k · 1)
916    ///
917    /// # Arguments
918    /// * `alpha` - Attenuation factor (typically 0.1-0.5, must be < `1/λ_max`)
919    /// * `max_iter` - Maximum iterations (default 100)
920    /// * `tol` - Convergence tolerance (default 1e-6)
921    ///
922    /// # Returns
923    /// Vector of Katz centrality scores
924    ///
925    /// # Performance
926    /// O(k·m) where k = iterations, m = edges
927    ///
928    /// # Examples
929    /// ```
930    /// use aprender::graph::Graph;
931    ///
932    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], true);
933    /// let kc = g.katz_centrality(0.1, 100, 1e-6).unwrap();
934    /// assert_eq!(kc.len(), 3);
935    /// ```
936    pub fn katz_centrality(
937        &self,
938        alpha: f64,
939        max_iter: usize,
940        tol: f64,
941    ) -> Result<Vec<f64>, String> {
942        if self.n_nodes == 0 {
943            return Ok(Vec::new());
944        }
945
946        if alpha <= 0.0 || alpha >= 1.0 {
947            return Err("Alpha must be in (0, 1)".to_string());
948        }
949
950        let n = self.n_nodes;
951        let mut x = vec![1.0; n]; // Initial vector of ones
952        let mut x_new = vec![0.0; n];
953
954        for _ in 0..max_iter {
955            // Katz iteration: x_new = β + α·A^T·x (where β = 1)
956            // Use incoming neighbors (transpose adjacency matrix)
957            #[allow(clippy::needless_range_loop)]
958            for v in 0..n {
959                let incoming = self.incoming_neighbors(v);
960                let neighbors_sum: f64 = incoming.iter().map(|&u| x[u]).sum();
961                x_new[v] = 1.0 + alpha * neighbors_sum;
962            }
963
964            // Check convergence
965            let diff: f64 = x.iter().zip(&x_new).map(|(a, b)| (a - b).abs()).sum();
966
967            if diff < tol {
968                return Ok(x_new);
969            }
970
971            std::mem::swap(&mut x, &mut x_new);
972        }
973
974        Ok(x)
975    }
976
977    /// Compute harmonic centrality for all nodes.
978    ///
979    /// Harmonic centrality is the sum of reciprocal distances to all other nodes.
980    /// More robust than closeness for disconnected graphs (Boldi & Vigna 2014).
981    ///
982    /// Formula: `C_H(v)` = Σ(1/d(v,u)) for all u ≠ v
983    ///
984    /// # Returns
985    /// Vector of harmonic centrality scores
986    ///
987    /// # Performance
988    /// O(n·(n + m)) where n = nodes, m = edges
989    ///
990    /// # Examples
991    /// ```
992    /// use aprender::graph::Graph;
993    ///
994    /// // Star graph: center has highest harmonic centrality
995    /// let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false);
996    /// let hc = g.harmonic_centrality();
997    /// assert!(hc[0] > hc[1]); // center most central
998    /// ```
999    #[must_use]
1000    pub fn harmonic_centrality(&self) -> Vec<f64> {
1001        if self.n_nodes == 0 {
1002            return Vec::new();
1003        }
1004
1005        let mut centrality = vec![0.0; self.n_nodes];
1006
1007        #[allow(clippy::needless_range_loop)]
1008        for v in 0..self.n_nodes {
1009            let distances = self.bfs_distances(v);
1010
1011            // Sum reciprocals of distances (skip unreachable nodes)
1012            for &dist in &distances {
1013                if dist > 0 && dist != usize::MAX {
1014                    centrality[v] += 1.0 / dist as f64;
1015                }
1016            }
1017        }
1018
1019        centrality
1020    }
1021
1022    /// Compute graph density.
1023    ///
1024    /// Density is the ratio of actual edges to possible edges.
1025    /// For undirected: d = 2m / (n(n-1))
1026    /// For directed: d = m / (n(n-1))
1027    ///
1028    /// # Returns
1029    /// Density in [0, 1] where 1 is a complete graph
1030    ///
1031    /// # Examples
1032    /// ```
1033    /// use aprender::graph::Graph;
1034    ///
1035    /// // Complete graph K4 has density 1.0
1036    /// let g = Graph::from_edges(&[(0,1), (0,2), (0,3), (1,2), (1,3), (2,3)], false);
1037    /// assert!((g.density() - 1.0).abs() < 1e-6);
1038    /// ```
1039    #[must_use]
1040    pub fn density(&self) -> f64 {
1041        if self.n_nodes <= 1 {
1042            return 0.0;
1043        }
1044
1045        let n = self.n_nodes as f64;
1046        let m = self.n_edges as f64;
1047        let possible = n * (n - 1.0);
1048
1049        if self.is_directed {
1050            m / possible
1051        } else {
1052            (2.0 * m) / possible
1053        }
1054    }
1055
1056    /// Compute graph diameter (longest shortest path).
1057    ///
1058    /// Returns None if graph is disconnected.
1059    ///
1060    /// # Returns
1061    /// Some(diameter) if connected, None otherwise
1062    ///
1063    /// # Performance
1064    /// O(n·(n + m)) - runs BFS from all nodes
1065    ///
1066    /// # Examples
1067    /// ```
1068    /// use aprender::graph::Graph;
1069    ///
1070    /// // Path graph 0--1--2--3 has diameter 3
1071    /// let g = Graph::from_edges(&[(0,1), (1,2), (2,3)], false);
1072    /// assert_eq!(g.diameter(), Some(3));
1073    /// ```
1074    #[must_use]
1075    pub fn diameter(&self) -> Option<usize> {
1076        if self.n_nodes == 0 {
1077            return None;
1078        }
1079
1080        let mut max_dist = 0;
1081
1082        for v in 0..self.n_nodes {
1083            let distances = self.bfs_distances(v);
1084
1085            for &dist in &distances {
1086                if dist == usize::MAX {
1087                    // Disconnected graph
1088                    return None;
1089                }
1090                if dist > max_dist {
1091                    max_dist = dist;
1092                }
1093            }
1094        }
1095
1096        Some(max_dist)
1097    }
1098
1099    /// Compute global clustering coefficient.
1100    ///
1101    /// Measures the probability that two neighbors of a node are connected.
1102    /// Formula: C = 3 × triangles / triads
1103    ///
1104    /// # Returns
1105    /// Clustering coefficient in [0, 1]
1106    ///
1107    /// # Performance
1108    /// O(n·d²) where d = average degree
1109    ///
1110    /// # Examples
1111    /// ```
1112    /// use aprender::graph::Graph;
1113    ///
1114    /// // Triangle has clustering coefficient 1.0
1115    /// let g = Graph::from_edges(&[(0,1), (1,2), (2,0)], false);
1116    /// assert!((g.clustering_coefficient() - 1.0).abs() < 1e-6);
1117    /// ```
1118    #[allow(clippy::cast_lossless)]
1119    #[must_use]
1120    pub fn clustering_coefficient(&self) -> f64 {
1121        if self.n_nodes == 0 {
1122            return 0.0;
1123        }
1124
1125        let mut triangles = 0;
1126        let mut triads = 0;
1127
1128        for v in 0..self.n_nodes {
1129            let neighbors = self.neighbors(v);
1130            let deg = neighbors.len();
1131
1132            if deg < 2 {
1133                continue;
1134            }
1135
1136            // Count triads (pairs of neighbors)
1137            triads += deg * (deg - 1) / 2;
1138
1139            // Count triangles (connected pairs of neighbors)
1140            for i in 0..neighbors.len() {
1141                for j in (i + 1)..neighbors.len() {
1142                    let u = neighbors[i];
1143                    let w = neighbors[j];
1144
1145                    // Check if u and w are connected
1146                    if self.neighbors(u).contains(&w) {
1147                        triangles += 1;
1148                    }
1149                }
1150            }
1151        }
1152
1153        if triads == 0 {
1154            return 0.0;
1155        }
1156
1157        // Each triangle is counted 3 times (once from each vertex)
1158        // So we divide by 3 to get actual triangle count
1159        (triangles as f64) / (triads as f64)
1160    }
1161
1162    /// Compute degree assortativity coefficient.
1163    ///
1164    /// Measures correlation between degrees of connected nodes.
1165    /// Positive: high-degree nodes connect to high-degree nodes
1166    /// Negative: high-degree nodes connect to low-degree nodes
1167    ///
1168    /// # Returns
1169    /// Assortativity coefficient in [-1, 1]
1170    ///
1171    /// # Performance
1172    /// O(m) where m = edges
1173    ///
1174    /// # Examples
1175    /// ```
1176    /// use aprender::graph::Graph;
1177    ///
1178    /// // Star graph has negative assortativity
1179    /// let g = Graph::from_edges(&[(0,1), (0,2), (0,3)], false);
1180    /// assert!(g.assortativity() < 0.0);
1181    /// ```
1182    #[must_use]
1183    pub fn assortativity(&self) -> f64 {
1184        if self.n_edges == 0 {
1185            return 0.0;
1186        }
1187
1188        // Compute degrees
1189        let degrees: Vec<f64> = (0..self.n_nodes)
1190            .map(|i| self.neighbors(i).len() as f64)
1191            .collect();
1192
1193        let m = self.n_edges as f64;
1194        let mut sum_jk = 0.0;
1195        let mut sum_j = 0.0;
1196        let mut sum_k = 0.0;
1197        let mut sum_j_sq = 0.0;
1198        let mut sum_k_sq = 0.0;
1199
1200        // Sum over all edges
1201        for v in 0..self.n_nodes {
1202            let j = degrees[v];
1203            for &u in self.neighbors(v) {
1204                let k = degrees[u];
1205                sum_jk += j * k;
1206                sum_j += j;
1207                sum_k += k;
1208                sum_j_sq += j * j;
1209                sum_k_sq += k * k;
1210            }
1211        }
1212
1213        // For undirected graphs, each edge is counted twice
1214        let normalization = if self.is_directed { m } else { 2.0 * m };
1215
1216        sum_jk /= normalization;
1217        sum_j /= normalization;
1218        sum_k /= normalization;
1219        sum_j_sq /= normalization;
1220        sum_k_sq /= normalization;
1221
1222        let numerator = sum_jk - sum_j * sum_k;
1223        let denominator = ((sum_j_sq - sum_j * sum_j) * (sum_k_sq - sum_k * sum_k)).sqrt();
1224
1225        if denominator < 1e-10 {
1226            return 0.0;
1227        }
1228
1229        numerator / denominator
1230    }
1231
1232    /// Compute shortest path between two nodes using BFS.
1233    ///
1234    /// Finds the shortest path (minimum number of hops) from source to target
1235    /// using breadth-first search. Works for both directed and undirected graphs.
1236    ///
1237    /// # Algorithm
1238    /// Uses BFS with predecessor tracking (Pohl 1971, bidirectional variant).
1239    ///
1240    /// # Arguments
1241    /// * `source` - Starting node ID
1242    /// * `target` - Destination node ID
1243    ///
1244    /// # Returns
1245    /// * `Some(path)` - Shortest path as vector of node IDs from source to target
1246    /// * `None` - No path exists between source and target
1247    ///
1248    /// # Complexity
1249    /// * Time: O(n + m) where n = nodes, m = edges
1250    /// * Space: O(n) for predecessor tracking
1251    ///
1252    /// # Examples
1253    /// ```
1254    /// use aprender::graph::Graph;
1255    ///
1256    /// let edges = vec![(0, 1), (1, 2), (2, 3), (0, 3)];
1257    /// let g = Graph::from_edges(&edges, false);
1258    ///
1259    /// // Shortest path from 0 to 3
1260    /// let path = g.shortest_path(0, 3).unwrap();
1261    /// assert_eq!(path.len(), 2); // 0 -> 3 (direct edge)
1262    /// assert_eq!(path[0], 0);
1263    /// assert_eq!(path[1], 3);
1264    ///
1265    /// // Path 0 to 2
1266    /// let path = g.shortest_path(0, 2).unwrap();
1267    /// assert!(path.len() <= 3); // Either 0->1->2 or 0->3->2
1268    /// ```
1269    #[must_use]
1270    pub fn shortest_path(&self, source: NodeId, target: NodeId) -> Option<Vec<NodeId>> {
1271        // Bounds checking
1272        if source >= self.n_nodes || target >= self.n_nodes {
1273            return None;
1274        }
1275
1276        // Special case: source == target
1277        if source == target {
1278            return Some(vec![source]);
1279        }
1280
1281        // BFS with predecessor tracking
1282        let mut visited = vec![false; self.n_nodes];
1283        let mut predecessor = vec![None; self.n_nodes];
1284        let mut queue = VecDeque::new();
1285
1286        visited[source] = true;
1287        queue.push_back(source);
1288
1289        while let Some(v) = queue.pop_front() {
1290            // Early termination if we reach target
1291            if v == target {
1292                break;
1293            }
1294
1295            for &w in self.neighbors(v) {
1296                if !visited[w] {
1297                    visited[w] = true;
1298                    predecessor[w] = Some(v);
1299                    queue.push_back(w);
1300                }
1301            }
1302        }
1303
1304        // Reconstruct path from target to source
1305        if !visited[target] {
1306            return None; // No path exists
1307        }
1308
1309        let mut path = Vec::new();
1310        let mut current = Some(target);
1311
1312        while let Some(node) = current {
1313            path.push(node);
1314            current = predecessor[node];
1315        }
1316
1317        path.reverse();
1318        Some(path)
1319    }
1320
1321    /// Compute shortest path using Dijkstra's algorithm for weighted graphs.
1322    ///
1323    /// Finds the shortest path from source to target using Dijkstra's algorithm
1324    /// with a binary heap priority queue. Handles both weighted and unweighted graphs.
1325    ///
1326    /// # Algorithm
1327    /// Uses Dijkstra's algorithm (1959) with priority queue for O((n+m) log n) complexity.
1328    ///
1329    /// # Arguments
1330    /// * `source` - Starting node ID
1331    /// * `target` - Destination node ID
1332    ///
1333    /// # Returns
1334    /// * `Some((path, distance))` - Shortest path and total distance
1335    /// * `None` - No path exists
1336    ///
1337    /// # Panics
1338    /// Panics if graph contains negative edge weights.
1339    ///
1340    /// # Complexity
1341    /// * Time: O((n + m) log n)
1342    /// * Space: O(n) for distance tracking and priority queue
1343    ///
1344    /// # Examples
1345    /// ```
1346    /// use aprender::graph::Graph;
1347    ///
1348    /// let g = Graph::from_weighted_edges(&[(0, 1, 1.0), (1, 2, 2.0), (0, 2, 5.0)], false);
1349    /// let (path, dist) = g.dijkstra(0, 2).unwrap();
1350    /// assert_eq!(dist, 3.0); // 0->1->2 is shorter than 0->2
1351    /// ```
1352    #[must_use]
1353    pub fn dijkstra(&self, source: NodeId, target: NodeId) -> Option<(Vec<NodeId>, f64)> {
1354        use std::cmp::Ordering;
1355        use std::collections::BinaryHeap;
1356
1357        // Bounds checking
1358        if source >= self.n_nodes || target >= self.n_nodes {
1359            return None;
1360        }
1361
1362        // Special case: source == target
1363        if source == target {
1364            return Some((vec![source], 0.0));
1365        }
1366
1367        // Priority queue entry: (negative distance, node)
1368        // Use Reverse to make min-heap
1369        #[derive(Copy, Clone, PartialEq)]
1370        struct State {
1371            cost: f64,
1372            node: NodeId,
1373        }
1374
1375        impl Eq for State {}
1376
1377        impl Ord for State {
1378            fn cmp(&self, other: &Self) -> Ordering {
1379                // Reverse ordering for min-heap (negate costs)
1380                other
1381                    .cost
1382                    .partial_cmp(&self.cost)
1383                    .unwrap_or(Ordering::Equal)
1384            }
1385        }
1386
1387        impl PartialOrd for State {
1388            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1389                Some(self.cmp(other))
1390            }
1391        }
1392
1393        let mut distances = vec![f64::INFINITY; self.n_nodes];
1394        let mut predecessor = vec![None; self.n_nodes];
1395        let mut heap = BinaryHeap::new();
1396
1397        distances[source] = 0.0;
1398        heap.push(State {
1399            cost: 0.0,
1400            node: source,
1401        });
1402
1403        while let Some(State { cost, node }) = heap.pop() {
1404            // Early termination if we reach target
1405            if node == target {
1406                break;
1407            }
1408
1409            // Skip if we've already found a better path
1410            if cost > distances[node] {
1411                continue;
1412            }
1413
1414            // Explore neighbors
1415            let start = self.row_ptr[node];
1416            let end = self.row_ptr[node + 1];
1417
1418            for i in start..end {
1419                let neighbor = self.col_indices[i];
1420                let edge_weight = if self.edge_weights.is_empty() {
1421                    1.0
1422                } else {
1423                    self.edge_weights[i]
1424                };
1425
1426                // Panic on negative weights (Dijkstra requirement)
1427                assert!(
1428                    edge_weight >= 0.0,
1429                    "Dijkstra's algorithm requires non-negative edge weights. \
1430                     Found negative weight {edge_weight} on edge ({node}, {neighbor})"
1431                );
1432
1433                let next_cost = cost + edge_weight;
1434
1435                // Relaxation step
1436                if next_cost < distances[neighbor] {
1437                    distances[neighbor] = next_cost;
1438                    predecessor[neighbor] = Some(node);
1439                    heap.push(State {
1440                        cost: next_cost,
1441                        node: neighbor,
1442                    });
1443                }
1444            }
1445        }
1446
1447        // Check if target is reachable
1448        if distances[target].is_infinite() {
1449            return None;
1450        }
1451
1452        // Reconstruct path
1453        let mut path = Vec::new();
1454        let mut current = Some(target);
1455
1456        while let Some(node) = current {
1457            path.push(node);
1458            current = predecessor[node];
1459        }
1460
1461        path.reverse();
1462        Some((path, distances[target]))
1463    }
1464
1465    /// Compute all-pairs shortest paths using repeated BFS.
1466    ///
1467    /// Computes the shortest path distance between all pairs of nodes.
1468    /// Uses BFS for unweighted graphs (O(nm)) which is faster than
1469    /// Floyd-Warshall (O(n³)) for sparse graphs.
1470    ///
1471    /// # Algorithm
1472    /// Runs BFS from each node (Floyd 1962 for weighted, BFS for unweighted).
1473    ///
1474    /// # Returns
1475    /// Distance matrix where `[i][j]` = shortest distance from node i to node j.
1476    /// Returns `None` if nodes are not connected.
1477    ///
1478    /// # Complexity
1479    /// * Time: O(n·(n + m)) for unweighted graphs
1480    /// * Space: O(n²) for distance matrix
1481    ///
1482    /// # Examples
1483    /// ```
1484    /// use aprender::graph::Graph;
1485    ///
1486    /// let g = Graph::from_edges(&[(0, 1), (1, 2)], false);
1487    /// let dist = g.all_pairs_shortest_paths();
1488    ///
1489    /// assert_eq!(dist[0][0], Some(0));
1490    /// assert_eq!(dist[0][1], Some(1));
1491    /// assert_eq!(dist[0][2], Some(2));
1492    /// ```
1493    #[must_use]
1494    pub fn all_pairs_shortest_paths(&self) -> Vec<Vec<Option<usize>>> {
1495        let n = self.n_nodes;
1496        let mut distances = vec![vec![None; n]; n];
1497
1498        // Run BFS from each node
1499        for (source, row) in distances.iter_mut().enumerate().take(n) {
1500            let dist = self.bfs_distances(source);
1501
1502            for (target, cell) in row.iter_mut().enumerate().take(n) {
1503                if dist[target] != usize::MAX {
1504                    *cell = Some(dist[target]);
1505                }
1506            }
1507        }
1508
1509        distances
1510    }
1511
1512    /// Compute shortest path using A* search algorithm with heuristic.
1513    ///
1514    /// Finds the shortest path from source to target using A* algorithm
1515    /// with a user-provided heuristic function. The heuristic must be
1516    /// admissible (never overestimate) for optimality guarantees.
1517    ///
1518    /// # Algorithm
1519    /// Uses A* search (Hart et al. 1968) with f(n) = g(n) + h(n) where:
1520    /// - g(n) = actual cost from source to n
1521    /// - h(n) = estimated cost from n to target (heuristic)
1522    ///
1523    /// # Arguments
1524    /// * `source` - Starting node ID
1525    /// * `target` - Destination node ID
1526    /// * `heuristic` - Function mapping `NodeId` to estimated distance to target
1527    ///
1528    /// # Returns
1529    /// * `Some(path)` - Shortest path as vector of node IDs
1530    /// * `None` - No path exists
1531    ///
1532    /// # Complexity
1533    /// * Time: O((n + m) log n) with admissible heuristic
1534    /// * Space: O(n) for tracking
1535    ///
1536    /// # Examples
1537    /// ```
1538    /// use aprender::graph::Graph;
1539    ///
1540    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false);
1541    ///
1542    /// // Manhattan distance heuristic (example)
1543    /// let heuristic = |node: usize| (3 - node) as f64;
1544    ///
1545    /// let path = g.a_star(0, 3, heuristic).unwrap();
1546    /// assert_eq!(path, vec![0, 1, 2, 3]);
1547    /// ```
1548    pub fn a_star<F>(&self, source: NodeId, target: NodeId, heuristic: F) -> Option<Vec<NodeId>>
1549    where
1550        F: Fn(NodeId) -> f64,
1551    {
1552        use std::cmp::Ordering;
1553        use std::collections::BinaryHeap;
1554
1555        // Bounds checking
1556        if source >= self.n_nodes || target >= self.n_nodes {
1557            return None;
1558        }
1559
1560        // Special case: source == target
1561        if source == target {
1562            return Some(vec![source]);
1563        }
1564
1565        // Priority queue entry with f-score
1566        #[derive(Copy, Clone, PartialEq)]
1567        struct State {
1568            f_score: f64, // g + h
1569            g_score: f64, // actual cost
1570            node: NodeId,
1571        }
1572
1573        impl Eq for State {}
1574
1575        impl Ord for State {
1576            fn cmp(&self, other: &Self) -> Ordering {
1577                // Min-heap: lower f-score has higher priority
1578                other
1579                    .f_score
1580                    .partial_cmp(&self.f_score)
1581                    .unwrap_or(Ordering::Equal)
1582            }
1583        }
1584
1585        impl PartialOrd for State {
1586            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1587                Some(self.cmp(other))
1588            }
1589        }
1590
1591        let mut g_scores = vec![f64::INFINITY; self.n_nodes];
1592        let mut predecessor = vec![None; self.n_nodes];
1593        let mut heap = BinaryHeap::new();
1594
1595        g_scores[source] = 0.0;
1596        heap.push(State {
1597            f_score: heuristic(source),
1598            g_score: 0.0,
1599            node: source,
1600        });
1601
1602        while let Some(State {
1603            f_score: _,
1604            g_score,
1605            node,
1606        }) = heap.pop()
1607        {
1608            // Early termination if we reach target
1609            if node == target {
1610                break;
1611            }
1612
1613            // Skip if we've already found a better path
1614            if g_score > g_scores[node] {
1615                continue;
1616            }
1617
1618            // Explore neighbors
1619            let start = self.row_ptr[node];
1620            let end = self.row_ptr[node + 1];
1621
1622            for i in start..end {
1623                let neighbor = self.col_indices[i];
1624                let edge_weight = if self.edge_weights.is_empty() {
1625                    1.0
1626                } else {
1627                    self.edge_weights[i]
1628                };
1629
1630                let tentative_g = g_score + edge_weight;
1631
1632                // Relaxation step
1633                if tentative_g < g_scores[neighbor] {
1634                    g_scores[neighbor] = tentative_g;
1635                    predecessor[neighbor] = Some(node);
1636
1637                    let f = tentative_g + heuristic(neighbor);
1638                    heap.push(State {
1639                        f_score: f,
1640                        g_score: tentative_g,
1641                        node: neighbor,
1642                    });
1643                }
1644            }
1645        }
1646
1647        // Check if target is reachable
1648        if g_scores[target].is_infinite() {
1649            return None;
1650        }
1651
1652        // Reconstruct path
1653        let mut path = Vec::new();
1654        let mut current = Some(target);
1655
1656        while let Some(node) = current {
1657            path.push(node);
1658            current = predecessor[node];
1659        }
1660
1661        path.reverse();
1662        Some(path)
1663    }
1664
1665    /// Depth-First Search (DFS) traversal starting from a given node.
1666    ///
1667    /// Returns nodes in the order they were visited (pre-order traversal).
1668    /// Only visits nodes reachable from the source node.
1669    ///
1670    /// # Arguments
1671    /// * `source` - Starting node for traversal
1672    ///
1673    /// # Returns
1674    /// Vector of visited nodes in DFS order, or None if source is invalid
1675    ///
1676    /// # Time Complexity
1677    /// O(n + m) where n = number of nodes, m = number of edges
1678    ///
1679    /// # Examples
1680    /// ```
1681    /// use aprender::graph::Graph;
1682    ///
1683    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 3)], false);
1684    /// let visited = g.dfs(0).expect("valid source");
1685    /// assert_eq!(visited.len(), 4); // All nodes reachable
1686    /// assert_eq!(visited[0], 0); // Starts at source
1687    /// ```
1688    #[must_use]
1689    pub fn dfs(&self, source: NodeId) -> Option<Vec<NodeId>> {
1690        // Validate source node
1691        if source >= self.n_nodes {
1692            return None;
1693        }
1694
1695        let mut visited = vec![false; self.n_nodes];
1696        let mut stack = Vec::new();
1697        let mut order = Vec::new();
1698
1699        // Start DFS from source
1700        stack.push(source);
1701
1702        while let Some(node) = stack.pop() {
1703            if visited[node] {
1704                continue;
1705            }
1706
1707            visited[node] = true;
1708            order.push(node);
1709
1710            // Add neighbors to stack (in reverse order for consistent left-to-right traversal)
1711            let neighbors = self.neighbors(node);
1712            for &neighbor in neighbors.iter().rev() {
1713                if !visited[neighbor] {
1714                    stack.push(neighbor);
1715                }
1716            }
1717        }
1718
1719        Some(order)
1720    }
1721
1722    /// Find connected components using Union-Find algorithm.
1723    ///
1724    /// Returns a vector where each index is a node ID and the value is its component ID.
1725    /// Nodes in the same component have the same component ID.
1726    ///
1727    /// For directed graphs, this finds weakly connected components (ignores edge direction).
1728    ///
1729    /// # Returns
1730    /// Vector mapping each node to its component ID (0-indexed)
1731    ///
1732    /// # Time Complexity
1733    /// O(m·α(n)) where α is the inverse Ackermann function (effectively constant)
1734    ///
1735    /// # Examples
1736    /// ```
1737    /// use aprender::graph::Graph;
1738    ///
1739    /// // Two disconnected components: (0,1) and (2,3)
1740    /// let g = Graph::from_edges(&[(0, 1), (2, 3)], false);
1741    /// let components = g.connected_components();
1742    ///
1743    /// assert_eq!(components[0], components[1]); // Same component
1744    /// assert_ne!(components[0], components[2]); // Different components
1745    /// ```
1746    #[must_use]
1747    pub fn connected_components(&self) -> Vec<usize> {
1748        let n = self.n_nodes;
1749        if n == 0 {
1750            return Vec::new();
1751        }
1752
1753        // Union-Find data structure
1754        let mut parent: Vec<usize> = (0..n).collect();
1755        let mut rank = vec![0; n];
1756
1757        // Find with path compression
1758        fn find(parent: &mut [usize], mut x: usize) -> usize {
1759            while parent[x] != x {
1760                let next = parent[x];
1761                parent[x] = parent[next]; // Path compression
1762                x = next;
1763            }
1764            x
1765        }
1766
1767        // Union by rank
1768        fn union(parent: &mut [usize], rank: &mut [usize], x: usize, y: usize) {
1769            let root_x = find(parent, x);
1770            let root_y = find(parent, y);
1771
1772            if root_x == root_y {
1773                return;
1774            }
1775
1776            // Union by rank
1777            use std::cmp::Ordering;
1778            match rank[root_x].cmp(&rank[root_y]) {
1779                Ordering::Less => parent[root_x] = root_y,
1780                Ordering::Greater => parent[root_y] = root_x,
1781                Ordering::Equal => {
1782                    parent[root_y] = root_x;
1783                    rank[root_x] += 1;
1784                }
1785            }
1786        }
1787
1788        // Process all edges (treat directed graphs as undirected for weak connectivity)
1789        for node in 0..n {
1790            for &neighbor in self.neighbors(node) {
1791                union(&mut parent, &mut rank, node, neighbor);
1792            }
1793        }
1794
1795        // Assign component IDs (compress paths and renumber)
1796        let mut component_map = HashMap::new();
1797        let mut next_component_id = 0;
1798        let mut result = vec![0; n];
1799
1800        for (node, component) in result.iter_mut().enumerate().take(n) {
1801            let root = find(&mut parent, node);
1802            let component_id = *component_map.entry(root).or_insert_with(|| {
1803                let id = next_component_id;
1804                next_component_id += 1;
1805                id
1806            });
1807            *component = component_id;
1808        }
1809
1810        result
1811    }
1812
1813    /// Find strongly connected components using Tarjan's algorithm.
1814    ///
1815    /// A strongly connected component (SCC) is a maximal set of vertices where
1816    /// every vertex is reachable from every other vertex in the set.
1817    ///
1818    /// Only meaningful for directed graphs. For undirected graphs, use `connected_components()`.
1819    ///
1820    /// # Returns
1821    /// Vector mapping each node to its SCC ID (0-indexed)
1822    ///
1823    /// # Time Complexity
1824    /// O(n + m) - single-pass DFS
1825    ///
1826    /// # Examples
1827    /// ```
1828    /// use aprender::graph::Graph;
1829    ///
1830    /// // Directed cycle: 0 -> 1 -> 2 -> 0
1831    /// let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], true);
1832    /// let sccs = g.strongly_connected_components();
1833    ///
1834    /// // All nodes in same SCC
1835    /// assert_eq!(sccs[0], sccs[1]);
1836    /// assert_eq!(sccs[1], sccs[2]);
1837    /// ```
1838    #[must_use]
1839    pub fn strongly_connected_components(&self) -> Vec<usize> {
1840        let n = self.n_nodes;
1841        if n == 0 {
1842            return Vec::new();
1843        }
1844
1845        // Tarjan's algorithm state
1846        struct TarjanState {
1847            disc: Vec<Option<usize>>,
1848            low: Vec<usize>,
1849            on_stack: Vec<bool>,
1850            stack: Vec<usize>,
1851            time: usize,
1852            scc_id: Vec<usize>,
1853            scc_counter: usize,
1854        }
1855
1856        impl TarjanState {
1857            fn new(n: usize) -> Self {
1858                Self {
1859                    disc: vec![None; n],
1860                    low: vec![0; n],
1861                    on_stack: vec![false; n],
1862                    stack: Vec::new(),
1863                    time: 0,
1864                    scc_id: vec![0; n],
1865                    scc_counter: 0,
1866                }
1867            }
1868
1869            fn dfs(&mut self, v: usize, graph: &Graph) {
1870                // Initialize discovery time and low-link value
1871                self.disc[v] = Some(self.time);
1872                self.low[v] = self.time;
1873                self.time += 1;
1874                self.stack.push(v);
1875                self.on_stack[v] = true;
1876
1877                // Visit all neighbors
1878                for &w in graph.neighbors(v) {
1879                    if self.disc[w].is_none() {
1880                        // Tree edge: recurse
1881                        self.dfs(w, graph);
1882                        self.low[v] = self.low[v].min(self.low[w]);
1883                    } else if self.on_stack[w] {
1884                        // Back edge to node on stack
1885                        self.low[v] =
1886                            self.low[v].min(self.disc[w].expect("disc[w] should be Some"));
1887                    }
1888                }
1889
1890                // If v is a root node, pop the stack and create SCC
1891                if let Some(disc_v) = self.disc[v] {
1892                    if self.low[v] == disc_v {
1893                        // Found an SCC
1894                        loop {
1895                            let w = self.stack.pop().expect("stack should not be empty");
1896                            self.on_stack[w] = false;
1897                            self.scc_id[w] = self.scc_counter;
1898                            if w == v {
1899                                break;
1900                            }
1901                        }
1902                        self.scc_counter += 1;
1903                    }
1904                }
1905            }
1906        }
1907
1908        let mut state = TarjanState::new(n);
1909
1910        // Run Tarjan's algorithm from each unvisited node
1911        for v in 0..n {
1912            if state.disc[v].is_none() {
1913                state.dfs(v, self);
1914            }
1915        }
1916
1917        state.scc_id
1918    }
1919
1920    /// Topological sort for directed acyclic graphs (DAGs).
1921    ///
1922    /// Returns a linear ordering of vertices where for every directed edge (u,v),
1923    /// u appears before v in the ordering.
1924    ///
1925    /// Only valid for DAGs. If the graph contains a cycle, returns None.
1926    ///
1927    /// # Returns
1928    /// `Some(Vec<NodeId>)` with nodes in topological order, or `None` if graph has cycles
1929    ///
1930    /// # Time Complexity
1931    /// O(n + m) - DFS-based approach
1932    ///
1933    /// # Examples
1934    /// ```
1935    /// use aprender::graph::Graph;
1936    ///
1937    /// // DAG: 0 -> 1 -> 2
1938    /// let g = Graph::from_edges(&[(0, 1), (1, 2)], true);
1939    /// let order = g.topological_sort().expect("DAG should have topological order");
1940    ///
1941    /// // 0 comes before 1, 1 comes before 2
1942    /// assert!(order.iter().position(|&x| x == 0) < order.iter().position(|&x| x == 1));
1943    /// assert!(order.iter().position(|&x| x == 1) < order.iter().position(|&x| x == 2));
1944    /// ```
1945    #[must_use]
1946    pub fn topological_sort(&self) -> Option<Vec<NodeId>> {
1947        let n = self.n_nodes;
1948        if n == 0 {
1949            return Some(Vec::new());
1950        }
1951
1952        // DFS-based topological sort with cycle detection
1953        let mut visited = vec![false; n];
1954        let mut in_stack = vec![false; n]; // For cycle detection
1955        let mut order = Vec::new();
1956
1957        fn dfs(
1958            v: usize,
1959            graph: &Graph,
1960            visited: &mut [bool],
1961            in_stack: &mut [bool],
1962            order: &mut Vec<usize>,
1963        ) -> bool {
1964            if in_stack[v] {
1965                // Back edge found - cycle detected
1966                return false;
1967            }
1968            if visited[v] {
1969                // Already processed
1970                return true;
1971            }
1972
1973            visited[v] = true;
1974            in_stack[v] = true;
1975
1976            // Visit all neighbors
1977            for &neighbor in graph.neighbors(v) {
1978                if !dfs(neighbor, graph, visited, in_stack, order) {
1979                    return false; // Cycle detected
1980                }
1981            }
1982
1983            in_stack[v] = false;
1984            order.push(v); // Add to order in post-order (reverse topological)
1985
1986            true
1987        }
1988
1989        // Run DFS from each unvisited node
1990        for v in 0..n {
1991            if !visited[v] && !dfs(v, self, &mut visited, &mut in_stack, &mut order) {
1992                return None; // Cycle detected
1993            }
1994        }
1995
1996        // Reverse to get topological order
1997        order.reverse();
1998        Some(order)
1999    }
2000
2001    /// Count common neighbors between two nodes (link prediction metric).
2002    ///
2003    /// Returns the number of neighbors shared by both nodes u and v.
2004    /// Used for link prediction: nodes with many common neighbors are
2005    /// more likely to form a connection.
2006    ///
2007    /// # Arguments
2008    /// * `u` - First node
2009    /// * `v` - Second node
2010    ///
2011    /// # Returns
2012    /// Number of common neighbors, or None if either node is invalid
2013    ///
2014    /// # Time Complexity
2015    /// O(min(deg(u), deg(v))) - intersection of neighbor sets
2016    ///
2017    /// # Examples
2018    /// ```
2019    /// use aprender::graph::Graph;
2020    ///
2021    /// // Triangle: 0-1, 0-2, 1-2
2022    /// let g = Graph::from_edges(&[(0, 1), (0, 2), (1, 2)], false);
2023    ///
2024    /// // Nodes 1 and 2 share neighbor 0
2025    /// assert_eq!(g.common_neighbors(1, 2), Some(1));
2026    /// ```
2027    #[must_use]
2028    pub fn common_neighbors(&self, u: NodeId, v: NodeId) -> Option<usize> {
2029        // Validate nodes
2030        if u >= self.n_nodes || v >= self.n_nodes {
2031            return None;
2032        }
2033
2034        let neighbors_u = self.neighbors(u);
2035        let neighbors_v = self.neighbors(v);
2036
2037        // Use two-pointer technique (both are sorted)
2038        let mut count = 0;
2039        let mut i = 0;
2040        let mut j = 0;
2041
2042        while i < neighbors_u.len() && j < neighbors_v.len() {
2043            use std::cmp::Ordering;
2044            match neighbors_u[i].cmp(&neighbors_v[j]) {
2045                Ordering::Equal => {
2046                    count += 1;
2047                    i += 1;
2048                    j += 1;
2049                }
2050                Ordering::Less => i += 1,
2051                Ordering::Greater => j += 1,
2052            }
2053        }
2054
2055        Some(count)
2056    }
2057
2058    /// Adamic-Adar index for link prediction between two nodes.
2059    ///
2060    /// Computes a weighted measure of common neighbors, where neighbors with
2061    /// fewer connections are weighted more heavily. This captures the intuition
2062    /// that sharing a rare neighbor is more significant than sharing a common one.
2063    ///
2064    /// Formula: AA(u,v) = Σ 1/log(deg(z)) for all common neighbors z
2065    ///
2066    /// # Arguments
2067    /// * `u` - First node
2068    /// * `v` - Second node
2069    ///
2070    /// # Returns
2071    /// Adamic-Adar index score, or None if either node is invalid
2072    ///
2073    /// # Time Complexity
2074    /// O(min(deg(u), deg(v))) - intersection of neighbor sets
2075    ///
2076    /// # Examples
2077    /// ```
2078    /// use aprender::graph::Graph;
2079    ///
2080    /// // Graph with shared neighbors
2081    /// let g = Graph::from_edges(&[(0, 1), (0, 2), (1, 2), (1, 3), (2, 3)], false);
2082    ///
2083    /// // Adamic-Adar index for nodes 1 and 2
2084    /// let aa = g.adamic_adar_index(1, 2).expect("valid nodes");
2085    /// assert!(aa > 0.0);
2086    /// ```
2087    #[must_use]
2088    pub fn adamic_adar_index(&self, u: NodeId, v: NodeId) -> Option<f64> {
2089        // Validate nodes
2090        if u >= self.n_nodes || v >= self.n_nodes {
2091            return None;
2092        }
2093
2094        let neighbors_u = self.neighbors(u);
2095        let neighbors_v = self.neighbors(v);
2096
2097        // Use two-pointer technique to find common neighbors
2098        let mut score = 0.0;
2099        let mut i = 0;
2100        let mut j = 0;
2101
2102        while i < neighbors_u.len() && j < neighbors_v.len() {
2103            use std::cmp::Ordering;
2104            match neighbors_u[i].cmp(&neighbors_v[j]) {
2105                Ordering::Equal => {
2106                    let common_neighbor = neighbors_u[i];
2107                    let degree = self.neighbors(common_neighbor).len();
2108
2109                    // Avoid log(1) = 0 division by zero
2110                    if degree > 1 {
2111                        score += 1.0 / (degree as f64).ln();
2112                    }
2113
2114                    i += 1;
2115                    j += 1;
2116                }
2117                Ordering::Less => i += 1,
2118                Ordering::Greater => j += 1,
2119            }
2120        }
2121
2122        Some(score)
2123    }
2124
2125    /// Label propagation algorithm for community detection.
2126    ///
2127    /// Iteratively assigns each node the most common label among its neighbors.
2128    /// Nodes with the same final label belong to the same community.
2129    ///
2130    /// # Arguments
2131    /// * `max_iter` - Maximum number of iterations (default: 100)
2132    /// * `seed` - Random seed for deterministic tie-breaking (optional)
2133    ///
2134    /// # Returns
2135    /// Vector mapping each node to its community label (0-indexed)
2136    ///
2137    /// # Time Complexity
2138    /// `O(max_iter` · m) where m = number of edges
2139    ///
2140    /// # Examples
2141    /// ```
2142    /// use aprender::graph::Graph;
2143    ///
2144    /// // Graph with two communities: (0,1,2) and (3,4,5)
2145    /// let g = Graph::from_edges(
2146    ///     &[(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)],
2147    ///     false
2148    /// );
2149    ///
2150    /// let communities = g.label_propagation(100, Some(42));
2151    /// // Nodes in same community have same label
2152    /// assert_eq!(communities[0], communities[1]);
2153    /// ```
2154    #[must_use]
2155    pub fn label_propagation(&self, max_iter: usize, seed: Option<u64>) -> Vec<usize> {
2156        let n = self.n_nodes;
2157        if n == 0 {
2158            return Vec::new();
2159        }
2160
2161        // Initialize each node with unique label
2162        let mut labels: Vec<usize> = (0..n).collect();
2163
2164        // Simple deterministic ordering based on seed
2165        let mut node_order: Vec<usize> = (0..n).collect();
2166        if let Some(s) = seed {
2167            // Simple shuffle based on seed for deterministic results
2168            for i in 0..n {
2169                let j = ((s.wrapping_mul(i as u64 + 1)) % (n as u64)) as usize;
2170                node_order.swap(i, j);
2171            }
2172        }
2173
2174        for _ in 0..max_iter {
2175            let mut changed = false;
2176
2177            // Process nodes in random order
2178            for &node in &node_order {
2179                let neighbors = self.neighbors(node);
2180                if neighbors.is_empty() {
2181                    continue;
2182                }
2183
2184                // Count neighbor labels
2185                let mut label_counts = HashMap::new();
2186                for &neighbor in neighbors {
2187                    *label_counts.entry(labels[neighbor]).or_insert(0) += 1;
2188                }
2189
2190                // Find most common label (with deterministic tie-breaking)
2191                let most_common_label = label_counts
2192                    .iter()
2193                    .max_by_key(|(label, count)| (*count, std::cmp::Reverse(*label)))
2194                    .map(|(label, _)| *label)
2195                    .expect("label_counts should not be empty");
2196
2197                if labels[node] != most_common_label {
2198                    labels[node] = most_common_label;
2199                    changed = true;
2200                }
2201            }
2202
2203            // Early termination if converged
2204            if !changed {
2205                break;
2206            }
2207        }
2208
2209        labels
2210    }
2211}
2212
2213/// Kahan summation for computing L1 distance between two vectors.
2214///
2215/// Uses compensated summation to prevent floating-point drift.
2216/// Accumulates O(n·ε) error where ε is machine epsilon without compensation.
2217fn kahan_diff(a: &[f64], b: &[f64]) -> f64 {
2218    let mut sum = 0.0;
2219    let mut c = 0.0;
2220    for i in 0..a.len() {
2221        let y = (a[i] - b[i]).abs() - c;
2222        let t = sum + y;
2223        c = (t - sum) - y;
2224        sum = t;
2225    }
2226    sum
2227}
2228
2229#[cfg(test)]
2230mod tests;