Skip to main content

rust_igraph/algorithms/properties/
edge_distribution_entropy.rs

1//! Edge distribution entropy indices (ALGO-TR-123).
2//!
3//! Three novel topological ratio indices characterising how edges are
4//! distributed across different degree-pair classes:
5//!
6//! - [`edge_degree_entropy`]: Shannon entropy of the distribution of edges
7//!   over distinct (`min_degree`, `max_degree`) endpoint pairs.
8//! - [`edge_weight_balance`]: normalised entropy of the degree-pair
9//!   distribution (0 = all edges in one class, 1 = uniform).
10//! - [`degree_pair_concentration`]: fraction of edges belonging to the
11//!   most common degree-pair class.
12
13use std::collections::HashMap;
14
15use crate::core::{Graph, IgraphResult};
16
17/// Shannon entropy of the edge degree-pair distribution.
18///
19/// Each edge (u, v) is classified by the ordered pair
20/// `(min(deg(u), deg(v)), max(deg(u), deg(v)))`. This function computes
21/// the Shannon entropy (in nats) of the resulting distribution over all
22/// distinct degree-pair classes.
23///
24/// Returns 0.0 for graphs with no edges.
25///
26/// # Examples
27///
28/// ```
29/// use rust_igraph::{Graph, edge_degree_entropy};
30///
31/// // K3: all edges have degree pair (2,2) → single class → entropy = 0
32/// let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
33/// assert!(edge_degree_entropy(&g).unwrap().abs() < 1e-10);
34/// ```
35pub fn edge_degree_entropy(graph: &Graph) -> IgraphResult<f64> {
36    let counts = degree_pair_counts(graph)?;
37    if counts.is_empty() {
38        return Ok(0.0);
39    }
40    let total: u64 = counts.values().sum();
41    if total == 0 {
42        return Ok(0.0);
43    }
44    #[allow(clippy::cast_precision_loss)]
45    let total_f = total as f64;
46    let mut entropy = 0.0_f64;
47    for &count in counts.values() {
48        if count > 0 {
49            #[allow(clippy::cast_precision_loss)]
50            let p = count as f64 / total_f;
51            entropy -= p * p.ln();
52        }
53    }
54    Ok(entropy)
55}
56
57/// Normalised entropy of the edge degree-pair distribution.
58///
59/// This is [`edge_degree_entropy`] divided by `ln(number_of_distinct_classes)`,
60/// yielding a value in \[0, 1\]. A value of 1 means edges are uniformly
61/// distributed across all degree-pair classes; 0 means all edges belong
62/// to a single class.
63///
64/// Returns 0.0 for graphs with fewer than 2 distinct degree-pair classes.
65///
66/// # Examples
67///
68/// ```
69/// use rust_igraph::{Graph, edge_weight_balance};
70///
71/// // Path 0-1-2: edges (0,1) has pair (1,2), edge (1,2) has pair (1,2)
72/// // Single class → balance = 0
73/// let g = Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap();
74/// assert!(edge_weight_balance(&g).unwrap().abs() < 1e-10);
75/// ```
76pub fn edge_weight_balance(graph: &Graph) -> IgraphResult<f64> {
77    let counts = degree_pair_counts(graph)?;
78    let num_classes = counts.len();
79    if num_classes <= 1 {
80        return Ok(0.0);
81    }
82    let total: u64 = counts.values().sum();
83    if total == 0 {
84        return Ok(0.0);
85    }
86    #[allow(clippy::cast_precision_loss)]
87    let total_f = total as f64;
88    let mut entropy = 0.0_f64;
89    for &count in counts.values() {
90        if count > 0 {
91            #[allow(clippy::cast_precision_loss)]
92            let p = count as f64 / total_f;
93            entropy -= p * p.ln();
94        }
95    }
96    #[allow(clippy::cast_precision_loss)]
97    let max_entropy = (num_classes as f64).ln();
98    Ok(entropy / max_entropy)
99}
100
101/// Fraction of edges in the most common degree-pair class.
102///
103/// Returns a value in (0, 1\]. A value of 1 means all edges connect
104/// vertices of the same degree pair. Lower values indicate more diverse
105/// edge connectivity patterns.
106///
107/// Returns 0.0 for graphs with no edges.
108///
109/// # Examples
110///
111/// ```
112/// use rust_igraph::{Graph, degree_pair_concentration};
113///
114/// // K3: all 3 edges have pair (2,2) → concentration = 1.0
115/// let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
116/// assert!((degree_pair_concentration(&g).unwrap() - 1.0).abs() < 1e-10);
117/// ```
118pub fn degree_pair_concentration(graph: &Graph) -> IgraphResult<f64> {
119    let counts = degree_pair_counts(graph)?;
120    if counts.is_empty() {
121        return Ok(0.0);
122    }
123    let total: u64 = counts.values().sum();
124    if total == 0 {
125        return Ok(0.0);
126    }
127    let max_count = *counts.values().max().unwrap();
128    #[allow(clippy::cast_precision_loss)]
129    Ok(max_count as f64 / total as f64)
130}
131
132/// Compute the count of edges in each `(min_deg, max_deg)` class.
133fn degree_pair_counts(graph: &Graph) -> IgraphResult<HashMap<(usize, usize), u64>> {
134    let mut counts: HashMap<(usize, usize), u64> = HashMap::new();
135    let n = graph.vcount();
136    if n == 0 {
137        return Ok(counts);
138    }
139    // Pre-compute degrees
140    let mut degrees = vec![0usize; n as usize];
141    for v in 0..n {
142        degrees[v as usize] = graph.degree(v)?;
143    }
144    // Classify each edge
145    for (u, v) in graph.edges() {
146        let du = degrees[u as usize];
147        let dv = degrees[v as usize];
148        let key = if du <= dv { (du, dv) } else { (dv, du) };
149        *counts.entry(key).or_insert(0) += 1;
150    }
151    Ok(counts)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    // --- edge_degree_entropy ---
159
160    #[test]
161    fn entropy_empty() {
162        let g = Graph::with_vertices(0);
163        assert!(edge_degree_entropy(&g).unwrap().abs() < 1e-12);
164    }
165
166    #[test]
167    fn entropy_edgeless() {
168        let g = Graph::with_vertices(5);
169        assert!(edge_degree_entropy(&g).unwrap().abs() < 1e-12);
170    }
171
172    #[test]
173    fn entropy_complete_graph() {
174        // K4: all edges have pair (3,3) → single class → entropy = 0
175        let g = Graph::from_edges(
176            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
177            false,
178            Some(4),
179        )
180        .unwrap();
181        assert!(edge_degree_entropy(&g).unwrap().abs() < 1e-10);
182    }
183
184    #[test]
185    fn entropy_star() {
186        // Star K1,4: all edges have pair (1,4) → single class → entropy = 0
187        let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap();
188        assert!(edge_degree_entropy(&g).unwrap().abs() < 1e-10);
189    }
190
191    #[test]
192    fn entropy_mixed() {
193        // Triangle + pendant: edges (0,1),(1,2),(0,2) have pair (2,3) or (3,3)?
194        // degrees: 0→2, 1→2, 2→3, 3→1
195        // edge(0,1): (2,2), edge(1,2): (2,3), edge(0,2): (2,3), edge(2,3): (1,3)
196        // Classes: (2,2):1, (2,3):2, (1,3):1 → 3 classes → entropy > 0
197        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
198        let h = edge_degree_entropy(&g).unwrap();
199        assert!(h > 0.0, "Mixed graph should have positive entropy, got {h}");
200    }
201
202    // --- edge_weight_balance ---
203
204    #[test]
205    fn balance_empty() {
206        let g = Graph::with_vertices(5);
207        assert!(edge_weight_balance(&g).unwrap().abs() < 1e-12);
208    }
209
210    #[test]
211    fn balance_single_class() {
212        // K3: single class → balance = 0
213        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
214        assert!(edge_weight_balance(&g).unwrap().abs() < 1e-10);
215    }
216
217    #[test]
218    fn balance_multiple_classes() {
219        // Triangle + pendant: 3 classes → balance in (0, 1)
220        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
221        let b = edge_weight_balance(&g).unwrap();
222        assert!(b > 0.0, "Should be > 0, got {b}");
223        assert!(b <= 1.0, "Should be <= 1, got {b}");
224    }
225
226    #[test]
227    fn balance_uniform_is_one() {
228        // Need equal edges in each class. Path 0-1-2-3:
229        // degrees: 0→1, 1→2, 2→2, 3→1
230        // edge(0,1): (1,2), edge(1,2): (2,2), edge(2,3): (1,2)
231        // Classes: (1,2):2, (2,2):1 → not uniform
232        // Let's use a graph where classes are equal:
233        // 0-1 (deg 1,2), 1-2 (deg 2,2), 2-3 (deg 2,1) → (1,2):2, (2,2):1 — not equal
234        // Just verify it's in range for now
235        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap();
236        let b = edge_weight_balance(&g).unwrap();
237        assert!(b > 0.0 && b <= 1.0, "Balance should be in (0,1], got {b}");
238    }
239
240    // --- degree_pair_concentration ---
241
242    #[test]
243    fn concentration_empty() {
244        let g = Graph::with_vertices(5);
245        assert!(degree_pair_concentration(&g).unwrap().abs() < 1e-12);
246    }
247
248    #[test]
249    fn concentration_single_class() {
250        // K4: all edges same class → concentration = 1.0
251        let g = Graph::from_edges(
252            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
253            false,
254            Some(4),
255        )
256        .unwrap();
257        assert!((degree_pair_concentration(&g).unwrap() - 1.0).abs() < 1e-10);
258    }
259
260    #[test]
261    fn concentration_mixed() {
262        // Triangle + pendant: classes (2,2):1, (2,3):2, (1,3):1
263        // max = 2, total = 4 → concentration = 0.5
264        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
265        assert!((degree_pair_concentration(&g).unwrap() - 0.5).abs() < 1e-10);
266    }
267
268    #[test]
269    fn concentration_path() {
270        // Path 0-1-2-3-4: degrees [1,2,2,2,1]
271        // edge(0,1): (1,2), edge(1,2): (2,2), edge(2,3): (2,2), edge(3,4): (1,2)
272        // Classes: (1,2):2, (2,2):2 → max=2, total=4 → concentration = 0.5
273        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
274        assert!((degree_pair_concentration(&g).unwrap() - 0.5).abs() < 1e-10);
275    }
276
277    #[test]
278    fn entropy_and_balance_consistency() {
279        // When there's only 1 class, both should be 0
280        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
281        assert!(edge_degree_entropy(&g).unwrap().abs() < 1e-10);
282        assert!(edge_weight_balance(&g).unwrap().abs() < 1e-10);
283        assert!((degree_pair_concentration(&g).unwrap() - 1.0).abs() < 1e-10);
284    }
285}