1use std::cmp::Ordering;
2use std::collections::BinaryHeap;
3use std::num::NonZeroUsize;
4
5use serde::{Deserialize, Serialize};
6
7use super::TraversalDirection;
8use crate::{EdgeId, ExternalId, Graph, GraphError, NodeId};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case", tag = "kind")]
13pub enum BetweennessMode {
14 Exact,
16 Sampled {
18 sample_count: NonZeroUsize,
20 seed: u64,
22 },
23 Auto {
25 exact_through: usize,
27 sample_count: NonZeroUsize,
29 seed: u64,
31 },
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PathWeight {
38 Unweighted,
40 Weighted,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub struct BetweennessOptions {
47 pub mode: BetweennessMode,
49 pub normalized: bool,
51 pub endpoints: bool,
53 pub weight: PathWeight,
55}
56
57impl Default for BetweennessOptions {
58 fn default() -> Self {
59 Self {
60 mode: BetweennessMode::Exact,
61 normalized: true,
62 endpoints: false,
63 weight: PathWeight::Unweighted,
64 }
65 }
66}
67
68impl BetweennessOptions {
69 pub fn graphify_default() -> Self {
71 Self {
72 mode: BetweennessMode::Auto {
73 exact_through: 1_000,
74 sample_count: NonZeroUsize::new(100).expect("100 is non-zero"),
75 seed: 42,
76 },
77 ..Self::default()
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct NodeScore {
85 pub node_id: NodeId,
87 pub score: f64,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct EdgeScore {
94 pub edge_id: EdgeId,
96 pub graphify_key: Option<ExternalId>,
98 pub source: NodeId,
100 pub target: NodeId,
102 pub score: f64,
104}
105
106#[derive(Debug, Clone, Copy)]
107struct HeapState {
108 distance: f64,
109 order: usize,
110 node: usize,
111}
112
113impl PartialEq for HeapState {
114 fn eq(&self, other: &Self) -> bool {
115 self.distance == other.distance && self.order == other.order && self.node == other.node
116 }
117}
118
119impl Eq for HeapState {}
120
121impl PartialOrd for HeapState {
122 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
123 Some(self.cmp(other))
124 }
125}
126
127impl Ord for HeapState {
128 fn cmp(&self, other: &Self) -> Ordering {
129 other
130 .distance
131 .total_cmp(&self.distance)
132 .then_with(|| other.order.cmp(&self.order))
133 .then_with(|| other.node.cmp(&self.node))
134 }
135}
136
137struct SingleSourceState {
138 stack: Vec<usize>,
139 predecessors: Vec<Vec<(usize, usize)>>,
140 path_counts: Vec<f64>,
141}
142
143impl Graph {
144 pub fn betweenness_centrality(
146 &self,
147 options: BetweennessOptions,
148 ) -> Result<Vec<NodeScore>, GraphError> {
149 self.validate_centrality_weight(options.weight)?;
150 let (sources, sampled_count) = centrality_sources(self.node_count(), options.mode);
151 let mut scores = vec![0.0; self.node_count()];
152 for source in sources {
153 let mut state = self.single_source_paths(source, options.weight);
154 let mut dependencies = vec![0.0; self.node_count()];
155 if options.endpoints {
156 scores[source] += state.stack.len().saturating_sub(1) as f64;
157 }
158 while let Some(node) = state.stack.pop() {
159 assert!(
160 state.path_counts[node] > 0.0,
161 "a settled shortest-path node has at least one path"
162 );
163 let coefficient = (1.0 + dependencies[node]) / state.path_counts[node];
164 for (predecessor, _edge) in &state.predecessors[node] {
165 dependencies[*predecessor] += state.path_counts[*predecessor] * coefficient;
166 }
167 if node != source {
168 scores[node] += dependencies[node] + f64::from(options.endpoints);
169 }
170 }
171 }
172 rescale_nodes(
173 &mut scores,
174 self.node_count(),
175 options.normalized,
176 self.is_directed(),
177 sampled_count,
178 options.endpoints,
179 );
180 Ok(scores
181 .into_iter()
182 .enumerate()
183 .map(|(node, score)| NodeScore {
184 node_id: self.node_id(node).clone(),
185 score,
186 })
187 .collect())
188 }
189
190 pub fn edge_betweenness_centrality(
192 &self,
193 options: BetweennessOptions,
194 ) -> Result<Vec<EdgeScore>, GraphError> {
195 self.validate_centrality_weight(options.weight)?;
196 let (sources, sampled_count) = centrality_sources(self.node_count(), options.mode);
197 let mut scores = vec![0.0; self.edge_count()];
198 for source in sources {
199 let mut state = self.single_source_paths(source, options.weight);
200 let mut dependencies = vec![0.0; self.node_count()];
201 while let Some(node) = state.stack.pop() {
202 assert!(
203 state.path_counts[node] > 0.0,
204 "a settled shortest-path node has at least one path"
205 );
206 let coefficient = (1.0 + dependencies[node]) / state.path_counts[node];
207 for (predecessor, edge) in &state.predecessors[node] {
208 let contribution = state.path_counts[*predecessor] * coefficient;
209 scores[*edge] += contribution;
210 dependencies[*predecessor] += contribution;
211 }
212 }
213 }
214 rescale_edges(
215 &mut scores,
216 self.node_count(),
217 options.normalized,
218 self.is_directed(),
219 sampled_count,
220 );
221 Ok(scores
222 .into_iter()
223 .enumerate()
224 .map(|(edge_index, score)| {
225 let edge = self.edge_at(edge_index);
226 EdgeScore {
227 edge_id: edge.id.clone(),
228 graphify_key: edge.graphify_key.clone(),
229 source: edge.source.clone(),
230 target: edge.target.clone(),
231 score,
232 }
233 })
234 .collect())
235 }
236
237 fn validate_centrality_weight(&self, weight: PathWeight) -> Result<(), GraphError> {
238 if weight == PathWeight::Weighted
239 && let Some(edge) = self
240 .edges()
241 .iter()
242 .find(|edge| edge.weight.is_some_and(|weight| weight == 0.0))
243 {
244 return Err(GraphError::InvalidOption(format!(
245 "weighted Brandes requires positive weights; edge {} has zero weight",
246 edge.id
247 )));
248 }
249 Ok(())
250 }
251
252 fn single_source_paths(&self, source: usize, weight: PathWeight) -> SingleSourceState {
253 match weight {
254 PathWeight::Unweighted => self.single_source_unweighted(source),
255 PathWeight::Weighted => self.single_source_weighted(source),
256 }
257 }
258
259 fn single_source_unweighted(&self, source: usize) -> SingleSourceState {
260 let mut stack = Vec::new();
261 let mut predecessors = vec![Vec::new(); self.node_count()];
262 let mut path_counts = vec![0.0; self.node_count()];
263 let mut distance = vec![usize::MAX; self.node_count()];
264 path_counts[source] = 1.0;
265 distance[source] = 0;
266 let mut queue = std::collections::VecDeque::from([source]);
267 while let Some(node) = queue.pop_front() {
268 stack.push(node);
269 for arc in self.arcs(node, TraversalDirection::Out) {
270 if distance[arc.neighbor] == usize::MAX {
271 distance[arc.neighbor] = distance[node] + 1;
272 queue.push_back(arc.neighbor);
273 }
274 if distance[arc.neighbor] == distance[node] + 1 {
275 path_counts[arc.neighbor] += path_counts[node];
276 predecessors[arc.neighbor].push((node, arc.edge));
277 }
278 }
279 }
280 SingleSourceState {
281 stack,
282 predecessors,
283 path_counts,
284 }
285 }
286
287 fn single_source_weighted(&self, source: usize) -> SingleSourceState {
288 let mut stack = Vec::new();
289 let mut predecessors = vec![Vec::new(); self.node_count()];
290 let mut path_counts = vec![0.0; self.node_count()];
291 let mut distance = vec![f64::INFINITY; self.node_count()];
292 let mut settled = vec![false; self.node_count()];
293 let mut heap = BinaryHeap::from([HeapState {
294 distance: 0.0,
295 order: 0,
296 node: source,
297 }]);
298 let mut order = 1;
299 distance[source] = 0.0;
300 path_counts[source] = 1.0;
301 while let Some(state) = heap.pop() {
302 if settled[state.node] || state.distance > distance[state.node] {
303 continue;
304 }
305 settled[state.node] = true;
306 stack.push(state.node);
307 for arc in self.arcs(state.node, TraversalDirection::Out) {
308 let next_distance = state.distance + self.edge_at(arc.edge).weight.unwrap_or(1.0);
309 if next_distance < distance[arc.neighbor] {
310 distance[arc.neighbor] = next_distance;
311 path_counts[arc.neighbor] = path_counts[state.node];
312 predecessors[arc.neighbor] = vec![(state.node, arc.edge)];
313 heap.push(HeapState {
314 distance: next_distance,
315 order,
316 node: arc.neighbor,
317 });
318 order += 1;
319 } else if next_distance == distance[arc.neighbor] {
320 path_counts[arc.neighbor] += path_counts[state.node];
321 predecessors[arc.neighbor].push((state.node, arc.edge));
322 }
323 }
324 }
325 SingleSourceState {
326 stack,
327 predecessors,
328 path_counts,
329 }
330 }
331}
332
333fn centrality_sources(node_count: usize, mode: BetweennessMode) -> (Vec<usize>, Option<usize>) {
334 let (sample_count, seed) = match mode {
335 BetweennessMode::Exact => return ((0..node_count).collect(), None),
336 BetweennessMode::Sampled { sample_count, seed } => (sample_count.get(), seed),
337 BetweennessMode::Auto {
338 exact_through,
339 sample_count,
340 seed,
341 } if node_count > exact_through => (sample_count.get(), seed),
342 BetweennessMode::Auto { .. } => return ((0..node_count).collect(), None),
343 };
344 let sample_count = sample_count.min(node_count);
345 let mut sources = (0..node_count).collect::<Vec<_>>();
346 let mut rng = seed;
347 for index in (1..sources.len()).rev() {
348 let other = (next_random(&mut rng) as usize) % (index + 1);
349 sources.swap(index, other);
350 }
351 sources.truncate(sample_count);
352 (sources, Some(sample_count))
353}
354
355fn next_random(seed: &mut u64) -> u64 {
356 *seed = seed
357 .wrapping_mul(6_364_136_223_846_793_005)
358 .wrapping_add(1_442_695_040_888_963_407);
359 *seed
360}
361
362fn rescale_nodes(
363 scores: &mut [f64],
364 node_count: usize,
365 normalized: bool,
366 directed: bool,
367 sampled_count: Option<usize>,
368 endpoints: bool,
369) {
370 let scale = if normalized && endpoints && node_count >= 2 {
371 Some(1.0 / (node_count * (node_count - 1)) as f64)
372 } else if normalized && !endpoints && node_count > 2 {
373 Some(1.0 / ((node_count - 1) * (node_count - 2)) as f64)
374 } else if !normalized && !directed {
375 Some(0.5)
376 } else {
377 None
378 };
379 if let Some(mut scale) = scale {
380 if let Some(sampled_count) = sampled_count
381 && sampled_count > 0
382 {
383 scale *= node_count as f64 / sampled_count as f64;
384 }
385 scores.iter_mut().for_each(|score| *score *= scale);
386 }
387}
388
389fn rescale_edges(
390 scores: &mut [f64],
391 node_count: usize,
392 normalized: bool,
393 directed: bool,
394 sampled_count: Option<usize>,
395) {
396 let scale = if normalized && node_count > 1 {
397 Some(1.0 / (node_count * (node_count - 1)) as f64)
398 } else if !normalized && !directed {
399 Some(0.5)
400 } else {
401 None
402 };
403 if let Some(mut scale) = scale {
404 if let Some(sampled_count) = sampled_count
405 && sampled_count > 0
406 {
407 scale *= node_count as f64 / sampled_count as f64;
408 }
409 scores.iter_mut().for_each(|score| *score *= scale);
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use approx::assert_abs_diff_eq;
416
417 use super::*;
418 use crate::{Edge, GraphKind, Node};
419
420 fn path(direction: GraphKind) -> Graph {
421 Graph::new(
422 direction,
423 [Node::new("a"), Node::new("b"), Node::new("c")],
424 [Edge::new("ab", "a", "b"), Edge::new("bc", "b", "c")],
425 )
426 .unwrap()
427 }
428
429 #[test]
430 fn exact_node_and_edge_scores_match_path_fixture() {
431 let graph = path(GraphKind::Graph);
432 let nodes = graph
433 .betweenness_centrality(BetweennessOptions {
434 normalized: false,
435 ..BetweennessOptions::default()
436 })
437 .unwrap();
438 assert_abs_diff_eq!(nodes[1].score, 1.0);
439
440 let edges = graph
441 .edge_betweenness_centrality(BetweennessOptions {
442 normalized: false,
443 ..BetweennessOptions::default()
444 })
445 .unwrap();
446 assert_abs_diff_eq!(edges[0].score, 2.0);
447 assert_abs_diff_eq!(edges[1].score, 2.0);
448 }
449
450 #[test]
451 fn sampled_scores_are_seed_deterministic() {
452 let graph = path(GraphKind::DiGraph);
453 let options = BetweennessOptions {
454 mode: BetweennessMode::Sampled {
455 sample_count: NonZeroUsize::new(2).unwrap(),
456 seed: 42,
457 },
458 ..BetweennessOptions::default()
459 };
460 assert_eq!(
461 graph.betweenness_centrality(options).unwrap(),
462 graph.betweenness_centrality(options).unwrap()
463 );
464 }
465
466 #[test]
467 fn weighted_mode_rejects_zero_weight() {
468 let graph = Graph::new(
469 GraphKind::DiGraph,
470 [Node::new("a"), Node::new("b")],
471 [Edge::new("ab", "a", "b").with_weight(0.0)],
472 )
473 .unwrap();
474 assert!(matches!(
475 graph.betweenness_centrality(BetweennessOptions {
476 weight: PathWeight::Weighted,
477 ..BetweennessOptions::default()
478 }),
479 Err(GraphError::InvalidOption(_))
480 ));
481 }
482
483 #[test]
484 fn parallel_edges_remain_distinct_in_edge_scores() {
485 let graph = Graph::new(
486 GraphKind::MultiDiGraph,
487 [Node::new("a"), Node::new("b")],
488 [Edge::new("one", "a", "b"), Edge::new("two", "a", "b")],
489 )
490 .unwrap();
491 let scores = graph
492 .edge_betweenness_centrality(BetweennessOptions {
493 normalized: false,
494 ..BetweennessOptions::default()
495 })
496 .unwrap();
497 assert_eq!(scores.len(), 2);
498 assert_abs_diff_eq!(scores[0].score, 0.5);
499 assert_abs_diff_eq!(scores[1].score, 0.5);
500 }
501
502 #[test]
503 fn diamond_scores_match_networkx_3_4_2_golden_fixture() {
504 let graph = Graph::new(
505 GraphKind::Graph,
506 ["a", "b", "c", "d"].into_iter().map(Node::new),
507 [
508 Edge::new("ab", "a", "b"),
509 Edge::new("ac", "a", "c"),
510 Edge::new("bd", "b", "d"),
511 Edge::new("cd", "c", "d"),
512 Edge::new("bc", "b", "c"),
513 ],
514 )
515 .unwrap();
516 let nodes = graph
517 .betweenness_centrality(BetweennessOptions::default())
518 .unwrap();
519 assert_abs_diff_eq!(nodes[0].score, 0.0);
520 assert_abs_diff_eq!(nodes[1].score, 1.0 / 6.0);
521 assert_abs_diff_eq!(nodes[2].score, 1.0 / 6.0);
522 assert_abs_diff_eq!(nodes[3].score, 0.0);
523
524 let endpoints = graph
525 .betweenness_centrality(BetweennessOptions {
526 endpoints: true,
527 ..BetweennessOptions::default()
528 })
529 .unwrap();
530 assert_abs_diff_eq!(endpoints[0].score, 0.5);
531 assert_abs_diff_eq!(endpoints[1].score, 7.0 / 12.0);
532 assert_abs_diff_eq!(endpoints[2].score, 7.0 / 12.0);
533 assert_abs_diff_eq!(endpoints[3].score, 0.5);
534 }
535
536 #[test]
537 fn weighted_directed_scores_match_networkx_3_4_2_golden_fixture() {
538 let graph = Graph::new(
539 GraphKind::DiGraph,
540 ["a", "b", "c", "d"].into_iter().map(Node::new),
541 [
542 Edge::new("ab", "a", "b").with_weight(1.0),
543 Edge::new("ac", "a", "c").with_weight(1.0),
544 Edge::new("ad", "a", "d").with_weight(3.0),
545 Edge::new("bd", "b", "d").with_weight(1.0),
546 Edge::new("cd", "c", "d").with_weight(1.0),
547 ],
548 )
549 .unwrap();
550 let options = BetweennessOptions {
551 normalized: false,
552 weight: PathWeight::Weighted,
553 ..BetweennessOptions::default()
554 };
555 let nodes = graph.betweenness_centrality(options).unwrap();
556 assert_abs_diff_eq!(nodes[1].score, 0.5);
557 assert_abs_diff_eq!(nodes[2].score, 0.5);
558 let edges = graph.edge_betweenness_centrality(options).unwrap();
559 let by_id = edges
560 .into_iter()
561 .map(|edge| (edge.edge_id, edge.score))
562 .collect::<std::collections::BTreeMap<_, _>>();
563 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("ab")], 1.5);
564 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("ac")], 1.5);
565 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("ad")], 0.0);
566 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("bd")], 1.5);
567 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("cd")], 1.5);
568 }
569
570 #[test]
571 fn multigraph_scores_match_networkx_3_4_2_golden_fixture() {
572 let graph = Graph::new(
573 GraphKind::MultiGraph,
574 [Node::new("a"), Node::new("b"), Node::new("c")],
575 [
576 Edge::new("one", "a", "b"),
577 Edge::new("two", "a", "b"),
578 Edge::new("bc", "b", "c"),
579 ],
580 )
581 .unwrap();
582 let options = BetweennessOptions {
583 normalized: false,
584 ..BetweennessOptions::default()
585 };
586 let nodes = graph.betweenness_centrality(options).unwrap();
587 assert_abs_diff_eq!(nodes[1].score, 1.0);
588 let by_id = graph
589 .edge_betweenness_centrality(options)
590 .unwrap()
591 .into_iter()
592 .map(|edge| (edge.edge_id, edge.score))
593 .collect::<std::collections::BTreeMap<_, _>>();
594 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("one")], 1.0);
595 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("two")], 1.0);
596 assert_abs_diff_eq!(by_id[&crate::EdgeId::from("bc")], 2.0);
597 }
598
599 #[test]
600 fn graphify_auto_and_sampled_edge_modes_cover_exact_and_clamped_sources() {
601 let graph = path(GraphKind::DiGraph);
602 let exact = graph
603 .betweenness_centrality(BetweennessOptions::graphify_default())
604 .unwrap();
605 assert_eq!(exact.len(), 3);
606
607 let options = BetweennessOptions {
608 mode: BetweennessMode::Auto {
609 exact_through: 1,
610 sample_count: NonZeroUsize::new(100).unwrap(),
611 seed: 42,
612 },
613 ..BetweennessOptions::default()
614 };
615 assert_eq!(
616 graph.edge_betweenness_centrality(options).unwrap(),
617 graph.edge_betweenness_centrality(options).unwrap()
618 );
619 let empty = Graph::new(GraphKind::DiGraph, [], []).unwrap();
620 assert!(empty
621 .edge_betweenness_centrality(BetweennessOptions {
622 mode: BetweennessMode::Sampled {
623 sample_count: NonZeroUsize::new(1).unwrap(),
624 seed: 7,
625 },
626 ..BetweennessOptions::default()
627 })
628 .unwrap()
629 .is_empty());
630 }
631}