Skip to main content

rust_igraph/algorithms/properties/
core_periphery_ratios.rs

1//! Core-periphery ratio indices (ALGO-TR-110).
2//!
3//! Measures capturing core-periphery structure via k-core decomposition:
4//!
5//! - **Core ratio** — fraction of vertices in the maximum core
6//! - **Core density** — density of the subgraph induced by max-core vertices
7//! - **Periphery fraction** — fraction of vertices with coreness 1
8//! - **Core-periphery gradient** — normalized range (`max_core` - 1) / (n - 1)
9
10#![allow(
11    clippy::cast_possible_truncation,
12    clippy::cast_precision_loss,
13    clippy::many_single_char_names,
14    clippy::needless_range_loop,
15    clippy::similar_names,
16    clippy::too_many_lines
17)]
18
19use crate::core::{Graph, IgraphResult};
20
21/// Compute the core ratio.
22///
23/// Fraction of vertices that belong to the maximum k-core (the densest
24/// cohesive substructure). Higher values indicate a large dense core.
25/// Returns 0.0 for empty graphs.
26///
27/// # Examples
28///
29/// ```
30/// use rust_igraph::{Graph, core_ratio};
31///
32/// // K_4: all vertices have coreness 3 → ratio = 1.0
33/// let g = Graph::from_edges(
34///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
35/// ).unwrap();
36/// assert!((core_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
37/// ```
38pub fn core_ratio(graph: &Graph) -> IgraphResult<f64> {
39    let n = graph.vcount() as usize;
40    if n == 0 {
41        return Ok(0.0);
42    }
43
44    let cores = compute_coreness(graph)?;
45    let max_core = cores.iter().copied().max().unwrap_or(0);
46    if max_core == 0 {
47        return Ok(0.0);
48    }
49
50    let count = cores.iter().filter(|&&c| c == max_core).count();
51    Ok(count as f64 / n as f64)
52}
53
54/// Compute the core density.
55///
56/// Edge density of the subgraph induced by vertices in the maximum
57/// k-core. For a complete graph this is 1.0. Returns 0.0 for empty
58/// graphs or when the max-core has fewer than 2 vertices.
59///
60/// # Examples
61///
62/// ```
63/// use rust_igraph::{Graph, core_density};
64///
65/// // K_4: max core = all 4 vertices, density = 1.0
66/// let g = Graph::from_edges(
67///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
68/// ).unwrap();
69/// assert!((core_density(&g).unwrap() - 1.0).abs() < 1e-10);
70/// ```
71pub fn core_density(graph: &Graph) -> IgraphResult<f64> {
72    let n = graph.vcount() as usize;
73    if n < 2 {
74        return Ok(0.0);
75    }
76
77    let cores = compute_coreness(graph)?;
78    let max_core = cores.iter().copied().max().unwrap_or(0);
79    if max_core == 0 {
80        return Ok(0.0);
81    }
82
83    let mut in_core = vec![false; n];
84    let mut core_size = 0_usize;
85    for (v, &c) in cores.iter().enumerate() {
86        if c == max_core {
87            in_core[v] = true;
88            core_size += 1;
89        }
90    }
91
92    if core_size < 2 {
93        return Ok(0.0);
94    }
95
96    let mut edges_in_core = 0_u64;
97    for v in 0..n {
98        if !in_core[v] {
99            continue;
100        }
101        let nbrs = graph.neighbors(v as u32)?;
102        for &u in &nbrs {
103            let ui = u as usize;
104            if in_core[ui] && ui > v {
105                edges_in_core += 1;
106            }
107        }
108    }
109
110    let max_edges = (core_size * (core_size - 1)) / 2;
111    if max_edges == 0 {
112        return Ok(0.0);
113    }
114
115    Ok(edges_in_core as f64 / max_edges as f64)
116}
117
118/// Compute the periphery fraction.
119///
120/// Fraction of vertices with coreness equal to 1 (the outermost shell).
121/// For trees all non-leaf vertices have coreness 1, so this can be high.
122/// Returns 0.0 for empty or edgeless graphs.
123///
124/// # Examples
125///
126/// ```
127/// use rust_igraph::{Graph, periphery_fraction};
128///
129/// // Star_5: center has coreness 1, leaves have coreness 1 → all are periphery
130/// let g = Graph::from_edges(
131///     &[(0,1),(0,2),(0,3),(0,4)], false, Some(5)
132/// ).unwrap();
133/// assert!((periphery_fraction(&g).unwrap() - 1.0).abs() < 1e-10);
134/// ```
135pub fn periphery_fraction(graph: &Graph) -> IgraphResult<f64> {
136    let n = graph.vcount() as usize;
137    if n == 0 {
138        return Ok(0.0);
139    }
140
141    let cores = compute_coreness(graph)?;
142    let max_core = cores.iter().copied().max().unwrap_or(0);
143    if max_core == 0 {
144        return Ok(0.0);
145    }
146
147    let count = cores.iter().filter(|&&c| c == 1).count();
148    Ok(count as f64 / n as f64)
149}
150
151/// Compute the core-periphery gradient.
152///
153/// `(max_coreness - 1) / (n - 1)` — a normalized measure of how many
154/// distinct core layers exist. Values near 0 indicate flat structure
155/// (all vertices in similar cores); values near 1 indicate deep
156/// hierarchical layering. Returns 0.0 for trivial or edgeless graphs.
157///
158/// # Examples
159///
160/// ```
161/// use rust_igraph::{Graph, core_periphery_gradient};
162///
163/// // K_4: max_coreness=3, n=4 → (3-1)/(4-1) = 2/3
164/// let g = Graph::from_edges(
165///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
166/// ).unwrap();
167/// assert!((core_periphery_gradient(&g).unwrap() - 2.0/3.0).abs() < 1e-10);
168/// ```
169pub fn core_periphery_gradient(graph: &Graph) -> IgraphResult<f64> {
170    let n = graph.vcount() as usize;
171    if n < 2 {
172        return Ok(0.0);
173    }
174
175    let cores = compute_coreness(graph)?;
176    let max_core = cores.iter().copied().max().unwrap_or(0);
177    if max_core <= 1 {
178        return Ok(0.0);
179    }
180
181    Ok((max_core - 1) as f64 / (n - 1) as f64)
182}
183
184/// Compute k-core decomposition (Batagelj-Zaversnik O(m) algorithm).
185fn compute_coreness(graph: &Graph) -> IgraphResult<Vec<usize>> {
186    let n = graph.vcount() as usize;
187    if n == 0 {
188        return Ok(Vec::new());
189    }
190
191    let mut deg = Vec::with_capacity(n);
192    for v in 0..n {
193        deg.push(graph.degree(v as u32)?);
194    }
195
196    let max_deg = deg.iter().copied().max().unwrap_or(0);
197
198    // Bin-sort
199    let mut bin = vec![0_usize; max_deg + 1];
200    for &d in &deg {
201        bin[d] += 1;
202    }
203
204    let mut start = vec![0_usize; max_deg + 1];
205    let mut cumulative = 0_usize;
206    for d in 0..=max_deg {
207        start[d] = cumulative;
208        cumulative += bin[d];
209    }
210
211    let mut vert = vec![0_usize; n]; // position → vertex
212    let mut pos = vec![0_usize; n]; // vertex → position
213    for v in 0..n {
214        pos[v] = start[deg[v]];
215        vert[pos[v]] = v;
216        start[deg[v]] += 1;
217    }
218
219    // Reset start
220    let mut cumulative = 0_usize;
221    for d in 0..=max_deg {
222        let count = bin[d];
223        start[d] = cumulative;
224        cumulative += count;
225    }
226
227    let mut core = deg.clone();
228
229    for i in 0..n {
230        let v = vert[i];
231        let nbrs = graph.neighbors(v as u32)?;
232        for &u in &nbrs {
233            let ui = u as usize;
234            if core[ui] > core[v] {
235                let du = core[ui];
236                let pu = pos[ui];
237                let pw = start[du];
238                let w = vert[pw];
239
240                if ui != w {
241                    vert[pu] = w;
242                    vert[pw] = ui;
243                    pos[w] = pu;
244                    pos[ui] = pw;
245                }
246
247                start[du] += 1;
248                core[ui] -= 1;
249            }
250        }
251    }
252
253    Ok(core)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn empty() -> Graph {
261        Graph::with_vertices(0)
262    }
263
264    fn single() -> Graph {
265        Graph::with_vertices(1)
266    }
267
268    fn isolated4() -> Graph {
269        Graph::with_vertices(4)
270    }
271
272    fn single_edge() -> Graph {
273        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
274    }
275
276    fn path3() -> Graph {
277        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
278    }
279
280    fn k3() -> Graph {
281        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
282    }
283
284    fn k4() -> Graph {
285        Graph::from_edges(
286            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
287            false,
288            Some(4),
289        )
290        .unwrap()
291    }
292
293    fn cycle4() -> Graph {
294        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
295    }
296
297    fn star5() -> Graph {
298        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
299    }
300
301    fn paw() -> Graph {
302        // 0-1, 1-2, 0-2, 2-3
303        // Coreness: 0→2, 1→2, 2→2, 3→1
304        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
305    }
306
307    fn diamond() -> Graph {
308        // K4 minus one edge: 0-1, 0-2, 0-3, 1-2, 2-3
309        // Degrees: 0→3, 1→2, 2→3, 3→2
310        // Coreness: all have coreness 2
311        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (2, 3)], false, Some(4)).unwrap()
312    }
313
314    // --- core_ratio ---
315
316    #[test]
317    fn cr_empty() {
318        assert!(core_ratio(&empty()).unwrap().abs() < 1e-10);
319    }
320
321    #[test]
322    fn cr_single() {
323        assert!(core_ratio(&single()).unwrap().abs() < 1e-10);
324    }
325
326    #[test]
327    fn cr_isolated() {
328        assert!(core_ratio(&isolated4()).unwrap().abs() < 1e-10);
329    }
330
331    #[test]
332    fn cr_single_edge() {
333        // Both vertices have coreness 1 → all in max core → 1.0
334        assert!((core_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
335    }
336
337    #[test]
338    fn cr_k3() {
339        // All coreness 2 → 1.0
340        assert!((core_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
341    }
342
343    #[test]
344    fn cr_k4() {
345        // All coreness 3 → 1.0
346        assert!((core_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
347    }
348
349    #[test]
350    fn cr_cycle4() {
351        // All coreness 2 → 1.0
352        assert!((core_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
353    }
354
355    #[test]
356    fn cr_star5() {
357        // All coreness 1 → 1.0
358        assert!((core_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
359    }
360
361    #[test]
362    fn cr_paw() {
363        // Coreness: {0,1,2}→2, {3}→1. Max core=2, count=3, n=4 → 3/4
364        assert!((core_ratio(&paw()).unwrap() - 3.0 / 4.0).abs() < 1e-10);
365    }
366
367    #[test]
368    fn cr_in_01() {
369        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
370            let r = core_ratio(g).unwrap();
371            assert!(r >= -1e-10);
372            assert!(r <= 1.0 + 1e-10);
373        }
374    }
375
376    // --- core_density ---
377
378    #[test]
379    fn cd_empty() {
380        assert!(core_density(&empty()).unwrap().abs() < 1e-10);
381    }
382
383    #[test]
384    fn cd_single() {
385        assert!(core_density(&single()).unwrap().abs() < 1e-10);
386    }
387
388    #[test]
389    fn cd_k4() {
390        // Max core = all 4 vertices, density = 6/6 = 1.0
391        assert!((core_density(&k4()).unwrap() - 1.0).abs() < 1e-10);
392    }
393
394    #[test]
395    fn cd_k3() {
396        // Max core = all 3 vertices, density = 3/3 = 1.0
397        assert!((core_density(&k3()).unwrap() - 1.0).abs() < 1e-10);
398    }
399
400    #[test]
401    fn cd_cycle4() {
402        // All coreness 2, all 4 vertices in max core
403        // Edges among them: 4, max possible: 6 → 4/6 = 2/3
404        assert!((core_density(&cycle4()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
405    }
406
407    #[test]
408    fn cd_paw() {
409        // Max core (coreness=2): {0,1,2}. Edges among them: (0,1),(1,2),(0,2)=3
410        // Max possible: 3. Density = 1.0
411        assert!((core_density(&paw()).unwrap() - 1.0).abs() < 1e-10);
412    }
413
414    #[test]
415    fn cd_diamond() {
416        // All coreness 2 (4 vertices). Edges: 5, max: 6 → 5/6
417        assert!((core_density(&diamond()).unwrap() - 5.0 / 6.0).abs() < 1e-10);
418    }
419
420    #[test]
421    fn cd_in_01() {
422        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
423            let r = core_density(g).unwrap();
424            assert!(r >= -1e-10);
425            assert!(r <= 1.0 + 1e-10);
426        }
427    }
428
429    // --- periphery_fraction ---
430
431    #[test]
432    fn pf_empty() {
433        assert!(periphery_fraction(&empty()).unwrap().abs() < 1e-10);
434    }
435
436    #[test]
437    fn pf_single() {
438        assert!(periphery_fraction(&single()).unwrap().abs() < 1e-10);
439    }
440
441    #[test]
442    fn pf_isolated() {
443        assert!(periphery_fraction(&isolated4()).unwrap().abs() < 1e-10);
444    }
445
446    #[test]
447    fn pf_single_edge() {
448        // Both coreness 1 → 1.0
449        assert!((periphery_fraction(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
450    }
451
452    #[test]
453    fn pf_star5() {
454        // All coreness 1 → 1.0
455        assert!((periphery_fraction(&star5()).unwrap() - 1.0).abs() < 1e-10);
456    }
457
458    #[test]
459    fn pf_path3() {
460        // All coreness 1 → 1.0
461        assert!((periphery_fraction(&path3()).unwrap() - 1.0).abs() < 1e-10);
462    }
463
464    #[test]
465    fn pf_k3() {
466        // All coreness 2 → no vertices with coreness 1 → 0.0
467        assert!(periphery_fraction(&k3()).unwrap().abs() < 1e-10);
468    }
469
470    #[test]
471    fn pf_k4() {
472        // All coreness 3 → 0.0
473        assert!(periphery_fraction(&k4()).unwrap().abs() < 1e-10);
474    }
475
476    #[test]
477    fn pf_paw() {
478        // Vertex 3 has coreness 1 → 1/4
479        assert!((periphery_fraction(&paw()).unwrap() - 1.0 / 4.0).abs() < 1e-10);
480    }
481
482    #[test]
483    fn pf_in_01() {
484        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
485            let r = periphery_fraction(g).unwrap();
486            assert!(r >= -1e-10);
487            assert!(r <= 1.0 + 1e-10);
488        }
489    }
490
491    // --- core_periphery_gradient ---
492
493    #[test]
494    fn cpg_empty() {
495        assert!(core_periphery_gradient(&empty()).unwrap().abs() < 1e-10);
496    }
497
498    #[test]
499    fn cpg_single() {
500        assert!(core_periphery_gradient(&single()).unwrap().abs() < 1e-10);
501    }
502
503    #[test]
504    fn cpg_isolated() {
505        assert!(core_periphery_gradient(&isolated4()).unwrap().abs() < 1e-10);
506    }
507
508    #[test]
509    fn cpg_single_edge() {
510        // max_coreness=1, ≤1 → 0.0
511        assert!(core_periphery_gradient(&single_edge()).unwrap().abs() < 1e-10);
512    }
513
514    #[test]
515    fn cpg_star5() {
516        // max_coreness=1 → 0.0
517        assert!(core_periphery_gradient(&star5()).unwrap().abs() < 1e-10);
518    }
519
520    #[test]
521    fn cpg_k3() {
522        // max_coreness=2, n=3 → (2-1)/(3-1) = 0.5
523        assert!((core_periphery_gradient(&k3()).unwrap() - 0.5).abs() < 1e-10);
524    }
525
526    #[test]
527    fn cpg_k4() {
528        // max_coreness=3, n=4 → (3-1)/(4-1) = 2/3
529        assert!((core_periphery_gradient(&k4()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
530    }
531
532    #[test]
533    fn cpg_cycle4() {
534        // max_coreness=2, n=4 → (2-1)/(4-1) = 1/3
535        assert!((core_periphery_gradient(&cycle4()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
536    }
537
538    #[test]
539    fn cpg_paw() {
540        // max_coreness=2, n=4 → (2-1)/(4-1) = 1/3
541        assert!((core_periphery_gradient(&paw()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
542    }
543
544    #[test]
545    fn cpg_nonneg() {
546        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
547            assert!(core_periphery_gradient(g).unwrap() >= -1e-10);
548        }
549    }
550
551    // --- cross-consistency ---
552
553    #[test]
554    fn complete_full_core() {
555        // K_n: all in max core, density = 1.0
556        assert!((core_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
557        assert!((core_density(&k4()).unwrap() - 1.0).abs() < 1e-10);
558    }
559
560    #[test]
561    fn tree_all_periphery() {
562        // Trees: all coreness 1, periphery fraction = 1.0
563        assert!((periphery_fraction(&star5()).unwrap() - 1.0).abs() < 1e-10);
564        assert!((periphery_fraction(&path3()).unwrap() - 1.0).abs() < 1e-10);
565    }
566
567    #[test]
568    fn tree_zero_gradient() {
569        // Trees: max_coreness=1 → gradient=0
570        assert!(core_periphery_gradient(&star5()).unwrap().abs() < 1e-10);
571        assert!(core_periphery_gradient(&path3()).unwrap().abs() < 1e-10);
572    }
573}