Skip to main content

rust_igraph/algorithms/properties/
neighborhood_density.rs

1//! Neighborhood density measures (ALGO-TR-100).
2//!
3//! Measures capturing local neighborhood structure:
4//!
5//! - **Average neighbor degree ratio** — mean ratio of each vertex's
6//!   average neighbor degree to its own degree
7//! - **Hub ratio** — fraction of vertices whose degree exceeds the average
8//! - **Leaf-to-hub ratio** — degree-1 vertices divided by hub vertices
9//! - **Degree centralization** — Freeman's degree centralization
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 average neighbor degree ratio.
23///
24/// For each vertex with degree >= 1, compute the ratio of the average
25/// degree of its neighbors to its own degree. Return the mean of these
26/// ratios. Returns 0.0 for graphs where no vertex has degree >= 1.
27///
28/// # Examples
29///
30/// ```
31/// use rust_igraph::{Graph, avg_neighbor_degree_ratio};
32///
33/// // K_3: each v has d=2, neighbors avg d=2, ratio=1.0 → avg=1.0
34/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
35/// assert!((avg_neighbor_degree_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
36/// ```
37pub fn avg_neighbor_degree_ratio(graph: &Graph) -> IgraphResult<f64> {
38    let n = graph.vcount() as usize;
39    if n == 0 {
40        return Ok(0.0);
41    }
42
43    let mut degrees = Vec::with_capacity(n);
44    for v in 0..n {
45        degrees.push(graph.degree(v as u32)?);
46    }
47
48    let mut sum = 0.0_f64;
49    let mut count = 0_usize;
50
51    for v in 0..n {
52        let dv = degrees[v];
53        if dv == 0 {
54            continue;
55        }
56
57        let neighbors = graph.neighbors(v as u32)?;
58        let mut neighbor_deg_sum = 0_usize;
59        for &u in &neighbors {
60            neighbor_deg_sum += degrees[u as usize];
61        }
62
63        let avg_neighbor_deg = neighbor_deg_sum as f64 / dv as f64;
64        sum += avg_neighbor_deg / dv as f64;
65        count += 1;
66    }
67
68    if count == 0 {
69        return Ok(0.0);
70    }
71
72    Ok(sum / count as f64)
73}
74
75/// Compute the hub ratio.
76///
77/// Fraction of vertices whose degree exceeds the average degree.
78/// Returns 0.0 for empty graphs.
79///
80/// # Examples
81///
82/// ```
83/// use rust_igraph::{Graph, hub_ratio};
84///
85/// // K_3: all d=2, avg=2, no vertex exceeds → 0.0
86/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
87/// assert!(hub_ratio(&g).unwrap().abs() < 1e-10);
88/// ```
89pub fn hub_ratio(graph: &Graph) -> IgraphResult<f64> {
90    let n = graph.vcount() as usize;
91    if n == 0 {
92        return Ok(0.0);
93    }
94
95    let mut sum_deg = 0_usize;
96    let mut degrees = Vec::with_capacity(n);
97    for v in 0..n {
98        let d = graph.degree(v as u32)?;
99        degrees.push(d);
100        sum_deg += d;
101    }
102
103    let avg_deg = sum_deg as f64 / n as f64;
104
105    let hub_count = degrees
106        .iter()
107        .filter(|&&d| d as f64 > avg_deg + 1e-15)
108        .count();
109
110    Ok(hub_count as f64 / n as f64)
111}
112
113/// Compute the leaf-to-hub ratio.
114///
115/// Number of degree-1 vertices divided by number of vertices whose
116/// degree exceeds the average. Returns 0.0 if there are no hub
117/// vertices or no leaves.
118///
119/// # Examples
120///
121/// ```
122/// use rust_igraph::{Graph, leaf_to_hub_ratio};
123///
124/// // Star S_5: 4 leaves, 1 hub (center d=4, avg=8/5=1.6) → 4/1 = 4.0
125/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
126/// assert!((leaf_to_hub_ratio(&g).unwrap() - 4.0).abs() < 1e-10);
127/// ```
128pub fn leaf_to_hub_ratio(graph: &Graph) -> IgraphResult<f64> {
129    let n = graph.vcount() as usize;
130    if n == 0 {
131        return Ok(0.0);
132    }
133
134    let mut sum_deg = 0_usize;
135    let mut degrees = Vec::with_capacity(n);
136    for v in 0..n {
137        let d = graph.degree(v as u32)?;
138        degrees.push(d);
139        sum_deg += d;
140    }
141
142    let avg_deg = sum_deg as f64 / n as f64;
143
144    let leaf_count = degrees.iter().filter(|&&d| d == 1).count();
145    let hub_count = degrees
146        .iter()
147        .filter(|&&d| d as f64 > avg_deg + 1e-15)
148        .count();
149
150    if hub_count == 0 || leaf_count == 0 {
151        return Ok(0.0);
152    }
153
154    Ok(leaf_count as f64 / hub_count as f64)
155}
156
157/// Compute Freeman's degree centralization.
158///
159/// `C_D = Σ (d_max - d(v)) / ((n-1)(n-2))`
160///
161/// The sum of deviations from the maximum degree, normalized by
162/// the theoretical maximum for a star graph. Ranges from 0 (regular
163/// graphs) to 1 (star-like). Returns 0.0 for graphs with fewer
164/// than 3 vertices.
165///
166/// # Examples
167///
168/// ```
169/// use rust_igraph::{Graph, freeman_degree_centralization};
170///
171/// // Star S_5: max=4, deviations=[0,3,3,3,3]=12, denom=(4)(3)=12 → 1.0
172/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
173/// assert!((freeman_degree_centralization(&g).unwrap() - 1.0).abs() < 1e-10);
174/// ```
175pub fn freeman_degree_centralization(graph: &Graph) -> IgraphResult<f64> {
176    let n = graph.vcount() as usize;
177    if n < 3 {
178        return Ok(0.0);
179    }
180
181    let mut max_deg = 0_usize;
182    let mut degrees = Vec::with_capacity(n);
183    for v in 0..n {
184        let d = graph.degree(v as u32)?;
185        degrees.push(d);
186        if d > max_deg {
187            max_deg = d;
188        }
189    }
190
191    let mut deviation_sum = 0_usize;
192    for &d in &degrees {
193        deviation_sum += max_deg - d;
194    }
195
196    let denom = (n - 1) * (n - 2);
197    if denom == 0 {
198        return Ok(0.0);
199    }
200
201    Ok(deviation_sum as f64 / denom as f64)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn single_edge() -> Graph {
209        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
210    }
211
212    fn path3() -> Graph {
213        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
214    }
215
216    fn k3() -> Graph {
217        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
218    }
219
220    fn k4() -> Graph {
221        Graph::from_edges(
222            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
223            false,
224            Some(4),
225        )
226        .unwrap()
227    }
228
229    fn cycle4() -> Graph {
230        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
231    }
232
233    fn star5() -> Graph {
234        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
235    }
236
237    fn paw() -> Graph {
238        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
239    }
240
241    // --- avg_neighbor_degree_ratio ---
242
243    #[test]
244    fn andr_empty() {
245        let g = Graph::with_vertices(0);
246        assert!(avg_neighbor_degree_ratio(&g).unwrap().abs() < 1e-10);
247    }
248
249    #[test]
250    fn andr_isolated() {
251        let g = Graph::with_vertices(5);
252        assert!(avg_neighbor_degree_ratio(&g).unwrap().abs() < 1e-10);
253    }
254
255    #[test]
256    fn andr_single_edge() {
257        // Both d=1, neighbor d=1, ratio=1/1=1 → avg=1.0
258        assert!((avg_neighbor_degree_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
259    }
260
261    #[test]
262    fn andr_k3() {
263        // All d=2, avg_neigh=2, ratio=2/2=1 → avg=1.0
264        assert!((avg_neighbor_degree_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
265    }
266
267    #[test]
268    fn andr_k4() {
269        // All d=3, ratio=1 → avg=1.0
270        assert!((avg_neighbor_degree_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
271    }
272
273    #[test]
274    fn andr_cycle4() {
275        // All d=2, ratio=1 → avg=1.0
276        assert!((avg_neighbor_degree_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
277    }
278
279    #[test]
280    fn andr_star5() {
281        // Center(d=4): avg_neigh=1, ratio=1/4=0.25
282        // Each leaf(d=1): avg_neigh=4, ratio=4/1=4
283        // avg = (0.25 + 4*4) / 5 = (0.25 + 16) / 5 = 16.25/5 = 3.25
284        assert!((avg_neighbor_degree_ratio(&star5()).unwrap() - 3.25).abs() < 1e-10);
285    }
286
287    // --- hub_ratio ---
288
289    #[test]
290    fn hr_empty() {
291        let g = Graph::with_vertices(0);
292        assert!(hub_ratio(&g).unwrap().abs() < 1e-10);
293    }
294
295    #[test]
296    fn hr_isolated() {
297        let g = Graph::with_vertices(5);
298        assert!(hub_ratio(&g).unwrap().abs() < 1e-10);
299    }
300
301    #[test]
302    fn hr_k3() {
303        // All d=2, avg=2, none exceed → 0
304        assert!(hub_ratio(&k3()).unwrap().abs() < 1e-10);
305    }
306
307    #[test]
308    fn hr_k4() {
309        // All d=3, avg=3, none exceed → 0
310        assert!(hub_ratio(&k4()).unwrap().abs() < 1e-10);
311    }
312
313    #[test]
314    fn hr_cycle4() {
315        // All d=2, none exceed → 0
316        assert!(hub_ratio(&cycle4()).unwrap().abs() < 1e-10);
317    }
318
319    #[test]
320    fn hr_star5() {
321        // degrees: [4,1,1,1,1], avg=8/5=1.6
322        // Center d=4 > 1.6 → 1 hub, 1/5 = 0.2
323        assert!((hub_ratio(&star5()).unwrap() - 0.2).abs() < 1e-10);
324    }
325
326    #[test]
327    fn hr_path3() {
328        // degrees: [1,2,1], avg=4/3≈1.33
329        // v1: d=2 > 1.33 → 1 hub, 1/3
330        assert!((hub_ratio(&path3()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
331    }
332
333    #[test]
334    fn hr_paw() {
335        // degrees: [2,2,3,1], avg=8/4=2
336        // v2: d=3 > 2 → 1 hub, 1/4 = 0.25
337        assert!((hub_ratio(&paw()).unwrap() - 0.25).abs() < 1e-10);
338    }
339
340    // --- leaf_to_hub_ratio ---
341
342    #[test]
343    fn lthr_empty() {
344        let g = Graph::with_vertices(0);
345        assert!(leaf_to_hub_ratio(&g).unwrap().abs() < 1e-10);
346    }
347
348    #[test]
349    fn lthr_isolated() {
350        let g = Graph::with_vertices(5);
351        assert!(leaf_to_hub_ratio(&g).unwrap().abs() < 1e-10);
352    }
353
354    #[test]
355    fn lthr_k3() {
356        // No leaves, no hubs → 0
357        assert!(leaf_to_hub_ratio(&k3()).unwrap().abs() < 1e-10);
358    }
359
360    #[test]
361    fn lthr_star5() {
362        // 4 leaves, 1 hub → 4/1 = 4.0
363        assert!((leaf_to_hub_ratio(&star5()).unwrap() - 4.0).abs() < 1e-10);
364    }
365
366    #[test]
367    fn lthr_path3() {
368        // degrees: [1,2,1], avg≈1.33
369        // 2 leaves, 1 hub → 2/1 = 2.0
370        assert!((leaf_to_hub_ratio(&path3()).unwrap() - 2.0).abs() < 1e-10);
371    }
372
373    #[test]
374    fn lthr_paw() {
375        // degrees: [2,2,3,1], avg=2
376        // 1 leaf (v3), 1 hub (v2 d=3 > 2) → 1/1 = 1.0
377        assert!((leaf_to_hub_ratio(&paw()).unwrap() - 1.0).abs() < 1e-10);
378    }
379
380    #[test]
381    fn lthr_k4() {
382        // No leaves → 0
383        assert!(leaf_to_hub_ratio(&k4()).unwrap().abs() < 1e-10);
384    }
385
386    #[test]
387    fn lthr_cycle4() {
388        // No leaves → 0
389        assert!(leaf_to_hub_ratio(&cycle4()).unwrap().abs() < 1e-10);
390    }
391
392    // --- freeman_degree_centralization ---
393
394    #[test]
395    fn fdc_empty() {
396        let g = Graph::with_vertices(0);
397        assert!(freeman_degree_centralization(&g).unwrap().abs() < 1e-10);
398    }
399
400    #[test]
401    fn fdc_single() {
402        let g = Graph::with_vertices(1);
403        assert!(freeman_degree_centralization(&g).unwrap().abs() < 1e-10);
404    }
405
406    #[test]
407    fn fdc_two() {
408        let g = Graph::with_vertices(2);
409        assert!(freeman_degree_centralization(&g).unwrap().abs() < 1e-10);
410    }
411
412    #[test]
413    fn fdc_k3() {
414        // Regular → 0
415        assert!(freeman_degree_centralization(&k3()).unwrap().abs() < 1e-10);
416    }
417
418    #[test]
419    fn fdc_k4() {
420        // Regular → 0
421        assert!(freeman_degree_centralization(&k4()).unwrap().abs() < 1e-10);
422    }
423
424    #[test]
425    fn fdc_cycle4() {
426        // Regular → 0
427        assert!(freeman_degree_centralization(&cycle4()).unwrap().abs() < 1e-10);
428    }
429
430    #[test]
431    fn fdc_star5() {
432        // max=4, deviations=[0,3,3,3,3]=12, denom=(4)(3)=12 → 1.0
433        assert!((freeman_degree_centralization(&star5()).unwrap() - 1.0).abs() < 1e-10);
434    }
435
436    #[test]
437    fn fdc_path3() {
438        // max=2, deviations=[1,0,1]=2, denom=(2)(1)=2 → 1.0
439        assert!((freeman_degree_centralization(&path3()).unwrap() - 1.0).abs() < 1e-10);
440    }
441
442    #[test]
443    fn fdc_paw() {
444        // max=3, deviations=[1,1,0,2]=4, denom=(3)(2)=6 → 4/6 = 2/3
445        assert!((freeman_degree_centralization(&paw()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
446    }
447
448    // --- cross-consistency ---
449
450    #[test]
451    fn andr_nonneg() {
452        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
453            assert!(avg_neighbor_degree_ratio(g).unwrap() >= -1e-10);
454        }
455    }
456
457    #[test]
458    fn hr_in_01() {
459        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
460            let r = hub_ratio(g).unwrap();
461            assert!(r >= -1e-10);
462            assert!(r <= 1.0 + 1e-10);
463        }
464    }
465
466    #[test]
467    fn lthr_nonneg() {
468        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
469            assert!(leaf_to_hub_ratio(g).unwrap() >= -1e-10);
470        }
471    }
472
473    #[test]
474    fn fdc_in_01() {
475        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
476            let r = freeman_degree_centralization(g).unwrap();
477            assert!(r >= -1e-10);
478            assert!(r <= 1.0 + 1e-10);
479        }
480    }
481
482    #[test]
483    fn regular_graphs_zero_centralization() {
484        assert!(freeman_degree_centralization(&k3()).unwrap().abs() < 1e-10);
485        assert!(freeman_degree_centralization(&k4()).unwrap().abs() < 1e-10);
486        assert!(freeman_degree_centralization(&cycle4()).unwrap().abs() < 1e-10);
487    }
488
489    #[test]
490    fn regular_graphs_unit_ratio() {
491        // In regular graphs, avg_neighbor_degree_ratio = 1.0
492        assert!((avg_neighbor_degree_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
493        assert!((avg_neighbor_degree_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
494        assert!((avg_neighbor_degree_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
495    }
496}