Skip to main content

rust_igraph/algorithms/properties/
distance_profile.rs

1//! Distance profile indices (ALGO-TR-125).
2//!
3//! Three novel topological ratio indices derived from the hop-distance
4//! distribution of a graph:
5//!
6//! - [`hop_entropy`]: Shannon entropy of the hop-distance histogram,
7//!   normalised to \[0, 1\].
8//! - [`distance_gini`]: Gini coefficient of the pairwise distance
9//!   distribution (measures inequality of distances).
10//! - [`reach_decay`]: average fraction of vertices reachable within
11//!   half the diameter (measures how quickly connectivity decays).
12
13use crate::core::{Graph, IgraphResult};
14
15/// Normalised Shannon entropy of the hop-distance distribution.
16///
17/// Computes BFS distances from all vertices, builds a histogram of
18/// finite distances (excluding self-loops d=0), and returns the Shannon
19/// entropy normalised by `ln(diameter)` so the result is in \[0, 1\].
20///
21/// Returns 0.0 for graphs with fewer than 2 finite distances or diameter ≤ 1.
22///
23/// # Examples
24///
25/// ```
26/// use rust_igraph::{Graph, hop_entropy};
27///
28/// // Path 0-1-2: distances {1,1,2} → 2 classes → entropy > 0
29/// let g = Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap();
30/// let h = hop_entropy(&g).unwrap();
31/// assert!(h > 0.0);
32/// assert!(h <= 1.0);
33/// ```
34pub fn hop_entropy(graph: &Graph) -> IgraphResult<f64> {
35    let hist = distance_histogram(graph)?;
36    if hist.is_empty() {
37        return Ok(0.0);
38    }
39    let num_bins = hist.len();
40    if num_bins <= 1 {
41        return Ok(0.0);
42    }
43
44    let total: u64 = hist.iter().sum();
45    if total == 0 {
46        return Ok(0.0);
47    }
48
49    #[allow(clippy::cast_precision_loss)]
50    let total_f = total as f64;
51    let mut entropy = 0.0_f64;
52    let mut non_empty = 0usize;
53    for &count in &hist {
54        if count > 0 {
55            #[allow(clippy::cast_precision_loss)]
56            let p = count as f64 / total_f;
57            entropy -= p * p.ln();
58            non_empty += 1;
59        }
60    }
61
62    if non_empty <= 1 {
63        return Ok(0.0);
64    }
65
66    #[allow(clippy::cast_precision_loss)]
67    let max_entropy = (non_empty as f64).ln();
68    Ok(entropy / max_entropy)
69}
70
71/// Gini coefficient of the pairwise distance distribution.
72///
73/// Measures inequality among all finite pairwise distances. A value of 0
74/// means all distances are equal (e.g. complete graph where all d=1).
75/// Higher values indicate more spread in the distance distribution.
76///
77/// Returns 0.0 for graphs with fewer than 2 finite distances.
78///
79/// # Examples
80///
81/// ```
82/// use rust_igraph::{Graph, distance_gini};
83///
84/// // K3: all distances = 1 → Gini = 0
85/// let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
86/// assert!(distance_gini(&g).unwrap().abs() < 1e-10);
87/// ```
88pub fn distance_gini(graph: &Graph) -> IgraphResult<f64> {
89    let distances = all_finite_distances(graph)?;
90    let n = distances.len();
91    if n < 2 {
92        return Ok(0.0);
93    }
94
95    let mut sorted = distances;
96    sorted.sort_unstable();
97
98    #[allow(clippy::cast_precision_loss)]
99    let n_f = n as f64;
100    let mean: f64 = sorted.iter().map(|&d| f64::from(d)).sum::<f64>() / n_f;
101    if mean < 1e-15 {
102        return Ok(0.0);
103    }
104
105    // Gini = (2 * sum_i((i+1)*x_i)) / (n * sum_i(x_i)) - (n+1)/n
106    let mut weighted_sum = 0.0_f64;
107    for (i, &d) in sorted.iter().enumerate() {
108        #[allow(clippy::cast_precision_loss)]
109        let rank = (i + 1) as f64;
110        weighted_sum += rank * f64::from(d);
111    }
112    let total_sum = mean * n_f;
113    let gini = (2.0 * weighted_sum) / (n_f * total_sum) - (n_f + 1.0) / n_f;
114    Ok(gini)
115}
116
117/// Average fraction of vertices reachable within half the diameter.
118///
119/// For each vertex, computes the fraction of other vertices reachable
120/// within `floor(diameter / 2)` hops, then averages across all vertices.
121/// Measures how quickly connectivity "fills in" relative to the graph's
122/// diameter.
123///
124/// Returns 0.0 for disconnected or trivial graphs.
125///
126/// # Examples
127///
128/// ```
129/// use rust_igraph::{Graph, reach_decay};
130///
131/// // K4: diameter=1, half=0 → no vertex reachable in 0 hops → 0
132/// // Actually half_diam = floor(1/2) = 0, so reach = 0
133/// let g = Graph::from_edges(
134///     &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
135///     false,
136///     Some(4),
137/// )
138/// .unwrap();
139/// let r = reach_decay(&g).unwrap();
140/// assert!(r >= 0.0 && r <= 1.0);
141/// ```
142pub fn reach_decay(graph: &Graph) -> IgraphResult<f64> {
143    let n = graph.vcount();
144    if n < 2 {
145        return Ok(0.0);
146    }
147
148    // Find diameter
149    let mut diameter: u32 = 0;
150    let n_us = n as usize;
151    let mut all_dists: Vec<Vec<Option<u32>>> = Vec::with_capacity(n_us);
152    for v in 0..n {
153        let dists = graph.distances(v)?;
154        for &d in &dists {
155            if let Some(dist) = d {
156                if dist > diameter {
157                    diameter = dist;
158                }
159            }
160        }
161        all_dists.push(dists);
162    }
163
164    if diameter == 0 {
165        return Ok(0.0);
166    }
167
168    let half_diam = diameter / 2;
169    if half_diam == 0 {
170        return Ok(0.0);
171    }
172
173    // For each vertex, count fraction reachable within half_diam
174    let mut total_fraction = 0.0_f64;
175    let others = f64::from(n - 1);
176    for dists in &all_dists {
177        let reachable = dists
178            .iter()
179            .filter(|&&d| matches!(d, Some(dist) if dist > 0 && dist <= half_diam))
180            .count();
181        #[allow(clippy::cast_precision_loss)]
182        let frac = reachable as f64 / others;
183        total_fraction += frac;
184    }
185
186    Ok(total_fraction / f64::from(n))
187}
188
189/// Build histogram of finite distances (excluding d=0).
190/// Returns vec where index i holds count of pairs at distance i+1.
191fn distance_histogram(graph: &Graph) -> IgraphResult<Vec<u64>> {
192    let n = graph.vcount();
193    if n < 2 {
194        return Ok(Vec::new());
195    }
196
197    let mut max_dist: u32 = 0;
198    let mut pairs: Vec<u32> = Vec::new();
199
200    for v in 0..n {
201        let dists = graph.distances(v)?;
202        for (u, &d) in dists.iter().enumerate() {
203            #[allow(clippy::cast_possible_truncation)]
204            let u_u32 = u as u32;
205            if u_u32 > v {
206                if let Some(dist) = d {
207                    if dist > 0 {
208                        pairs.push(dist);
209                        if dist > max_dist {
210                            max_dist = dist;
211                        }
212                    }
213                }
214            }
215        }
216    }
217
218    if max_dist == 0 {
219        return Ok(Vec::new());
220    }
221
222    let mut hist = vec![0u64; max_dist as usize];
223    for &d in &pairs {
224        hist[(d - 1) as usize] += 1;
225    }
226    Ok(hist)
227}
228
229/// Collect all finite pairwise distances (excluding d=0), one per unordered pair.
230fn all_finite_distances(graph: &Graph) -> IgraphResult<Vec<u32>> {
231    let n = graph.vcount();
232    if n < 2 {
233        return Ok(Vec::new());
234    }
235
236    let mut distances = Vec::new();
237    for v in 0..n {
238        let dists = graph.distances(v)?;
239        for (u, &d) in dists.iter().enumerate() {
240            #[allow(clippy::cast_possible_truncation)]
241            if (u as u32) > v {
242                if let Some(dist) = d {
243                    if dist > 0 {
244                        distances.push(dist);
245                    }
246                }
247            }
248        }
249    }
250    Ok(distances)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // --- hop_entropy ---
258
259    #[test]
260    fn hop_entropy_empty() {
261        let g = Graph::with_vertices(0);
262        assert!(hop_entropy(&g).unwrap().abs() < 1e-12);
263    }
264
265    #[test]
266    fn hop_entropy_edgeless() {
267        let g = Graph::with_vertices(5);
268        assert!(hop_entropy(&g).unwrap().abs() < 1e-12);
269    }
270
271    #[test]
272    fn hop_entropy_complete() {
273        // K4: all distances = 1, single bin → entropy = 0
274        let g = Graph::from_edges(
275            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
276            false,
277            Some(4),
278        )
279        .unwrap();
280        assert!(hop_entropy(&g).unwrap().abs() < 1e-10);
281    }
282
283    #[test]
284    fn hop_entropy_path() {
285        // Path: multiple distance values → positive entropy
286        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
287        let h = hop_entropy(&g).unwrap();
288        assert!(h > 0.0, "Path should have positive hop entropy, got {h}");
289        assert!(h <= 1.0, "Should be <= 1, got {h}");
290    }
291
292    // --- distance_gini ---
293
294    #[test]
295    fn gini_empty() {
296        let g = Graph::with_vertices(0);
297        assert!(distance_gini(&g).unwrap().abs() < 1e-12);
298    }
299
300    #[test]
301    fn gini_complete() {
302        // K4: all distances = 1 → Gini = 0
303        let g = Graph::from_edges(
304            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
305            false,
306            Some(4),
307        )
308        .unwrap();
309        assert!(distance_gini(&g).unwrap().abs() < 1e-10);
310    }
311
312    #[test]
313    fn gini_path() {
314        // Path: distances vary → positive Gini
315        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap();
316        let gini = distance_gini(&g).unwrap();
317        assert!(gini > 0.0, "Path should have positive Gini, got {gini}");
318        assert!(gini < 1.0, "Gini should be < 1, got {gini}");
319    }
320
321    // --- reach_decay ---
322
323    #[test]
324    fn reach_empty() {
325        let g = Graph::with_vertices(0);
326        assert!(reach_decay(&g).unwrap().abs() < 1e-12);
327    }
328
329    #[test]
330    fn reach_path() {
331        // Path 0-1-2-3-4: diameter=4, half=2
332        // Each vertex can reach some within 2 hops
333        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
334        let r = reach_decay(&g).unwrap();
335        assert!(r > 0.0, "Path should have positive reach, got {r}");
336        assert!(r <= 1.0, "Reach should be <= 1, got {r}");
337    }
338
339    #[test]
340    fn reach_star() {
341        // Star K1,4: diameter=2, half=1
342        // Hub reaches all 4 in 1 hop (fraction=1.0)
343        // Leaves reach hub in 1 hop (fraction=1/4=0.25)
344        // Average = (1.0 + 0.25*4) / 5 = 2.0/5 = 0.4
345        let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap();
346        let r = reach_decay(&g).unwrap();
347        assert!((r - 0.4).abs() < 1e-10, "Star reach should be 0.4, got {r}");
348    }
349}