Skip to main content

xgraph/core/algo/
centrality.rs

1//! Centrality measures, written once against [`GraphView`].
2//!
3//! Unifying the two previous copies also settles where they disagreed. The heterogeneous
4//! versions of Brandes' algorithm and of closeness mixed edge weights into a breadth-first
5//! sweep: nodes came off the queue in hop order while distances accumulated weights, so
6//! with non-uniform weights the shortest-path counts were simply wrong. Both measures are
7//! defined here on hop counts, which is what "betweenness" and "closeness" mean when no
8//! distance metric is named, and matches the homogeneous implementations.
9//!
10//! The iterative measures — eigenvector, PageRank and Katz — do use weights, and validate
11//! their parameters instead of quietly returning a diverged vector.
12
13use crate::core::algo::connectivity::Connectivity;
14use crate::core::algo::traversal::TraversalError;
15use crate::core::view::{GraphView, NodeId};
16use std::collections::{HashMap, VecDeque};
17
18/// Error type for centrality computation failures.
19#[derive(Debug, Clone, PartialEq, thiserror::Error)]
20pub enum CentralityError {
21    /// A node referenced during the computation does not exist.
22    #[error("Invalid node reference: node ID {0} not found")]
23    InvalidNode(NodeId),
24    /// A parameter fell outside the range the measure is defined for.
25    #[error("Parameter `{name}` is {value}, expected {expected}")]
26    InvalidParameter {
27        /// The parameter's name.
28        name: &'static str,
29        /// The value supplied.
30        value: f64,
31        /// What the measure requires.
32        expected: &'static str,
33    },
34}
35
36impl From<TraversalError> for CentralityError {
37    fn from(error: TraversalError) -> Self {
38        match error {
39            TraversalError::InvalidNode(id) => CentralityError::InvalidNode(id),
40        }
41    }
42}
43
44/// Centrality measures available on every [`GraphView`].
45pub trait Centrality: GraphView {
46    /// Degree centrality: the number of edges incident to each node.
47    ///
48    /// Parallel edges count separately, so a node joined to one neighbour by three edges
49    /// has degree three.
50    fn degree_centrality(&self) -> HashMap<NodeId, usize> {
51        self.node_id_vec()
52            .into_iter()
53            .map(|node| (node, self.degree(node)))
54            .collect()
55    }
56
57    /// Betweenness centrality: how often each node lies on a shortest path between others.
58    ///
59    /// Computed with Brandes' algorithm over hop counts, normalized so that the maximum
60    /// possible value is 1. Graphs of fewer than three nodes have no intermediate positions
61    /// to occupy, so every score is zero.
62    fn betweenness_centrality(&self) -> Result<HashMap<NodeId, f64>, CentralityError> {
63        let nodes = self.node_id_vec();
64        let count = nodes.len();
65
66        // With fewer than three nodes there is no path with an interior, and the usual
67        // normalisation constant would be a division by zero.
68        if count < 3 {
69            return Ok(nodes.into_iter().map(|node| (node, 0.0)).collect());
70        }
71
72        // Node IDs may be sparse, so the per-source arrays are indexed by position.
73        let index_of: HashMap<NodeId, usize> = nodes
74            .iter()
75            .enumerate()
76            .map(|(index, &id)| (id, index))
77            .collect();
78
79        let contributions = nodes
80            .iter()
81            .map(|&source| brandes_from(self, source, &nodes, &index_of))
82            .collect::<Result<Vec<_>, _>>()?;
83
84        let betweenness = accumulate_betweenness(&nodes, contributions);
85
86        // On an undirected graph each pair is walked from both ends.
87        let pairs = ((count - 1) * (count - 2)) as f64;
88        let scale = if self.is_directed() {
89            1.0 / pairs
90        } else {
91            2.0 / pairs
92        };
93        let halve = if self.is_directed() { 1.0 } else { 0.5 };
94
95        Ok(normalize_betweenness(betweenness, halve * scale))
96    }
97
98    /// Betweenness centrality, with the per-source sweeps shared across cores.
99    ///
100    /// Identical in result to [`betweenness_centrality`](Centrality::betweenness_centrality)
101    /// — each source contributes independently, so splitting the work changes only how long
102    /// it takes. Worth reaching for on large graphs, where the serial version is
103    /// O(|V| · |E|).
104    ///
105    /// Offered as a separate method rather than switching the existing one on a feature:
106    /// cargo unifies features across a dependency graph, so a feature that changed this
107    /// method's bounds would break unrelated crates that happen to share the build.
108    #[cfg(feature = "parallel")]
109    fn par_betweenness_centrality(&self) -> Result<HashMap<NodeId, f64>, CentralityError>
110    where
111        Self: Sync,
112    {
113        use rayon::prelude::*;
114
115        let nodes = self.node_id_vec();
116        let count = nodes.len();
117        if count < 3 {
118            return Ok(nodes.into_iter().map(|node| (node, 0.0)).collect());
119        }
120
121        let index_of: HashMap<NodeId, usize> = nodes
122            .iter()
123            .enumerate()
124            .map(|(index, &id)| (id, index))
125            .collect();
126
127        let contributions = nodes
128            .par_iter()
129            .map(|&source| brandes_from(self, source, &nodes, &index_of))
130            .collect::<Result<Vec<_>, _>>()?;
131
132        let betweenness = accumulate_betweenness(&nodes, contributions);
133        let pairs = ((count - 1) * (count - 2)) as f64;
134        let scale = if self.is_directed() {
135            1.0 / pairs
136        } else {
137            2.0 / pairs
138        };
139        let halve = if self.is_directed() { 1.0 } else { 0.5 };
140        Ok(normalize_betweenness(betweenness, halve * scale))
141    }
142
143    /// Closeness centrality: how near each node is to all the nodes it can reach.
144    ///
145    /// Measured in hops, as the number of reachable nodes divided by the total distance to
146    /// them. A node that reaches nothing scores zero.
147    fn closeness_centrality(&self) -> Result<HashMap<NodeId, f64>, CentralityError> {
148        Ok(self
149            .node_id_vec()
150            .into_iter()
151            .map(|source| (source, closeness_from(self, source)))
152            .collect())
153    }
154
155    /// Closeness centrality, with the per-source sweeps shared across cores.
156    ///
157    /// Identical in result to [`closeness_centrality`](Centrality::closeness_centrality).
158    #[cfg(feature = "parallel")]
159    fn par_closeness_centrality(&self) -> Result<HashMap<NodeId, f64>, CentralityError>
160    where
161        Self: Sync,
162    {
163        use rayon::prelude::*;
164
165        Ok(self
166            .node_id_vec()
167            .par_iter()
168            .map(|&source| (source, closeness_from(self, source)))
169            .collect())
170    }
171
172    /// Eigenvector centrality: a node is important when its neighbours are important.
173    ///
174    /// Computed by power iteration, stopping once the largest change between rounds falls
175    /// below `tolerance` or `max_iter` rounds have run. The result is scaled to sum to 1.
176    fn eigenvector_centrality(
177        &self,
178        max_iter: usize,
179        tolerance: f64,
180    ) -> Result<HashMap<NodeId, f64>, CentralityError>
181    where
182        Self::Weight: Into<f64>,
183    {
184        check_non_negative("tolerance", tolerance)?;
185
186        let nodes = self.node_id_vec();
187        let mut centrality: HashMap<NodeId, f64> = nodes.iter().map(|&node| (node, 1.0)).collect();
188        if nodes.is_empty() {
189            return Ok(centrality);
190        }
191
192        for _ in 0..max_iter {
193            let mut next: HashMap<NodeId, f64> = HashMap::with_capacity(nodes.len());
194            let mut largest_change = 0.0_f64;
195
196            for &node in &nodes {
197                let mut score = 1.0;
198                for link in self.links(node) {
199                    let weight: f64 = link.weight.into();
200                    score += weight * centrality.get(&link.target).copied().unwrap_or(0.0);
201                }
202                largest_change = largest_change.max((score - centrality[&node]).abs());
203                next.insert(node, score);
204            }
205
206            let norm = next.values().map(|value| value * value).sum::<f64>().sqrt();
207            if norm > 0.0 {
208                for value in next.values_mut() {
209                    *value /= norm;
210                }
211            }
212            centrality = next;
213
214            if largest_change < tolerance {
215                break;
216            }
217        }
218
219        normalize_to_sum(&mut centrality);
220        Ok(centrality)
221    }
222
223    /// PageRank: the stationary distribution of a random walk that teleports.
224    ///
225    /// `damping` is the probability of following an edge rather than teleporting; 0.85 is
226    /// the conventional value. Iteration stops once the total change between rounds falls
227    /// below `tolerance`.
228    ///
229    /// The rank a node passes on is split among its outgoing edges in proportion to their
230    /// weights. A node with no outgoing edges keeps nothing: its rank leaks out of the
231    /// system, so the vector is renormalized at the end.
232    fn pagerank(
233        &self,
234        damping: f64,
235        max_iter: usize,
236        tolerance: f64,
237    ) -> Result<HashMap<NodeId, f64>, CentralityError>
238    where
239        Self::Weight: Into<f64>,
240    {
241        check_probability("damping", damping)?;
242        check_non_negative("tolerance", tolerance)?;
243
244        let nodes = self.node_id_vec();
245        let count = nodes.len();
246        if count == 0 {
247            return Ok(HashMap::new());
248        }
249
250        let mut rank: HashMap<NodeId, f64> = nodes
251            .iter()
252            .map(|&node| (node, 1.0 / count as f64))
253            .collect();
254
255        // Total outgoing weight per node, computed once. The previous implementation
256        // rediscovered a node's inbound links by scanning every other node on every
257        // iteration, which made each round cost O(|V|^2) instead of O(|V| + |E|).
258        let out_weight: HashMap<NodeId, f64> = nodes
259            .iter()
260            .map(|&node| {
261                let total: f64 = self.links(node).map(|link| link.weight.into()).sum();
262                (node, total)
263            })
264            .collect();
265
266        let teleport = (1.0 - damping) / count as f64;
267
268        for _ in 0..max_iter {
269            let mut next: HashMap<NodeId, f64> =
270                nodes.iter().map(|&node| (node, teleport)).collect();
271
272            for &node in &nodes {
273                let outgoing = out_weight[&node];
274                if outgoing <= 0.0 {
275                    continue;
276                }
277                let share = damping * rank[&node] / outgoing;
278                for link in self.links(node) {
279                    let weight: f64 = link.weight.into();
280                    if let Some(slot) = next.get_mut(&link.target) {
281                        *slot += share * weight;
282                    }
283                }
284            }
285
286            let change: f64 = nodes
287                .iter()
288                .map(|node| (next[node] - rank[node]).abs())
289                .sum();
290            rank = next;
291
292            if change < tolerance {
293                break;
294            }
295        }
296
297        normalize_to_sum(&mut rank);
298        Ok(rank)
299    }
300
301    /// Katz centrality: influence that decays with distance.
302    ///
303    /// Each node starts at `beta` and accumulates `alpha` times the score of each
304    /// neighbour, weighted by the connecting edge. `alpha` must be small enough for the
305    /// series to converge — below the reciprocal of the largest eigenvalue, for which the
306    /// reciprocal of the maximum weighted degree is a safe bound.
307    fn katz_centrality(
308        &self,
309        alpha: f64,
310        beta: f64,
311        max_iter: usize,
312        tolerance: f64,
313    ) -> Result<HashMap<NodeId, f64>, CentralityError>
314    where
315        Self::Weight: Into<f64>,
316    {
317        check_non_negative("tolerance", tolerance)?;
318        if alpha <= 0.0 {
319            return Err(CentralityError::InvalidParameter {
320                name: "alpha",
321                value: alpha,
322                expected: "a positive attenuation factor",
323            });
324        }
325
326        let nodes = self.node_id_vec();
327        let mut centrality: HashMap<NodeId, f64> = nodes.iter().map(|&node| (node, beta)).collect();
328        if nodes.is_empty() {
329            return Ok(centrality);
330        }
331
332        for _ in 0..max_iter {
333            let mut next: HashMap<NodeId, f64> = HashMap::with_capacity(nodes.len());
334            let mut largest_change = 0.0_f64;
335
336            for &node in &nodes {
337                let mut score = beta;
338                for link in self.links(node) {
339                    let weight: f64 = link.weight.into();
340                    score += alpha * weight * centrality.get(&link.target).copied().unwrap_or(0.0);
341                }
342                largest_change = largest_change.max((score - centrality[&node]).abs());
343                next.insert(node, score);
344            }
345
346            centrality = next;
347            if largest_change < tolerance {
348                break;
349            }
350        }
351        Ok(centrality)
352    }
353
354    /// The in-degree of each node: the number of edges pointing at it.
355    ///
356    /// On an undirected view this coincides with [`degree_centrality`](Centrality::degree_centrality).
357    fn in_degree_centrality(&self) -> HashMap<NodeId, usize> {
358        self.predecessors()
359            .into_iter()
360            .map(|(node, sources)| (node, sources.len()))
361            .collect()
362    }
363}
364
365impl<G: GraphView + ?Sized> Centrality for G {}
366
367/// How near one node is to everything it can reach, in hops.
368///
369/// Kept as a free function so the serial and parallel drivers share one implementation.
370fn closeness_from<G>(graph: &G, source: NodeId) -> f64
371where
372    G: GraphView + ?Sized,
373{
374    let mut distance: HashMap<NodeId, usize> = HashMap::from([(source, 0)]);
375    let mut queue = VecDeque::from([source]);
376
377    while let Some(node) = queue.pop_front() {
378        let step = distance[&node] + 1;
379        for link in graph.links(node) {
380            if let std::collections::hash_map::Entry::Vacant(slot) = distance.entry(link.target) {
381                slot.insert(step);
382                queue.push_back(link.target);
383            }
384        }
385    }
386
387    let total: usize = distance.values().sum();
388    let reachable = distance.len().saturating_sub(1);
389    if total > 0 && reachable > 0 {
390        reachable as f64 / total as f64
391    } else {
392        0.0
393    }
394}
395
396/// Sums the per-source dependencies into one score per node.
397fn accumulate_betweenness(nodes: &[NodeId], contributions: Vec<Vec<f64>>) -> HashMap<NodeId, f64> {
398    let mut betweenness: HashMap<NodeId, f64> = nodes.iter().map(|&node| (node, 0.0)).collect();
399    for dependencies in contributions {
400        for (index, dependency) in dependencies.into_iter().enumerate() {
401            *betweenness
402                .get_mut(&nodes[index])
403                .expect("every node has an entry") += dependency;
404        }
405    }
406    betweenness
407}
408
409/// Scales the raw scores so that the maximum possible value is 1.
410fn normalize_betweenness(
411    mut betweenness: HashMap<NodeId, f64>,
412    scale: f64,
413) -> HashMap<NodeId, f64> {
414    for value in betweenness.values_mut() {
415        *value *= scale;
416    }
417    betweenness
418}
419
420/// One source's pass of Brandes' algorithm.
421///
422/// Returns, per node position, how much of the shortest-path traffic out of `source` runs
423/// through that node. Summing these over every source gives betweenness. Kept as a free
424/// function so that the serial and parallel drivers share one implementation.
425fn brandes_from<G>(
426    graph: &G,
427    source: NodeId,
428    nodes: &[NodeId],
429    index_of: &HashMap<NodeId, usize>,
430) -> Result<Vec<f64>, CentralityError>
431where
432    G: GraphView + ?Sized,
433{
434    let count = nodes.len();
435    let source_index = index_of[&source];
436
437    let mut order = Vec::with_capacity(count);
438    let mut predecessors: Vec<Vec<usize>> = vec![Vec::new(); count];
439    let mut path_count = vec![0.0_f64; count];
440    let mut distance: Vec<Option<usize>> = vec![None; count];
441
442    path_count[source_index] = 1.0;
443    distance[source_index] = Some(0);
444
445    let mut queue = VecDeque::from([source]);
446    while let Some(node) = queue.pop_front() {
447        let node_index = index_of[&node];
448        order.push(node_index);
449
450        for link in graph.links(node) {
451            let target_index = *index_of
452                .get(&link.target)
453                .ok_or(CentralityError::InvalidNode(link.target))?;
454            let step = distance[node_index].expect("dequeued nodes carry a distance") + 1;
455
456            if distance[target_index].is_none() {
457                distance[target_index] = Some(step);
458                queue.push_back(link.target);
459            }
460            // A parallel edge is a second shortest path through the same pair, so it
461            // contributes to the count exactly as a distinct route would.
462            if distance[target_index] == Some(step) {
463                path_count[target_index] += path_count[node_index];
464                predecessors[target_index].push(node_index);
465            }
466        }
467    }
468
469    let mut dependency = vec![0.0_f64; count];
470    for &node_index in order.iter().rev() {
471        for &predecessor in &predecessors[node_index] {
472            dependency[predecessor] +=
473                (path_count[predecessor] / path_count[node_index]) * (1.0 + dependency[node_index]);
474        }
475    }
476    // The source itself is not an intermediate node on its own paths.
477    dependency[source_index] = 0.0;
478    Ok(dependency)
479}
480
481/// Rejects a parameter that should be a probability.
482fn check_probability(name: &'static str, value: f64) -> Result<(), CentralityError> {
483    if !(0.0..=1.0).contains(&value) || value.is_nan() {
484        return Err(CentralityError::InvalidParameter {
485            name,
486            value,
487            expected: "a value between 0 and 1",
488        });
489    }
490    Ok(())
491}
492
493/// Rejects a parameter that should not be negative.
494fn check_non_negative(name: &'static str, value: f64) -> Result<(), CentralityError> {
495    if value < 0.0 || value.is_nan() {
496        return Err(CentralityError::InvalidParameter {
497            name,
498            value,
499            expected: "a non-negative value",
500        });
501    }
502    Ok(())
503}
504
505/// Scales a score map so its values sum to 1, leaving an all-zero map untouched.
506fn normalize_to_sum(scores: &mut HashMap<NodeId, f64>) {
507    let total: f64 = scores.values().sum();
508    if total > 0.0 {
509        for value in scores.values_mut() {
510            *value /= total;
511        }
512    }
513}
514
515#[cfg(test)]
516#[cfg(feature = "graph")]
517mod tests {
518    use super::*;
519    use crate::core::EdgeFiltered;
520    use crate::graph::Graph;
521    use float_cmp::approx_eq;
522
523    /// A path a - b - c, in which only `b` sits between anything.
524    fn path_of_three() -> (Graph<u32, u32, ()>, NodeId, NodeId, NodeId) {
525        let mut graph = Graph::<u32, u32, ()>::new(false);
526        let a = graph.add_node(0);
527        let b = graph.add_node(1);
528        let c = graph.add_node(2);
529        graph.add_edge(a, b, 1, ()).unwrap();
530        graph.add_edge(b, c, 1, ()).unwrap();
531        (graph, a, b, c)
532    }
533
534    #[test]
535    fn test_degree_counts_parallel_edges_separately() {
536        let mut graph = Graph::<u32, u32, ()>::new(false);
537        let a = graph.add_node(0);
538        let b = graph.add_node(1);
539        graph.add_edge(a, b, 1, ()).unwrap();
540        graph.add_edge(a, b, 2, ()).unwrap();
541
542        let degree = graph.degree_centrality();
543        assert_eq!(degree[&a], 2);
544        assert_eq!(degree[&b], 2);
545    }
546
547    #[test]
548    fn test_betweenness_finds_the_middle_of_a_path() {
549        let (graph, a, b, c) = path_of_three();
550        let betweenness = graph.betweenness_centrality().unwrap();
551
552        assert!(approx_eq!(f64, betweenness[&b], 1.0, epsilon = 1e-9));
553        assert!(approx_eq!(f64, betweenness[&a], 0.0, epsilon = 1e-9));
554        assert!(approx_eq!(f64, betweenness[&c], 0.0, epsilon = 1e-9));
555    }
556
557    /// The normalisation constant divides by `(n - 1)(n - 2)`, so graphs of one or two
558    /// nodes have to be answered before it is applied rather than yielding NaN.
559    #[test]
560    fn test_betweenness_of_tiny_graphs_is_zero() {
561        let mut graph = Graph::<u32, u32, ()>::new(false);
562        let a = graph.add_node(0);
563        let b = graph.add_node(1);
564        graph.add_edge(a, b, 1, ()).unwrap();
565
566        let betweenness = graph.betweenness_centrality().unwrap();
567        assert_eq!(betweenness[&a], 0.0);
568        assert_eq!(betweenness[&b], 0.0);
569        assert!(betweenness.values().all(|value| value.is_finite()));
570    }
571
572    #[test]
573    fn test_betweenness_survives_sparse_ids() {
574        let mut graph = Graph::<u32, u32, ()>::new(false);
575        let a = graph.add_node(0);
576        let gap = graph.add_node(1);
577        let b = graph.add_node(2);
578        let c = graph.add_node(3);
579        graph.remove_node(gap).unwrap();
580        graph.add_edge(a, b, 1, ()).unwrap();
581        graph.add_edge(b, c, 1, ()).unwrap();
582
583        let betweenness = graph.betweenness_centrality().unwrap();
584        assert_eq!(betweenness.len(), 3);
585        assert!(approx_eq!(f64, betweenness[&b], 1.0, epsilon = 1e-9));
586    }
587
588    #[test]
589    fn test_closeness_prefers_the_centre() {
590        let (graph, a, b, _) = path_of_three();
591        let closeness = graph.closeness_centrality().unwrap();
592
593        // `b` reaches both others in one hop; `a` needs one and two.
594        assert!(approx_eq!(f64, closeness[&b], 1.0, epsilon = 1e-9));
595        assert!(approx_eq!(f64, closeness[&a], 2.0 / 3.0, epsilon = 1e-9));
596    }
597
598    #[test]
599    fn test_isolated_node_has_zero_closeness() {
600        let mut graph = Graph::<u32, u32, ()>::new(false);
601        let alone = graph.add_node(0);
602        assert_eq!(graph.closeness_centrality().unwrap()[&alone], 0.0);
603    }
604
605    #[test]
606    fn test_pagerank_sums_to_one_and_favours_the_hub() {
607        let mut graph = Graph::<f64, u32, ()>::new(true);
608        let hub = graph.add_node(0);
609        let spokes: Vec<_> = (1..4).map(|i| graph.add_node(i)).collect();
610        for &spoke in &spokes {
611            graph.add_edge(spoke, hub, 1.0, ()).unwrap();
612        }
613
614        let rank = graph.pagerank(0.85, 100, 1e-9).unwrap();
615        let total: f64 = rank.values().sum();
616        assert!(approx_eq!(f64, total, 1.0, epsilon = 1e-9));
617        assert!(spokes.iter().all(|spoke| rank[&hub] > rank[spoke]));
618    }
619
620    #[test]
621    fn test_pagerank_rejects_a_damping_factor_outside_zero_to_one() {
622        let mut graph = Graph::<f64, u32, ()>::new(true);
623        graph.add_node(0);
624
625        assert!(matches!(
626            graph.pagerank(1.5, 10, 1e-6),
627            Err(CentralityError::InvalidParameter {
628                name: "damping",
629                ..
630            })
631        ));
632        assert!(matches!(
633            graph.pagerank(f64::NAN, 10, 1e-6),
634            Err(CentralityError::InvalidParameter {
635                name: "damping",
636                ..
637            })
638        ));
639    }
640
641    #[test]
642    fn test_katz_rejects_a_non_positive_attenuation() {
643        let mut graph = Graph::<f64, u32, ()>::new(false);
644        graph.add_node(0);
645
646        assert!(matches!(
647            graph.katz_centrality(0.0, 1.0, 10, 1e-6),
648            Err(CentralityError::InvalidParameter { name: "alpha", .. })
649        ));
650    }
651
652    #[test]
653    fn test_eigenvector_and_katz_rank_the_hub_highest() {
654        let mut graph = Graph::<f64, u32, ()>::new(false);
655        let hub = graph.add_node(0);
656        let spokes: Vec<_> = (1..5).map(|i| graph.add_node(i)).collect();
657        for &spoke in &spokes {
658            graph.add_edge(hub, spoke, 1.0, ()).unwrap();
659        }
660
661        let eigenvector = graph.eigenvector_centrality(200, 1e-9).unwrap();
662        let katz = graph.katz_centrality(0.1, 1.0, 200, 1e-9).unwrap();
663        for spoke in &spokes {
664            assert!(eigenvector[&hub] > eigenvector[spoke]);
665            assert!(katz[&hub] > katz[spoke]);
666        }
667    }
668
669    #[test]
670    fn test_centrality_over_a_filtered_view() {
671        let mut graph = Graph::<u32, u32, &str>::new(false);
672        let a = graph.add_node(0);
673        let b = graph.add_node(1);
674        let c = graph.add_node(2);
675        let rail = graph.add_edge(a, b, 1, "rail").unwrap();
676        graph.add_edge(b, c, 1, "road").unwrap();
677
678        let rail_only = EdgeFiltered::new(&graph, |edge| edge == rail);
679        let degree = rail_only.degree_centrality();
680        assert_eq!(degree[&a], 1);
681        assert_eq!(degree[&c], 0);
682    }
683
684    /// The point of the parallel variants is that they change only the wall time.
685    #[cfg(feature = "parallel")]
686    #[test]
687    fn test_the_parallel_measures_agree_with_the_serial_ones() {
688        let mut graph = Graph::<u32, u32, ()>::new(false);
689        let nodes: Vec<_> = (0..40).map(|i| graph.add_node(i)).collect();
690        // A ring with chords, so the shortest-path structure is not trivial.
691        for i in 0..nodes.len() {
692            graph
693                .add_edge(nodes[i], nodes[(i + 1) % nodes.len()], 1, ())
694                .unwrap();
695            if i % 3 == 0 {
696                graph
697                    .add_edge(nodes[i], nodes[(i + 7) % nodes.len()], 1, ())
698                    .unwrap();
699            }
700        }
701
702        let serial = graph.betweenness_centrality().unwrap();
703        let parallel = graph.par_betweenness_centrality().unwrap();
704        assert_eq!(serial.len(), parallel.len());
705        for (node, value) in &serial {
706            assert!(
707                approx_eq!(f64, *value, parallel[node], epsilon = 1e-12),
708                "{:?}: {} vs {}",
709                node,
710                value,
711                parallel[node]
712            );
713        }
714
715        assert_eq!(
716            graph.closeness_centrality().unwrap(),
717            graph.par_closeness_centrality().unwrap()
718        );
719    }
720}