Skip to main content

oximedia_graph/
metrics_graph.rs

1//! Graph metrics: diameter, clustering coefficient, and betweenness centrality approximation.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5/// A simple adjacency-list graph for metric computation.
6#[allow(dead_code)]
7#[derive(Debug, Clone)]
8pub struct MetricsGraph {
9    /// Adjacency list: node_id -> set of neighbor ids.
10    adjacency: HashMap<usize, HashSet<usize>>,
11    /// Whether the graph is directed.
12    directed: bool,
13}
14
15impl MetricsGraph {
16    /// Create a new directed metrics graph.
17    #[allow(dead_code)]
18    pub fn new_directed() -> Self {
19        Self {
20            adjacency: HashMap::new(),
21            directed: true,
22        }
23    }
24
25    /// Create a new undirected metrics graph.
26    #[allow(dead_code)]
27    pub fn new_undirected() -> Self {
28        Self {
29            adjacency: HashMap::new(),
30            directed: false,
31        }
32    }
33
34    /// Add a node.
35    #[allow(dead_code)]
36    pub fn add_node(&mut self, id: usize) {
37        self.adjacency.entry(id).or_default();
38    }
39
40    /// Add an edge between two nodes. In undirected mode, adds both directions.
41    #[allow(dead_code)]
42    pub fn add_edge(&mut self, from: usize, to: usize) {
43        self.adjacency.entry(from).or_default().insert(to);
44        self.adjacency.entry(to).or_default(); // Ensure target node exists
45        if !self.directed {
46            self.adjacency.entry(to).or_default().insert(from);
47        }
48    }
49
50    /// Returns the number of nodes.
51    #[allow(dead_code)]
52    pub fn node_count(&self) -> usize {
53        self.adjacency.len()
54    }
55
56    /// Returns the number of edges.
57    #[allow(dead_code)]
58    pub fn edge_count(&self) -> usize {
59        let total: usize = self.adjacency.values().map(|n| n.len()).sum();
60        if self.directed {
61            total
62        } else {
63            total / 2
64        }
65    }
66
67    /// BFS shortest path distances from a source node.
68    #[allow(dead_code)]
69    fn bfs_distances(&self, source: usize) -> HashMap<usize, usize> {
70        let mut dist = HashMap::new();
71        let mut queue = VecDeque::new();
72        dist.insert(source, 0usize);
73        queue.push_back(source);
74
75        while let Some(u) = queue.pop_front() {
76            let d = dist[&u];
77            if let Some(neighbors) = self.adjacency.get(&u) {
78                for &v in neighbors {
79                    if let std::collections::hash_map::Entry::Vacant(e) = dist.entry(v) {
80                        e.insert(d + 1);
81                        queue.push_back(v);
82                    }
83                }
84            }
85        }
86        dist
87    }
88
89    /// Compute the graph diameter (longest shortest path between any two nodes).
90    /// Returns `None` if the graph is empty or disconnected (between the connected components).
91    #[allow(dead_code)]
92    pub fn diameter(&self) -> Option<usize> {
93        if self.adjacency.is_empty() {
94            return None;
95        }
96
97        let nodes: Vec<usize> = self.adjacency.keys().copied().collect();
98        let mut max_dist = 0usize;
99        let mut found_any = false;
100
101        for &source in &nodes {
102            let distances = self.bfs_distances(source);
103            for (&target, &d) in &distances {
104                if target != source {
105                    found_any = true;
106                    if d > max_dist {
107                        max_dist = d;
108                    }
109                }
110            }
111        }
112
113        if found_any {
114            Some(max_dist)
115        } else {
116            None
117        }
118    }
119
120    /// Compute the local clustering coefficient for a given node.
121    ///
122    /// For an undirected graph: C(v) = 2 * edges_between_neighbors / (k * (k-1))
123    /// where k = degree of v.
124    #[allow(dead_code)]
125    pub fn local_clustering_coefficient(&self, node: usize) -> f64 {
126        let neighbors = match self.adjacency.get(&node) {
127            Some(n) => n,
128            None => return 0.0,
129        };
130
131        let k = neighbors.len();
132        if k < 2 {
133            return 0.0;
134        }
135
136        let neighbors_vec: Vec<usize> = neighbors.iter().copied().collect();
137        let mut triangle_edges = 0usize;
138
139        for i in 0..k {
140            for j in (i + 1)..k {
141                let u = neighbors_vec[i];
142                let v = neighbors_vec[j];
143                // Check if u-v edge exists
144                if let Some(u_neighbors) = self.adjacency.get(&u) {
145                    if u_neighbors.contains(&v) {
146                        triangle_edges += 1;
147                    }
148                }
149            }
150        }
151
152        2.0 * triangle_edges as f64 / (k * (k - 1)) as f64
153    }
154
155    /// Compute the average clustering coefficient of the graph.
156    #[allow(dead_code)]
157    pub fn average_clustering_coefficient(&self) -> f64 {
158        if self.adjacency.is_empty() {
159            return 0.0;
160        }
161        let nodes: Vec<usize> = self.adjacency.keys().copied().collect();
162        let sum: f64 = nodes
163            .iter()
164            .map(|&n| self.local_clustering_coefficient(n))
165            .sum();
166        sum / nodes.len() as f64
167    }
168
169    /// Approximate betweenness centrality using Brandes' algorithm (exact for small graphs).
170    ///
171    /// Returns a map of node_id -> centrality score (unnormalized).
172    #[allow(dead_code)]
173    pub fn betweenness_centrality(&self) -> HashMap<usize, f64> {
174        let nodes: Vec<usize> = self.adjacency.keys().copied().collect();
175        let mut centrality: HashMap<usize, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
176
177        for &source in &nodes {
178            // BFS to compute sigma (number of shortest paths) and dist
179            let mut stack: Vec<usize> = Vec::new();
180            let mut pred: HashMap<usize, Vec<usize>> =
181                nodes.iter().map(|&n| (n, Vec::new())).collect();
182            let mut sigma: HashMap<usize, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
183            let mut dist: HashMap<usize, i64> = nodes.iter().map(|&n| (n, -1)).collect();
184
185            sigma.insert(source, 1.0);
186            dist.insert(source, 0);
187
188            let mut queue = VecDeque::new();
189            queue.push_back(source);
190
191            while let Some(v) = queue.pop_front() {
192                stack.push(v);
193                let dv = dist[&v];
194
195                if let Some(neighbors) = self.adjacency.get(&v) {
196                    for &w in neighbors {
197                        let dw = dist[&w];
198                        if dw < 0 {
199                            queue.push_back(w);
200                            dist.insert(w, dv + 1);
201                        }
202                        if dist[&w] == dv + 1 {
203                            let sw = sigma[&v];
204                            // SAFETY: sigma and pred are pre-populated with all node ids
205                            if let Some(sw_entry) = sigma.get_mut(&w) {
206                                *sw_entry += sw;
207                            }
208                            if let Some(pred_entry) = pred.get_mut(&w) {
209                                pred_entry.push(v);
210                            }
211                        }
212                    }
213                }
214            }
215
216            // Accumulation
217            let mut delta: HashMap<usize, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
218            while let Some(w) = stack.pop() {
219                let sw = sigma[&w];
220                let dw = delta[&w];
221                for &v in &pred[&w] {
222                    let sv = sigma[&v];
223                    let contribution = (sv / sw) * (1.0 + dw);
224                    // SAFETY: delta is pre-populated with all node ids
225                    if let Some(dv_entry) = delta.get_mut(&v) {
226                        *dv_entry += contribution;
227                    }
228                }
229                if w != source {
230                    let dw_val = delta[&w];
231                    // SAFETY: centrality is pre-populated with all node ids
232                    if let Some(c) = centrality.get_mut(&w) {
233                        *c += dw_val;
234                    }
235                }
236            }
237        }
238
239        centrality
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn make_path_graph(n: usize) -> MetricsGraph {
248        let mut g = MetricsGraph::new_undirected();
249        for i in 0..n {
250            g.add_node(i);
251        }
252        for i in 0..n.saturating_sub(1) {
253            g.add_edge(i, i + 1);
254        }
255        g
256    }
257
258    fn make_complete_graph(n: usize) -> MetricsGraph {
259        let mut g = MetricsGraph::new_undirected();
260        for i in 0..n {
261            g.add_node(i);
262        }
263        for i in 0..n {
264            for j in (i + 1)..n {
265                g.add_edge(i, j);
266            }
267        }
268        g
269    }
270
271    #[test]
272    fn test_node_count() {
273        let g = make_path_graph(4);
274        assert_eq!(g.node_count(), 4);
275    }
276
277    #[test]
278    fn test_edge_count_undirected() {
279        let g = make_path_graph(4);
280        assert_eq!(g.edge_count(), 3);
281    }
282
283    #[test]
284    fn test_diameter_path_graph() {
285        let g = make_path_graph(5);
286        assert_eq!(g.diameter(), Some(4));
287    }
288
289    #[test]
290    fn test_diameter_single_node() {
291        let mut g = MetricsGraph::new_undirected();
292        g.add_node(0);
293        assert_eq!(g.diameter(), None);
294    }
295
296    #[test]
297    fn test_diameter_empty() {
298        let g = MetricsGraph::new_undirected();
299        assert_eq!(g.diameter(), None);
300    }
301
302    #[test]
303    fn test_diameter_complete_graph() {
304        let g = make_complete_graph(4);
305        assert_eq!(g.diameter(), Some(1));
306    }
307
308    #[test]
309    fn test_clustering_coefficient_complete_graph() {
310        let g = make_complete_graph(4);
311        // Complete graph: every node's neighborhood is fully connected
312        for i in 0..4 {
313            let cc = g.local_clustering_coefficient(i);
314            assert!((cc - 1.0).abs() < 1e-10, "node {i}: cc={cc}");
315        }
316    }
317
318    #[test]
319    fn test_clustering_coefficient_path_graph() {
320        // Path graph has no triangles, so CC = 0 for all degree-2 nodes
321        let g = make_path_graph(5);
322        let cc = g.local_clustering_coefficient(2);
323        assert_eq!(cc, 0.0);
324    }
325
326    #[test]
327    fn test_clustering_coefficient_low_degree() {
328        let g = make_path_graph(4);
329        // Endpoint has degree 1, CC = 0
330        let cc = g.local_clustering_coefficient(0);
331        assert_eq!(cc, 0.0);
332    }
333
334    #[test]
335    fn test_average_clustering_complete() {
336        let g = make_complete_graph(4);
337        let avg = g.average_clustering_coefficient();
338        assert!((avg - 1.0).abs() < 1e-10);
339    }
340
341    #[test]
342    fn test_average_clustering_empty() {
343        let g = MetricsGraph::new_undirected();
344        assert_eq!(g.average_clustering_coefficient(), 0.0);
345    }
346
347    #[test]
348    fn test_betweenness_centrality_path() {
349        // In a path 0-1-2-3-4, node 2 (middle) should have highest centrality
350        let g = make_path_graph(5);
351        let bc = g.betweenness_centrality();
352        let mid = bc[&2];
353        let end = bc[&0];
354        assert!(
355            mid > end,
356            "middle node should have higher betweenness than endpoint"
357        );
358    }
359
360    #[test]
361    fn test_betweenness_centrality_complete_symmetric() {
362        let g = make_complete_graph(4);
363        let bc = g.betweenness_centrality();
364        // In a complete graph all centralities should be equal
365        let vals: Vec<f64> = bc.values().copied().collect();
366        let first = vals[0];
367        for v in &vals {
368            assert!(
369                (v - first).abs() < 1e-9,
370                "values should be equal: {first} vs {v}"
371            );
372        }
373    }
374
375    #[test]
376    fn test_directed_graph_edge_count() {
377        let mut g = MetricsGraph::new_directed();
378        g.add_edge(0, 1);
379        g.add_edge(1, 2);
380        assert_eq!(g.edge_count(), 2);
381    }
382}