Skip to main content

evolving_sheaf/
spectrum.rs

1use crate::graph::Graph;
2use crate::sheaf::SheafConfig;
3use crate::flow::FlowFn;
4use nalgebra::DMatrix;
5
6/// Computed spectrum at a single time point.
7#[derive(Debug, Clone)]
8pub struct Spectrum {
9    /// Time point.
10    pub t: f64,
11    /// All eigenvalues, sorted ascending.
12    pub eigenvalues: Vec<f64>,
13    /// Smallest nonzero eigenvalue (spectral gap λ₁).
14    pub lambda1: f64,
15    /// Second nonzero eigenvalue (λ₂).
16    pub lambda2: f64,
17    /// Largest eigenvalue.
18    pub max_eigenvalue: f64,
19    /// Trace of the Laplacian.
20    pub trace: f64,
21}
22
23impl Spectrum {
24    /// Number of eigenvalues.
25    pub fn n_eigenvalues(&self) -> usize {
26        self.eigenvalues.len()
27    }
28}
29
30/// Compute the Hodge Laplacian spectrum for the sheaf at time t.
31///
32/// The Laplacian is built as:
33///   L[i,i] += r², L[j,j] += r², L[i,j] -= r², L[j,i] -= r²
34/// for each edge (i,j) with restriction r = cfg.eval_restriction(flow(e, t)) * weight.
35pub fn compute_spectrum(
36    g: &Graph,
37    cfg: &SheafConfig,
38    flow: FlowFn,
39    t: f64,
40) -> Spectrum {
41    let n = g.n_vertices;
42    let mut laplacian = DMatrix::<f64>::zeros(n, n);
43
44    for (e_idx, edge) in g.edges.iter().enumerate() {
45        let i = edge.src;
46        let j = edge.dst;
47        let e_energy = flow(e_idx, t);
48        let r = cfg.eval_restriction(e_energy) * edge.weight;
49
50        laplacian[(i, i)] += r * r;
51        laplacian[(j, j)] += r * r;
52        laplacian[(i, j)] -= r * r;
53        laplacian[(j, i)] -= r * r;
54    }
55
56    // Use Jacobi rotation for symmetric eigenvalue decomposition
57    let eigenvalues = jacobi_eigenvalues(&laplacian, 100 * n);
58    let max_eigenvalue = eigenvalues[n - 1];
59    let trace: f64 = eigenvalues.iter().sum();
60
61    // Find λ₁ = smallest nonzero eigenvalue, λ₂ = second nonzero
62    let mut lambda1 = -1.0;
63    let mut lambda2 = -1.0;
64    let mut found = false;
65    for &ev in &eigenvalues {
66        if ev > 1e-10 {
67            if !found {
68                lambda1 = ev;
69                found = true;
70            } else {
71                lambda2 = ev;
72                break;
73            }
74        }
75    }
76    if lambda2 < 0.0 {
77        lambda2 = lambda1;
78    }
79
80    Spectrum {
81        t,
82        eigenvalues,
83        lambda1,
84        lambda2,
85        max_eigenvalue,
86        trace,
87    }
88}
89
90/// Jacobi rotation eigenvalue solver for symmetric matrices.
91///
92/// Iteratively zeroes out off-diagonal elements using Givens rotations.
93fn jacobi_eigenvalues(mat: &DMatrix<f64>, max_sweeps: usize) -> Vec<f64> {
94    let n = mat.nrows();
95    let mut a = mat.clone();
96
97    for _sweep in 0..max_sweeps {
98        // Find the largest off-diagonal element (above diagonal)
99        let mut max_off = 0.0;
100        let mut p = 0;
101        let mut q = 1;
102        for i in 0..n {
103            for j in (i + 1)..n {
104                let val = a[(i, j)].abs();
105                if val > max_off {
106                    max_off = val;
107                    p = i;
108                    q = j;
109                }
110            }
111        }
112
113        if max_off < 1e-14 * n as f64 {
114            break;
115        }
116
117        // Compute rotation angle
118        let app = a[(p, p)];
119        let aqq = a[(q, q)];
120        let apq = a[(p, q)];
121        let theta = if (app - aqq).abs() < 1e-30 {
122            std::f64::consts::FRAC_PI_4
123        } else {
124            0.5 * (2.0 * apq / (app - aqq)).atan()
125        };
126
127        let c = theta.cos();
128        let s = theta.sin();
129
130        // Apply rotation: A' = G^T A G
131        for i in 0..n {
132            if i == p || i == q {
133                continue;
134            }
135            let mip = a[(i, p)];
136            let miq = a[(i, q)];
137            a[(i, p)] = c * mip + s * miq;
138            a[(p, i)] = a[(i, p)];
139            a[(i, q)] = -s * mip + c * miq;
140            a[(q, i)] = a[(i, q)];
141        }
142
143        let new_pp = c * c * app + 2.0 * s * c * apq + s * s * aqq;
144        let new_qq = s * s * app - 2.0 * s * c * apq + c * c * aqq;
145
146        a[(p, p)] = new_pp;
147        a[(q, q)] = new_qq;
148        a[(p, q)] = 0.0;
149        a[(q, p)] = 0.0;
150    }
151
152    // Extract diagonal and sort
153    let mut evals: Vec<f64> = (0..n).map(|i| a[(i, i)]).collect();
154    evals.sort_by(|a, b| a.partial_cmp(b).unwrap());
155    evals
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::flow::flow_constant;
162    use crate::graph::Graph;
163
164    const TOL: f64 = 0.05;
165
166    #[test]
167    fn test_static_cycle4_known_values() {
168        let g = Graph::cycle(4);
169        let cfg = SheafConfig::static_sheaf(1.0);
170        let sp = compute_spectrum(&g, &cfg, flow_constant, 0.0);
171
172        assert_eq!(sp.n_eigenvalues(), 4);
173        assert!((sp.eigenvalues[0] - 0.0).abs() < TOL, "kernel eigenvalue ~ 0");
174        assert!((sp.lambda1 - 2.0).abs() < TOL, "λ₁ ≈ 2.0 for C4, got {}", sp.lambda1);
175        assert!((sp.max_eigenvalue - 4.0).abs() < TOL, "λ_max ≈ 4.0, got {}", sp.max_eigenvalue);
176    }
177
178    #[test]
179    fn test_static_trace_formula() {
180        let g = Graph::cycle(4);
181        let cfg = SheafConfig::static_sheaf(2.0);
182        let sp = compute_spectrum(&g, &cfg, flow_constant, 0.0);
183        // 4 edges, R=2: trace = 4 * 2 * 4 = 32
184        assert!((sp.trace - 32.0).abs() < 0.5, "trace ≈ 32, got {}", sp.trace);
185    }
186
187    #[test]
188    fn test_static_gap_scales_r0_squared() {
189        let g = Graph::cycle(4);
190        let sp1 = compute_spectrum(&g, &SheafConfig::static_sheaf(1.0), flow_constant, 0.0);
191        let sp2 = compute_spectrum(&g, &SheafConfig::static_sheaf(2.0), flow_constant, 0.0);
192        let ratio = sp2.lambda1 / sp1.lambda1;
193        assert!((ratio - 4.0).abs() < 0.1, "gap scales as R₀²: ratio={}", ratio);
194    }
195
196    #[test]
197    fn test_static_gap_constant_over_time() {
198        let g = Graph::cycle(4);
199        let cfg = SheafConfig::static_sheaf(1.0);
200        let gaps: Vec<f64> = (0..5)
201            .map(|i| {
202                let sp = compute_spectrum(&g, &cfg, crate::flow::flow_sinusoidal, i as f64 * 2.0);
203                sp.lambda1
204            })
205            .collect();
206        for i in 1..5 {
207            assert!((gaps[i] - gaps[0]).abs() < 0.001,
208                "static gap constant over time: i={}", i);
209        }
210    }
211
212    #[test]
213    fn test_single_edge() {
214        let edges = vec![crate::graph::Edge::new(0, 1, 1.0)];
215        let g = Graph::new(2, edges);
216        let cfg = SheafConfig::static_sheaf(1.0);
217        let sp = compute_spectrum(&g, &cfg, flow_constant, 0.0);
218        assert_eq!(sp.n_eigenvalues(), 2);
219        assert!((sp.eigenvalues[0] - 0.0).abs() < TOL, "kernel");
220        assert!(sp.lambda1 > 0.0, "positive gap");
221    }
222
223    #[test]
224    fn test_large_cycle_20() {
225        let g = Graph::cycle(20);
226        let cfg = SheafConfig::linear(1.0, 0.3);
227        let sp = compute_spectrum(&g, &cfg, crate::flow::flow_sinusoidal, 2.0);
228        assert_eq!(sp.n_eigenvalues(), 20);
229        assert!(sp.lambda1 > 0.0, "positive gap");
230    }
231
232    #[test]
233    fn test_path_gap() {
234        let g = Graph::path(4);
235        let cfg = SheafConfig::static_sheaf(1.0);
236        let sp = compute_spectrum(&g, &cfg, flow_constant, 0.0);
237        assert!(sp.lambda1 > 0.0);
238        assert!(sp.lambda1 < 2.0, "path gap < cycle gap for same |V|");
239    }
240
241    #[test]
242    fn test_complete_gap() {
243        let g = Graph::complete(5);
244        let cfg = SheafConfig::static_sheaf(1.0);
245        let sp = compute_spectrum(&g, &cfg, flow_constant, 0.0);
246        assert!(sp.lambda1 > 3.0, "K5 gap > 3.0 (good expansion)");
247    }
248
249    #[test]
250    fn test_complete_vs_cycle_gap() {
251        let c4 = Graph::cycle(4);
252        let k4 = Graph::complete(4);
253        let cfg = SheafConfig::static_sheaf(1.0);
254        let cs = compute_spectrum(&c4, &cfg, flow_constant, 0.0);
255        let ks = compute_spectrum(&k4, &cfg, flow_constant, 0.0);
256        assert!(ks.lambda1 > cs.lambda1, "K4 gap > C4 gap");
257    }
258
259    #[test]
260    fn test_linear_gap_differs_from_static() {
261        let g = Graph::cycle(4);
262        let ss = compute_spectrum(&g, &SheafConfig::static_sheaf(1.0),
263                                  crate::flow::flow_sinusoidal, 5.0);
264        let ls = compute_spectrum(&g, &SheafConfig::linear(1.0, 0.5),
265                                  crate::flow::flow_sinusoidal, 5.0);
266        assert!((ls.lambda1 - ss.lambda1).abs() > 0.01,
267                "linear evolving gap differs from static: diff={}",
268                (ls.lambda1 - ss.lambda1).abs());
269    }
270
271    #[test]
272    fn test_linear_alpha_zero_equals_static() {
273        let g = Graph::cycle(4);
274        let ss = compute_spectrum(&g, &SheafConfig::static_sheaf(1.0),
275                                  crate::flow::flow_sinusoidal, 3.0);
276        let ls = compute_spectrum(&g, &SheafConfig::linear(1.0, 0.0),
277                                  crate::flow::flow_sinusoidal, 3.0);
278        assert!((ss.lambda1 - ls.lambda1).abs() < 0.001, "α=0 → same as static");
279    }
280
281    #[test]
282    fn test_nonlinear_sigmoid_vs_static() {
283        let g = Graph::cycle(4);
284        let cfg = SheafConfig::nonlinear(2.0, crate::sheaf::NonlinearFn::Sigmoid, 2.0);
285        let sp = compute_spectrum(&g, &cfg, crate::flow::flow_sinusoidal, 2.0);
286        assert!(sp.lambda1 > 0.0, "sigmoid gap > 0");
287    }
288
289    #[test]
290    fn test_nonlinear_tanh_gap() {
291        let g = Graph::cycle(4);
292        let cfg = SheafConfig::nonlinear(2.0, crate::sheaf::NonlinearFn::Tanh, 1.0);
293        let sp = compute_spectrum(&g, &cfg, crate::flow::flow_sinusoidal, 0.0);
294        assert!(sp.lambda1 > 0.0);
295    }
296
297    #[test]
298    fn test_spectrum_ordering() {
299        let g = Graph::cycle(4);
300        let cfg = SheafConfig::static_sheaf(1.0);
301        let sp = compute_spectrum(&g, &cfg, flow_constant, 0.0);
302        for i in 1..sp.n_eigenvalues() {
303            assert!(sp.eigenvalues[i - 1] <= sp.eigenvalues[i] + 1e-10,
304                    "eigenvalues sorted ascending");
305        }
306    }
307
308    #[test]
309    fn test_symmetric_laplacian() {
310        let g = Graph::cycle(10);
311        let cfg = SheafConfig::linear(1.0, 0.5);
312        let n = g.n_vertices;
313        let mut lap = DMatrix::<f64>::zeros(n, n);
314
315        for (e_idx, edge) in g.edges.iter().enumerate() {
316            let e_energy = crate::flow::flow_sinusoidal(e_idx, 1.0);
317            let r = cfg.eval_restriction(e_energy) * edge.weight;
318            lap[(edge.src, edge.src)] += r * r;
319            lap[(edge.dst, edge.dst)] += r * r;
320            lap[(edge.src, edge.dst)] -= r * r;
321            lap[(edge.dst, edge.src)] -= r * r;
322        }
323
324        // Upper and lower parts should match
325        for i in 0..n {
326            for j in 0..n {
327                assert!((lap[(i, j)] - lap[(j, i)]).abs() < 1e-12,
328                        "Laplacian symmetric at ({},{})", i, j);
329            }
330        }
331    }
332}