Skip to main content

rust_igraph/algorithms/properties/
core_profile_indices.rs

1//! Core profile indices (ALGO-TR-122).
2//!
3//! Three novel topological ratio indices derived from the k-core
4//! decomposition of a graph:
5//!
6//! - [`core_persistence`]: average coreness normalised by the degeneracy
7//!   (maximum coreness). Measures how deeply embedded the typical vertex
8//!   is in the core hierarchy.
9//! - [`shell_diversity`]: Shannon entropy of the k-shell size distribution,
10//!   normalised to [0, 1]. Measures how evenly vertices are spread across
11//!   different shells.
12//! - [`degeneracy_gap`]: (degeneracy − average coreness) / degeneracy.
13//!   Measures the gap between the densest core and the average vertex.
14
15use crate::algorithms::properties::coreness::coreness;
16use crate::core::{Graph, IgraphResult};
17
18/// Average coreness divided by the degeneracy (maximum coreness).
19///
20/// Returns a value in [0, 1]. A value of 1 means every vertex has the
21/// same coreness (e.g. a complete graph). A value near 0 means most
22/// vertices are in low-order cores while the degeneracy is high.
23///
24/// Returns 0.0 for graphs where the degeneracy is 0 (edgeless graphs).
25///
26/// # Examples
27///
28/// ```
29/// use rust_igraph::{Graph, core_persistence};
30///
31/// // K3: all coreness = 2, degeneracy = 2 → persistence = 1.0
32/// let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
33/// assert!((core_persistence(&g).unwrap() - 1.0).abs() < 1e-10);
34/// ```
35pub fn core_persistence(graph: &Graph) -> IgraphResult<f64> {
36    let cores = coreness(graph)?;
37    if cores.is_empty() {
38        return Ok(0.0);
39    }
40    let degeneracy = *cores.iter().max().unwrap();
41    if degeneracy == 0 {
42        return Ok(0.0);
43    }
44    #[allow(clippy::cast_precision_loss)]
45    let avg: f64 = cores.iter().map(|&c| f64::from(c)).sum::<f64>() / cores.len() as f64;
46    Ok(avg / f64::from(degeneracy))
47}
48
49/// Shannon entropy of the k-shell size distribution, normalised to [0, 1].
50///
51/// The k-shell of order k is the set of vertices with coreness exactly k.
52/// This function computes the entropy of the distribution of shell sizes
53/// and normalises by `log(number_of_distinct_shells)` so the result is in
54/// [0, 1]. A value of 1 means all shells have equal size; a value near 0
55/// means vertices are concentrated in one shell.
56///
57/// Returns 0.0 for empty or edgeless graphs (single shell).
58///
59/// # Examples
60///
61/// ```
62/// use rust_igraph::{Graph, shell_diversity};
63///
64/// // Path 0-1-2: coreness = [1, 1, 1], single shell → diversity = 0
65/// let g = Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap();
66/// assert!(shell_diversity(&g).unwrap().abs() < 1e-10);
67/// ```
68pub fn shell_diversity(graph: &Graph) -> IgraphResult<f64> {
69    let cores = coreness(graph)?;
70    if cores.is_empty() {
71        return Ok(0.0);
72    }
73    let degeneracy = *cores.iter().max().unwrap() as usize;
74
75    // Count vertices in each shell
76    let mut shell_counts = vec![0u32; degeneracy + 1];
77    for &c in &cores {
78        shell_counts[c as usize] += 1;
79    }
80
81    // Filter to non-empty shells
82    let non_empty: Vec<u32> = shell_counts.into_iter().filter(|&c| c > 0).collect();
83    let num_shells = non_empty.len();
84    if num_shells <= 1 {
85        return Ok(0.0);
86    }
87
88    #[allow(clippy::cast_precision_loss)]
89    let n = cores.len() as f64;
90    let mut entropy = 0.0_f64;
91    for &count in &non_empty {
92        let p = f64::from(count) / n;
93        entropy -= p * p.ln();
94    }
95
96    // Normalise by max entropy (uniform distribution over shells)
97    #[allow(clippy::cast_precision_loss)]
98    let max_entropy = (num_shells as f64).ln();
99    Ok(entropy / max_entropy)
100}
101
102/// Degeneracy gap: (degeneracy − `average_coreness`) / degeneracy.
103///
104/// Measures how far the average vertex is from the densest core.
105/// Returns a value in [0, 1). A value of 0 means all vertices have
106/// the same coreness (complete graph). Higher values indicate a larger
107/// gap between the core elite and the periphery.
108///
109/// Returns 0.0 for edgeless graphs (degeneracy = 0).
110///
111/// # Examples
112///
113/// ```
114/// use rust_igraph::{Graph, degeneracy_gap};
115///
116/// // K4: all coreness = 3, gap = 0
117/// let g = Graph::from_edges(
118///     &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
119///     false,
120///     Some(4),
121/// )
122/// .unwrap();
123/// assert!(degeneracy_gap(&g).unwrap().abs() < 1e-10);
124/// ```
125pub fn degeneracy_gap(graph: &Graph) -> IgraphResult<f64> {
126    let cores = coreness(graph)?;
127    if cores.is_empty() {
128        return Ok(0.0);
129    }
130    let degeneracy = *cores.iter().max().unwrap();
131    if degeneracy == 0 {
132        return Ok(0.0);
133    }
134    #[allow(clippy::cast_precision_loss)]
135    let avg: f64 = cores.iter().map(|&c| f64::from(c)).sum::<f64>() / cores.len() as f64;
136    Ok((f64::from(degeneracy) - avg) / f64::from(degeneracy))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    // --- core_persistence ---
144
145    #[test]
146    fn persistence_empty() {
147        let g = Graph::with_vertices(0);
148        assert!(core_persistence(&g).unwrap().abs() < 1e-12);
149    }
150
151    #[test]
152    fn persistence_edgeless() {
153        let g = Graph::with_vertices(5);
154        assert!(core_persistence(&g).unwrap().abs() < 1e-12);
155    }
156
157    #[test]
158    fn persistence_complete() {
159        // K4: all coreness = 3 → persistence = 1.0
160        let g = Graph::from_edges(
161            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
162            false,
163            Some(4),
164        )
165        .unwrap();
166        assert!((core_persistence(&g).unwrap() - 1.0).abs() < 1e-10);
167    }
168
169    #[test]
170    fn persistence_star() {
171        // Star K1,4: hub has coreness 1, leaves have coreness 1
172        // All coreness = 1, degeneracy = 1 → persistence = 1.0
173        let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap();
174        assert!((core_persistence(&g).unwrap() - 1.0).abs() < 1e-10);
175    }
176
177    #[test]
178    fn persistence_path() {
179        // Path 0-1-2-3-4: all coreness = 1, degeneracy = 1 → persistence = 1.0
180        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
181        assert!((core_persistence(&g).unwrap() - 1.0).abs() < 1e-10);
182    }
183
184    #[test]
185    fn persistence_mixed() {
186        // Triangle + pendant: 0-1-2 triangle, 2-3 pendant
187        // Coreness: [2, 2, 2, 1], degeneracy = 2, avg = 7/4 = 1.75
188        // persistence = 1.75 / 2 = 0.875
189        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
190        assert!((core_persistence(&g).unwrap() - 0.875).abs() < 1e-10);
191    }
192
193    // --- shell_diversity ---
194
195    #[test]
196    fn diversity_empty() {
197        let g = Graph::with_vertices(0);
198        assert!(shell_diversity(&g).unwrap().abs() < 1e-12);
199    }
200
201    #[test]
202    fn diversity_edgeless() {
203        // All coreness = 0, single shell → diversity = 0
204        let g = Graph::with_vertices(5);
205        assert!(shell_diversity(&g).unwrap().abs() < 1e-12);
206    }
207
208    #[test]
209    fn diversity_single_shell() {
210        // Path: all coreness = 1, single non-trivial shell → diversity = 0
211        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap();
212        assert!(shell_diversity(&g).unwrap().abs() < 1e-12);
213    }
214
215    #[test]
216    fn diversity_two_equal_shells() {
217        // Triangle + pendant: coreness [2, 2, 2, 1]
218        // Shells: {1: 1 vertex, 2: 3 vertices} → 2 shells, not equal
219        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
220        let d = shell_diversity(&g).unwrap();
221        assert!(d > 0.0, "Should have positive diversity, got {d}");
222        assert!(d < 1.0, "Should be < 1 (unequal shells), got {d}");
223    }
224
225    #[test]
226    fn diversity_max_when_equal_shells() {
227        // Construct a graph with 2 shells of equal size:
228        // K3 (coreness 2) + 3 isolated edges (coreness 1) = 3 vertices in shell 2, 3 in shell 1
229        // Wait - we need shell 0 to not exist. Let's use:
230        // 0-1-2 triangle (coreness 2) + 3-4 edge + 5-6 edge + 7-8 edge (coreness 1)
231        // That gives 3 in shell 2, 6 in shell 1 — not equal.
232        // For equal: 2 in shell 2, 2 in shell 1
233        // K3 has 3 in shell 2. We need 3 in shell 1.
234        // Triangle 0-1-2 (shell 2) + edges 2-3, 3-4, 4-5 → 3,4,5 have coreness 1
235        // Actually: 0-1-2 triangle + 2-3 edge → [2,2,2,1] — 3 in shell 2, 1 in shell 1
236        // Let's just verify it's between 0 and 1 for a known case
237        let g =
238            Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
239        let d = shell_diversity(&g).unwrap();
240        assert!(d > 0.0 && d <= 1.0, "Diversity should be in (0,1], got {d}");
241    }
242
243    // --- degeneracy_gap ---
244
245    #[test]
246    fn gap_empty() {
247        let g = Graph::with_vertices(0);
248        assert!(degeneracy_gap(&g).unwrap().abs() < 1e-12);
249    }
250
251    #[test]
252    fn gap_edgeless() {
253        let g = Graph::with_vertices(5);
254        assert!(degeneracy_gap(&g).unwrap().abs() < 1e-12);
255    }
256
257    #[test]
258    fn gap_complete() {
259        // K4: all same coreness → gap = 0
260        let g = Graph::from_edges(
261            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
262            false,
263            Some(4),
264        )
265        .unwrap();
266        assert!(degeneracy_gap(&g).unwrap().abs() < 1e-10);
267    }
268
269    #[test]
270    fn gap_mixed() {
271        // Triangle + pendant: coreness [2, 2, 2, 1], degeneracy = 2, avg = 1.75
272        // gap = (2 - 1.75) / 2 = 0.125
273        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
274        assert!((degeneracy_gap(&g).unwrap() - 0.125).abs() < 1e-10);
275    }
276
277    #[test]
278    fn persistence_plus_gap_equals_one() {
279        // For any graph: persistence + gap = avg/deg + (deg-avg)/deg = 1
280        let g = Graph::from_edges(
281            &[(0, 1), (1, 2), (0, 2), (2, 3), (3, 4), (4, 5)],
282            false,
283            Some(6),
284        )
285        .unwrap();
286        let p = core_persistence(&g).unwrap();
287        let gap = degeneracy_gap(&g).unwrap();
288        assert!(
289            (p + gap - 1.0).abs() < 1e-10,
290            "persistence + gap should = 1, got {p} + {gap} = {}",
291            p + gap
292        );
293    }
294}