Skip to main content

rust_igraph/algorithms/properties/
local_structure_ratios.rs

1//! Local structure ratio indices (ALGO-TR-113).
2//!
3//! Measures capturing local structural patterns around vertices:
4//!
5//! - **Local density ratio** — mean density of ego-networks (1-hop neighborhoods)
6//! - **Neighbor connectivity** — mean min-degree among neighbors / max-degree
7//!   among neighbors, averaged over vertices
8//! - **Degree-neighbor correlation** — Pearson r between vertex degree and
9//!   mean neighbor degree
10
11#![allow(
12    clippy::cast_lossless,
13    clippy::cast_possible_truncation,
14    clippy::cast_precision_loss,
15    clippy::many_single_char_names,
16    clippy::needless_range_loop,
17    clippy::similar_names,
18    clippy::too_many_lines
19)]
20
21use crate::core::{Graph, IgraphResult};
22
23/// Compute the local density ratio.
24///
25/// Mean density of ego-networks: for each vertex v with degree ≥ 2,
26/// compute the density of the subgraph induced by v's neighbors
27/// (edges among neighbors / possible edges among neighbors). Average
28/// over all qualifying vertices. This equals the mean local clustering
29/// coefficient. Returns 0.0 for trivial graphs.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, local_density_ratio};
35///
36/// // K_4: every vertex's neighborhood is K_3 → density 1.0
37/// let g = Graph::from_edges(
38///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
39/// ).unwrap();
40/// assert!((local_density_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
41/// ```
42pub fn local_density_ratio(graph: &Graph) -> IgraphResult<f64> {
43    let n = graph.vcount() as usize;
44    if n < 3 {
45        return Ok(0.0);
46    }
47
48    let mut sum_density = 0.0_f64;
49    let mut count = 0_u64;
50
51    for v in 0..n {
52        let nbrs = graph.neighbors(v as u32)?;
53        let d = nbrs.len();
54        if d < 2 {
55            continue;
56        }
57
58        let max_edges = d * (d - 1) / 2;
59        let mut actual_edges = 0_u64;
60        for i in 0..d {
61            for j in (i + 1)..d {
62                if graph.has_edge(nbrs[i], nbrs[j]) {
63                    actual_edges += 1;
64                }
65            }
66        }
67
68        sum_density += actual_edges as f64 / max_edges as f64;
69        count += 1;
70    }
71
72    if count == 0 {
73        return Ok(0.0);
74    }
75
76    Ok(sum_density / count as f64)
77}
78
79/// Compute the neighbor connectivity ratio.
80///
81/// For each vertex v with degree ≥ 1, computes
82/// `min_neighbor_degree / max_neighbor_degree`. Averages over all
83/// qualifying vertices. Values near 1 indicate homogeneous neighbor
84/// degrees; values near 0 indicate high disparity. Returns 0.0 for
85/// edgeless graphs.
86///
87/// # Examples
88///
89/// ```
90/// use rust_igraph::{Graph, neighbor_connectivity_ratio};
91///
92/// // K_3: all neighbor degrees = 2 → min/max = 1.0 per vertex
93/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
94/// assert!((neighbor_connectivity_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
95/// ```
96pub fn neighbor_connectivity_ratio(graph: &Graph) -> IgraphResult<f64> {
97    let n = graph.vcount() as usize;
98    if n == 0 {
99        return Ok(0.0);
100    }
101
102    let mut degrees = Vec::with_capacity(n);
103    for v in 0..n {
104        degrees.push(graph.degree(v as u32)?);
105    }
106
107    let mut sum_ratio = 0.0_f64;
108    let mut count = 0_u64;
109
110    for v in 0..n {
111        if degrees[v] == 0 {
112            continue;
113        }
114        let nbrs = graph.neighbors(v as u32)?;
115        let mut min_d = usize::MAX;
116        let mut max_d = 0_usize;
117        for &u in &nbrs {
118            let du = degrees[u as usize];
119            if du < min_d {
120                min_d = du;
121            }
122            if du > max_d {
123                max_d = du;
124            }
125        }
126        if max_d > 0 {
127            sum_ratio += min_d as f64 / max_d as f64;
128            count += 1;
129        }
130    }
131
132    if count == 0 {
133        return Ok(0.0);
134    }
135
136    Ok(sum_ratio / count as f64)
137}
138
139/// Compute the degree-neighbor correlation.
140///
141/// Pearson correlation between vertex degree and mean neighbor degree
142/// across all vertices with degree ≥ 1. Negative values indicate
143/// disassortative mixing (high-degree nodes connect to low-degree
144/// nodes). Returns 0.0 for trivial graphs or when variance is zero.
145///
146/// # Examples
147///
148/// ```
149/// use rust_igraph::{Graph, degree_neighbor_correlation};
150///
151/// // K_4: all degrees and mean-neighbor-degrees equal → 0.0
152/// let g = Graph::from_edges(
153///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
154/// ).unwrap();
155/// assert!(degree_neighbor_correlation(&g).unwrap().abs() < 1e-10);
156/// ```
157pub fn degree_neighbor_correlation(graph: &Graph) -> IgraphResult<f64> {
158    let n = graph.vcount() as usize;
159    if n < 2 {
160        return Ok(0.0);
161    }
162
163    let mut degrees = Vec::with_capacity(n);
164    for v in 0..n {
165        degrees.push(graph.degree(v as u32)?);
166    }
167
168    let mut x_vals = Vec::new(); // degree
169    let mut y_vals = Vec::new(); // mean neighbor degree
170
171    for v in 0..n {
172        let dv = degrees[v];
173        if dv == 0 {
174            continue;
175        }
176        let nbrs = graph.neighbors(v as u32)?;
177        let mean_nbr: f64 = nbrs
178            .iter()
179            .map(|&u| degrees[u as usize] as f64)
180            .sum::<f64>()
181            / dv as f64;
182        x_vals.push(dv as f64);
183        y_vals.push(mean_nbr);
184    }
185
186    if x_vals.len() < 2 {
187        return Ok(0.0);
188    }
189
190    let count = x_vals.len() as f64;
191    let mean_x: f64 = x_vals.iter().sum::<f64>() / count;
192    let mean_y: f64 = y_vals.iter().sum::<f64>() / count;
193
194    let mut cov = 0.0_f64;
195    let mut var_x = 0.0_f64;
196    let mut var_y = 0.0_f64;
197
198    for i in 0..x_vals.len() {
199        let dx = x_vals[i] - mean_x;
200        let dy = y_vals[i] - mean_y;
201        cov += dx * dy;
202        var_x += dx * dx;
203        var_y += dy * dy;
204    }
205
206    if var_x < 1e-30 || var_y < 1e-30 {
207        return Ok(0.0);
208    }
209
210    Ok(cov / (var_x.sqrt() * var_y.sqrt()))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn empty() -> Graph {
218        Graph::with_vertices(0)
219    }
220
221    fn single() -> Graph {
222        Graph::with_vertices(1)
223    }
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    // --- local_density_ratio ---
259
260    #[test]
261    fn ldr_empty() {
262        assert!(local_density_ratio(&empty()).unwrap().abs() < 1e-10);
263    }
264
265    #[test]
266    fn ldr_single() {
267        assert!(local_density_ratio(&single()).unwrap().abs() < 1e-10);
268    }
269
270    #[test]
271    fn ldr_single_edge() {
272        // deg < 2 for both → 0.0
273        assert!(local_density_ratio(&single_edge()).unwrap().abs() < 1e-10);
274    }
275
276    #[test]
277    fn ldr_k3() {
278        // Each vertex has 2 neighbors connected → density 1.0
279        assert!((local_density_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
280    }
281
282    #[test]
283    fn ldr_k4() {
284        // Each vertex has 3 neighbors forming K_3 → density 1.0
285        assert!((local_density_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
286    }
287
288    #[test]
289    fn ldr_cycle4() {
290        // Each vertex has 2 neighbors NOT connected → density 0.0
291        assert!(local_density_ratio(&cycle4()).unwrap().abs() < 1e-10);
292    }
293
294    #[test]
295    fn ldr_star5() {
296        // Center: 4 neighbors, no edges among them → density 0.0
297        // Leaves: degree 1 < 2 → excluded
298        // Only center qualifies → 0.0
299        assert!(local_density_ratio(&star5()).unwrap().abs() < 1e-10);
300    }
301
302    #[test]
303    fn ldr_in_01() {
304        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
305            let r = local_density_ratio(g).unwrap();
306            assert!(r >= -1e-10);
307            assert!(r <= 1.0 + 1e-10);
308        }
309    }
310
311    // --- neighbor_connectivity_ratio ---
312
313    #[test]
314    fn ncr_empty() {
315        assert!(neighbor_connectivity_ratio(&empty()).unwrap().abs() < 1e-10);
316    }
317
318    #[test]
319    fn ncr_single() {
320        assert!(neighbor_connectivity_ratio(&single()).unwrap().abs() < 1e-10);
321    }
322
323    #[test]
324    fn ncr_single_edge() {
325        // Both: min=max=1 → 1.0
326        assert!((neighbor_connectivity_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
327    }
328
329    #[test]
330    fn ncr_k3() {
331        assert!((neighbor_connectivity_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
332    }
333
334    #[test]
335    fn ncr_k4() {
336        assert!((neighbor_connectivity_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
337    }
338
339    #[test]
340    fn ncr_cycle4() {
341        // All neighbor degrees = 2 → 1.0
342        assert!((neighbor_connectivity_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
343    }
344
345    #[test]
346    fn ncr_star5() {
347        // Center: neighbors all deg=1 → min/max=1/1=1.0
348        // Leaves: neighbor is center deg=4 → min/max=4/4=1.0
349        assert!((neighbor_connectivity_ratio(&star5()).unwrap() - 1.0).abs() < 1e-10);
350    }
351
352    #[test]
353    fn ncr_paw() {
354        // Degrees: 0→2, 1→2, 2→3, 3→1
355        // v0: nbrs={1(2),2(3)} → min=2,max=3 → 2/3
356        // v1: nbrs={0(2),2(3)} → 2/3
357        // v2: nbrs={0(2),1(2),3(1)} → 1/2
358        // v3: nbrs={2(3)} → 3/3=1
359        // mean = (2/3 + 2/3 + 1/2 + 1)/4 = (8/12 + 8/12 + 6/12 + 12/12)/4 = 34/48 = 17/24
360        let r = neighbor_connectivity_ratio(&paw()).unwrap();
361        assert!((r - 17.0 / 24.0).abs() < 1e-10);
362    }
363
364    #[test]
365    fn ncr_in_01() {
366        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
367            let r = neighbor_connectivity_ratio(g).unwrap();
368            assert!(r >= -1e-10);
369            assert!(r <= 1.0 + 1e-10);
370        }
371    }
372
373    // --- degree_neighbor_correlation ---
374
375    #[test]
376    fn dnc_empty() {
377        assert!(degree_neighbor_correlation(&empty()).unwrap().abs() < 1e-10);
378    }
379
380    #[test]
381    fn dnc_single() {
382        assert!(degree_neighbor_correlation(&single()).unwrap().abs() < 1e-10);
383    }
384
385    #[test]
386    fn dnc_k3() {
387        // All same degree → zero variance → 0.0
388        assert!(degree_neighbor_correlation(&k3()).unwrap().abs() < 1e-10);
389    }
390
391    #[test]
392    fn dnc_k4() {
393        assert!(degree_neighbor_correlation(&k4()).unwrap().abs() < 1e-10);
394    }
395
396    #[test]
397    fn dnc_cycle4() {
398        assert!(degree_neighbor_correlation(&cycle4()).unwrap().abs() < 1e-10);
399    }
400
401    #[test]
402    fn dnc_star5() {
403        // Center: deg=4, mean_nbr_deg=1
404        // Leaves: deg=1, mean_nbr_deg=4
405        // Perfect negative correlation → -1.0
406        assert!((degree_neighbor_correlation(&star5()).unwrap() + 1.0).abs() < 1e-10);
407    }
408
409    #[test]
410    fn dnc_in_range() {
411        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
412            let r = degree_neighbor_correlation(g).unwrap();
413            assert!(r >= -1.0 - 1e-10);
414            assert!(r <= 1.0 + 1e-10);
415        }
416    }
417
418    // --- cross-consistency ---
419
420    #[test]
421    fn regular_zero_correlation() {
422        assert!(degree_neighbor_correlation(&k3()).unwrap().abs() < 1e-10);
423        assert!(degree_neighbor_correlation(&k4()).unwrap().abs() < 1e-10);
424        assert!(degree_neighbor_correlation(&cycle4()).unwrap().abs() < 1e-10);
425    }
426
427    #[test]
428    fn complete_full_local_density() {
429        assert!((local_density_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
430        assert!((local_density_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
431    }
432}