Skip to main content

helix_graph_algorithms/algorithms/
centrality.rs

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/// Source-selection policy for Brandes centrality.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case", tag = "kind")]
13pub enum BetweennessMode {
14    /// Use every node as a source.
15    Exact,
16    /// Use a deterministic sample of source nodes.
17    Sampled {
18        /// Requested unique sources, clamped to graph size.
19        sample_count: NonZeroUsize,
20        /// Deterministic shuffle seed.
21        seed: u64,
22    },
23    /// Use exact mode through a threshold, then sampled mode.
24    Auto {
25        /// Largest graph using exact mode.
26        exact_through: usize,
27        /// Requested unique sources above the threshold.
28        sample_count: NonZeroUsize,
29        /// Deterministic shuffle seed.
30        seed: u64,
31    },
32}
33
34/// Whether shortest paths use edge weights.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PathWeight {
38    /// Every edge costs one.
39    Unweighted,
40    /// Use the graph's selected weight, defaulting missing weights to one.
41    Weighted,
42}
43
44/// Node and edge betweenness options.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub struct BetweennessOptions {
47    /// Exact, sampled, or graph-size-selected sources.
48    pub mode: BetweennessMode,
49    /// Apply NetworkX-compatible normalization.
50    pub normalized: bool,
51    /// Include path endpoints in node centrality. Ignored by edge centrality.
52    pub endpoints: bool,
53    /// Weighted or unweighted shortest paths.
54    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    /// Graphify's audited exact/sample policy.
70    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/// One node centrality score.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct NodeScore {
85    /// External node identity.
86    pub node_id: NodeId,
87    /// Centrality score.
88    pub score: f64,
89}
90
91/// One stable edge centrality score.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct EdgeScore {
94    /// Stable edge identity.
95    pub edge_id: EdgeId,
96    /// Optional Graphify multigraph key.
97    pub graphify_key: Option<ExternalId>,
98    /// Stored source identity.
99    pub source: NodeId,
100    /// Stored target identity.
101    pub target: NodeId,
102    /// Centrality score.
103    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    /// Compute node betweenness centrality with NetworkX-compatible scaling.
145    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    /// Compute edge betweenness centrality, retaining parallel-edge identity.
191    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}