Skip to main content

rust_igraph/algorithms/properties/
spectral_ratios.rs

1//! Spectral ratio indices (ALGO-TR-099).
2//!
3//! Lightweight spectral-inspired measures derived from degree sequences
4//! (no eigenvalue computation required):
5//!
6//! - **Degree-based spectral gap estimate** — max degree minus second-max degree
7//!   normalized by max degree
8//! - **Degree variance ratio** — degree variance / max possible variance
9//! - **Edge-vertex ratio** — m / n (average half-degree)
10//! - **Cyclomatic density** — circuit rank / n
11
12#![allow(
13    clippy::cast_possible_truncation,
14    clippy::cast_possible_wrap,
15    clippy::cast_precision_loss,
16    clippy::many_single_char_names,
17    clippy::needless_range_loop,
18    clippy::similar_names,
19    clippy::too_many_lines
20)]
21
22use crate::core::{Graph, IgraphResult};
23
24/// Compute the degree-based spectral gap estimate.
25///
26/// `(d_max - d_second) / d_max` where `d_max` is the maximum degree
27/// and `d_second` is the second-largest degree. This is a rough proxy
28/// for the spectral gap (difference between the two largest eigenvalues
29/// of the adjacency matrix). Returns 0.0 for graphs with fewer than 2
30/// vertices or where max degree is 0.
31///
32/// # Examples
33///
34/// ```
35/// use rust_igraph::{Graph, degree_spectral_gap_estimate};
36///
37/// // Star S_5: max_deg=4, second=1 → (4-1)/4 = 0.75
38/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
39/// assert!((degree_spectral_gap_estimate(&g).unwrap() - 0.75).abs() < 1e-10);
40/// ```
41pub fn degree_spectral_gap_estimate(graph: &Graph) -> IgraphResult<f64> {
42    let n = graph.vcount() as usize;
43    if n < 2 {
44        return Ok(0.0);
45    }
46
47    let mut max_deg = 0_usize;
48    let mut second_deg = 0_usize;
49
50    for v in 0..n {
51        let d = graph.degree(v as u32)?;
52        if d >= max_deg {
53            second_deg = max_deg;
54            max_deg = d;
55        } else if d > second_deg {
56            second_deg = d;
57        }
58    }
59
60    if max_deg == 0 {
61        return Ok(0.0);
62    }
63
64    Ok((max_deg - second_deg) as f64 / max_deg as f64)
65}
66
67/// Compute the degree variance ratio.
68///
69/// `Var(d) / Var_max` where `Var_max = (n-1)² · n / (4n)` for simple
70/// undirected graphs (the variance of a star on n vertices). Simplifies
71/// to `Var(d) · 4 / (n-1)²`. Returns 0.0 for graphs with fewer than
72/// 2 vertices.
73///
74/// # Examples
75///
76/// ```
77/// use rust_igraph::{Graph, degree_variance_ratio};
78///
79/// // K_3: all d=2, Var=0 → 0.0
80/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
81/// assert!(degree_variance_ratio(&g).unwrap().abs() < 1e-10);
82/// ```
83pub fn degree_variance_ratio(graph: &Graph) -> IgraphResult<f64> {
84    let n = graph.vcount() as usize;
85    if n < 2 {
86        return Ok(0.0);
87    }
88
89    let mut sum = 0_u64;
90    let mut sum_sq = 0_u64;
91
92    for v in 0..n {
93        let d = graph.degree(v as u32)? as u64;
94        sum += d;
95        sum_sq += d * d;
96    }
97
98    let nf = n as f64;
99    let mean = sum as f64 / nf;
100    let variance = sum_sq as f64 / nf - mean * mean;
101
102    let max_deg = (n - 1) as f64;
103    let var_max = max_deg * max_deg / 4.0;
104
105    if var_max < 1e-15 {
106        return Ok(0.0);
107    }
108
109    Ok((variance / var_max).min(1.0))
110}
111
112/// Compute the edge-vertex ratio.
113///
114/// `m / n` — the number of edges per vertex (half the average degree).
115/// Returns 0.0 for graphs with no vertices.
116///
117/// # Examples
118///
119/// ```
120/// use rust_igraph::{Graph, edge_vertex_ratio};
121///
122/// // K_3: m=3, n=3, ratio=1.0
123/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
124/// assert!((edge_vertex_ratio(&g).unwrap() - 1.0).abs() < 1e-10);
125/// ```
126pub fn edge_vertex_ratio(graph: &Graph) -> IgraphResult<f64> {
127    let n = graph.vcount();
128    if n == 0 {
129        return Ok(0.0);
130    }
131
132    let m = graph.ecount() as f64;
133    Ok(m / f64::from(n))
134}
135
136/// Compute the cyclomatic density.
137///
138/// `(m - n + c) / n` where c is the number of connected components.
139/// The circuit rank divided by the number of vertices gives the
140/// density of independent cycles per vertex. Returns 0.0 for graphs
141/// with no vertices.
142///
143/// # Examples
144///
145/// ```
146/// use rust_igraph::{Graph, cyclomatic_density};
147///
148/// // K_3: m=3, n=3, c=1, circuit_rank=1 → 1/3
149/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
150/// assert!((cyclomatic_density(&g).unwrap() - 1.0/3.0).abs() < 1e-10);
151/// ```
152pub fn cyclomatic_density(graph: &Graph) -> IgraphResult<f64> {
153    let n = graph.vcount() as usize;
154    if n == 0 {
155        return Ok(0.0);
156    }
157
158    let m = graph.ecount() as i64;
159    let ni = n as i64;
160    let c = count_components(graph)? as i64;
161    let circuit_rank = (m - ni + c).max(0);
162
163    Ok(circuit_rank as f64 / n as f64)
164}
165
166fn count_components(graph: &Graph) -> IgraphResult<usize> {
167    let n = graph.vcount() as usize;
168    if n == 0 {
169        return Ok(0);
170    }
171
172    let mut visited = vec![false; n];
173    let mut count = 0_usize;
174
175    for start in 0..n {
176        if visited[start] {
177            continue;
178        }
179        count += 1;
180        let mut stack = vec![start];
181        visited[start] = true;
182        while let Some(v) = stack.pop() {
183            let neighbors = graph.neighbors(v as u32)?;
184            for &u in &neighbors {
185                let ui = u as usize;
186                if !visited[ui] {
187                    visited[ui] = true;
188                    stack.push(ui);
189                }
190            }
191        }
192    }
193
194    Ok(count)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn single_edge() -> Graph {
202        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
203    }
204
205    fn path3() -> Graph {
206        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
207    }
208
209    fn k3() -> Graph {
210        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
211    }
212
213    fn k4() -> Graph {
214        Graph::from_edges(
215            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
216            false,
217            Some(4),
218        )
219        .unwrap()
220    }
221
222    fn cycle4() -> Graph {
223        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
224    }
225
226    fn star5() -> Graph {
227        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
228    }
229
230    fn paw() -> Graph {
231        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
232    }
233
234    // --- degree_spectral_gap_estimate ---
235
236    #[test]
237    fn dsge_empty() {
238        let g = Graph::with_vertices(0);
239        assert!(degree_spectral_gap_estimate(&g).unwrap().abs() < 1e-10);
240    }
241
242    #[test]
243    fn dsge_single() {
244        let g = Graph::with_vertices(1);
245        assert!(degree_spectral_gap_estimate(&g).unwrap().abs() < 1e-10);
246    }
247
248    #[test]
249    fn dsge_isolated() {
250        let g = Graph::with_vertices(5);
251        assert!(degree_spectral_gap_estimate(&g).unwrap().abs() < 1e-10);
252    }
253
254    #[test]
255    fn dsge_single_edge() {
256        // max=1, second=1 → (1-1)/1 = 0
257        assert!(degree_spectral_gap_estimate(&single_edge()).unwrap().abs() < 1e-10);
258    }
259
260    #[test]
261    fn dsge_k3() {
262        // All d=2 → gap=0
263        assert!(degree_spectral_gap_estimate(&k3()).unwrap().abs() < 1e-10);
264    }
265
266    #[test]
267    fn dsge_k4() {
268        // All d=3 → gap=0
269        assert!(degree_spectral_gap_estimate(&k4()).unwrap().abs() < 1e-10);
270    }
271
272    #[test]
273    fn dsge_cycle4() {
274        // All d=2 → gap=0
275        assert!(degree_spectral_gap_estimate(&cycle4()).unwrap().abs() < 1e-10);
276    }
277
278    #[test]
279    fn dsge_star5() {
280        // max=4, second=1 → (4-1)/4 = 0.75
281        assert!((degree_spectral_gap_estimate(&star5()).unwrap() - 0.75).abs() < 1e-10);
282    }
283
284    #[test]
285    fn dsge_paw() {
286        // degrees: [2,2,3,1] → max=3, second=2 → (3-2)/3 = 1/3
287        assert!((degree_spectral_gap_estimate(&paw()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
288    }
289
290    #[test]
291    fn dsge_path3() {
292        // degrees: [1,2,1] → max=2, second=1 → (2-1)/2 = 0.5
293        assert!((degree_spectral_gap_estimate(&path3()).unwrap() - 0.5).abs() < 1e-10);
294    }
295
296    // --- degree_variance_ratio ---
297
298    #[test]
299    fn dvr_empty() {
300        let g = Graph::with_vertices(0);
301        assert!(degree_variance_ratio(&g).unwrap().abs() < 1e-10);
302    }
303
304    #[test]
305    fn dvr_single() {
306        let g = Graph::with_vertices(1);
307        assert!(degree_variance_ratio(&g).unwrap().abs() < 1e-10);
308    }
309
310    #[test]
311    fn dvr_isolated() {
312        let g = Graph::with_vertices(5);
313        assert!(degree_variance_ratio(&g).unwrap().abs() < 1e-10);
314    }
315
316    #[test]
317    fn dvr_k3() {
318        // All d=2, variance=0 → 0
319        assert!(degree_variance_ratio(&k3()).unwrap().abs() < 1e-10);
320    }
321
322    #[test]
323    fn dvr_k4() {
324        // All d=3, variance=0 → 0
325        assert!(degree_variance_ratio(&k4()).unwrap().abs() < 1e-10);
326    }
327
328    #[test]
329    fn dvr_cycle4() {
330        // All d=2, variance=0 → 0
331        assert!(degree_variance_ratio(&cycle4()).unwrap().abs() < 1e-10);
332    }
333
334    #[test]
335    fn dvr_star5() {
336        // degrees: [4,1,1,1,1], mean=8/5=1.6
337        // variance = (4-1.6)²·1/5 + (1-1.6)²·4/5 = (2.4²·1+0.6²·4)/5 = (5.76+1.44)/5 = 7.2/5 = 1.44
338        // var_max = (n-1)²/4 = 16/4 = 4
339        // ratio = 1.44/4 = 0.36
340        assert!((degree_variance_ratio(&star5()).unwrap() - 0.36).abs() < 1e-10);
341    }
342
343    #[test]
344    fn dvr_single_edge() {
345        // degrees: [1,1], variance=0 → 0
346        assert!(degree_variance_ratio(&single_edge()).unwrap().abs() < 1e-10);
347    }
348
349    #[test]
350    fn dvr_paw() {
351        // degrees: [2,2,3,1], mean=8/4=2
352        // variance = ((2-2)²+(2-2)²+(3-2)²+(1-2)²)/4 = (0+0+1+1)/4 = 0.5
353        // var_max = (4-1)²/4 = 9/4 = 2.25
354        // ratio = 0.5/2.25 = 2/9
355        assert!((degree_variance_ratio(&paw()).unwrap() - 2.0 / 9.0).abs() < 1e-10);
356    }
357
358    // --- edge_vertex_ratio ---
359
360    #[test]
361    fn evr_empty() {
362        let g = Graph::with_vertices(0);
363        assert!(edge_vertex_ratio(&g).unwrap().abs() < 1e-10);
364    }
365
366    #[test]
367    fn evr_isolated() {
368        let g = Graph::with_vertices(5);
369        assert!(edge_vertex_ratio(&g).unwrap().abs() < 1e-10);
370    }
371
372    #[test]
373    fn evr_single_edge() {
374        // m=1, n=2 → 0.5
375        assert!((edge_vertex_ratio(&single_edge()).unwrap() - 0.5).abs() < 1e-10);
376    }
377
378    #[test]
379    fn evr_k3() {
380        // m=3, n=3 → 1.0
381        assert!((edge_vertex_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
382    }
383
384    #[test]
385    fn evr_k4() {
386        // m=6, n=4 → 1.5
387        assert!((edge_vertex_ratio(&k4()).unwrap() - 1.5).abs() < 1e-10);
388    }
389
390    #[test]
391    fn evr_cycle4() {
392        // m=4, n=4 → 1.0
393        assert!((edge_vertex_ratio(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
394    }
395
396    #[test]
397    fn evr_star5() {
398        // m=4, n=5 → 0.8
399        assert!((edge_vertex_ratio(&star5()).unwrap() - 0.8).abs() < 1e-10);
400    }
401
402    #[test]
403    fn evr_path3() {
404        // m=2, n=3 → 2/3
405        assert!((edge_vertex_ratio(&path3()).unwrap() - 2.0 / 3.0).abs() < 1e-10);
406    }
407
408    #[test]
409    fn evr_paw() {
410        // m=4, n=4 → 1.0
411        assert!((edge_vertex_ratio(&paw()).unwrap() - 1.0).abs() < 1e-10);
412    }
413
414    // --- cyclomatic_density ---
415
416    #[test]
417    fn cd_empty() {
418        let g = Graph::with_vertices(0);
419        assert!(cyclomatic_density(&g).unwrap().abs() < 1e-10);
420    }
421
422    #[test]
423    fn cd_isolated() {
424        let g = Graph::with_vertices(5);
425        assert!(cyclomatic_density(&g).unwrap().abs() < 1e-10);
426    }
427
428    #[test]
429    fn cd_single_edge() {
430        // m=1, n=2, c=1, cr=0 → 0/2 = 0
431        assert!(cyclomatic_density(&single_edge()).unwrap().abs() < 1e-10);
432    }
433
434    #[test]
435    fn cd_path3() {
436        // Tree → cr=0 → 0
437        assert!(cyclomatic_density(&path3()).unwrap().abs() < 1e-10);
438    }
439
440    #[test]
441    fn cd_k3() {
442        // m=3, n=3, c=1, cr=1 → 1/3
443        assert!((cyclomatic_density(&k3()).unwrap() - 1.0 / 3.0).abs() < 1e-10);
444    }
445
446    #[test]
447    fn cd_k4() {
448        // m=6, n=4, c=1, cr=3 → 3/4 = 0.75
449        assert!((cyclomatic_density(&k4()).unwrap() - 0.75).abs() < 1e-10);
450    }
451
452    #[test]
453    fn cd_cycle4() {
454        // m=4, n=4, c=1, cr=1 → 1/4 = 0.25
455        assert!((cyclomatic_density(&cycle4()).unwrap() - 0.25).abs() < 1e-10);
456    }
457
458    #[test]
459    fn cd_star5() {
460        // Tree → cr=0 → 0
461        assert!(cyclomatic_density(&star5()).unwrap().abs() < 1e-10);
462    }
463
464    #[test]
465    fn cd_paw() {
466        // m=4, n=4, c=1, cr=1 → 1/4 = 0.25
467        assert!((cyclomatic_density(&paw()).unwrap() - 0.25).abs() < 1e-10);
468    }
469
470    // --- cross-consistency ---
471
472    #[test]
473    fn dsge_in_01() {
474        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
475            let r = degree_spectral_gap_estimate(g).unwrap();
476            assert!(r >= -1e-10);
477            assert!(r <= 1.0 + 1e-10);
478        }
479    }
480
481    #[test]
482    fn dvr_in_01() {
483        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
484            let r = degree_variance_ratio(g).unwrap();
485            assert!(r >= -1e-10);
486            assert!(r <= 1.0 + 1e-10);
487        }
488    }
489
490    #[test]
491    fn evr_nonneg() {
492        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
493            assert!(edge_vertex_ratio(g).unwrap() >= -1e-10);
494        }
495    }
496
497    #[test]
498    fn cd_nonneg() {
499        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
500            assert!(cyclomatic_density(g).unwrap() >= -1e-10);
501        }
502    }
503
504    #[test]
505    fn regular_graphs_zero_variance() {
506        assert!(degree_variance_ratio(&k3()).unwrap().abs() < 1e-10);
507        assert!(degree_variance_ratio(&k4()).unwrap().abs() < 1e-10);
508        assert!(degree_variance_ratio(&cycle4()).unwrap().abs() < 1e-10);
509    }
510
511    #[test]
512    fn regular_graphs_zero_gap() {
513        assert!(degree_spectral_gap_estimate(&k3()).unwrap().abs() < 1e-10);
514        assert!(degree_spectral_gap_estimate(&k4()).unwrap().abs() < 1e-10);
515        assert!(degree_spectral_gap_estimate(&cycle4()).unwrap().abs() < 1e-10);
516    }
517
518    #[test]
519    fn trees_zero_cyclomatic() {
520        assert!(cyclomatic_density(&path3()).unwrap().abs() < 1e-10);
521        assert!(cyclomatic_density(&star5()).unwrap().abs() < 1e-10);
522        assert!(cyclomatic_density(&single_edge()).unwrap().abs() < 1e-10);
523    }
524}