entropy_conservation/
spectrum.rs1use crate::flow::FlowNetwork;
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct SpectralAnalysis {
11 pub eigenvalues: Vec<f64>,
13 pub spectral_gap: f64,
15 pub algebraic_connectivity: f64,
17 pub mixing_time_bound: f64,
19 pub components: usize,
21}
22
23impl SpectralAnalysis {
24 pub fn from_network(network: &FlowNetwork) -> Self {
26 let n = network.modules.len();
27 if n == 0 {
28 return Self {
29 eigenvalues: vec![],
30 spectral_gap: 0.0,
31 algebraic_connectivity: 0.0,
32 mixing_time_bound: f64::INFINITY,
33 components: 0,
34 };
35 }
36
37 let laplacian = network.laplacian();
38 let eigenvalues = power_iteration_eigenvalues(&laplacian, n.min(20));
39
40 let components = eigenvalues.iter().filter(|&&e| e.abs() < 1e-8).count().max(1);
42
43 let spectral_gap = eigenvalues
45 .iter()
46 .filter(|&&e| e > 1e-8)
47 .cloned()
48 .next()
49 .unwrap_or(0.0);
50
51 let mixing_time_bound = if spectral_gap > 1e-12 {
53 (1.0 / spectral_gap) * (100.0_f64).ln()
54 } else {
55 f64::INFINITY
56 };
57
58 Self {
59 eigenvalues,
60 spectral_gap,
61 algebraic_connectivity: spectral_gap,
62 mixing_time_bound,
63 components,
64 }
65 }
66
67 pub fn from_adjacency(adj: &ndarray::Array2<f64>) -> Self {
69 let n = adj.nrows();
70 if n == 0 {
71 return Self {
72 eigenvalues: vec![],
73 spectral_gap: 0.0,
74 algebraic_connectivity: 0.0,
75 mixing_time_bound: f64::INFINITY,
76 components: 0,
77 };
78 }
79
80 let mut laplacian = ndarray::Array2::<f64>::zeros((n, n));
82 for i in 0..n {
83 let mut row_sum = 0.0;
84 for j in 0..n {
85 row_sum += adj[[i, j]];
86 }
87 laplacian[[i, i]] = row_sum;
88 for j in 0..n {
89 laplacian[[i, j]] -= adj[[i, j]];
90 }
91 }
92
93 let eigenvalues = power_iteration_eigenvalues(&laplacian, n.min(20));
94 let components = eigenvalues.iter().filter(|&&e| e.abs() < 1e-8).count().max(1);
95 let spectral_gap = eigenvalues
96 .iter()
97 .filter(|&&e| e > 1e-8)
98 .cloned()
99 .next()
100 .unwrap_or(0.0);
101 let mixing_time_bound = if spectral_gap > 1e-12 {
102 (1.0 / spectral_gap) * (100.0_f64).ln()
103 } else {
104 f64::INFINITY
105 };
106
107 Self {
108 eigenvalues,
109 spectral_gap,
110 algebraic_connectivity: spectral_gap,
111 mixing_time_bound,
112 components,
113 }
114 }
115
116 pub fn heat_kernel_trace(&self, t: f64) -> f64 {
120 self.eigenvalues.iter().map(|&lambda| (-t * lambda).exp()).sum()
121 }
122}
123
124fn power_iteration_eigenvalues(mat: &ndarray::Array2<f64>, max_eigs: usize) -> Vec<f64> {
127 let n = mat.nrows();
128 if n == 0 {
129 return vec![];
130 }
131
132 let mut a = mat.clone();
134 let iterations = 50;
135
136 for _ in 0..iterations {
137 let (q, r) = qr_decompose(&a);
139 a = r.dot(&q);
140 }
141
142 let mut eigs: Vec<f64> = (0..n).map(|i| a[[i, i]]).collect();
144
145 eigs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
148
149 eigs.truncate(max_eigs);
151 eigs
152}
153
154fn qr_decompose(a: &ndarray::Array2<f64>) -> (ndarray::Array2<f64>, ndarray::Array2<f64>) {
156 let n = a.nrows();
157 let m = a.ncols();
158 let mut q = ndarray::Array2::<f64>::zeros((n, m));
159 let mut r = ndarray::Array2::<f64>::zeros((m, m));
160
161 for j in 0..m {
162 let mut v: Vec<f64> = (0..n).map(|i| a[[i, j]]).collect();
164
165 for i in 0..j {
166 let dot: f64 = (0..n).map(|k| q[[k, i]] * v[k]).sum();
168 r[[i, j]] = dot;
169 for k in 0..n {
170 v[k] -= dot * q[[k, i]];
171 }
172 }
173
174 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
175 r[[j, j]] = norm;
176
177 if norm > 1e-15 {
178 for k in 0..n {
179 q[[k, j]] = v[k] / norm;
180 }
181 }
182 }
183
184 (q, r)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::flow::EntropyFlow;
191
192 fn cycle_network() -> FlowNetwork {
193 FlowNetwork::new(vec![
194 EntropyFlow { source: "A".into(), sink: "B".into(), rate: 1.0 },
195 EntropyFlow { source: "B".into(), sink: "C".into(), rate: 1.0 },
196 EntropyFlow { source: "C".into(), sink: "D".into(), rate: 1.0 },
197 EntropyFlow { source: "D".into(), sink: "A".into(), rate: 1.0 },
198 ])
199 }
200
201 #[test]
202 fn spectral_gap_positive_for_connected() {
203 let net = cycle_network();
204 let analysis = SpectralAnalysis::from_network(&net);
205 assert!(
206 analysis.spectral_gap > 0.01,
207 "connected graph should have positive spectral gap: {}",
208 analysis.spectral_gap
209 );
210 }
211
212 #[test]
213 fn one_component_for_connected() {
214 let net = cycle_network();
215 let analysis = SpectralAnalysis::from_network(&net);
216 assert_eq!(analysis.components, 1);
217 }
218
219 #[test]
220 fn mixing_time_finite_for_connected() {
221 let net = cycle_network();
222 let analysis = SpectralAnalysis::from_network(&net);
223 assert!(analysis.mixing_time_bound.is_finite());
224 }
225
226 #[test]
227 fn eigenvalues_non_negative() {
228 let net = cycle_network();
229 let analysis = SpectralAnalysis::from_network(&net);
230 for &e in &analysis.eigenvalues {
231 assert!(e >= -1e-6, "Laplacian eigenvalues must be ≥ 0, got {e}");
232 }
233 }
234
235 #[test]
236 fn smallest_eigenvalue_is_zero() {
237 let net = cycle_network();
238 let analysis = SpectralAnalysis::from_network(&net);
239 assert!(
240 analysis.eigenvalues[0].abs() < 0.1,
241 "smallest Laplacian eigenvalue should be ≈ 0"
242 );
243 }
244
245 #[test]
246 fn heat_kernel_trace_decreases() {
247 let net = cycle_network();
248 let analysis = SpectralAnalysis::from_network(&net);
249 let trace_t1 = analysis.heat_kernel_trace(0.1);
250 let trace_t10 = analysis.heat_kernel_trace(1.0);
251 assert!(
252 trace_t10 <= trace_t1 + 1e-10,
253 "heat kernel trace should decrease with time"
254 );
255 }
256
257 #[test]
258 fn spectral_analysis_from_adjacency() {
259 let mut adj = ndarray::Array2::<f64>::zeros((3, 3));
260 adj[[0, 1]] = 1.0;
261 adj[[1, 0]] = 1.0;
262 adj[[1, 2]] = 1.0;
263 adj[[2, 1]] = 1.0;
264 let analysis = SpectralAnalysis::from_adjacency(&adj);
265 assert_eq!(analysis.components, 1);
266 assert!(analysis.eigenvalues[0].abs() < 0.5);
267 }
268}