Skip to main content

graphmind_graph_algorithms/
lcc.rs

1//! Local Clustering Coefficient (LCC)
2//!
3//! Computes the local clustering coefficient for each node.
4//!
5//! Undirected: LCC(v) = 2 * T(v) / (deg(v) * (deg(v) - 1))
6//! Directed:   LCC(v) = T(v) / (deg(v) * (deg(v) - 1))
7//!
8//! where T(v) is the number of triangles (edges among neighbors) containing v,
9//! and deg(v) is the undirected degree (union of successors + predecessors) for
10//! undirected mode, or the number of distinct neighbors for directed mode.
11
12use super::common::{GraphView, NodeId};
13use std::collections::{HashMap, HashSet};
14
15/// Result of LCC computation
16#[derive(Debug, Clone)]
17pub struct LccResult {
18    /// Clustering coefficient per node
19    pub coefficients: HashMap<NodeId, f64>,
20    /// Global average clustering coefficient
21    pub average: f64,
22}
23
24/// Compute local clustering coefficients for all nodes (undirected mode).
25///
26/// Uses undirected edges (union of successors + predecessors).
27/// This is the backward-compatible entry point.
28pub fn local_clustering_coefficient(view: &GraphView) -> LccResult {
29    local_clustering_coefficient_directed(view, false)
30}
31
32/// Compute local clustering coefficients for all nodes.
33///
34/// When `directed=false`: uses undirected neighbor sets (union of successors +
35/// predecessors), counts undirected edges among neighbors, divides by
36/// `d*(d-1)/2`.
37///
38/// When `directed=true`: uses undirected neighbor sets for neighborhood
39/// discovery, but counts *directed* edges (u→w) among neighbors, divides by
40/// `d*(d-1)` (the maximum number of directed edges among d nodes).
41pub fn local_clustering_coefficient_directed(view: &GraphView, directed: bool) -> LccResult {
42    let n = view.node_count;
43    if n == 0 {
44        return LccResult {
45            coefficients: HashMap::new(),
46            average: 0.0,
47        };
48    }
49
50    // Build undirected neighbor sets for each node (used for neighborhood)
51    let mut neighbors: Vec<HashSet<usize>> = Vec::with_capacity(n);
52    for idx in 0..n {
53        let mut set = HashSet::new();
54        for &s in view.successors(idx) {
55            if s != idx {
56                set.insert(s);
57            }
58        }
59        for &p in view.predecessors(idx) {
60            if p != idx {
61                set.insert(p);
62            }
63        }
64        neighbors.push(set);
65    }
66
67    // For directed mode, also build successor sets for fast directed edge lookup
68    let successor_sets: Option<Vec<HashSet<usize>>> = if directed {
69        let mut sets = Vec::with_capacity(n);
70        for idx in 0..n {
71            let set: HashSet<usize> = view.successors(idx).iter().copied().collect();
72            sets.push(set);
73        }
74        Some(sets)
75    } else {
76        None
77    };
78
79    let mut coefficients = HashMap::with_capacity(n);
80    let mut sum = 0.0;
81
82    for idx in 0..n {
83        let deg = neighbors[idx].len();
84        if deg < 2 {
85            coefficients.insert(view.index_to_node[idx], 0.0);
86            continue;
87        }
88
89        let neighbor_vec: Vec<usize> = neighbors[idx].iter().cloned().collect();
90
91        if directed {
92            // Directed mode: count directed edges u→w among neighbors
93            let succ_sets = successor_sets.as_ref().unwrap();
94            let mut directed_edges = 0usize;
95            for i in 0..neighbor_vec.len() {
96                for j in 0..neighbor_vec.len() {
97                    if i != j && succ_sets[neighbor_vec[i]].contains(&neighbor_vec[j]) {
98                        directed_edges += 1;
99                    }
100                }
101            }
102            let max_edges = deg * (deg - 1); // d*(d-1) for directed
103            let cc = directed_edges as f64 / max_edges as f64;
104            coefficients.insert(view.index_to_node[idx], cc);
105            sum += cc;
106        } else {
107            // Undirected mode: count undirected edges among neighbors
108            let mut triangle_edges = 0usize;
109            for i in 0..neighbor_vec.len() {
110                for j in (i + 1)..neighbor_vec.len() {
111                    if neighbors[neighbor_vec[i]].contains(&neighbor_vec[j]) {
112                        triangle_edges += 1;
113                    }
114                }
115            }
116            let max_edges = deg * (deg - 1) / 2; // d*(d-1)/2 for undirected
117            let cc = triangle_edges as f64 / max_edges as f64;
118            coefficients.insert(view.index_to_node[idx], cc);
119            sum += cc;
120        }
121    }
122
123    let average = if n > 0 { sum / n as f64 } else { 0.0 };
124
125    LccResult {
126        coefficients,
127        average,
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::common::GraphView;
135    use std::collections::HashMap;
136
137    #[test]
138    fn test_lcc_triangle() {
139        // Complete triangle: 1-2, 2-3, 1-3
140        let index_to_node = vec![1, 2, 3];
141        let mut node_to_index = HashMap::new();
142        node_to_index.insert(1, 0);
143        node_to_index.insert(2, 1);
144        node_to_index.insert(3, 2);
145
146        let outgoing = vec![vec![1, 2], vec![0, 2], vec![0, 1]];
147        let incoming = vec![vec![1, 2], vec![0, 2], vec![0, 1]];
148
149        let view = GraphView::from_adjacency_list(
150            3,
151            index_to_node,
152            node_to_index,
153            outgoing,
154            incoming,
155            None,
156        );
157        let result = local_clustering_coefficient(&view);
158
159        // All nodes in a complete triangle have LCC = 1.0
160        for (_node, cc) in &result.coefficients {
161            assert!(
162                (cc - 1.0).abs() < 1e-10,
163                "Complete triangle LCC should be 1.0, got {}",
164                cc
165            );
166        }
167        assert!((result.average - 1.0).abs() < 1e-10);
168    }
169
170    #[test]
171    fn test_lcc_star() {
172        // Star: center 1 connected to 2, 3, 4 (no edges among 2,3,4)
173        let index_to_node = vec![1, 2, 3, 4];
174        let mut node_to_index = HashMap::new();
175        for (i, &id) in index_to_node.iter().enumerate() {
176            node_to_index.insert(id, i);
177        }
178
179        let outgoing = vec![vec![1, 2, 3], vec![0], vec![0], vec![0]];
180        let incoming = vec![vec![1, 2, 3], vec![0], vec![0], vec![0]];
181
182        let view = GraphView::from_adjacency_list(
183            4,
184            index_to_node,
185            node_to_index,
186            outgoing,
187            incoming,
188            None,
189        );
190        let result = local_clustering_coefficient(&view);
191
192        // Center node: 3 neighbors, no edges among them -> LCC = 0
193        assert!((result.coefficients[&1] - 0.0).abs() < 1e-10);
194        // Leaf nodes: degree 1 -> LCC = 0
195        assert!((result.coefficients[&2] - 0.0).abs() < 1e-10);
196    }
197
198    #[test]
199    fn test_lcc_empty() {
200        let view = GraphView::from_adjacency_list(0, vec![], HashMap::new(), vec![], vec![], None);
201        let result = local_clustering_coefficient(&view);
202        assert!(result.coefficients.is_empty());
203        assert!((result.average - 0.0).abs() < 1e-10);
204    }
205
206    #[test]
207    fn test_lcc_directed_triangle() {
208        // Directed triangle: 1->2, 2->3, 3->1 (cycle)
209        // Each node has 2 neighbors (undirected), and there is exactly 1 directed
210        // edge among those 2 neighbors. max_edges = 2*(2-1) = 2.
211        // LCC = 1/2 = 0.5 for each node.
212        let index_to_node = vec![1, 2, 3];
213        let mut node_to_index = HashMap::new();
214        node_to_index.insert(1, 0);
215        node_to_index.insert(2, 1);
216        node_to_index.insert(3, 2);
217
218        // 0->1, 1->2, 2->0
219        let outgoing = vec![vec![1], vec![2], vec![0]];
220        let incoming = vec![vec![2], vec![0], vec![1]];
221
222        let view = GraphView::from_adjacency_list(
223            3,
224            index_to_node,
225            node_to_index,
226            outgoing,
227            incoming,
228            None,
229        );
230        let result = local_clustering_coefficient_directed(&view, true);
231
232        // Node 0 (id=1): neighbors are {1, 2}. Directed edges among them: 1->2 = 1.
233        // max = 2*1 = 2.  LCC = 1/2 = 0.5
234        for (&_node, &cc) in &result.coefficients {
235            assert!(
236                (cc - 0.5).abs() < 1e-10,
237                "Directed cycle triangle LCC should be 0.5, got {}",
238                cc
239            );
240        }
241    }
242
243    #[test]
244    fn test_lcc_directed_complete_triangle() {
245        // Fully connected directed triangle: all 6 directed edges present
246        // Each node has 2 neighbors, 2 directed edges among them, max = 2.
247        // LCC = 2/2 = 1.0
248        let index_to_node = vec![1, 2, 3];
249        let mut node_to_index = HashMap::new();
250        node_to_index.insert(1, 0);
251        node_to_index.insert(2, 1);
252        node_to_index.insert(3, 2);
253
254        let outgoing = vec![vec![1, 2], vec![0, 2], vec![0, 1]];
255        let incoming = vec![vec![1, 2], vec![0, 2], vec![0, 1]];
256
257        let view = GraphView::from_adjacency_list(
258            3,
259            index_to_node,
260            node_to_index,
261            outgoing,
262            incoming,
263            None,
264        );
265        let result = local_clustering_coefficient_directed(&view, true);
266
267        for (&_node, &cc) in &result.coefficients {
268            assert!(
269                (cc - 1.0).abs() < 1e-10,
270                "Fully connected directed triangle LCC should be 1.0, got {}",
271                cc
272            );
273        }
274    }
275}