1use crate::graph::Graph;
2use crate::sheaf::SheafConfig;
3use crate::flow::FlowFn;
4use nalgebra::DMatrix;
5
6#[derive(Debug, Clone)]
8pub struct Spectrum {
9 pub t: f64,
11 pub eigenvalues: Vec<f64>,
13 pub lambda1: f64,
15 pub lambda2: f64,
17 pub max_eigenvalue: f64,
19 pub trace: f64,
21}
22
23impl Spectrum {
24 pub fn n_eigenvalues(&self) -> usize {
26 self.eigenvalues.len()
27 }
28}
29
30pub 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 let eigenvalues = jacobi_eigenvalues(&laplacian, 100 * n);
58 let max_eigenvalue = eigenvalues[n - 1];
59 let trace: f64 = eigenvalues.iter().sum();
60
61 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
90fn 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 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 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 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 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 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 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}