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}