Skip to main content

cjc_runtime/
sparse_eigen.rs

1//! Sparse Eigenvalue Solvers — Lanczos and Arnoldi
2//!
3//! Deterministic sparse eigenvalue extraction for symmetric (Lanczos) and
4//! general (Arnoldi) matrices. All operations use Kahan summation for
5//! order-invariant deterministic reductions.
6
7use crate::sparse::SparseCsr;
8use cjc_repro::kahan_sum_f64;
9
10// ---------------------------------------------------------------------------
11// Lanczos Algorithm (Symmetric Matrices)
12// ---------------------------------------------------------------------------
13
14/// Result of a Lanczos eigenvalue computation.
15#[derive(Debug, Clone)]
16pub struct LanczosResult {
17    /// Eigenvalues (sorted ascending).
18    pub eigenvalues: Vec<f64>,
19    /// Eigenvectors as rows (each row is an eigenvector).
20    pub eigenvectors: Vec<Vec<f64>>,
21}
22
23/// Compute the `k` largest eigenvalues of a symmetric sparse matrix using
24/// the Lanczos algorithm.
25///
26/// # Arguments
27/// * `matrix` - Symmetric sparse CSR matrix
28/// * `k` - Number of eigenvalues to compute (must be <= matrix dimension)
29/// * `max_iter` - Maximum Lanczos iterations (typically 2*k to 10*k)
30///
31/// # Returns
32/// `LanczosResult` with eigenvalues and corresponding eigenvectors.
33///
34/// # Determinism
35/// Uses Kahan summation for all dot products and norms.
36/// Result is deterministic for the same input.
37pub fn lanczos_eigsh(matrix: &SparseCsr, k: usize, max_iter: usize) -> LanczosResult {
38    let n = matrix.nrows;
39    assert_eq!(n, matrix.ncols, "lanczos_eigsh: matrix must be square");
40    assert!(k > 0 && k <= n, "lanczos_eigsh: k must be in 1..=n");
41
42    let iters = max_iter.min(n);
43
44    // Lanczos vectors (orthonormal basis)
45    let mut v_prev = vec![0.0_f64; n];
46    // Start with uniform vector (deterministic, has components along all eigenvectors)
47    let inv_sqrt_n = 1.0 / (n as f64).sqrt();
48    let mut v_curr = vec![inv_sqrt_n; n];
49
50    // Tridiagonal matrix elements
51    let mut alpha = Vec::with_capacity(iters); // diagonal
52    let mut beta = Vec::with_capacity(iters);  // off-diagonal
53
54    for j in 0..iters {
55        // w = A * v_j
56        let w_raw = matrix.matvec(&v_curr).unwrap();
57        let mut w = w_raw;
58
59        // alpha_j = v_j^T * w
60        let dot_products: Vec<f64> = v_curr.iter().zip(w.iter())
61            .map(|(&a, &b)| a * b)
62            .collect();
63        let alpha_j = kahan_sum_f64(&dot_products);
64        alpha.push(alpha_j);
65
66        // w = w - alpha_j * v_j - beta_{j-1} * v_{j-1}
67        let beta_prev = if j > 0 { beta[j - 1] } else { 0.0 };
68        for i in 0..n {
69            w[i] -= alpha_j * v_curr[i] + beta_prev * v_prev[i];
70        }
71
72        // beta_j = ||w||
73        let norm_sq: Vec<f64> = w.iter().map(|&x| x * x).collect();
74        let beta_j = kahan_sum_f64(&norm_sq).sqrt();
75
76        if beta_j < 1e-14 {
77            // Invariant subspace found
78            break;
79        }
80        beta.push(beta_j);
81
82        // v_{j+1} = w / beta_j
83        v_prev = v_curr;
84        v_curr = w.iter().map(|&x| x / beta_j).collect();
85    }
86
87    // Solve tridiagonal eigenvalue problem using QR iteration
88    let (eigenvalues, _) = tridiagonal_qr(&alpha, &beta);
89
90    // Return the k largest eigenvalues (sorted ascending)
91    let mut sorted_evals: Vec<f64> = eigenvalues;
92    sorted_evals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
93
94    let k_actual = k.min(sorted_evals.len());
95    let top_k: Vec<f64> = sorted_evals[sorted_evals.len() - k_actual..].to_vec();
96
97    LanczosResult {
98        eigenvalues: top_k,
99        eigenvectors: vec![], // Eigenvector recovery deferred to library layer
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Arnoldi Algorithm (General Matrices)
105// ---------------------------------------------------------------------------
106
107/// Result of an Arnoldi eigenvalue computation for a general (non-symmetric)
108/// sparse matrix.
109#[derive(Debug, Clone)]
110pub struct ArnoldiResult {
111    /// Real parts of the Ritz eigenvalue approximations.
112    pub eigenvalues_real: Vec<f64>,
113    /// Imaginary parts of the Ritz eigenvalue approximations.
114    pub eigenvalues_imag: Vec<f64>,
115}
116
117/// Compute eigenvalue approximations of a general sparse matrix using
118/// the Arnoldi iteration to build an upper Hessenberg matrix, then
119/// extract eigenvalues from the Hessenberg form.
120///
121/// # Arguments
122/// * `matrix` - Sparse CSR matrix (square)
123/// * `k` - Number of Arnoldi iterations (determines subspace size)
124///
125/// # Returns
126/// `ArnoldiResult` with real and imaginary parts of eigenvalue approximations.
127pub fn arnoldi_eigs(matrix: &SparseCsr, k: usize) -> ArnoldiResult {
128    let n = matrix.nrows;
129    assert_eq!(n, matrix.ncols, "arnoldi_eigs: matrix must be square");
130    let m = k.min(n);
131
132    // Arnoldi vectors
133    let mut q: Vec<Vec<f64>> = Vec::with_capacity(m + 1);
134    let mut h: Vec<Vec<f64>> = vec![vec![0.0; m]; m + 1]; // Upper Hessenberg (m+1) x m
135
136    // q_0 = e_0 (deterministic starting vector)
137    let mut q0 = vec![0.0_f64; n];
138    q0[0] = 1.0;
139    q.push(q0);
140
141    for j in 0..m {
142        // w = A * q_j
143        let mut w = matrix.matvec(&q[j]).unwrap();
144
145        // Modified Gram-Schmidt
146        for i in 0..=j {
147            let dots: Vec<f64> = q[i].iter().zip(w.iter())
148                .map(|(&a, &b)| a * b)
149                .collect();
150            h[i][j] = kahan_sum_f64(&dots);
151            for l in 0..n {
152                w[l] -= h[i][j] * q[i][l];
153            }
154        }
155
156        // h_{j+1,j} = ||w||
157        let norm_sq: Vec<f64> = w.iter().map(|&x| x * x).collect();
158        let h_next = kahan_sum_f64(&norm_sq).sqrt();
159
160        if j + 1 < m + 1 {
161            h[j + 1][j] = h_next;
162        }
163
164        if h_next < 1e-14 {
165            break;
166        }
167
168        // q_{j+1} = w / h_{j+1,j}
169        let q_next: Vec<f64> = w.iter().map(|&x| x / h_next).collect();
170        q.push(q_next);
171    }
172
173    // Extract eigenvalues from the m x m upper Hessenberg matrix
174    // For now, use the diagonal as approximations (Ritz values)
175    let actual_m = (q.len() - 1).min(m);
176    let eigenvalues_real: Vec<f64> = (0..actual_m).map(|i| h[i][i]).collect();
177    let eigenvalues_imag = vec![0.0_f64; actual_m];
178
179    ArnoldiResult {
180        eigenvalues_real,
181        eigenvalues_imag,
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Tridiagonal QR Iteration (Internal)
187// ---------------------------------------------------------------------------
188
189/// Solve a symmetric tridiagonal eigenvalue problem using implicit QR iteration.
190///
191/// # Arguments
192/// * `diag` - Diagonal elements (alpha)
193/// * `offdiag` - Off-diagonal elements (beta)
194///
195/// # Returns
196/// (eigenvalues, number of iterations used)
197fn tridiagonal_qr(diag: &[f64], offdiag: &[f64]) -> (Vec<f64>, usize) {
198    let n = diag.len();
199    if n == 0 {
200        return (vec![], 0);
201    }
202    if n == 1 {
203        return (vec![diag[0]], 0);
204    }
205
206    let mut d = diag.to_vec();
207    let mut e = offdiag.to_vec();
208    // Pad e to length n (e[n-1] unused but simplifies indexing)
209    while e.len() < n {
210        e.push(0.0);
211    }
212
213    let max_iter = 30 * n;
214    let mut total_iters = 0;
215
216    // Implicit QL algorithm with Wilkinson shift (LAPACK tql1 approach)
217    for l_start in 0..n {
218        let mut iters_this = 0;
219        loop {
220            // Find the unreduced submatrix: smallest m >= l_start with e[m] negligible
221            let mut m = l_start;
222            while m < n - 1 {
223                let tst = d[m].abs() + d[m + 1].abs();
224                if e[m].abs() <= 1e-15 * tst {
225                    break;
226                }
227                m += 1;
228            }
229            if m == l_start {
230                break; // eigenvalue d[l_start] converged
231            }
232
233            iters_this += 1;
234            total_iters += 1;
235            if iters_this > max_iter {
236                break;
237            }
238
239            // Wilkinson shift
240            let mut g = (d[l_start + 1] - d[l_start]) / (2.0 * e[l_start]);
241            let r = g.hypot(1.0);
242            g = d[m] - d[l_start] + e[l_start] / (g + r.copysign(g));
243
244            let mut s = 1.0;
245            let mut c = 1.0;
246            let mut p = 0.0;
247            let mut early_break = false;
248
249            // Chase bulge from m-1 down to l_start
250            for i in (l_start..m).rev() {
251                let f = s * e[i];
252                let b = c * e[i];
253                let r = f.hypot(g);
254                e[i + 1] = r;
255                if r.abs() < 1e-30 {
256                    // Deflation
257                    d[i + 1] -= p;
258                    e[m] = 0.0;
259                    early_break = true;
260                    break;
261                }
262                s = f / r;
263                c = g / r;
264                g = d[i + 1] - p;
265                let r2 = (d[i] - g) * s + 2.0 * c * b;
266                p = s * r2;
267                d[i + 1] = g + p;
268                g = c * r2 - b;
269            }
270
271            if !early_break {
272                d[l_start] -= p;
273                e[l_start] = g;
274                e[m] = 0.0;
275            }
276        }
277    }
278
279    (d, total_iters)
280}
281
282// ---------------------------------------------------------------------------
283// Tests
284// ---------------------------------------------------------------------------
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::sparse::SparseCoo;
290
291    fn identity_csr(n: usize) -> SparseCsr {
292        let mut values = Vec::new();
293        let mut row_indices = Vec::new();
294        let mut col_indices = Vec::new();
295        for i in 0..n {
296            values.push(1.0);
297            row_indices.push(i);
298            col_indices.push(i);
299        }
300        let coo = SparseCoo::new(values, row_indices, col_indices, n, n);
301        SparseCsr::from_coo(&coo)
302    }
303
304    fn diagonal_csr(diag: &[f64]) -> SparseCsr {
305        let n = diag.len();
306        let mut values = Vec::new();
307        let mut row_indices = Vec::new();
308        let mut col_indices = Vec::new();
309        for (i, &d) in diag.iter().enumerate() {
310            values.push(d);
311            row_indices.push(i);
312            col_indices.push(i);
313        }
314        let coo = SparseCoo::new(values, row_indices, col_indices, n, n);
315        SparseCsr::from_coo(&coo)
316    }
317
318    #[test]
319    fn test_lanczos_identity() {
320        let mat = identity_csr(5);
321        let result = lanczos_eigsh(&mat, 3, 20);
322        // All eigenvalues of identity are 1.0
323        for &ev in &result.eigenvalues {
324            assert!((ev - 1.0).abs() < 1e-10, "expected 1.0, got {ev}");
325        }
326    }
327
328    #[test]
329    fn test_lanczos_diagonal() {
330        let mat = diagonal_csr(&[1.0, 2.0, 3.0, 4.0, 5.0]);
331        let result = lanczos_eigsh(&mat, 2, 50);
332        // Top 2 eigenvalues should be 4 and 5
333        assert!(result.eigenvalues.len() >= 2);
334        let top = &result.eigenvalues;
335        assert!((top[top.len() - 1] - 5.0).abs() < 1e-8,
336            "expected 5.0, got {}", top[top.len() - 1]);
337    }
338
339    #[test]
340    fn test_arnoldi_identity() {
341        let mat = identity_csr(4);
342        let result = arnoldi_eigs(&mat, 4);
343        // Arnoldi on identity should give eigenvalues close to 1.0
344        for &ev in &result.eigenvalues_real {
345            assert!((ev - 1.0).abs() < 1e-10, "expected ~1.0, got {ev}");
346        }
347    }
348
349    #[test]
350    fn test_tridiagonal_qr_simple() {
351        // 2x2 tridiagonal: [[3, 1], [1, 3]] -> eigenvalues 2 and 4
352        let (evals, _) = tridiagonal_qr(&[3.0, 3.0], &[1.0]);
353        let mut sorted = evals.clone();
354        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
355        assert!((sorted[0] - 2.0).abs() < 1e-10, "expected 2.0, got {}", sorted[0]);
356        assert!((sorted[1] - 4.0).abs() < 1e-10, "expected 4.0, got {}", sorted[1]);
357    }
358
359    #[test]
360    fn test_lanczos_deterministic() {
361        let mat = diagonal_csr(&[1.0, 3.0, 5.0, 7.0, 9.0]);
362        let r1 = lanczos_eigsh(&mat, 3, 30);
363        let r2 = lanczos_eigsh(&mat, 3, 30);
364        assert_eq!(r1.eigenvalues.len(), r2.eigenvalues.len());
365        for (a, b) in r1.eigenvalues.iter().zip(r2.eigenvalues.iter()) {
366            assert_eq!(a.to_bits(), b.to_bits(), "non-deterministic eigenvalue");
367        }
368    }
369}