1use crate::core::algo::connectivity::Connectivity;
14use crate::core::algo::traversal::TraversalError;
15use crate::core::view::{GraphView, NodeId};
16use std::collections::{HashMap, VecDeque};
17
18#[derive(Debug, Clone, PartialEq, thiserror::Error)]
20pub enum CentralityError {
21 #[error("Invalid node reference: node ID {0} not found")]
23 InvalidNode(NodeId),
24 #[error("Parameter `{name}` is {value}, expected {expected}")]
26 InvalidParameter {
27 name: &'static str,
29 value: f64,
31 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
44pub trait Centrality: GraphView {
46 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 fn betweenness_centrality(&self) -> Result<HashMap<NodeId, f64>, CentralityError> {
63 let nodes = self.node_id_vec();
64 let count = nodes.len();
65
66 if count < 3 {
69 return Ok(nodes.into_iter().map(|node| (node, 0.0)).collect());
70 }
71
72 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 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 #[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 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 #[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 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 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 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 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 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
367fn 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
396fn 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
409fn 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
420fn 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 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 dependency[source_index] = 0.0;
478 Ok(dependency)
479}
480
481fn 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
493fn 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
505fn 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 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 #[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 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 #[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 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}