Skip to main content

rust_igraph/algorithms/properties/
smallworld_ratios.rs

1//! Small-world ratio indices (ALGO-TR-108).
2//!
3//! Ratios capturing small-world properties:
4//!
5//! - **Small-world sigma** — `(C/C_rand) / (L/L_rand)` where C is
6//!   clustering and L is average path length
7//! - **Small-world omega** — `L_rand/L - C/C_lattice` difference measure
8//! - **Clustering path ratio** — `C * n / L` normalized product
9//! - **Navigability ratio** — `log(n) / L` how close to logarithmic
10//!   path lengths
11
12#![allow(
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 small-world sigma estimate.
24///
25/// `(C / C_rand) / (L / L_rand)` where:
26/// - C is the global clustering coefficient
27/// - L is the average shortest path length
28/// - `C_rand = mean_degree / n` (expected clustering for Erdos-Renyi)
29/// - `L_rand = ln(n) / ln(mean_degree)` (expected path length for ER)
30///
31/// Values > 1 suggest small-world structure. Returns 0.0 for
32/// disconnected, trivial, or sparse graphs where the ratio is
33/// undefined.
34///
35/// # Examples
36///
37/// ```
38/// use rust_igraph::{Graph, smallworld_sigma};
39///
40/// // K_4: C=1, L=1, C_rand=3/4, L_rand=ln4/ln3
41/// // sigma = (1/(3/4)) / (1/(ln4/ln3)) = (4/3) * (ln4/ln3) ≈ 1.59
42/// let g = Graph::from_edges(
43///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
44/// ).unwrap();
45/// assert!(smallworld_sigma(&g).unwrap() > 1.0);
46/// ```
47pub fn smallworld_sigma(graph: &Graph) -> IgraphResult<f64> {
48    let n = graph.vcount() as usize;
49    if n < 4 {
50        return Ok(0.0);
51    }
52
53    let (cc, apl) = clustering_and_apl(graph)?;
54    if apl < 1e-30 || cc < 1e-30 {
55        return Ok(0.0);
56    }
57
58    let mean_deg = 2.0 * graph.ecount() as f64 / n as f64;
59    if mean_deg < 1.0 + 1e-10 {
60        return Ok(0.0);
61    }
62
63    let c_rand = mean_deg / n as f64;
64    let l_rand = (n as f64).ln() / mean_deg.ln();
65
66    if c_rand < 1e-30 || l_rand < 1e-30 {
67        return Ok(0.0);
68    }
69
70    let gamma = cc / c_rand;
71    let lambda = apl / l_rand;
72
73    if lambda < 1e-30 {
74        return Ok(0.0);
75    }
76
77    Ok(gamma / lambda)
78}
79
80/// Compute the small-world omega estimate.
81///
82/// `L_rand / L - C / C_lattice` where:
83/// - `L_rand = ln(n) / ln(mean_degree)` (ER expected)
84/// - `C_lattice = 3/4` (ring lattice approximation for k≥4)
85///
86/// Values near 0 suggest small-world; negative → lattice-like;
87/// positive → random-like. Returns 0.0 for disconnected, trivial,
88/// or sparse graphs.
89///
90/// # Examples
91///
92/// ```
93/// use rust_igraph::{Graph, smallworld_omega};
94///
95/// // K_4: highly clustered and short paths
96/// let g = Graph::from_edges(
97///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
98/// ).unwrap();
99/// let w = smallworld_omega(&g).unwrap();
100/// assert!(w > -2.0 && w < 2.0);
101/// ```
102pub fn smallworld_omega(graph: &Graph) -> IgraphResult<f64> {
103    let n = graph.vcount() as usize;
104    if n < 4 {
105        return Ok(0.0);
106    }
107
108    let (cc, apl) = clustering_and_apl(graph)?;
109    if apl < 1e-30 {
110        return Ok(0.0);
111    }
112
113    let mean_deg = 2.0 * graph.ecount() as f64 / n as f64;
114    if mean_deg < 1.0 + 1e-10 {
115        return Ok(0.0);
116    }
117
118    let l_rand = (n as f64).ln() / mean_deg.ln();
119    let c_lattice = 0.75_f64;
120
121    let lambda_inv = l_rand / apl;
122    let gamma_lat = cc / c_lattice;
123
124    Ok(lambda_inv - gamma_lat)
125}
126
127/// Compute the clustering-path ratio.
128///
129/// `C * n / L` — a normalized product of clustering and inverse
130/// path length. Higher values indicate stronger small-world
131/// properties. Returns 0.0 for disconnected or trivial graphs.
132///
133/// # Examples
134///
135/// ```
136/// use rust_igraph::{Graph, clustering_path_ratio};
137///
138/// // K_4: C=1, L=1, n=4 → 4.0
139/// let g = Graph::from_edges(
140///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
141/// ).unwrap();
142/// assert!((clustering_path_ratio(&g).unwrap() - 4.0).abs() < 1e-10);
143/// ```
144pub fn clustering_path_ratio(graph: &Graph) -> IgraphResult<f64> {
145    let n = graph.vcount() as usize;
146    if n < 3 {
147        return Ok(0.0);
148    }
149
150    let (cc, apl) = clustering_and_apl(graph)?;
151    if apl < 1e-30 {
152        return Ok(0.0);
153    }
154
155    Ok(cc * n as f64 / apl)
156}
157
158/// Compute the navigability ratio.
159///
160/// `ln(n) / L` — how close the average path length is to the
161/// theoretical minimum for a graph with logarithmic diameter.
162/// Values near 1 suggest the graph is navigable like a random
163/// graph. Returns 0.0 for disconnected or trivial graphs.
164///
165/// # Examples
166///
167/// ```
168/// use rust_igraph::{Graph, navigability_ratio};
169///
170/// // K_4: L=1, ln(4)≈1.386 → ratio≈1.386
171/// let g = Graph::from_edges(
172///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
173/// ).unwrap();
174/// let r = navigability_ratio(&g).unwrap();
175/// assert!((r - 4.0_f64.ln()).abs() < 1e-10);
176/// ```
177pub fn navigability_ratio(graph: &Graph) -> IgraphResult<f64> {
178    let n = graph.vcount() as usize;
179    if n < 3 {
180        return Ok(0.0);
181    }
182
183    let (_, apl) = clustering_and_apl(graph)?;
184    if apl < 1e-30 {
185        return Ok(0.0);
186    }
187
188    Ok((n as f64).ln() / apl)
189}
190
191fn clustering_and_apl(graph: &Graph) -> IgraphResult<(f64, f64)> {
192    let n = graph.vcount() as usize;
193    if n < 2 {
194        return Ok((0.0, 0.0));
195    }
196
197    let mut total_triplets = 0_u64;
198    let mut closed_triplets = 0_u64;
199    let mut total_dist = 0_u64;
200    let mut dist_pairs = 0_u64;
201
202    for v in 0..n {
203        let neighbors = graph.neighbors(v as u32)?;
204        let d = neighbors.len();
205
206        if d >= 2 {
207            let possible = (d * (d - 1)) / 2;
208            total_triplets += possible as u64;
209
210            for i in 0..d {
211                for j in (i + 1)..d {
212                    if graph.has_edge(neighbors[i], neighbors[j]) {
213                        closed_triplets += 1;
214                    }
215                }
216            }
217        }
218
219        let mut dist = vec![u32::MAX; n];
220        dist[v] = 0;
221        let mut queue = std::collections::VecDeque::new();
222        queue.push_back(v);
223        while let Some(curr) = queue.pop_front() {
224            let cd = dist[curr];
225            let nbrs = graph.neighbors(curr as u32)?;
226            for &u in &nbrs {
227                let ui = u as usize;
228                if dist[ui] == u32::MAX {
229                    dist[ui] = cd + 1;
230                    queue.push_back(ui);
231                }
232            }
233        }
234
235        for u in (v + 1)..n {
236            if dist[u] == u32::MAX {
237                return Ok((0.0, 0.0));
238            }
239            total_dist += u64::from(dist[u]);
240            dist_pairs += 1;
241        }
242    }
243
244    let cc = if total_triplets > 0 {
245        closed_triplets as f64 / total_triplets as f64
246    } else {
247        0.0
248    };
249
250    let apl = if dist_pairs > 0 {
251        total_dist as f64 / dist_pairs as f64
252    } else {
253        0.0
254    };
255
256    Ok((cc, apl))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn path3() -> Graph {
264        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
265    }
266
267    fn k3() -> Graph {
268        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
269    }
270
271    fn k4() -> Graph {
272        Graph::from_edges(
273            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
274            false,
275            Some(4),
276        )
277        .unwrap()
278    }
279
280    fn cycle4() -> Graph {
281        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
282    }
283
284    fn star5() -> Graph {
285        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
286    }
287
288    fn paw() -> Graph {
289        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
290    }
291
292    // --- smallworld_sigma ---
293
294    #[test]
295    fn sigma_empty() {
296        let g = Graph::with_vertices(0);
297        assert!(smallworld_sigma(&g).unwrap().abs() < 1e-10);
298    }
299
300    #[test]
301    fn sigma_small() {
302        let g = Graph::with_vertices(3);
303        assert!(smallworld_sigma(&g).unwrap().abs() < 1e-10);
304    }
305
306    #[test]
307    fn sigma_k4() {
308        let r = smallworld_sigma(&k4()).unwrap();
309        assert!(r > 1.0);
310    }
311
312    #[test]
313    fn sigma_cycle4() {
314        // C=0 (no triangles), so sigma=0
315        assert!(smallworld_sigma(&cycle4()).unwrap().abs() < 1e-10);
316    }
317
318    #[test]
319    fn sigma_star5() {
320        // C=0 (no triangles among leaves), so sigma=0
321        assert!(smallworld_sigma(&star5()).unwrap().abs() < 1e-10);
322    }
323
324    #[test]
325    fn sigma_paw() {
326        let r = smallworld_sigma(&paw()).unwrap();
327        assert!(r > 0.0);
328    }
329
330    #[test]
331    fn sigma_disconnected() {
332        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
333        assert!(smallworld_sigma(&g).unwrap().abs() < 1e-10);
334    }
335
336    // --- smallworld_omega ---
337
338    #[test]
339    fn omega_empty() {
340        let g = Graph::with_vertices(0);
341        assert!(smallworld_omega(&g).unwrap().abs() < 1e-10);
342    }
343
344    #[test]
345    fn omega_small() {
346        let g = Graph::with_vertices(3);
347        assert!(smallworld_omega(&g).unwrap().abs() < 1e-10);
348    }
349
350    #[test]
351    fn omega_k4() {
352        let w = smallworld_omega(&k4()).unwrap();
353        assert!(w > -2.0 && w < 2.0);
354    }
355
356    #[test]
357    fn omega_cycle4() {
358        let w = smallworld_omega(&cycle4()).unwrap();
359        assert!(w.is_finite());
360    }
361
362    #[test]
363    fn omega_disconnected() {
364        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
365        assert!(smallworld_omega(&g).unwrap().abs() < 1e-10);
366    }
367
368    // --- clustering_path_ratio ---
369
370    #[test]
371    fn cpr_empty() {
372        let g = Graph::with_vertices(0);
373        assert!(clustering_path_ratio(&g).unwrap().abs() < 1e-10);
374    }
375
376    #[test]
377    fn cpr_k3() {
378        // C=1, L=1, n=3 → 3.0
379        assert!((clustering_path_ratio(&k3()).unwrap() - 3.0).abs() < 1e-10);
380    }
381
382    #[test]
383    fn cpr_k4() {
384        // C=1, L=1, n=4 → 4.0
385        assert!((clustering_path_ratio(&k4()).unwrap() - 4.0).abs() < 1e-10);
386    }
387
388    #[test]
389    fn cpr_cycle4() {
390        // C=0, so ratio=0
391        assert!(clustering_path_ratio(&cycle4()).unwrap().abs() < 1e-10);
392    }
393
394    #[test]
395    fn cpr_star5() {
396        // C=0, so ratio=0
397        assert!(clustering_path_ratio(&star5()).unwrap().abs() < 1e-10);
398    }
399
400    #[test]
401    fn cpr_path3() {
402        // C=0 (no triangles), so ratio=0
403        assert!(clustering_path_ratio(&path3()).unwrap().abs() < 1e-10);
404    }
405
406    #[test]
407    fn cpr_disconnected() {
408        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
409        assert!(clustering_path_ratio(&g).unwrap().abs() < 1e-10);
410    }
411
412    #[test]
413    fn cpr_nonneg() {
414        for g in &[path3(), k3(), k4(), cycle4(), star5(), paw()] {
415            assert!(clustering_path_ratio(g).unwrap() >= -1e-10);
416        }
417    }
418
419    // --- navigability_ratio ---
420
421    #[test]
422    fn nr_empty() {
423        let g = Graph::with_vertices(0);
424        assert!(navigability_ratio(&g).unwrap().abs() < 1e-10);
425    }
426
427    #[test]
428    fn nr_k3() {
429        // L=1, ln(3)/1 = ln(3)
430        assert!((navigability_ratio(&k3()).unwrap() - 3.0_f64.ln()).abs() < 1e-10);
431    }
432
433    #[test]
434    fn nr_k4() {
435        // L=1, ln(4)/1 = ln(4)
436        assert!((navigability_ratio(&k4()).unwrap() - 4.0_f64.ln()).abs() < 1e-10);
437    }
438
439    #[test]
440    fn nr_cycle4() {
441        // L=(1+2+1+1+2+1)/(6 pairs)=8/6=4/3 ≈ wait no
442        // Pairs: (0,1)=1,(0,2)=2,(0,3)=1,(1,2)=1,(1,3)=2,(2,3)=1 → sum=8, pairs=6 → L=4/3
443        // ln(4)/(4/3) = 3*ln(4)/4
444        let expected = 4.0_f64.ln() / (4.0 / 3.0);
445        assert!((navigability_ratio(&cycle4()).unwrap() - expected).abs() < 1e-10);
446    }
447
448    #[test]
449    fn nr_disconnected() {
450        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
451        assert!(navigability_ratio(&g).unwrap().abs() < 1e-10);
452    }
453
454    #[test]
455    fn nr_positive() {
456        for g in &[path3(), k3(), k4(), cycle4(), star5(), paw()] {
457            assert!(navigability_ratio(g).unwrap() > 0.0);
458        }
459    }
460
461    // --- cross-consistency ---
462
463    #[test]
464    fn complete_high_sigma() {
465        assert!(smallworld_sigma(&k4()).unwrap() > 1.0);
466    }
467
468    #[test]
469    fn triangle_free_zero_cpr() {
470        assert!(clustering_path_ratio(&cycle4()).unwrap().abs() < 1e-10);
471        assert!(clustering_path_ratio(&star5()).unwrap().abs() < 1e-10);
472        assert!(clustering_path_ratio(&path3()).unwrap().abs() < 1e-10);
473    }
474}