Skip to main content

rust_igraph/algorithms/properties/
spectral_gap_ratios.rs

1//! Spectral gap ratio indices (ALGO-TR-114).
2//!
3//! Measures derived from the eigenvalue spectrum of the adjacency matrix:
4//!
5//! - **Spectral gap ratio** — (λ₁ − λ₂) / λ₁ where λ₁ ≥ λ₂ are the two
6//!   largest eigenvalues of the adjacency matrix
7//! - **Spectral radius ratio** — λ₁ / sqrt(`max_degree` × (n-1)), normalized
8//!   spectral radius
9//! - **Energy ratio** — graph energy (sum |λᵢ|) / (n × sqrt(2m/n)), normalized
10//!   by a random graph baseline
11
12#![allow(
13    clippy::cast_lossless,
14    clippy::cast_possible_truncation,
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 spectral gap ratio.
25///
26/// `(λ₁ − λ₂) / λ₁` where λ₁ and λ₂ are the two largest eigenvalues
27/// of the adjacency matrix (computed via power iteration). A large gap
28/// indicates an expander-like structure; a small gap suggests the graph
29/// is close to disconnected. Returns 0.0 for graphs with fewer than 2
30/// vertices or if λ₁ ≈ 0.
31///
32/// # Examples
33///
34/// ```
35/// use rust_igraph::{Graph, adjacency_spectral_gap_ratio};
36///
37/// // K_4: λ₁=3, λ₂=-1 → gap = (3-(-1))/3 = 4/3 ≈ 1.333
38/// // But we only consider the two LARGEST eigenvalues:
39/// // K_n has eigenvalues n-1 (mult 1) and -1 (mult n-1)
40/// // Two largest: 3 and -1 → (3 - (-1))/3 = 4/3
41/// // Actually sorted by magnitude: the two largest eigenvalues are 3, -1
42/// // Sorted descending: λ₁=3, λ₂=-1
43/// // ratio = (3 - (-1))/3 = 4/3
44/// let g = Graph::from_edges(
45///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
46/// ).unwrap();
47/// let r = adjacency_spectral_gap_ratio(&g).unwrap();
48/// assert!(r > 1.0);
49/// ```
50pub fn adjacency_spectral_gap_ratio(graph: &Graph) -> IgraphResult<f64> {
51    let n = graph.vcount() as usize;
52    if n < 2 {
53        return Ok(0.0);
54    }
55
56    let eigs = top_two_eigenvalues(graph)?;
57    let (lambda1, lambda2) = eigs;
58
59    if lambda1.abs() < 1e-12 {
60        return Ok(0.0);
61    }
62
63    Ok((lambda1 - lambda2) / lambda1)
64}
65
66/// Compute the spectral radius ratio.
67///
68/// `λ₁ / sqrt(max_degree × (n-1))` — the spectral radius normalized
69/// by its theoretical upper bound (Cauchy-Schwarz). Values near 1
70/// indicate the graph approaches the bound (e.g. stars); values near 0
71/// indicate sparse, low-spectral-radius graphs. Returns 0.0 for trivial
72/// graphs.
73///
74/// # Examples
75///
76/// ```
77/// use rust_igraph::{Graph, spectral_radius_ratio};
78///
79/// // K_3: λ₁=2, max_deg=2, n=3 → 2/sqrt(2×2)=2/2=1.0
80/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
81/// assert!((spectral_radius_ratio(&g).unwrap() - 1.0).abs() < 0.05);
82/// ```
83pub fn spectral_radius_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 lambda1 = largest_eigenvalue(graph)?;
90    if lambda1.abs() < 1e-12 {
91        return Ok(0.0);
92    }
93
94    let mut max_deg = 0_usize;
95    for v in 0..n {
96        let d = graph.degree(v as u32)?;
97        if d > max_deg {
98            max_deg = d;
99        }
100    }
101
102    if max_deg == 0 {
103        return Ok(0.0);
104    }
105
106    let bound = ((max_deg as f64) * ((n - 1) as f64)).sqrt();
107    Ok(lambda1 / bound)
108}
109
110/// Compute the energy ratio.
111///
112/// `energy / (n × sqrt(2m/n))` where energy = Σ|λᵢ| is the graph energy
113/// and the denominator is the expected energy of an Erdős–Rényi random
114/// graph with the same density. Values > 1 indicate the graph is more
115/// "energetic" than a random graph of the same density. Returns 0.0 for
116/// edgeless or trivial graphs.
117///
118/// # Examples
119///
120/// ```
121/// use rust_igraph::{Graph, energy_ratio};
122///
123/// // K_3: eigenvalues {2,-1,-1}, energy=4, m=3, n=3
124/// // baseline = 3*sqrt(2*3/3) = 3*sqrt(2) ≈ 4.243
125/// // ratio ≈ 4/4.243 ≈ 0.943
126/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
127/// let r = energy_ratio(&g).unwrap();
128/// assert!(r > 0.5 && r < 1.5);
129/// ```
130pub fn energy_ratio(graph: &Graph) -> IgraphResult<f64> {
131    let n = graph.vcount() as usize;
132    if n < 2 {
133        return Ok(0.0);
134    }
135
136    let m = graph.ecount();
137    if m == 0 {
138        return Ok(0.0);
139    }
140
141    let eigenvalues = all_eigenvalues(graph)?;
142    let energy: f64 = eigenvalues.iter().map(|e| e.abs()).sum();
143
144    let baseline = (n as f64) * (2.0 * m as f64 / n as f64).sqrt();
145    if baseline < 1e-12 {
146        return Ok(0.0);
147    }
148
149    Ok(energy / baseline)
150}
151
152/// Power iteration to find the largest eigenvalue of the adjacency matrix.
153fn largest_eigenvalue(graph: &Graph) -> IgraphResult<f64> {
154    let n = graph.vcount() as usize;
155    if n == 0 {
156        return Ok(0.0);
157    }
158
159    let mut x = vec![1.0_f64 / (n as f64).sqrt(); n];
160    let mut y = vec![0.0_f64; n];
161
162    for _ in 0..200 {
163        for i in 0..n {
164            y[i] = 0.0;
165        }
166        for v in 0..n {
167            let nbrs = graph.neighbors(v as u32)?;
168            for &u in &nbrs {
169                y[v] += x[u as usize];
170            }
171        }
172
173        let norm: f64 = y.iter().map(|v| v * v).sum::<f64>().sqrt();
174        if norm < 1e-30 {
175            return Ok(0.0);
176        }
177        for i in 0..n {
178            x[i] = y[i] / norm;
179        }
180    }
181
182    let mut lambda = 0.0_f64;
183    for v in 0..n {
184        let nbrs = graph.neighbors(v as u32)?;
185        let mut ax_v = 0.0_f64;
186        for &u in &nbrs {
187            ax_v += x[u as usize];
188        }
189        lambda += x[v] * ax_v;
190    }
191
192    Ok(lambda)
193}
194
195/// Find the top two eigenvalues using power iteration with deflation.
196fn top_two_eigenvalues(graph: &Graph) -> IgraphResult<(f64, f64)> {
197    let n = graph.vcount() as usize;
198    if n < 2 {
199        return Ok((0.0, 0.0));
200    }
201
202    let lambda1 = largest_eigenvalue(graph)?;
203
204    // Get the first eigenvector
205    let mut x1 = vec![1.0_f64 / (n as f64).sqrt(); n];
206    let mut y = vec![0.0_f64; n];
207
208    for _ in 0..200 {
209        for i in 0..n {
210            y[i] = 0.0;
211        }
212        for v in 0..n {
213            let nbrs = graph.neighbors(v as u32)?;
214            for &u in &nbrs {
215                y[v] += x1[u as usize];
216            }
217        }
218        let norm: f64 = y.iter().map(|v| v * v).sum::<f64>().sqrt();
219        if norm < 1e-30 {
220            return Ok((lambda1, 0.0));
221        }
222        for i in 0..n {
223            x1[i] = y[i] / norm;
224        }
225    }
226
227    // Power iteration on deflated matrix A - lambda1 * x1 * x1^T
228    let mut x2 = vec![0.0_f64; n];
229    // Start with vector orthogonal to x1
230    x2[0] = -x1[1];
231    x2[1] = x1[0];
232    let norm: f64 = x2.iter().map(|v| v * v).sum::<f64>().sqrt();
233    if norm < 1e-30 {
234        for i in 0..n {
235            x2[i] = if i == 0 { 1.0 } else { 0.0 };
236        }
237    } else {
238        for i in 0..n {
239            x2[i] /= norm;
240        }
241    }
242
243    for _ in 0..300 {
244        for i in 0..n {
245            y[i] = 0.0;
246        }
247        // y = A * x2
248        for v in 0..n {
249            let nbrs = graph.neighbors(v as u32)?;
250            for &u in &nbrs {
251                y[v] += x2[u as usize];
252            }
253        }
254        // Deflate: y = y - lambda1 * (x1^T x2) * x1
255        let dot: f64 = x1.iter().zip(x2.iter()).map(|(a, b)| a * b).sum();
256        for i in 0..n {
257            y[i] -= lambda1 * dot * x1[i];
258        }
259
260        let norm: f64 = y.iter().map(|v| v * v).sum::<f64>().sqrt();
261        if norm < 1e-30 {
262            return Ok((lambda1, 0.0));
263        }
264        for i in 0..n {
265            x2[i] = y[i] / norm;
266        }
267    }
268
269    // Compute Rayleigh quotient for x2 on deflated matrix
270    let mut ax2 = vec![0.0_f64; n];
271    for v in 0..n {
272        let nbrs = graph.neighbors(v as u32)?;
273        for &u in &nbrs {
274            ax2[v] += x2[u as usize];
275        }
276    }
277    let dot_x1_x2: f64 = x1.iter().zip(x2.iter()).map(|(a, b)| a * b).sum();
278    for i in 0..n {
279        ax2[i] -= lambda1 * dot_x1_x2 * x1[i];
280    }
281    let lambda2: f64 = x2.iter().zip(ax2.iter()).map(|(a, b)| a * b).sum();
282
283    Ok((lambda1, lambda2))
284}
285
286/// Compute all eigenvalues using QR iteration on the tridiagonal form.
287/// For small graphs we use the adjacency matrix directly via Householder
288/// reduction to tridiagonal form, then QR iteration.
289fn all_eigenvalues(graph: &Graph) -> IgraphResult<Vec<f64>> {
290    let n = graph.vcount() as usize;
291    if n == 0 {
292        return Ok(Vec::new());
293    }
294    if n == 1 {
295        return Ok(vec![0.0]);
296    }
297
298    // Build adjacency matrix
299    let mut a = vec![0.0_f64; n * n];
300    for v in 0..n {
301        let nbrs = graph.neighbors(v as u32)?;
302        for &u in &nbrs {
303            a[v * n + u as usize] = 1.0;
304        }
305    }
306
307    // Householder reduction to tridiagonal
308    let mut diag = vec![0.0_f64; n];
309    let mut offdiag = vec![0.0_f64; n];
310    tridiagonalize(&mut a, n, &mut diag, &mut offdiag);
311
312    // QR iteration on tridiagonal matrix
313    tql2(&mut diag, &mut offdiag, n);
314
315    Ok(diag)
316}
317
318/// Householder reduction to tridiagonal form (symmetric matrix).
319fn tridiagonalize(a: &mut [f64], n: usize, d: &mut [f64], e: &mut [f64]) {
320    for i in (1..n).rev() {
321        let l = i - 1;
322        let mut h = 0.0_f64;
323        let mut scale = 0.0_f64;
324
325        if l > 0 {
326            for k in 0..=l {
327                scale += a[i * n + k].abs();
328            }
329            if scale < 1e-30 {
330                e[i] = a[i * n + l];
331            } else {
332                for k in 0..=l {
333                    a[i * n + k] /= scale;
334                    h += a[i * n + k] * a[i * n + k];
335                }
336                let mut f = a[i * n + l];
337                let g = if f >= 0.0 { -h.sqrt() } else { h.sqrt() };
338                e[i] = scale * g;
339                h -= f * g;
340                a[i * n + l] = f - g;
341                f = 0.0;
342                for j in 0..=l {
343                    a[j * n + i] = a[i * n + j] / h;
344                    let mut g_val = 0.0_f64;
345                    for k in 0..=j {
346                        g_val += a[j * n + k] * a[i * n + k];
347                    }
348                    for k in (j + 1)..=l {
349                        g_val += a[k * n + j] * a[i * n + k];
350                    }
351                    e[j] = g_val / h;
352                    f += e[j] * a[i * n + j];
353                }
354                let hh = f / (h + h);
355                for j in 0..=l {
356                    f = a[i * n + j];
357                    let g_val = e[j] - hh * f;
358                    e[j] = g_val;
359                    for k in 0..=j {
360                        a[j * n + k] -= f * e[k] + g_val * a[i * n + k];
361                    }
362                }
363            }
364        } else {
365            e[i] = a[i * n + l];
366        }
367        d[i] = h;
368    }
369
370    d[0] = 0.0;
371    e[0] = 0.0;
372
373    for i in 0..n {
374        d[i] = a[i * n + i];
375    }
376}
377
378/// QL implicit-shift iteration for eigenvalues of a symmetric tridiagonal matrix.
379fn tql2(d: &mut [f64], e: &mut [f64], n: usize) {
380    for i in 1..n {
381        e[i - 1] = e[i];
382    }
383    e[n - 1] = 0.0;
384
385    let mut f = 0.0_f64;
386    let mut tst1 = 0.0_f64;
387    let eps = 1e-15_f64;
388
389    for l in 0..n {
390        tst1 = tst1.max(d[l].abs() + e[l].abs());
391        let mut m = l;
392        while m < n {
393            if e[m].abs() <= eps * tst1 {
394                break;
395            }
396            m += 1;
397        }
398
399        if m > l {
400            let mut iter_count = 0_u32;
401            loop {
402                iter_count += 1;
403                if iter_count > 300 {
404                    break;
405                }
406
407                let mut g = d[l];
408                let mut p = (d[l + 1] - g) / (2.0 * e[l]);
409                let mut r = (p * p + 1.0_f64).sqrt();
410                if p < 0.0 {
411                    r = -r;
412                }
413                d[l] = e[l] / (p + r);
414                d[l + 1] = e[l] * (p + r);
415                let dl1 = d[l + 1];
416                let mut h = g - d[l];
417                for i in (l + 2)..n {
418                    d[i] -= h;
419                }
420                f += h;
421
422                p = d[m];
423                let mut c = 1.0_f64;
424                let mut c2 = c;
425                let mut c3 = c;
426                let el1 = e[l + 1];
427                let mut s = 0.0_f64;
428                let mut s2 = 0.0_f64;
429
430                let mut i = m;
431                while i > l {
432                    i -= 1;
433                    c3 = c2;
434                    c2 = c;
435                    s2 = s;
436                    g = c * e[i];
437                    h = c * p;
438                    r = (p * p + e[i] * e[i]).sqrt();
439                    e[i + 1] = s * r;
440                    s = e[i] / r;
441                    c = p / r;
442                    p = c * d[i] - s * g;
443                    d[i + 1] = h + s * (c * g + s * d[i]);
444                }
445
446                p = -s * s2 * c3 * el1 * e[l] / dl1;
447                e[l] = s * p;
448                d[l] = c * p;
449
450                if e[l].abs() <= eps * tst1 {
451                    break;
452                }
453            }
454        }
455        d[l] += f;
456        e[l] = 0.0;
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn empty() -> Graph {
465        Graph::with_vertices(0)
466    }
467
468    fn single() -> Graph {
469        Graph::with_vertices(1)
470    }
471
472    fn single_edge() -> Graph {
473        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
474    }
475
476    fn path3() -> Graph {
477        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
478    }
479
480    fn k3() -> Graph {
481        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
482    }
483
484    fn k4() -> Graph {
485        Graph::from_edges(
486            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
487            false,
488            Some(4),
489        )
490        .unwrap()
491    }
492
493    fn cycle4() -> Graph {
494        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
495    }
496
497    fn star5() -> Graph {
498        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
499    }
500
501    fn paw() -> Graph {
502        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
503    }
504
505    // --- adjacency_spectral_gap_ratio ---
506
507    #[test]
508    fn sgr_empty() {
509        assert!(adjacency_spectral_gap_ratio(&empty()).unwrap().abs() < 1e-10);
510    }
511
512    #[test]
513    fn sgr_single() {
514        assert!(adjacency_spectral_gap_ratio(&single()).unwrap().abs() < 1e-10);
515    }
516
517    #[test]
518    fn sgr_single_edge() {
519        // λ₁=1, λ₂=-1 → (1-(-1))/1 = 2.0
520        let r = adjacency_spectral_gap_ratio(&single_edge()).unwrap();
521        assert!((r - 2.0).abs() < 0.1);
522    }
523
524    #[test]
525    fn sgr_k3() {
526        // K_3: λ₁=2, λ₂=-1 → (2-(-1))/2 = 1.5
527        let r = adjacency_spectral_gap_ratio(&k3()).unwrap();
528        assert!((r - 1.5).abs() < 0.1);
529    }
530
531    #[test]
532    fn sgr_k4() {
533        // K_4: λ₁=3, λ₂=-1 → (3-(-1))/3 = 4/3 ≈ 1.333
534        let r = adjacency_spectral_gap_ratio(&k4()).unwrap();
535        assert!((r - 4.0 / 3.0).abs() < 0.1);
536    }
537
538    #[test]
539    fn sgr_positive_connected() {
540        for g in &[single_edge(), k3(), k4(), cycle4(), star5(), paw()] {
541            let r = adjacency_spectral_gap_ratio(g).unwrap();
542            assert!(
543                r > 0.0,
544                "spectral gap ratio should be positive for connected graphs"
545            );
546        }
547    }
548
549    // --- spectral_radius_ratio ---
550
551    #[test]
552    fn srr_empty() {
553        assert!(spectral_radius_ratio(&empty()).unwrap().abs() < 1e-10);
554    }
555
556    #[test]
557    fn srr_single() {
558        assert!(spectral_radius_ratio(&single()).unwrap().abs() < 1e-10);
559    }
560
561    #[test]
562    fn srr_k3() {
563        // λ₁=2, max_deg=2, n=3 → 2/sqrt(2*2) = 2/2 = 1.0
564        let r = spectral_radius_ratio(&k3()).unwrap();
565        assert!((r - 1.0).abs() < 0.05);
566    }
567
568    #[test]
569    fn srr_single_edge() {
570        // λ₁=1, max_deg=1, n=2 → 1/sqrt(1*1) = 1.0
571        let r = spectral_radius_ratio(&single_edge()).unwrap();
572        assert!((r - 1.0).abs() < 0.05);
573    }
574
575    #[test]
576    fn srr_in_01() {
577        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
578            let r = spectral_radius_ratio(g).unwrap();
579            assert!(r >= -0.01);
580            assert!(r <= 1.01);
581        }
582    }
583
584    // --- energy_ratio ---
585
586    #[test]
587    fn er_empty() {
588        assert!(energy_ratio(&empty()).unwrap().abs() < 1e-10);
589    }
590
591    #[test]
592    fn er_single() {
593        assert!(energy_ratio(&single()).unwrap().abs() < 1e-10);
594    }
595
596    #[test]
597    fn er_k3() {
598        // eigenvalues: {2,-1,-1}, energy=4, m=3, n=3
599        // baseline = 3*sqrt(2*3/3) = 3*sqrt(2) ≈ 4.243
600        // ratio ≈ 4/4.243 ≈ 0.943
601        let r = energy_ratio(&k3()).unwrap();
602        assert!((r - 4.0 / (3.0 * 2.0_f64.sqrt())).abs() < 0.1);
603    }
604
605    #[test]
606    fn er_single_edge() {
607        // eigenvalues: {1,-1}, energy=2, m=1, n=2
608        // baseline = 2*sqrt(2*1/2) = 2*1 = 2
609        // ratio = 2/2 = 1.0
610        let r = energy_ratio(&single_edge()).unwrap();
611        assert!((r - 1.0).abs() < 0.1);
612    }
613
614    #[test]
615    fn er_positive() {
616        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
617            let r = energy_ratio(g).unwrap();
618            assert!(r > 0.0);
619        }
620    }
621
622    #[test]
623    fn er_finite() {
624        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
625            assert!(energy_ratio(g).unwrap().is_finite());
626        }
627    }
628
629    // --- cross-consistency ---
630
631    #[test]
632    fn complete_graphs_high_gap() {
633        // Complete graphs are good expanders → high spectral gap ratio
634        let r3 = adjacency_spectral_gap_ratio(&k3()).unwrap();
635        let r4 = adjacency_spectral_gap_ratio(&k4()).unwrap();
636        assert!(r3 > 1.0);
637        assert!(r4 > 1.0);
638    }
639
640    #[test]
641    fn all_indices_finite() {
642        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
643            assert!(adjacency_spectral_gap_ratio(g).unwrap().is_finite());
644            assert!(spectral_radius_ratio(g).unwrap().is_finite());
645            assert!(energy_ratio(g).unwrap().is_finite());
646        }
647    }
648}