Skip to content

Commit 89d8d8c

Browse files
committed
Added test coverage for betweenness_centrality algo.
Signed-off-by: Marvin Hansen <[email protected]>
1 parent a573118 commit 89d8d8c

18 files changed

+705
-403
lines changed

ultragraph/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ pub mod errors;
99

1010
mod traits;
1111
pub mod types;
12+
pub mod utils;
13+
1214
// Errors
1315
pub use crate::errors::graph_error::GraphError;
1416
// Traits

ultragraph/src/traits/graph_algo_centrality.rs

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,52 +6,13 @@ use crate::{GraphError, GraphView};
66

77
pub trait CentralityGraphAlgorithms<N, W>: GraphView<N, W> {
88
/// Calculates the betweenness centrality of each node in the graph.
9-
///
10-
/// Betweenness centrality measures a node's importance by counting how often it
11-
/// appears on the shortest paths between all other pairs of nodes. It is a powerful
12-
/// metric for identifying bottlenecks, bridges, and critical control points in a network.
13-
///
14-
/// The implementation uses Brandes' algorithm, which is highly efficient.
15-
///
16-
/// # Arguments
17-
///
18-
/// * `directed`: If `true`, the calculation considers the graph's edge directions.
19-
/// If `false`, all edges are treated as bidirectional.
20-
/// * `normalized`: If `true`, the centrality scores are normalized by dividing by the
21-
/// number of possible pairs of nodes. This allows for comparison between graphs of
22-
/// different sizes. If `false`, the raw count of paths is returned.
23-
///
24-
/// # Returns
25-
///
26-
/// A `Result` containing a vector of `(node_index, centrality_score)` tuples.
27-
/// The centrality score is an `f64` representing the node's importance.
28-
/// The vector is unsorted.
299
fn betweenness_centrality(
3010
&self,
3111
directed: bool,
3212
normalized: bool,
3313
) -> Result<Vec<(usize, f64)>, GraphError>;
3414

3515
/// Calculates betweenness centrality across a specific set of critical pathways.
36-
///
37-
/// This function is a highly efficient tool for targeted analysis, such as root cause
38-
/// investigation or identifying bottlenecks in specific set of graph pathways. Instead of
39-
/// analyzing all possible paths, it only considers the shortest paths between the
40-
/// start and end nodes of the pathways you provide.
41-
///
42-
/// The algorithm correctly routes these paths through the entire graph, ensuring that
43-
/// the results are accurate even if the shortest path temporarily leaves a conceptual
44-
/// "subgraph".
45-
///
46-
/// # Arguments
47-
/// * `pathways`: A slice of `(start_node, end_node)` tuples defining the pathways to analyze.
48-
/// * `directed`: If `true`, considers edge directions.
49-
/// * `normalized`: If `true`, normalizes scores by the number of provided pathways.
50-
///
51-
/// # Returns
52-
/// An unsorted vector of `(node_index, centrality_score)` tuples for nodes that lie on
53-
/// one or more of the specified pathways.
54-
///
5516
fn pathway_betweenness_centrality(
5617
&self,
5718
pathways: &[(usize, usize)],

ultragraph/src/traits/graph_algo_pathfinder.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,6 @@ pub trait PathfindingGraphAlgorithms<N, W>: GraphView<N, W> {
2323
) -> Result<Option<Vec<usize>>, GraphError>;
2424

2525
/// Finds the shortest path in a weighted graph using Dijkstra's algorithm.
26-
///
27-
/// The edge weight type `W` must support addition, comparison, and have a zero value.
28-
///
29-
/// # Returns
30-
/// A tuple containing the sequence of node indices in the path and the total cost of that path.
31-
/// Returns `None` if no path exists.
3226
fn shortest_weighted_path(
3327
&self,
3428
start_index: usize,

ultragraph/src/traits/graph_algo_structural.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
use crate::{GraphError, GraphView};
66

77
pub trait StructuralGraphAlgorithms<N, W>: GraphView<N, W> {
8-
/// Finds all Strongly Connected Components in the graph using Tarjan's algorithm.
9-
///
10-
/// # Returns
11-
/// A vector of vectors, where each inner vector is a list of node indices
12-
/// belonging to a single SCC.
8+
/// Finds all Strongly Connected Components in the graph.
139
fn strongly_connected_components(&self) -> Result<Vec<Vec<usize>>, GraphError>;
1410
}

ultragraph/src/traits/graph_algo_topological.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,9 @@ use crate::{GraphError, GraphView};
66

77
pub trait TopologicalGraphAlgorithms<N, W>: GraphView<N, W> {
88
/// Finds a single cycle in the graph and returns the path of nodes that form it.
9-
///
10-
/// This is the most powerful cycle detection method, as it not only confirms the
11-
/// presence of a cycle but also identifies the specific nodes involved. This is
12-
/// invaluable for debugging dynamically generated graphs.
13-
///
14-
/// # Returns
15-
/// `Some(Vec<usize>)` containing the sequence of node indices that form a cycle
16-
/// (e.g., `[0, 1, 0]`). Returns `None` if the graph is a DAG.
179
fn find_cycle(&self) -> Result<Option<Vec<usize>>, GraphError>;
1810

1911
/// Checks if the graph contains any directed cycles.
20-
///
2112
/// This method should be implemented as a simple call to `self.find_cycle().is_some()`.
2213
fn has_cycle(&self) -> Result<bool, GraphError>;
2314

ultragraph/src/traits/graph_traversal.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,6 @@ pub trait GraphTraversal<N, W>: GraphView<N, W> {
44
// --- Traversal ---
55

66
/// Returns a non-allocating iterator over the direct successors (outgoing edges) of node `a`.
7-
///
8-
/// This method provides a direct, high-performance view into the graph's internal
9-
/// structure without any intermediate memory allocations.
10-
///
11-
/// # Returns
12-
/// A `Result` containing an iterator that yields the `usize` indices of the neighbor nodes.
137
fn outbound_edges(&self, a: usize) -> Result<impl Iterator<Item = usize> + '_, GraphError>;
148

159
/// Returns a non-allocating iterator over the direct predecessors (incoming edges) of node `a`.

ultragraph/src/traits/graph_view.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,7 @@ pub trait GraphView<N, W> {
1818
fn contains_edge(&self, a: usize, b: usize) -> bool;
1919
fn number_edges(&self) -> usize;
2020

21-
/// Returns a vector of immutable references to all active nodes in the graph.
22-
///
23-
/// The order of the nodes in the returned vector is not guaranteed.
24-
/// This operation is O(V) as it iterates through all possible node slots
2521
fn get_all_nodes(&self) -> Vec<&N>;
26-
27-
/// Retrieves a list of all outgoing edges from a given source node.
28-
/// Returns `None` if the source node does not exist.
29-
/// The returned vector contains tuples of `(target_node_index, edge_weight_reference)`.
3022
fn get_edges(&self, source: usize) -> Option<Vec<(usize, &W)>>;
3123

3224
fn get_last_index(&self) -> Option<usize>;

ultragraph/src/types/storage/graph_csm/graph_csm_algo_centrality.rs

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ where
1010
N: Clone,
1111
W: Clone + Default,
1212
{
13+
/// Calculates the betweenness centrality of each node in the graph.
1314
fn betweenness_centrality(
1415
&self,
1516
directed: bool,
@@ -22,6 +23,11 @@ where
2223

2324
let mut centrality = vec![0.0; num_nodes];
2425

26+
// Betweenness centrality is 0 for graphs with less than 3 nodes.
27+
if num_nodes < 3 {
28+
return Ok(centrality.into_iter().enumerate().collect());
29+
}
30+
2531
for s in 0..num_nodes {
2632
let (_, sigma, pred, mut stack) = self._brandes_bfs_and_path_counting(s, directed)?;
2733

@@ -46,6 +52,13 @@ where
4652
}
4753
}
4854

55+
// It aligns the code with the standard formal definition.
56+
if !directed {
57+
for score in centrality.iter_mut() {
58+
*score /= 2.0;
59+
}
60+
}
61+
4962
// Normalization
5063
if normalized {
5164
let scale = if directed {
@@ -64,6 +77,7 @@ where
6477
Ok(centrality.into_iter().enumerate().collect())
6578
}
6679

80+
/// Calculates betweenness centrality across a specific set of critical pathways.
6781
fn pathway_betweenness_centrality(
6882
&self,
6983
pathways: &[(usize, usize)],
@@ -72,54 +86,70 @@ where
7286
) -> Result<Vec<(usize, f64)>, GraphError> {
7387
let num_nodes = self.number_nodes();
7488
if num_nodes == 0 {
75-
return Ok(Vec::new());
89+
return Ok(vec![]);
7690
}
7791

7892
let mut centrality = vec![0.0; num_nodes];
93+
let mut valid_pathways = 0;
7994

95+
// Unlike the global version, we cannot easily group by source,
96+
// as the dependency calculation is specific to each target `t`.
8097
for &(s, t) in pathways {
8198
if !self.contains_node(s) || !self.contains_node(t) {
82-
// Skip invalid pathways
8399
continue;
84100
}
85101

102+
if s == t {
103+
valid_pathways += 1;
104+
continue;
105+
}
106+
107+
// We need a fresh BFS for each s->t pair to get the correct stack order
86108
let (dist, sigma, pred, mut stack) =
87109
self._brandes_bfs_and_path_counting(s, directed)?;
88110

89-
// Accumulation for specific pathway (s, t)
90-
if dist[t].is_some() {
91-
// Only accumulate if t is reachable from s
92-
let mut delta = vec![0.0; num_nodes];
93-
delta[t] = 1.0;
94-
95-
while let Some(w) = stack.pop() {
96-
for &v in &pred[w] {
97-
let sigma_v = sigma[v];
98-
let sigma_w = sigma[w];
99-
if sigma_w == 0.0 {
100-
return Err(GraphError::AlgorithmError(
101-
"Division by zero in sigma calculation for pathway",
102-
));
103-
}
104-
delta[v] += (sigma_v / sigma_w) * (1.0 + delta[w]);
105-
}
106-
if w != s {
107-
centrality[w] += delta[w];
111+
// If t is not reachable from s, this pathway contributes nothing.
112+
if dist[t].is_none() {
113+
continue;
114+
}
115+
valid_pathways += 1;
116+
117+
let mut delta = vec![0.0; num_nodes];
118+
119+
// Process nodes in reverse order from the stack
120+
while let Some(w) = stack.pop() {
121+
// The contribution to dependency is only propagated from nodes on a shortest path to t.
122+
// This can be simplified: if a node `w` is part of an s-t path, its `delta` will be non-zero
123+
// if one of its successors on the shortest path has non-zero delta. We start this at `t`.
124+
if w == t {
125+
// This is the starting point of our back-propagation for this s-t path
126+
delta[w] = 1.0;
127+
}
128+
129+
for &v in &pred[w] {
130+
// The dependency of v is its share of the dependency of w.
131+
if sigma[w] > 0.0 {
132+
delta[v] += (sigma[v] / sigma[w]) * delta[w];
108133
}
109134
}
110135
}
111-
}
112136

113-
// Normalization
114-
if normalized {
115-
let num_pathways = pathways.len() as f64;
116-
if num_pathways > 0.0 {
117-
for score in centrality.iter_mut() {
118-
*score /= num_pathways;
137+
// Add the accumulated dependencies to the final centrality scores
138+
// The endpoints s and t themselves do not get centrality credit from this path.
139+
for i in 0..num_nodes {
140+
if i != s && i != t {
141+
centrality[i] += delta[i];
119142
}
120143
}
121144
}
122145

146+
if normalized && valid_pathways > 0 {
147+
let scale = valid_pathways as f64;
148+
for score in &mut centrality {
149+
*score /= scale;
150+
}
151+
}
152+
123153
Ok(centrality.into_iter().enumerate().collect())
124154
}
125155
}

ultragraph/src/types/ultra_graph/graph_algo.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@ where
2020
N: Clone,
2121
W: Clone + Default,
2222
{
23+
/// Calculates the betweenness centrality of each node in the graph.
24+
///
25+
/// Betweenness centrality measures a node's importance by counting how often it
26+
/// appears on the shortest paths between all other pairs of nodes. It is a powerful
27+
/// metric for identifying bottlenecks, bridges, and critical control points in a network.
28+
///
29+
/// The implementation uses Brandes' algorithm, which is highly efficient.
30+
///
31+
/// # Arguments
32+
///
33+
/// * `directed`: If `true`, the calculation considers the graph's edge directions.
34+
/// If `false`, all edges are treated as bidirectional.
35+
/// * `normalized`: If `true`, the centrality scores are normalized by dividing by the
36+
/// number of possible pairs of nodes. This allows for comparison between graphs of
37+
/// different sizes. If `false`, the raw count of paths is returned.
38+
///
39+
/// # Returns
40+
///
41+
/// A `Result` containing a vector of `(node_index, centrality_score)` tuples.
42+
/// The centrality score is an `f64` representing the node's importance.
43+
/// The vector is unsorted.
2344
fn betweenness_centrality(
2445
&self,
2546
directed: bool,
@@ -31,6 +52,26 @@ where
3152
}
3253
}
3354

55+
/// Calculates betweenness centrality across a specific set of critical pathways.
56+
///
57+
/// This function is a highly efficient tool for targeted analysis, such as root cause
58+
/// investigation or identifying bottlenecks in specific set of graph pathways. Instead of
59+
/// analyzing all possible paths, it only considers the shortest paths between the
60+
/// start and end nodes of the pathways you provide.
61+
///
62+
/// The algorithm correctly routes these paths through the entire graph, ensuring that
63+
/// the results are accurate even if the shortest path temporarily leaves a conceptual
64+
/// "subgraph".
65+
///
66+
/// # Arguments
67+
/// * `pathways`: A slice of `(start_node, end_node)` tuples defining the pathways to analyze.
68+
/// * `directed`: If `true`, considers edge directions.
69+
/// * `normalized`: If `true`, normalizes scores by the number of provided pathways.
70+
///
71+
/// # Returns
72+
/// An unsorted vector of `(node_index, centrality_score)` tuples for nodes that lie on
73+
/// one or more of the specified pathways.
74+
///
3475
fn pathway_betweenness_centrality(
3576
&self,
3677
pathways: &[(usize, usize)],
@@ -120,7 +161,6 @@ where
120161
/// - Returns `GraphError::GraphNotFrozen` if the graph is in a `Dynamic` state.
121162
/// - Returns an error if either node index is invalid.
122163
/// - Returns an error if the graph contains negative cycles (for algorithms like Dijkstra's).
123-
///
124164
fn shortest_weighted_path(
125165
&self,
126166
start_index: usize,

ultragraph/src/types/ultra_graph/graph_view.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ where
6767
}
6868
}
6969

70+
/// The order of the nodes in the returned vector is not guaranteed.
71+
/// This operation is O(V) as it iterates through all possible node slots
7072
fn get_all_nodes(&self) -> Vec<&N> {
7173
match &self.state {
7274
GraphState::Dynamic(g) => g.get_all_nodes(),
@@ -75,6 +77,8 @@ where
7577
}
7678

7779
/// Returns a list of outgoing edges from a source node, including target index and weight.
80+
/// Returns `None` if the source node does not exist.
81+
/// The returned vector contains tuples of `(target_node_index, edge_weight_reference)`.
7882
fn get_edges(&self, source: usize) -> Option<Vec<(usize, &W)>> {
7983
match &self.state {
8084
GraphState::Dynamic(g) => g.get_edges(source),

0 commit comments

Comments
 (0)