Skip to main content

rust_igraph/algorithms/properties/
walk_diversity.rs

1//! Walk diversity indices (ALGO-TR-095).
2//!
3//! Walk-based measures that capture connectivity patterns from
4//! different perspectives:
5//!
6//! - **Walk entropy** — Shannon entropy of walk-count distribution
7//! - **Walk regularity** — coefficient of variation of walk counts
8//! - **Degree laplacian energy** — Σ |d(v) - 2m/n| normalized
9//! - **Average neighbor connectivity** — mean of neighbor degree ratios
10
11#![allow(
12    clippy::cast_possible_truncation,
13    clippy::cast_precision_loss,
14    clippy::many_single_char_names,
15    clippy::needless_range_loop,
16    clippy::similar_names,
17    clippy::too_many_lines
18)]
19
20use crate::core::{Graph, IgraphResult};
21
22/// Compute the walk entropy of the graph.
23///
24/// `H = -Σ p(v)·ln(p(v))` where `p(v) = d(v) / (2m)`
25///
26/// Shannon entropy of the degree distribution treated as a
27/// probability distribution over edge endpoints. Higher entropy
28/// indicates more uniform degree distribution. Returns 0.0 for
29/// graphs with no edges.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, walk_entropy};
35///
36/// // K_3: d=2 for all, p=1/3 each → H = -3·(1/3)·ln(1/3) = ln(3)
37/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
38/// assert!((walk_entropy(&g).unwrap() - 3.0_f64.ln()).abs() < 1e-10);
39/// ```
40pub fn walk_entropy(graph: &Graph) -> IgraphResult<f64> {
41    let n = graph.vcount() as usize;
42    let mut sum_d: u64 = 0;
43    let mut degrees = Vec::with_capacity(n);
44
45    for v in 0..n {
46        let d = graph.degree(v as u32)?;
47        degrees.push(d);
48        sum_d = sum_d.saturating_add(d as u64);
49    }
50
51    if sum_d == 0 {
52        return Ok(0.0);
53    }
54
55    let two_m: f64 = sum_d as f64;
56    let mut h: f64 = 0.0;
57
58    for &d in &degrees {
59        if d > 0 {
60            let p: f64 = d as f64 / two_m;
61            h -= p * p.ln();
62        }
63    }
64
65    Ok(h)
66}
67
68/// Compute the walk regularity index of the graph.
69///
70/// `WR = 1 - CV(d)` where `CV = σ/μ` is the coefficient of variation
71/// of the degree sequence.
72///
73/// Equals 1.0 for regular graphs (all degrees equal) and decreases
74/// toward 0 for highly irregular graphs. Returns 1.0 for graphs with
75/// fewer than 2 vertices or no edges.
76///
77/// # Examples
78///
79/// ```
80/// use rust_igraph::{Graph, walk_regularity};
81///
82/// // K_3: regular → WR = 1.0
83/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
84/// assert!((walk_regularity(&g).unwrap() - 1.0).abs() < 1e-10);
85/// ```
86pub fn walk_regularity(graph: &Graph) -> IgraphResult<f64> {
87    let n = graph.vcount() as usize;
88    if n < 2 {
89        return Ok(1.0);
90    }
91
92    let mut sum_d: f64 = 0.0;
93    let mut sum_d2: f64 = 0.0;
94
95    for v in 0..n {
96        let d: f64 = graph.degree(v as u32)? as f64;
97        sum_d += d;
98        sum_d2 += d * d;
99    }
100
101    if sum_d < 1e-15 {
102        return Ok(1.0);
103    }
104
105    let mean: f64 = sum_d / n as f64;
106    let variance: f64 = sum_d2 / n as f64 - mean * mean;
107
108    if variance < 1e-15 {
109        return Ok(1.0);
110    }
111
112    let cv: f64 = variance.sqrt() / mean;
113    Ok((1.0 - cv).max(0.0))
114}
115
116/// Compute the degree Laplacian energy of the graph.
117///
118/// `DLE = Σ |d(v) - 2m/n| / (n · d_max)`
119///
120/// Normalized sum of absolute deviations of degrees from their mean.
121/// Measures how far the degree sequence is from uniform. Zero for
122/// regular graphs. Returns 0.0 for graphs with fewer than 2 vertices
123/// or no edges.
124///
125/// # Examples
126///
127/// ```
128/// use rust_igraph::{Graph, degree_laplacian_energy};
129///
130/// // K_3: d=2 for all, mean=2 → DLE = 0/... = 0.0
131/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
132/// assert!(degree_laplacian_energy(&g).unwrap().abs() < 1e-10);
133/// ```
134pub fn degree_laplacian_energy(graph: &Graph) -> IgraphResult<f64> {
135    let n = graph.vcount() as usize;
136    if n < 2 {
137        return Ok(0.0);
138    }
139
140    let mut sum_d: f64 = 0.0;
141    let mut d_max: f64 = 0.0;
142    let mut degrees = Vec::with_capacity(n);
143
144    for v in 0..n {
145        let d: f64 = graph.degree(v as u32)? as f64;
146        degrees.push(d);
147        sum_d += d;
148        if d > d_max {
149            d_max = d;
150        }
151    }
152
153    if sum_d < 1e-15 || d_max < 1e-15 {
154        return Ok(0.0);
155    }
156
157    let mean: f64 = sum_d / n as f64;
158    let mut sum_abs_dev: f64 = 0.0;
159
160    for &d in &degrees {
161        sum_abs_dev += (d - mean).abs();
162    }
163
164    Ok(sum_abs_dev / (n as f64 * d_max))
165}
166
167/// Compute the average neighbor connectivity of the graph.
168///
169/// `ANC = (1/n) Σ_v (Σ_{u∈N(v)} d(u)) / d(v)`
170///
171/// For each vertex with degree > 0, the ratio of total neighbor
172/// degree to own degree, averaged over all such vertices. For
173/// regular graphs equals degree. Returns 0.0 for graphs with no
174/// edges.
175///
176/// # Examples
177///
178/// ```
179/// use rust_igraph::{Graph, avg_neighbor_connectivity};
180///
181/// // K_3: each vertex has 2 neighbors each with degree 2
182/// // ratio = (2+2)/2 = 2.0 for each, mean = 2.0
183/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
184/// assert!((avg_neighbor_connectivity(&g).unwrap() - 2.0).abs() < 1e-10);
185/// ```
186pub fn avg_neighbor_connectivity(graph: &Graph) -> IgraphResult<f64> {
187    let n = graph.vcount() as usize;
188    if n == 0 {
189        return Ok(0.0);
190    }
191
192    let mut degrees = Vec::with_capacity(n);
193    for v in 0..n {
194        degrees.push(graph.degree(v as u32)?);
195    }
196
197    let mut sum_ratio: f64 = 0.0;
198    let mut count = 0_u32;
199
200    for v in 0..n {
201        let dv = degrees[v];
202        if dv == 0 {
203            continue;
204        }
205        let neighbors = graph.neighbors(v as u32)?;
206        let mut neighbor_deg_sum: u64 = 0;
207        for &u in &neighbors {
208            neighbor_deg_sum = neighbor_deg_sum.saturating_add(degrees[u as usize] as u64);
209        }
210        sum_ratio += neighbor_deg_sum as f64 / dv as f64;
211        count += 1;
212    }
213
214    if count == 0 {
215        return Ok(0.0);
216    }
217
218    Ok(sum_ratio / f64::from(count))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn single_edge() -> Graph {
226        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
227    }
228
229    fn path3() -> Graph {
230        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
231    }
232
233    fn k3() -> Graph {
234        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
235    }
236
237    fn k4() -> Graph {
238        Graph::from_edges(
239            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
240            false,
241            Some(4),
242        )
243        .unwrap()
244    }
245
246    fn cycle4() -> Graph {
247        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
248    }
249
250    fn star5() -> Graph {
251        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
252    }
253
254    fn paw() -> Graph {
255        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
256    }
257
258    // --- walk_entropy ---
259
260    #[test]
261    fn we_empty() {
262        let g = Graph::with_vertices(0);
263        assert!(walk_entropy(&g).unwrap().abs() < 1e-10);
264    }
265
266    #[test]
267    fn we_isolated() {
268        let g = Graph::with_vertices(5);
269        assert!(walk_entropy(&g).unwrap().abs() < 1e-10);
270    }
271
272    #[test]
273    fn we_single_edge() {
274        // p(0)=1/2, p(1)=1/2 → H = ln(2)
275        assert!((walk_entropy(&single_edge()).unwrap() - 2.0_f64.ln()).abs() < 1e-10);
276    }
277
278    #[test]
279    fn we_k3() {
280        // p=1/3 each → H = ln(3)
281        assert!((walk_entropy(&k3()).unwrap() - 3.0_f64.ln()).abs() < 1e-10);
282    }
283
284    #[test]
285    fn we_k4() {
286        // p=1/4 each → H = ln(4)
287        assert!((walk_entropy(&k4()).unwrap() - 4.0_f64.ln()).abs() < 1e-10);
288    }
289
290    #[test]
291    fn we_cycle4() {
292        // All d=2, p=1/4 each → H = ln(4)
293        assert!((walk_entropy(&cycle4()).unwrap() - 4.0_f64.ln()).abs() < 1e-10);
294    }
295
296    #[test]
297    fn we_path3() {
298        // degrees [1,2,1], 2m=4
299        // p = [1/4, 2/4, 1/4]
300        // H = -2·(1/4)·ln(1/4) - (1/2)·ln(1/2)
301        //   = (1/2)·ln(4) + (1/2)·ln(2)
302        //   = ln(2) + (1/2)·ln(2) = (3/2)·ln(2)
303        let expected: f64 = 1.5 * 2.0_f64.ln();
304        assert!((walk_entropy(&path3()).unwrap() - expected).abs() < 1e-10);
305    }
306
307    #[test]
308    fn we_star5() {
309        // degrees [4,1,1,1,1], 2m=8
310        // p = [4/8, 1/8, 1/8, 1/8, 1/8]
311        // H = -(1/2)·ln(1/2) - 4·(1/8)·ln(1/8)
312        //   = (1/2)·ln(2) + (1/2)·ln(8)
313        //   = (1/2)·ln(2) + (3/2)·ln(2) = 2·ln(2)
314        let expected: f64 = 2.0 * 2.0_f64.ln();
315        assert!((walk_entropy(&star5()).unwrap() - expected).abs() < 1e-10);
316    }
317
318    #[test]
319    fn we_paw() {
320        // degrees [2,2,3,1], 2m=8
321        // p = [2/8, 2/8, 3/8, 1/8]
322        // H = -2·(1/4)·ln(1/4) - (3/8)·ln(3/8) - (1/8)·ln(1/8)
323        let p: [f64; 4] = [0.25, 0.25, 0.375, 0.125];
324        let expected: f64 = -p.iter().map(|&pi| pi * pi.ln()).sum::<f64>();
325        assert!((walk_entropy(&paw()).unwrap() - expected).abs() < 1e-10);
326    }
327
328    #[test]
329    fn we_nonneg() {
330        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
331            assert!(walk_entropy(g).unwrap() >= -1e-10);
332        }
333    }
334
335    // --- walk_regularity ---
336
337    #[test]
338    fn wr_empty() {
339        let g = Graph::with_vertices(0);
340        assert!((walk_regularity(&g).unwrap() - 1.0).abs() < 1e-10);
341    }
342
343    #[test]
344    fn wr_single() {
345        let g = Graph::with_vertices(1);
346        assert!((walk_regularity(&g).unwrap() - 1.0).abs() < 1e-10);
347    }
348
349    #[test]
350    fn wr_isolated() {
351        let g = Graph::with_vertices(5);
352        assert!((walk_regularity(&g).unwrap() - 1.0).abs() < 1e-10);
353    }
354
355    #[test]
356    fn wr_regular() {
357        // Regular → CV=0 → WR=1.0
358        assert!((walk_regularity(&k3()).unwrap() - 1.0).abs() < 1e-10);
359        assert!((walk_regularity(&k4()).unwrap() - 1.0).abs() < 1e-10);
360        assert!((walk_regularity(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
361    }
362
363    #[test]
364    fn wr_single_edge() {
365        // d=1 for all → regular → 1.0
366        assert!((walk_regularity(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
367    }
368
369    #[test]
370    fn wr_path3() {
371        // degrees [1,2,1], mean=4/3, var=(1+4+1)/3-(4/3)²=2-16/9=2/9
372        // σ = √(2/9) = √2/3, CV = (√2/3)/(4/3) = √2/4
373        // WR = 1 - √2/4
374        let cv: f64 = (2.0_f64 / 9.0).sqrt() / (4.0 / 3.0);
375        let expected: f64 = 1.0 - cv;
376        assert!((walk_regularity(&path3()).unwrap() - expected).abs() < 1e-10);
377    }
378
379    #[test]
380    fn wr_star5() {
381        // degrees [4,1,1,1,1], mean=8/5=1.6
382        // var = (16+1+1+1+1)/5 - 1.6² = 4.0 - 2.56 = 1.44
383        // σ = 1.2, CV = 1.2/1.6 = 0.75
384        // WR = 1 - 0.75 = 0.25
385        assert!((walk_regularity(&star5()).unwrap() - 0.25).abs() < 1e-10);
386    }
387
388    #[test]
389    fn wr_paw() {
390        // degrees [2,2,3,1], mean=8/4=2
391        // var = (4+4+9+1)/4 - 4 = 4.5 - 4 = 0.5
392        // σ = √0.5, CV = √0.5/2
393        // WR = 1 - √0.5/2
394        let cv: f64 = 0.5_f64.sqrt() / 2.0;
395        let expected: f64 = 1.0 - cv;
396        assert!((walk_regularity(&paw()).unwrap() - expected).abs() < 1e-10);
397    }
398
399    #[test]
400    fn wr_in_01() {
401        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
402            let w = walk_regularity(g).unwrap();
403            assert!(w >= -1e-10);
404            assert!(w <= 1.0 + 1e-10);
405        }
406    }
407
408    // --- degree_laplacian_energy ---
409
410    #[test]
411    fn dle_empty() {
412        let g = Graph::with_vertices(0);
413        assert!(degree_laplacian_energy(&g).unwrap().abs() < 1e-10);
414    }
415
416    #[test]
417    fn dle_single() {
418        let g = Graph::with_vertices(1);
419        assert!(degree_laplacian_energy(&g).unwrap().abs() < 1e-10);
420    }
421
422    #[test]
423    fn dle_isolated() {
424        let g = Graph::with_vertices(5);
425        assert!(degree_laplacian_energy(&g).unwrap().abs() < 1e-10);
426    }
427
428    #[test]
429    fn dle_regular() {
430        // All d = mean → sum_abs_dev = 0
431        assert!(degree_laplacian_energy(&k3()).unwrap().abs() < 1e-10);
432        assert!(degree_laplacian_energy(&k4()).unwrap().abs() < 1e-10);
433        assert!(degree_laplacian_energy(&cycle4()).unwrap().abs() < 1e-10);
434    }
435
436    #[test]
437    fn dle_single_edge() {
438        // d=1 for all → regular → 0
439        assert!(degree_laplacian_energy(&single_edge()).unwrap().abs() < 1e-10);
440    }
441
442    #[test]
443    fn dle_path3() {
444        // degrees [1,2,1], mean=4/3, d_max=2
445        // |1-4/3| + |2-4/3| + |1-4/3| = 1/3 + 2/3 + 1/3 = 4/3
446        // DLE = (4/3) / (3·2) = 4/18 = 2/9
447        let expected: f64 = 2.0 / 9.0;
448        assert!((degree_laplacian_energy(&path3()).unwrap() - expected).abs() < 1e-10);
449    }
450
451    #[test]
452    fn dle_star5() {
453        // degrees [4,1,1,1,1], mean=8/5=1.6, d_max=4
454        // |4-1.6|+4·|1-1.6| = 2.4+4·0.6 = 2.4+2.4 = 4.8
455        // DLE = 4.8 / (5·4) = 4.8/20 = 0.24
456        assert!((degree_laplacian_energy(&star5()).unwrap() - 0.24).abs() < 1e-10);
457    }
458
459    #[test]
460    fn dle_paw() {
461        // degrees [2,2,3,1], mean=2, d_max=3
462        // |2-2|+|2-2|+|3-2|+|1-2| = 0+0+1+1 = 2
463        // DLE = 2 / (4·3) = 2/12 = 1/6
464        let expected: f64 = 1.0 / 6.0;
465        assert!((degree_laplacian_energy(&paw()).unwrap() - expected).abs() < 1e-10);
466    }
467
468    #[test]
469    fn dle_nonneg() {
470        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
471            assert!(degree_laplacian_energy(g).unwrap() >= -1e-10);
472        }
473    }
474
475    // --- avg_neighbor_connectivity ---
476
477    #[test]
478    fn anc_empty() {
479        let g = Graph::with_vertices(0);
480        assert!(avg_neighbor_connectivity(&g).unwrap().abs() < 1e-10);
481    }
482
483    #[test]
484    fn anc_isolated() {
485        let g = Graph::with_vertices(5);
486        assert!(avg_neighbor_connectivity(&g).unwrap().abs() < 1e-10);
487    }
488
489    #[test]
490    fn anc_single_edge() {
491        // Both vertices: neighbors have d=1, own d=1 → ratio=1, mean=1
492        assert!((avg_neighbor_connectivity(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
493    }
494
495    #[test]
496    fn anc_k3() {
497        // Each vertex: 2 neighbors each with d=2, own d=2
498        // ratio = (2+2)/2 = 2.0, mean = 2.0
499        assert!((avg_neighbor_connectivity(&k3()).unwrap() - 2.0).abs() < 1e-10);
500    }
501
502    #[test]
503    fn anc_k4() {
504        // Each vertex: 3 neighbors each with d=3, own d=3
505        // ratio = 9/3 = 3.0, mean = 3.0
506        assert!((avg_neighbor_connectivity(&k4()).unwrap() - 3.0).abs() < 1e-10);
507    }
508
509    #[test]
510    fn anc_cycle4() {
511        // Each vertex: 2 neighbors each with d=2, own d=2
512        // ratio = 4/2 = 2.0, mean = 2.0
513        assert!((avg_neighbor_connectivity(&cycle4()).unwrap() - 2.0).abs() < 1e-10);
514    }
515
516    #[test]
517    fn anc_path3() {
518        // v0: neighbors={1}, d(1)=2, own d=1 → ratio=2/1=2
519        // v1: neighbors={0,2}, d(0)=d(2)=1, own d=2 → ratio=2/2=1
520        // v2: neighbors={1}, d(1)=2, own d=1 → ratio=2/1=2
521        // mean = (2+1+2)/3 = 5/3
522        let expected: f64 = 5.0 / 3.0;
523        assert!((avg_neighbor_connectivity(&path3()).unwrap() - expected).abs() < 1e-10);
524    }
525
526    #[test]
527    fn anc_star5() {
528        // v0 (hub): neighbors={1,2,3,4}, all d=1, own d=4 → ratio=4/4=1
529        // v1-v4 (leaves): neighbor={0}, d(0)=4, own d=1 → ratio=4/1=4
530        // mean = (1+4+4+4+4)/5 = 17/5 = 3.4
531        let expected: f64 = 17.0 / 5.0;
532        assert!((avg_neighbor_connectivity(&star5()).unwrap() - expected).abs() < 1e-10);
533    }
534
535    #[test]
536    fn anc_paw() {
537        // degrees [2,2,3,1]
538        // v0: neighbors={1,2}, d(1)=2,d(2)=3, own d=2 → ratio=(2+3)/2=2.5
539        // v1: neighbors={0,2}, d(0)=2,d(2)=3, own d=2 → ratio=(2+3)/2=2.5
540        // v2: neighbors={0,1,3}, d(0)=2,d(1)=2,d(3)=1, own d=3 → ratio=(2+2+1)/3=5/3
541        // v3: neighbor={2}, d(2)=3, own d=1 → ratio=3/1=3
542        // mean = (2.5+2.5+5/3+3)/4 = (2.5+2.5+1.6667+3)/4 = 9.6667/4
543        let v2_ratio: f64 = 5.0 / 3.0;
544        let expected: f64 = (2.5 + 2.5 + v2_ratio + 3.0) / 4.0;
545        assert!((avg_neighbor_connectivity(&paw()).unwrap() - expected).abs() < 1e-10);
546    }
547
548    #[test]
549    fn anc_positive() {
550        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
551            assert!(avg_neighbor_connectivity(g).unwrap() > 0.0);
552        }
553    }
554
555    // --- cross-consistency ---
556
557    #[test]
558    fn regular_entropy_maximal() {
559        // For n active vertices, regular graph has H = ln(n)
560        let h_k3 = walk_entropy(&k3()).unwrap();
561        assert!((h_k3 - 3.0_f64.ln()).abs() < 1e-10);
562
563        let h_k4 = walk_entropy(&k4()).unwrap();
564        assert!((h_k4 - 4.0_f64.ln()).abs() < 1e-10);
565    }
566
567    #[test]
568    fn regular_dle_zero() {
569        assert!(degree_laplacian_energy(&k3()).unwrap().abs() < 1e-10);
570        assert!(degree_laplacian_energy(&k4()).unwrap().abs() < 1e-10);
571        assert!(degree_laplacian_energy(&cycle4()).unwrap().abs() < 1e-10);
572    }
573
574    #[test]
575    fn regular_wr_one() {
576        assert!((walk_regularity(&k3()).unwrap() - 1.0).abs() < 1e-10);
577        assert!((walk_regularity(&k4()).unwrap() - 1.0).abs() < 1e-10);
578        assert!((walk_regularity(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
579    }
580
581    #[test]
582    fn regular_anc_equals_degree() {
583        // For r-regular graphs, ANC = r
584        assert!((avg_neighbor_connectivity(&k3()).unwrap() - 2.0).abs() < 1e-10);
585        assert!((avg_neighbor_connectivity(&k4()).unwrap() - 3.0).abs() < 1e-10);
586        assert!((avg_neighbor_connectivity(&cycle4()).unwrap() - 2.0).abs() < 1e-10);
587    }
588}