Skip to main content

entropy_conservation/
spectrum.rs

1//! Spectral analysis of the entropy graph.
2//!
3//! Eigenvalues of the entropy Laplacian, heat kernel,
4//! and spectral gap bounds on mixing time.
5
6use crate::flow::FlowNetwork;
7
8/// Result of spectral analysis on the verification graph.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct SpectralAnalysis {
11    /// Eigenvalues of the normalised Laplacian, sorted ascending.
12    pub eigenvalues: Vec<f64>,
13    /// Spectral gap: λ₁ (the smallest non-zero eigenvalue).
14    pub spectral_gap: f64,
15    /// Algebraic connectivity (= λ₁ of the Laplacian).
16    pub algebraic_connectivity: f64,
17    /// Upper bound on mixing time from the spectral gap.
18    pub mixing_time_bound: f64,
19    /// Number of connected components (count of zero eigenvalues).
20    pub components: usize,
21}
22
23impl SpectralAnalysis {
24    /// Perform spectral analysis on a flow network's Laplacian.
25    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        // Count near-zero eigenvalues → connected components
41        let components = eigenvalues.iter().filter(|&&e| e.abs() < 1e-8).count().max(1);
42
43        // Spectral gap = smallest non-zero eigenvalue
44        let spectral_gap = eigenvalues
45            .iter()
46            .filter(|&&e| e > 1e-8)
47            .cloned()
48            .next()
49            .unwrap_or(0.0);
50
51        // Mixing time bound: τ ≤ (1/λ₁) log(1/ε)  with ε=0.01
52        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    /// Perform spectral analysis on an arbitrary adjacency matrix.
68    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        // Build Laplacian
81        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    /// Heat kernel: H(t) = exp(-tL). Approximated via eigendecomposition.
117    ///
118    /// Returns the heat kernel trace at time t.
119    pub fn heat_kernel_trace(&self, t: f64) -> f64 {
120        self.eigenvalues.iter().map(|&lambda| (-t * lambda).exp()).sum()
121    }
122}
123
124/// Compute eigenvalues using QR iteration (simplified).
125/// Returns eigenvalues sorted in ascending order.
126fn 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    // For small matrices, use direct QR iteration
133    let mut a = mat.clone();
134    let iterations = 50;
135
136    for _ in 0..iterations {
137        // QR decomposition via Gram-Schmidt
138        let (q, r) = qr_decompose(&a);
139        a = r.dot(&q);
140    }
141
142    // Extract diagonal as approximate eigenvalues
143    let mut eigs: Vec<f64> = (0..n).map(|i| a[[i, i]]).collect();
144
145    // Handle 2x2 blocks (complex eigenvalues → take real part)
146    // For simplicity, just sort and return
147    eigs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
148
149    // Truncate if needed
150    eigs.truncate(max_eigs);
151    eigs
152}
153
154/// Simple QR decomposition using modified Gram-Schmidt.
155fn 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        // v = a[:, j]
163        let mut v: Vec<f64> = (0..n).map(|i| a[[i, j]]).collect();
164
165        for i in 0..j {
166            // r[i,j] = q[:,i] · v
167            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}