Skip to main content

rust_igraph/algorithms/properties/
signless_laplacian.rs

1//! Signless Laplacian spectrum and derived metrics (ALGO-TR-026).
2//!
3//! The **signless Laplacian** `Q = D + A` is the positive-definite
4//! analogue of the combinatorial Laplacian `L = D - A`. Its spectrum
5//! is particularly useful for detecting bipartiteness and odd cycles.
6//!
7//! - **Signless Laplacian spectrum**: eigenvalues `q_1 ≤ … ≤ q_n`
8//!   of `Q = D + A`, sorted ascending.
9//! - **Smallest `Q`-eigenvalue**: `q_1(Q)` — equals 0 iff the graph
10//!   has a bipartite component.
11//! - **Largest `Q`-eigenvalue**: `q_n(Q)` — the signless Laplacian
12//!   spectral radius.
13//! - **Signless Laplacian energy**: `QE = Σ |q_i - 2m/n|` where
14//!   `m = |E|`, analogous to graph energy but centered on the mean
15//!   eigenvalue.
16
17#![allow(
18    clippy::cast_possible_truncation,
19    clippy::cast_precision_loss,
20    clippy::many_single_char_names,
21    clippy::needless_range_loop,
22    clippy::similar_names,
23    clippy::too_many_lines
24)]
25
26use crate::core::{Graph, IgraphError, IgraphResult};
27
28/// Build the dense signless Laplacian Q = D + A (row-major, n×n).
29fn dense_signless_laplacian(graph: &Graph) -> Vec<f64> {
30    let n = graph.vcount() as usize;
31    let mut q = vec![0.0_f64; n * n];
32    for (u, v) in graph.edges() {
33        let ui = u as usize;
34        let vi = v as usize;
35        q[ui * n + vi] += 1.0;
36        q[vi * n + ui] += 1.0;
37        q[ui * n + ui] += 1.0;
38        q[vi * n + vi] += 1.0;
39    }
40    q
41}
42
43/// Jacobi eigenvalue algorithm for real symmetric matrices.
44/// Returns eigenvalues sorted in **increasing** order.
45fn jacobi_eigen_ascending(mat: &mut [f64], n: usize) -> Vec<f64> {
46    if n == 0 {
47        return Vec::new();
48    }
49    if n == 1 {
50        return vec![mat[0]];
51    }
52
53    let max_sweeps = 100;
54    for _ in 0..max_sweeps {
55        let mut max_off = 0.0_f64;
56        let mut p = 0;
57        let mut q = 1;
58        for i in 0..n {
59            for j in (i + 1)..n {
60                let val = mat[i * n + j].abs();
61                if val > max_off {
62                    max_off = val;
63                    p = i;
64                    q = j;
65                }
66            }
67        }
68
69        if max_off < 1e-14 {
70            break;
71        }
72
73        let app = mat[p * n + p];
74        let aqq = mat[q * n + q];
75        let apq = mat[p * n + q];
76
77        let (cos, sin) = if (app - aqq).abs() < 1e-300 {
78            let s = std::f64::consts::FRAC_1_SQRT_2;
79            (s, s)
80        } else {
81            let tau = (aqq - app) / (2.0 * apq);
82            let t = if tau >= 0.0 {
83                1.0 / (tau + (1.0 + tau * tau).sqrt())
84            } else {
85                -1.0 / (-tau + (1.0 + tau * tau).sqrt())
86            };
87            let c = 1.0 / (1.0 + t * t).sqrt();
88            (c, t * c)
89        };
90
91        for i in 0..n {
92            if i == p || i == q {
93                continue;
94            }
95            let aip = mat[i * n + p];
96            let aiq = mat[i * n + q];
97            mat[i * n + p] = cos * aip - sin * aiq;
98            mat[p * n + i] = mat[i * n + p];
99            mat[i * n + q] = sin * aip + cos * aiq;
100            mat[q * n + i] = mat[i * n + q];
101        }
102
103        let new_pp = cos * cos * app - 2.0 * sin * cos * apq + sin * sin * aqq;
104        let new_qq = sin * sin * app + 2.0 * sin * cos * apq + cos * cos * aqq;
105        mat[p * n + p] = new_pp;
106        mat[q * n + q] = new_qq;
107        mat[p * n + q] = 0.0;
108        mat[q * n + p] = 0.0;
109    }
110
111    let mut eigenvalues: Vec<f64> = (0..n).map(|i| mat[i * n + i]).collect();
112    eigenvalues.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
113    eigenvalues
114}
115
116/// Compute the signless Laplacian spectrum, sorted ascending.
117///
118/// The signless Laplacian `Q = D + A` has all non-negative eigenvalues.
119/// The smallest eigenvalue `q_1 = 0` if and only if the graph has a
120/// bipartite connected component.
121///
122/// For undirected graphs only.
123///
124/// # Examples
125///
126/// ```
127/// use rust_igraph::{Graph, signless_laplacian_spectrum};
128///
129/// // K_3: Q eigenvalues {1, 1, 4} (sorted: {1, 1, 4})
130/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
131/// let spec = signless_laplacian_spectrum(&g).unwrap();
132/// assert!((spec[0] - 1.0).abs() < 0.1);
133/// assert!((spec[2] - 4.0).abs() < 0.1);
134/// ```
135pub fn signless_laplacian_spectrum(graph: &Graph) -> IgraphResult<Vec<f64>> {
136    if graph.is_directed() {
137        return Err(IgraphError::InvalidArgument(
138            "signless_laplacian_spectrum is defined for undirected graphs only".into(),
139        ));
140    }
141    let n = graph.vcount() as usize;
142    if n == 0 {
143        return Ok(Vec::new());
144    }
145    let mut q = dense_signless_laplacian(graph);
146    Ok(jacobi_eigen_ascending(&mut q, n))
147}
148
149/// Compute the smallest signless Laplacian eigenvalue `q_1(Q)`.
150///
151/// Equals 0 for graphs with a bipartite component, positive otherwise.
152/// This is a useful bipartiteness test: a connected graph is bipartite
153/// if and only if `q_1 = 0`.
154///
155/// # Examples
156///
157/// ```
158/// use rust_igraph::{Graph, signless_laplacian_smallest};
159///
160/// // K_{2,2} (bipartite): q_1 = 0
161/// let g = Graph::from_edges(&[(0,2),(0,3),(1,2),(1,3)], false, Some(4)).unwrap();
162/// let q1 = signless_laplacian_smallest(&g).unwrap();
163/// assert!(q1.abs() < 0.01);
164///
165/// // K_3 (non-bipartite): q_1 = 1
166/// let k3 = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
167/// let q1k = signless_laplacian_smallest(&k3).unwrap();
168/// assert!(q1k > 0.5);
169/// ```
170pub fn signless_laplacian_smallest(graph: &Graph) -> IgraphResult<f64> {
171    let spec = signless_laplacian_spectrum(graph)?;
172    if spec.is_empty() {
173        return Ok(0.0);
174    }
175    Ok(spec[0].max(0.0))
176}
177
178/// Compute the signless Laplacian spectral radius `q_n(Q)`.
179///
180/// The largest eigenvalue of `Q = D + A`. Bounded above by
181/// `2 · max_degree`.
182///
183/// # Examples
184///
185/// ```
186/// use rust_igraph::{Graph, signless_laplacian_spectral_radius};
187///
188/// // K_3: Q eigenvalues {1, 1, 4} → q_max = 4
189/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
190/// let qn = signless_laplacian_spectral_radius(&g).unwrap();
191/// assert!((qn - 4.0).abs() < 0.1);
192/// ```
193pub fn signless_laplacian_spectral_radius(graph: &Graph) -> IgraphResult<f64> {
194    let spec = signless_laplacian_spectrum(graph)?;
195    if spec.is_empty() {
196        return Ok(0.0);
197    }
198    Ok(spec[spec.len() - 1])
199}
200
201/// Compute the signless Laplacian energy.
202///
203/// `QE = Σ |q_i - 2m/n|` where `m = |E|` and `n = |V|`.
204/// This is the deviation of the signless Laplacian eigenvalues from
205/// their mean value `2m/n`.
206///
207/// # Examples
208///
209/// ```
210/// use rust_igraph::{Graph, signless_laplacian_energy};
211///
212/// // K_3: eigenvalues {1, 1, 4}, mean = 6/3 = 2
213/// // QE = |1-2| + |1-2| + |4-2| = 1 + 1 + 2 = 4
214/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
215/// let qe = signless_laplacian_energy(&g).unwrap();
216/// assert!((qe - 4.0).abs() < 0.1);
217/// ```
218pub fn signless_laplacian_energy(graph: &Graph) -> IgraphResult<f64> {
219    if graph.is_directed() {
220        return Err(IgraphError::InvalidArgument(
221            "signless_laplacian_energy is defined for undirected graphs only".into(),
222        ));
223    }
224    let n = graph.vcount() as usize;
225    if n == 0 {
226        return Ok(0.0);
227    }
228
229    let spec = signless_laplacian_spectrum(graph)?;
230    let m = graph.ecount() as f64;
231    let mean = 2.0 * m / n as f64;
232
233    Ok(spec.iter().map(|&q| (q - mean).abs()).sum())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn k3() -> Graph {
241        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
242    }
243
244    fn k4() -> Graph {
245        Graph::from_edges(
246            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
247            false,
248            Some(4),
249        )
250        .unwrap()
251    }
252
253    fn path4() -> Graph {
254        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
255    }
256
257    fn cycle4() -> Graph {
258        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
259    }
260
261    fn star4() -> Graph {
262        Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap()
263    }
264
265    fn k22() -> Graph {
266        Graph::from_edges(&[(0, 2), (0, 3), (1, 2), (1, 3)], false, Some(4)).unwrap()
267    }
268
269    // --- signless_laplacian_spectrum ---
270
271    #[test]
272    fn sls_empty() {
273        let g = Graph::with_vertices(0);
274        let spec = signless_laplacian_spectrum(&g).unwrap();
275        assert!(spec.is_empty());
276    }
277
278    #[test]
279    fn sls_single() {
280        let g = Graph::with_vertices(1);
281        let spec = signless_laplacian_spectrum(&g).unwrap();
282        assert_eq!(spec.len(), 1);
283        assert!(spec[0].abs() < 1e-10);
284    }
285
286    #[test]
287    fn sls_k3() {
288        let g = k3();
289        let spec = signless_laplacian_spectrum(&g).unwrap();
290        assert_eq!(spec.len(), 3);
291        // Q(K_3) = D + A = 2I + A; A eigenvalues {2,-1,-1}
292        // so Q eigenvalues: {4, 1, 1}
293        assert!((spec[0] - 1.0).abs() < 0.1);
294        assert!((spec[1] - 1.0).abs() < 0.1);
295        assert!((spec[2] - 4.0).abs() < 0.1);
296    }
297
298    #[test]
299    fn sls_k4() {
300        let g = k4();
301        let spec = signless_laplacian_spectrum(&g).unwrap();
302        // Q(K_n) = (n-1)I + A; A eigenvalues {n-1, -1, ..., -1}
303        // K_4: Q eigenvalues = {6, 2, 2, 2}
304        assert!((spec[0] - 2.0).abs() < 0.1);
305        assert!((spec[3] - 6.0).abs() < 0.1);
306    }
307
308    #[test]
309    fn sls_nonneg() {
310        let g = star4();
311        let spec = signless_laplacian_spectrum(&g).unwrap();
312        for &v in &spec {
313            assert!(v >= -0.01, "eigenvalue {v} < 0");
314        }
315    }
316
317    #[test]
318    fn sls_ascending() {
319        let g = cycle4();
320        let spec = signless_laplacian_spectrum(&g).unwrap();
321        for i in 1..spec.len() {
322            assert!(spec[i] >= spec[i - 1] - 1e-10);
323        }
324    }
325
326    #[test]
327    fn sls_bipartite_has_zero() {
328        let g = k22();
329        let spec = signless_laplacian_spectrum(&g).unwrap();
330        assert!(spec[0].abs() < 0.01);
331    }
332
333    #[test]
334    fn sls_path_bipartite_has_zero() {
335        let g = path4();
336        let spec = signless_laplacian_spectrum(&g).unwrap();
337        assert!(spec[0] < 0.01);
338    }
339
340    #[test]
341    fn sls_isolated_vertices() {
342        let g = Graph::with_vertices(3);
343        let spec = signless_laplacian_spectrum(&g).unwrap();
344        for &v in &spec {
345            assert!(v.abs() < 0.01);
346        }
347    }
348
349    #[test]
350    fn sls_directed_error() {
351        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
352        assert!(signless_laplacian_spectrum(&g).is_err());
353    }
354
355    // --- signless_laplacian_smallest ---
356
357    #[test]
358    fn slsmall_bipartite_zero() {
359        let g = k22();
360        let q1 = signless_laplacian_smallest(&g).unwrap();
361        assert!(q1.abs() < 0.01);
362    }
363
364    #[test]
365    fn slsmall_nonbipartite_positive() {
366        let g = k3();
367        let q1 = signless_laplacian_smallest(&g).unwrap();
368        assert!(q1 > 0.5);
369    }
370
371    #[test]
372    fn slsmall_single() {
373        let g = Graph::with_vertices(1);
374        let q1 = signless_laplacian_smallest(&g).unwrap();
375        assert!(q1.abs() < 1e-10);
376    }
377
378    // --- signless_laplacian_spectral_radius ---
379
380    #[test]
381    fn slsr_k3() {
382        let g = k3();
383        let qn = signless_laplacian_spectral_radius(&g).unwrap();
384        // Q eigenvalues {1,1,4} → q_max = 4
385        assert!((qn - 4.0).abs() < 0.1);
386    }
387
388    #[test]
389    fn slsr_k4() {
390        let g = k4();
391        let qn = signless_laplacian_spectral_radius(&g).unwrap();
392        // Q eigenvalues {2,2,2,6} → q_max = 6
393        assert!((qn - 6.0).abs() < 0.1);
394    }
395
396    #[test]
397    fn slsr_at_most_2maxdeg() {
398        let g = star4();
399        let qn = signless_laplacian_spectral_radius(&g).unwrap();
400        // max_degree = 3, so q_n ≤ 6
401        assert!(qn <= 6.01);
402    }
403
404    // --- signless_laplacian_energy ---
405
406    #[test]
407    fn sle_k3() {
408        let g = k3();
409        let qe = signless_laplacian_energy(&g).unwrap();
410        // eigenvalues {1,1,4}, mean=2, QE = |1-2|+|1-2|+|4-2| = 4
411        assert!((qe - 4.0).abs() < 0.1);
412    }
413
414    #[test]
415    fn sle_nonneg() {
416        let g = cycle4();
417        let qe = signless_laplacian_energy(&g).unwrap();
418        assert!(qe >= -1e-10);
419    }
420
421    #[test]
422    fn sle_empty() {
423        let g = Graph::with_vertices(0);
424        let qe = signless_laplacian_energy(&g).unwrap();
425        assert!(qe.abs() < 1e-10);
426    }
427
428    // --- cross-consistency ---
429
430    #[test]
431    fn trace_equals_twice_edges() {
432        let g = star4();
433        let spec = signless_laplacian_spectrum(&g).unwrap();
434        let trace: f64 = spec.iter().sum();
435        // tr(Q) = tr(D) + tr(A) = 2m + 0 = 2m (for simple graphs tr(A)=0)
436        let m = g.ecount() as f64;
437        assert!((trace - 2.0 * m).abs() < 0.1);
438    }
439
440    #[test]
441    fn q_and_l_share_same_trace() {
442        // tr(Q) = tr(L) = 2m since tr(D+A) = tr(D-A) + 2·tr(A)
443        // and tr(A) = 0 for simple graphs
444        let g = cycle4();
445        let q_spec = signless_laplacian_spectrum(&g).unwrap();
446        let q_trace: f64 = q_spec.iter().sum();
447        let m = g.ecount() as f64;
448        assert!((q_trace - 2.0 * m).abs() < 0.1);
449    }
450
451    #[test]
452    fn regular_q_eigenvalue_relation() {
453        // For a k-regular graph: q_i = k + λ_i where λ_i are adjacency eigenvalues
454        // C_4 is 2-regular, adjacency eigenvalues {2, 0, 0, -2}
455        // So Q eigenvalues should be {4, 2, 2, 0}
456        let g = cycle4();
457        let spec = signless_laplacian_spectrum(&g).unwrap();
458        // sorted ascending: {0, 2, 2, 4}
459        assert!(spec[0].abs() < 0.1);
460        assert!((spec[1] - 2.0).abs() < 0.1);
461        assert!((spec[2] - 2.0).abs() < 0.1);
462        assert!((spec[3] - 4.0).abs() < 0.1);
463    }
464}