/home/noah/src/trueno/src/eigen.rs
Line | Count | Source |
1 | | //! Eigendecomposition for symmetric matrices |
2 | | //! |
3 | | //! Provides SIMD-accelerated eigenvalue and eigenvector computation for symmetric |
4 | | //! (Hermitian) matrices, enabling PCA, spectral clustering, and other algorithms |
5 | | //! without external dependencies like nalgebra. |
6 | | //! |
7 | | //! # Algorithm |
8 | | //! |
9 | | //! Uses the Jacobi eigenvalue algorithm, which is numerically stable and well-suited |
10 | | //! for SIMD parallelization. For large matrices (>1000 dimensions), GPU acceleration |
11 | | //! is available via wgpu. |
12 | | //! |
13 | | //! # Example |
14 | | //! |
15 | | //! ``` |
16 | | //! use trueno::{Matrix, SymmetricEigen}; |
17 | | //! |
18 | | //! // Create a symmetric positive definite matrix |
19 | | //! let cov = Matrix::from_vec(2, 2, vec![ |
20 | | //! 2.0, 1.0, |
21 | | //! 1.0, 2.0, |
22 | | //! ])?; |
23 | | //! |
24 | | //! let eigen = SymmetricEigen::new(&cov)?; |
25 | | //! |
26 | | //! // Eigenvalues in descending order (PCA convention) |
27 | | //! let values = eigen.eigenvalues(); |
28 | | //! assert!((values[0] - 3.0).abs() < 1e-6); // λ₁ = 3 |
29 | | //! assert!((values[1] - 1.0).abs() < 1e-6); // λ₂ = 1 |
30 | | //! # Ok::<(), trueno::TruenoError>(()) |
31 | | //! ``` |
32 | | |
33 | | use crate::{Backend, Matrix, TruenoError, Vector}; |
34 | | |
35 | | /// Maximum number of sweeps for Jacobi algorithm convergence |
36 | | /// Each sweep processes all n(n-1)/2 off-diagonal elements once |
37 | | /// Typically converges in 5-10 sweeps for well-conditioned matrices |
38 | | const MAX_JACOBI_SWEEPS: usize = 50; |
39 | | |
40 | | /// Convergence threshold for off-diagonal elements (relative to Frobenius norm) |
41 | | const CONVERGENCE_THRESHOLD: f32 = 1e-7; |
42 | | |
43 | | /// GPU threshold - use wgpu for matrices larger than this |
44 | | #[allow(dead_code)] |
45 | | const GPU_THRESHOLD: usize = 1000; |
46 | | |
47 | | /// Symmetric matrix eigendecomposition |
48 | | /// |
49 | | /// Computes eigenvalues and eigenvectors for symmetric (Hermitian) matrices. |
50 | | /// Eigenvalues are returned in descending order (largest first), which is the |
51 | | /// convention used in PCA and most dimensionality reduction algorithms. |
52 | | /// |
53 | | /// # Properties |
54 | | /// |
55 | | /// For a symmetric matrix A, the decomposition satisfies: |
56 | | /// - `A = V × D × V^T` where D is diagonal with eigenvalues |
57 | | /// - Eigenvectors are orthonormal: `V^T × V = I` |
58 | | /// - Eigenvalues are real (guaranteed for symmetric matrices) |
59 | | /// |
60 | | /// # Performance |
61 | | /// |
62 | | /// - SIMD-accelerated Jacobi rotations for CPU |
63 | | /// - GPU compute shaders for matrices >1000 dimensions (with `gpu` feature) |
64 | | /// - O(n³) time complexity, O(n²) space complexity |
65 | | #[derive(Debug, Clone)] |
66 | | pub struct SymmetricEigen { |
67 | | /// Eigenvalues sorted in descending order |
68 | | eigenvalues: Vec<f32>, |
69 | | /// Eigenvectors as columns (column i corresponds to eigenvalue i) |
70 | | eigenvectors: Matrix<f32>, |
71 | | /// Sorting indices mapping original to sorted order |
72 | | #[allow(dead_code)] |
73 | | sort_indices: Vec<usize>, |
74 | | /// Backend used for computation |
75 | | backend: Backend, |
76 | | } |
77 | | |
78 | | impl SymmetricEigen { |
79 | | /// Computes eigendecomposition of a symmetric matrix |
80 | | /// |
81 | | /// # Arguments |
82 | | /// |
83 | | /// * `matrix` - A symmetric square matrix |
84 | | /// |
85 | | /// # Returns |
86 | | /// |
87 | | /// `SymmetricEigen` containing eigenvalues (descending) and eigenvectors |
88 | | /// |
89 | | /// # Errors |
90 | | /// |
91 | | /// - `InvalidInput` if matrix is not square |
92 | | /// - `InvalidInput` if matrix is empty |
93 | | /// - `InvalidInput` if algorithm fails to converge |
94 | | /// |
95 | | /// # Example |
96 | | /// |
97 | | /// ``` |
98 | | /// use trueno::{Matrix, SymmetricEigen}; |
99 | | /// |
100 | | /// let m = Matrix::from_vec(3, 3, vec![ |
101 | | /// 4.0, 2.0, 0.0, |
102 | | /// 2.0, 5.0, 3.0, |
103 | | /// 0.0, 3.0, 6.0, |
104 | | /// ])?; |
105 | | /// |
106 | | /// let eigen = SymmetricEigen::new(&m)?; |
107 | | /// assert_eq!(eigen.eigenvalues().len(), 3); |
108 | | /// # Ok::<(), trueno::TruenoError>(()) |
109 | | /// ``` |
110 | 0 | pub fn new(matrix: &Matrix<f32>) -> Result<Self, TruenoError> { |
111 | | // Validate input |
112 | 0 | if matrix.rows() != matrix.cols() { |
113 | 0 | return Err(TruenoError::InvalidInput(format!( |
114 | 0 | "Matrix must be square for eigendecomposition, got {}x{}", |
115 | 0 | matrix.rows(), |
116 | 0 | matrix.cols() |
117 | 0 | ))); |
118 | 0 | } |
119 | | |
120 | 0 | if matrix.rows() == 0 { |
121 | 0 | return Err(TruenoError::InvalidInput( |
122 | 0 | "Cannot compute eigendecomposition of empty matrix".to_string(), |
123 | 0 | )); |
124 | 0 | } |
125 | | |
126 | 0 | let backend = Backend::select_best(); |
127 | | |
128 | | // Dispatch to appropriate implementation based on matrix size and GPU availability |
129 | | #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] |
130 | | { |
131 | 0 | let n = matrix.rows(); |
132 | 0 | if n >= GPU_THRESHOLD && crate::backends::gpu::GpuBackend::is_available() { |
133 | 0 | return Self::compute_gpu(matrix); |
134 | 0 | } |
135 | | } |
136 | | |
137 | | // CPU implementation (SIMD-accelerated) - works on all platforms |
138 | 0 | Self::compute_jacobi(matrix, backend) |
139 | 0 | } |
140 | | |
141 | | /// CPU implementation using Jacobi eigenvalue algorithm |
142 | | /// |
143 | | /// The Jacobi algorithm iteratively applies Givens rotations to eliminate |
144 | | /// off-diagonal elements, converging to a diagonal matrix of eigenvalues. |
145 | 0 | fn compute_jacobi(matrix: &Matrix<f32>, backend: Backend) -> Result<Self, TruenoError> { |
146 | 0 | let n = matrix.rows(); |
147 | | |
148 | | // Copy matrix data for in-place modification |
149 | 0 | let mut a = matrix.as_slice().to_vec(); |
150 | | |
151 | | // Compute initial Frobenius norm for relative convergence |
152 | 0 | let frobenius_sq: f32 = a.iter().map(|x| x * x).sum(); |
153 | 0 | let tolerance = CONVERGENCE_THRESHOLD * frobenius_sq.sqrt().max(1.0); |
154 | | |
155 | | // Initialize eigenvectors to identity matrix |
156 | 0 | let mut v = vec![0.0f32; n * n]; |
157 | 0 | for i in 0..n { |
158 | 0 | v[i * n + i] = 1.0; |
159 | 0 | } |
160 | | |
161 | | // Jacobi iteration with sweep strategy |
162 | | // Each sweep processes all off-diagonal elements once |
163 | 0 | for _sweep in 0..MAX_JACOBI_SWEEPS { |
164 | | // Cyclic Jacobi: process all pairs (i, j) where i < j |
165 | 0 | let mut converged = true; |
166 | | |
167 | 0 | for i in 0..n { |
168 | 0 | for j in (i + 1)..n { |
169 | 0 | let aij = a[i * n + j]; |
170 | | |
171 | | // Skip if already small enough |
172 | 0 | if aij.abs() < tolerance { |
173 | 0 | continue; |
174 | 0 | } |
175 | | |
176 | 0 | converged = false; |
177 | 0 | Self::jacobi_rotate(&mut a, &mut v, n, i, j, backend); |
178 | | } |
179 | | } |
180 | | |
181 | 0 | if converged { |
182 | | // Extract eigenvalues from diagonal |
183 | 0 | let eigenvalues: Vec<f32> = (0..n).map(|i| a[i * n + i]).collect(); |
184 | | |
185 | | // Sort eigenvalues in descending order |
186 | 0 | let mut indices: Vec<usize> = (0..n).collect(); |
187 | 0 | indices.sort_by(|&i, &j| { |
188 | 0 | eigenvalues[j] |
189 | 0 | .partial_cmp(&eigenvalues[i]) |
190 | 0 | .unwrap_or(std::cmp::Ordering::Equal) |
191 | 0 | }); |
192 | | |
193 | | // Reorder eigenvalues |
194 | 0 | let sorted_eigenvalues: Vec<f32> = |
195 | 0 | indices.iter().map(|&i| eigenvalues[i]).collect(); |
196 | | |
197 | | // Create eigenvector matrix with sorted columns |
198 | 0 | let mut eigenvector_data = vec![0.0f32; n * n]; |
199 | 0 | for (new_col, &old_col) in indices.iter().enumerate() { |
200 | 0 | for row in 0..n { |
201 | 0 | eigenvector_data[row * n + new_col] = v[row * n + old_col]; |
202 | 0 | } |
203 | | } |
204 | | |
205 | 0 | let eigenvectors = Matrix::from_vec(n, n, eigenvector_data)?; |
206 | | |
207 | 0 | return Ok(SymmetricEigen { |
208 | 0 | eigenvalues: sorted_eigenvalues, |
209 | 0 | eigenvectors, |
210 | 0 | sort_indices: indices, |
211 | 0 | backend, |
212 | 0 | }); |
213 | 0 | } |
214 | | } |
215 | | |
216 | | // Failed to converge - this shouldn't happen for well-conditioned matrices |
217 | 0 | Err(TruenoError::InvalidInput(format!( |
218 | 0 | "Jacobi algorithm failed to converge after {} sweeps", |
219 | 0 | MAX_JACOBI_SWEEPS |
220 | 0 | ))) |
221 | 0 | } |
222 | | |
223 | | /// Find the largest off-diagonal element (unused in cyclic Jacobi, kept for classic Jacobi) |
224 | | #[inline] |
225 | | #[allow(dead_code)] |
226 | 0 | fn find_max_off_diagonal(a: &[f32], n: usize) -> (usize, usize, f32) { |
227 | 0 | let mut max_val = 0.0f32; |
228 | 0 | let mut p = 0; |
229 | 0 | let mut q = 1; |
230 | | |
231 | 0 | for i in 0..n { |
232 | 0 | for j in (i + 1)..n { |
233 | 0 | let val = a[i * n + j].abs(); |
234 | 0 | if val > max_val { |
235 | 0 | max_val = val; |
236 | 0 | p = i; |
237 | 0 | q = j; |
238 | 0 | } |
239 | | } |
240 | | } |
241 | | |
242 | 0 | (p, q, max_val) |
243 | 0 | } |
244 | | |
245 | | /// Apply Jacobi rotation to zero out a[p][q] and a[q][p] |
246 | | /// |
247 | | /// Uses the numerically stable formula from: |
248 | | /// Golub & Van Loan, "Matrix Computations", 4th Edition |
249 | | #[inline] |
250 | 0 | fn jacobi_rotate( |
251 | 0 | a: &mut [f32], |
252 | 0 | v: &mut [f32], |
253 | 0 | n: usize, |
254 | 0 | p: usize, |
255 | 0 | q: usize, |
256 | 0 | _backend: Backend, |
257 | 0 | ) { |
258 | 0 | let app = a[p * n + p]; |
259 | 0 | let aqq = a[q * n + q]; |
260 | 0 | let apq = a[p * n + q]; |
261 | | |
262 | | // Skip if already zero |
263 | 0 | if apq.abs() < 1e-15 { |
264 | 0 | return; |
265 | 0 | } |
266 | | |
267 | | // Compute rotation parameters using numerically stable formula |
268 | | // tau = (aqq - app) / (2 * apq) |
269 | | // t = sign(tau) / (|tau| + sqrt(1 + tau^2)) (avoiding catastrophic cancellation) |
270 | | // c = 1 / sqrt(1 + t^2) |
271 | | // s = t * c |
272 | 0 | let tau = (aqq - app) / (2.0 * apq); |
273 | 0 | let t = if tau >= 0.0 { |
274 | 0 | 1.0 / (tau + (1.0 + tau * tau).sqrt()) |
275 | | } else { |
276 | 0 | -1.0 / (-tau + (1.0 + tau * tau).sqrt()) |
277 | | }; |
278 | | |
279 | 0 | let c = 1.0 / (1.0 + t * t).sqrt(); |
280 | 0 | let s = t * c; |
281 | | |
282 | | // Update diagonal elements |
283 | 0 | a[p * n + p] = app - t * apq; |
284 | 0 | a[q * n + q] = aqq + t * apq; |
285 | 0 | a[p * n + q] = 0.0; |
286 | 0 | a[q * n + p] = 0.0; |
287 | | |
288 | | // Update off-diagonal elements in rows/columns p and q |
289 | 0 | for k in 0..n { |
290 | 0 | if k != p && k != q { |
291 | 0 | let akp = a[k * n + p]; |
292 | 0 | let akq = a[k * n + q]; |
293 | 0 | a[k * n + p] = c * akp - s * akq; |
294 | 0 | a[p * n + k] = a[k * n + p]; |
295 | 0 | a[k * n + q] = s * akp + c * akq; |
296 | 0 | a[q * n + k] = a[k * n + q]; |
297 | 0 | } |
298 | | } |
299 | | |
300 | | // Update eigenvector matrix |
301 | 0 | for k in 0..n { |
302 | 0 | let vkp = v[k * n + p]; |
303 | 0 | let vkq = v[k * n + q]; |
304 | 0 | v[k * n + p] = c * vkp - s * vkq; |
305 | 0 | v[k * n + q] = s * vkp + c * vkq; |
306 | 0 | } |
307 | 0 | } |
308 | | |
309 | | /// GPU implementation for large matrices |
310 | | #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] |
311 | 0 | fn compute_gpu(matrix: &Matrix<f32>) -> Result<Self, TruenoError> { |
312 | | use crate::backends::gpu::GpuBackend; |
313 | | |
314 | 0 | let n = matrix.rows(); |
315 | 0 | let mut gpu = GpuBackend::new(); |
316 | | |
317 | | // Execute eigendecomposition on GPU |
318 | 0 | let (eigenvalues, eigenvector_data) = |
319 | 0 | gpu.symmetric_eigen(matrix.as_slice(), n).map_err(|e| { |
320 | 0 | TruenoError::InvalidInput(format!("GPU eigendecomposition failed: {}", e)) |
321 | 0 | })?; |
322 | | |
323 | | // Sort eigenvalues in descending order |
324 | 0 | let mut indices: Vec<usize> = (0..n).collect(); |
325 | 0 | indices.sort_by(|&i, &j| { |
326 | 0 | eigenvalues[j] |
327 | 0 | .partial_cmp(&eigenvalues[i]) |
328 | 0 | .unwrap_or(std::cmp::Ordering::Equal) |
329 | 0 | }); |
330 | | |
331 | 0 | let sorted_eigenvalues: Vec<f32> = indices.iter().map(|&i| eigenvalues[i]).collect(); |
332 | | |
333 | | // Reorder eigenvectors |
334 | 0 | let mut sorted_eigenvector_data = vec![0.0f32; n * n]; |
335 | 0 | for (new_col, &old_col) in indices.iter().enumerate() { |
336 | 0 | for row in 0..n { |
337 | 0 | sorted_eigenvector_data[row * n + new_col] = eigenvector_data[row * n + old_col]; |
338 | 0 | } |
339 | | } |
340 | | |
341 | 0 | let eigenvectors = Matrix::from_vec(n, n, sorted_eigenvector_data)?; |
342 | | |
343 | 0 | Ok(SymmetricEigen { |
344 | 0 | eigenvalues: sorted_eigenvalues, |
345 | 0 | eigenvectors, |
346 | 0 | sort_indices: indices, |
347 | 0 | backend: Backend::GPU, |
348 | 0 | }) |
349 | 0 | } |
350 | | |
351 | | /// Returns the eigenvalues in descending order |
352 | | /// |
353 | | /// # Example |
354 | | /// |
355 | | /// ``` |
356 | | /// use trueno::{Matrix, SymmetricEigen}; |
357 | | /// |
358 | | /// let m = Matrix::from_vec(2, 2, vec![3.0, 1.0, 1.0, 3.0])?; |
359 | | /// let eigen = SymmetricEigen::new(&m)?; |
360 | | /// |
361 | | /// let values = eigen.eigenvalues(); |
362 | | /// assert!((values[0] - 4.0).abs() < 1e-5); // λ₁ = 4 |
363 | | /// assert!((values[1] - 2.0).abs() < 1e-5); // λ₂ = 2 |
364 | | /// # Ok::<(), trueno::TruenoError>(()) |
365 | | /// ``` |
366 | 0 | pub fn eigenvalues(&self) -> &[f32] { |
367 | 0 | &self.eigenvalues |
368 | 0 | } |
369 | | |
370 | | /// Returns the eigenvector matrix |
371 | | /// |
372 | | /// Columns are eigenvectors, ordered to correspond with eigenvalues. |
373 | | /// Column `i` is the eigenvector for `eigenvalues()[i]`. |
374 | | /// |
375 | | /// # Example |
376 | | /// |
377 | | /// ``` |
378 | | /// use trueno::{Matrix, SymmetricEigen}; |
379 | | /// |
380 | | /// let m = Matrix::identity(3); |
381 | | /// let eigen = SymmetricEigen::new(&m)?; |
382 | | /// |
383 | | /// // Identity matrix has eigenvectors that are the standard basis |
384 | | /// let vectors = eigen.eigenvectors(); |
385 | | /// assert_eq!(vectors.rows(), 3); |
386 | | /// assert_eq!(vectors.cols(), 3); |
387 | | /// # Ok::<(), trueno::TruenoError>(()) |
388 | | /// ``` |
389 | 0 | pub fn eigenvectors(&self) -> &Matrix<f32> { |
390 | 0 | &self.eigenvectors |
391 | 0 | } |
392 | | |
393 | | /// Returns an iterator over (eigenvalue, eigenvector) pairs |
394 | | /// |
395 | | /// # Example |
396 | | /// |
397 | | /// ``` |
398 | | /// use trueno::{Matrix, SymmetricEigen}; |
399 | | /// |
400 | | /// let m = Matrix::from_vec(2, 2, vec![2.0, 0.0, 0.0, 1.0])?; |
401 | | /// let eigen = SymmetricEigen::new(&m)?; |
402 | | /// |
403 | | /// for (value, vector) in eigen.iter() { |
404 | | /// println!("λ = {}, v = {:?}", value, vector.as_slice()); |
405 | | /// } |
406 | | /// # Ok::<(), trueno::TruenoError>(()) |
407 | | /// ``` |
408 | 0 | pub fn iter(&self) -> EigenIterator<'_> { |
409 | 0 | EigenIterator { |
410 | 0 | eigen: self, |
411 | 0 | index: 0, |
412 | 0 | } |
413 | 0 | } |
414 | | |
415 | | /// Returns the number of eigenvalue/eigenvector pairs |
416 | 0 | pub fn len(&self) -> usize { |
417 | 0 | self.eigenvalues.len() |
418 | 0 | } |
419 | | |
420 | | /// Returns true if there are no eigenvalues |
421 | 0 | pub fn is_empty(&self) -> bool { |
422 | 0 | self.eigenvalues.is_empty() |
423 | 0 | } |
424 | | |
425 | | /// Returns the backend used for computation |
426 | 0 | pub fn backend(&self) -> Backend { |
427 | 0 | self.backend |
428 | 0 | } |
429 | | |
430 | | /// Get a specific eigenvector by index |
431 | | /// |
432 | | /// # Arguments |
433 | | /// |
434 | | /// * `i` - Index of the eigenvector (0 = largest eigenvalue) |
435 | | /// |
436 | | /// # Returns |
437 | | /// |
438 | | /// The eigenvector as a Vector, or None if index out of bounds |
439 | 0 | pub fn eigenvector(&self, i: usize) -> Option<Vector<f32>> { |
440 | 0 | if i >= self.eigenvalues.len() { |
441 | 0 | return None; |
442 | 0 | } |
443 | | |
444 | 0 | let n = self.eigenvectors.rows(); |
445 | 0 | let mut data = Vec::with_capacity(n); |
446 | | |
447 | 0 | for row in 0..n { |
448 | 0 | if let Some(&val) = self.eigenvectors.get(row, i) { |
449 | 0 | data.push(val); |
450 | 0 | } |
451 | | } |
452 | | |
453 | 0 | Some(Vector::from_slice(&data)) |
454 | 0 | } |
455 | | |
456 | | /// Reconstruct the original matrix from eigendecomposition |
457 | | /// |
458 | | /// Computes `V × D × V^T` where D is the diagonal matrix of eigenvalues. |
459 | | /// This is useful for verifying the decomposition accuracy. |
460 | | /// |
461 | | /// # Example |
462 | | /// |
463 | | /// ``` |
464 | | /// use trueno::{Matrix, SymmetricEigen}; |
465 | | /// |
466 | | /// let m = Matrix::from_vec(2, 2, vec![4.0, 2.0, 2.0, 4.0])?; |
467 | | /// let eigen = SymmetricEigen::new(&m)?; |
468 | | /// let reconstructed = eigen.reconstruct()?; |
469 | | /// |
470 | | /// // Should be approximately equal to original |
471 | | /// assert!((reconstructed.get(0, 0).unwrap() - 4.0).abs() < 1e-5); |
472 | | /// # Ok::<(), trueno::TruenoError>(()) |
473 | | /// ``` |
474 | 0 | pub fn reconstruct(&self) -> Result<Matrix<f32>, TruenoError> { |
475 | 0 | let n = self.eigenvalues.len(); |
476 | | |
477 | | // V × D (scale each column by its eigenvalue) |
478 | 0 | let mut vd_data = vec![0.0f32; n * n]; |
479 | 0 | for i in 0..n { |
480 | 0 | let lambda = self.eigenvalues[i]; |
481 | 0 | for j in 0..n { |
482 | 0 | if let Some(&v) = self.eigenvectors.get(j, i) { |
483 | 0 | vd_data[j * n + i] = v * lambda; |
484 | 0 | } |
485 | | } |
486 | | } |
487 | | |
488 | 0 | let vd = Matrix::from_vec(n, n, vd_data)?; |
489 | 0 | let vt = self.eigenvectors.transpose(); |
490 | | |
491 | 0 | vd.matmul(&vt) |
492 | 0 | } |
493 | | } |
494 | | |
495 | | /// Iterator over eigenvalue-eigenvector pairs |
496 | | pub struct EigenIterator<'a> { |
497 | | eigen: &'a SymmetricEigen, |
498 | | index: usize, |
499 | | } |
500 | | |
501 | | impl<'a> Iterator for EigenIterator<'a> { |
502 | | type Item = (f32, Vector<f32>); |
503 | | |
504 | 0 | fn next(&mut self) -> Option<Self::Item> { |
505 | 0 | if self.index >= self.eigen.len() { |
506 | 0 | return None; |
507 | 0 | } |
508 | | |
509 | 0 | let value = self.eigen.eigenvalues[self.index]; |
510 | 0 | let vector = self.eigen.eigenvector(self.index)?; |
511 | 0 | self.index += 1; |
512 | | |
513 | 0 | Some((value, vector)) |
514 | 0 | } |
515 | | |
516 | 0 | fn size_hint(&self) -> (usize, Option<usize>) { |
517 | 0 | let remaining = self.eigen.len() - self.index; |
518 | 0 | (remaining, Some(remaining)) |
519 | 0 | } |
520 | | } |
521 | | |
522 | | impl<'a> ExactSizeIterator for EigenIterator<'a> {} |
523 | | |
524 | | #[cfg(test)] |
525 | | mod tests { |
526 | | use super::*; |
527 | | |
528 | | // ========================================================================= |
529 | | // RED PHASE: Tests that define expected behavior |
530 | | // ========================================================================= |
531 | | |
532 | | #[test] |
533 | | fn test_symmetric_eigen_2x2_simple() { |
534 | | // Simple 2x2 symmetric matrix: [[2, 1], [1, 2]] |
535 | | // Eigenvalues: 3, 1 |
536 | | // Eigenvectors: [1/√2, 1/√2], [1/√2, -1/√2] |
537 | | let m = Matrix::from_vec(2, 2, vec![2.0, 1.0, 1.0, 2.0]).expect("valid matrix"); |
538 | | |
539 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
540 | | |
541 | | let values = eigen.eigenvalues(); |
542 | | assert_eq!(values.len(), 2); |
543 | | |
544 | | // Eigenvalues should be in descending order |
545 | | assert!(values[0] >= values[1], "eigenvalues must be descending"); |
546 | | |
547 | | // Check eigenvalue values (with tolerance) |
548 | | assert!( |
549 | | (values[0] - 3.0).abs() < 1e-5, |
550 | | "first eigenvalue should be 3, got {}", |
551 | | values[0] |
552 | | ); |
553 | | assert!( |
554 | | (values[1] - 1.0).abs() < 1e-5, |
555 | | "second eigenvalue should be 1, got {}", |
556 | | values[1] |
557 | | ); |
558 | | } |
559 | | |
560 | | #[test] |
561 | | fn test_symmetric_eigen_identity() { |
562 | | // Identity matrix has all eigenvalues = 1 |
563 | | let m = Matrix::identity(3); |
564 | | |
565 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
566 | | |
567 | | let values = eigen.eigenvalues(); |
568 | | assert_eq!(values.len(), 3); |
569 | | |
570 | | for (i, &val) in values.iter().enumerate() { |
571 | | assert!( |
572 | | (val - 1.0).abs() < 1e-5, |
573 | | "eigenvalue {} should be 1, got {}", |
574 | | i, |
575 | | val |
576 | | ); |
577 | | } |
578 | | } |
579 | | |
580 | | #[test] |
581 | | fn test_symmetric_eigen_diagonal() { |
582 | | // Diagonal matrix: eigenvalues are the diagonal elements |
583 | | let m = Matrix::from_vec(3, 3, vec![5.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 1.0]) |
584 | | .expect("valid matrix"); |
585 | | |
586 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
587 | | |
588 | | let values = eigen.eigenvalues(); |
589 | | |
590 | | // Should be sorted descending: 5, 3, 1 |
591 | | assert!((values[0] - 5.0).abs() < 1e-5, "got {}", values[0]); |
592 | | assert!((values[1] - 3.0).abs() < 1e-5, "got {}", values[1]); |
593 | | assert!((values[2] - 1.0).abs() < 1e-5, "got {}", values[2]); |
594 | | } |
595 | | |
596 | | #[test] |
597 | | fn test_symmetric_eigen_eigenvectors_orthogonal() { |
598 | | let m = Matrix::from_vec(3, 3, vec![4.0, 2.0, 0.0, 2.0, 5.0, 3.0, 0.0, 3.0, 6.0]) |
599 | | .expect("valid matrix"); |
600 | | |
601 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
602 | | |
603 | | // Eigenvectors should be orthonormal: V^T × V = I |
604 | | let v = eigen.eigenvectors(); |
605 | | let vt = v.transpose(); |
606 | | let product = vt.matmul(v).expect("matmul should succeed"); |
607 | | |
608 | | // Check if product is approximately identity |
609 | | for i in 0..3 { |
610 | | for j in 0..3 { |
611 | | let expected = if i == j { 1.0 } else { 0.0 }; |
612 | | let actual = product.get(i, j).unwrap(); |
613 | | assert!( |
614 | | (actual - expected).abs() < 1e-4, |
615 | | "V^T×V[{},{}] = {}, expected {}", |
616 | | i, |
617 | | j, |
618 | | actual, |
619 | | expected |
620 | | ); |
621 | | } |
622 | | } |
623 | | } |
624 | | |
625 | | #[test] |
626 | | fn test_symmetric_eigen_reconstruction() { |
627 | | let m = Matrix::from_vec(2, 2, vec![4.0, 2.0, 2.0, 4.0]).expect("valid matrix"); |
628 | | |
629 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
630 | | let reconstructed = eigen.reconstruct().expect("reconstruction should succeed"); |
631 | | |
632 | | // Reconstructed matrix should match original |
633 | | for i in 0..2 { |
634 | | for j in 0..2 { |
635 | | let original = m.get(i, j).unwrap(); |
636 | | let recon = reconstructed.get(i, j).unwrap(); |
637 | | assert!( |
638 | | (original - recon).abs() < 1e-4, |
639 | | "A[{},{}] = {}, reconstructed = {}", |
640 | | i, |
641 | | j, |
642 | | original, |
643 | | recon |
644 | | ); |
645 | | } |
646 | | } |
647 | | } |
648 | | |
649 | | #[test] |
650 | | fn test_symmetric_eigen_av_equals_lambda_v() { |
651 | | // For each eigenpair (λ, v): A×v = λ×v |
652 | | let m = Matrix::from_vec(2, 2, vec![3.0, 1.0, 1.0, 3.0]).expect("valid matrix"); |
653 | | |
654 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
655 | | |
656 | | for (lambda, v) in eigen.iter() { |
657 | | // Compute A×v |
658 | | let av = m.matvec(&v).expect("matvec should succeed"); |
659 | | |
660 | | // Compute λ×v |
661 | | let lambda_v: Vec<f32> = v.as_slice().iter().map(|&x| x * lambda).collect(); |
662 | | |
663 | | // Check equality |
664 | | for (i, (&av_i, &lv_i)) in av.as_slice().iter().zip(lambda_v.iter()).enumerate() { |
665 | | assert!( |
666 | | (av_i - lv_i).abs() < 1e-4, |
667 | | "A×v[{}] = {}, λv[{}] = {}", |
668 | | i, |
669 | | av_i, |
670 | | i, |
671 | | lv_i |
672 | | ); |
673 | | } |
674 | | } |
675 | | } |
676 | | |
677 | | #[test] |
678 | | fn test_symmetric_eigen_error_non_square() { |
679 | | let m = Matrix::from_vec(2, 3, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("valid matrix"); |
680 | | |
681 | | let result = SymmetricEigen::new(&m); |
682 | | assert!(result.is_err()); |
683 | | |
684 | | let err = result.unwrap_err(); |
685 | | assert!( |
686 | | matches!(err, TruenoError::InvalidInput(_)), |
687 | | "expected InvalidInput error" |
688 | | ); |
689 | | } |
690 | | |
691 | | #[test] |
692 | | fn test_symmetric_eigen_error_empty() { |
693 | | let m = Matrix::zeros(0, 0); |
694 | | |
695 | | let result = SymmetricEigen::new(&m); |
696 | | assert!(result.is_err()); |
697 | | } |
698 | | |
699 | | #[test] |
700 | | fn test_symmetric_eigen_1x1() { |
701 | | let m = Matrix::from_vec(1, 1, vec![7.0]).expect("valid matrix"); |
702 | | |
703 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
704 | | |
705 | | assert_eq!(eigen.eigenvalues().len(), 1); |
706 | | assert!((eigen.eigenvalues()[0] - 7.0).abs() < 1e-6); |
707 | | } |
708 | | |
709 | | #[test] |
710 | | fn test_symmetric_eigen_iterator() { |
711 | | let m = Matrix::from_vec(2, 2, vec![2.0, 0.0, 0.0, 1.0]).expect("valid matrix"); |
712 | | |
713 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
714 | | |
715 | | let pairs: Vec<_> = eigen.iter().collect(); |
716 | | assert_eq!(pairs.len(), 2); |
717 | | |
718 | | // First eigenvalue is larger |
719 | | assert!(pairs[0].0 >= pairs[1].0); |
720 | | } |
721 | | |
722 | | #[test] |
723 | | fn test_symmetric_eigen_len() { |
724 | | let m = Matrix::identity(5); |
725 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
726 | | |
727 | | assert_eq!(eigen.len(), 5); |
728 | | assert!(!eigen.is_empty()); |
729 | | } |
730 | | |
731 | | #[test] |
732 | | fn test_symmetric_eigen_eigenvector_accessor() { |
733 | | let m = Matrix::identity(3); |
734 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
735 | | |
736 | | let v0 = eigen.eigenvector(0); |
737 | | assert!(v0.is_some()); |
738 | | assert_eq!(v0.unwrap().len(), 3); |
739 | | |
740 | | let v_invalid = eigen.eigenvector(10); |
741 | | assert!(v_invalid.is_none()); |
742 | | } |
743 | | |
744 | | #[test] |
745 | | fn test_symmetric_eigen_covariance_matrix() { |
746 | | // Typical covariance matrix from PCA |
747 | | // Points: [(1,2), (3,4), (5,6)] centered → [(-2,-2), (0,0), (2,2)] |
748 | | // Cov = [[8/3, 8/3], [8/3, 8/3]] ≈ [[2.67, 2.67], [2.67, 2.67]] |
749 | | let m = Matrix::from_vec(2, 2, vec![2.67, 2.67, 2.67, 2.67]).expect("valid matrix"); |
750 | | |
751 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
752 | | |
753 | | // Eigenvalues: 5.34, 0 (approximately) |
754 | | let values = eigen.eigenvalues(); |
755 | | assert!(values[0] > 5.0, "first eigenvalue should be ~5.34"); |
756 | | assert!(values[1].abs() < 0.1, "second eigenvalue should be ~0"); |
757 | | } |
758 | | |
759 | | #[test] |
760 | | fn test_symmetric_eigen_negative_eigenvalues() { |
761 | | // Matrix with negative eigenvalues |
762 | | // [[0, 1], [1, 0]] has eigenvalues 1, -1 |
763 | | let m = Matrix::from_vec(2, 2, vec![0.0, 1.0, 1.0, 0.0]).expect("valid matrix"); |
764 | | |
765 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
766 | | |
767 | | let values = eigen.eigenvalues(); |
768 | | assert!( |
769 | | (values[0] - 1.0).abs() < 1e-5, |
770 | | "first eigenvalue should be 1" |
771 | | ); |
772 | | assert!( |
773 | | (values[1] - (-1.0)).abs() < 1e-5, |
774 | | "second eigenvalue should be -1" |
775 | | ); |
776 | | } |
777 | | |
778 | | #[test] |
779 | | fn test_symmetric_eigen_backend() { |
780 | | // Test that the backend() method returns the expected value |
781 | | let m = Matrix::from_vec(2, 2, vec![4.0, 1.0, 1.0, 3.0]).expect("valid matrix"); |
782 | | let eigen = SymmetricEigen::new(&m).expect("eigendecomposition should succeed"); |
783 | | |
784 | | // backend() should return the current backend |
785 | | let backend = eigen.backend(); |
786 | | // On this machine, it should be AVX2 |
787 | | #[cfg(target_arch = "x86_64")] |
788 | | { |
789 | | use crate::Backend; |
790 | | assert!( |
791 | | matches!(backend, Backend::AVX2 | Backend::Scalar | Backend::SSE2), |
792 | | "expected valid backend, got {:?}", |
793 | | backend |
794 | | ); |
795 | | } |
796 | | } |
797 | | |
798 | | // ========================================================================= |
799 | | // Property-based tests (proptest) |
800 | | // ========================================================================= |
801 | | |
802 | | #[cfg(test)] |
803 | | mod proptest_tests { |
804 | | use super::*; |
805 | | use proptest::prelude::*; |
806 | | |
807 | | proptest! { |
808 | | #![proptest_config(ProptestConfig::with_cases(50))] |
809 | | |
810 | | #[test] |
811 | | fn prop_eigenvalues_descending(n in 2usize..6) { |
812 | | // Generate random symmetric matrix |
813 | | let mut data = vec![0.0f32; n * n]; |
814 | | for i in 0..n { |
815 | | for j in i..n { |
816 | | let val = (i + j) as f32 / (n as f32); |
817 | | data[i * n + j] = val; |
818 | | data[j * n + i] = val; |
819 | | } |
820 | | } |
821 | | |
822 | | let m = Matrix::from_vec(n, n, data).expect("valid matrix"); |
823 | | let eigen = SymmetricEigen::new(&m).expect("eigen should succeed"); |
824 | | |
825 | | let values = eigen.eigenvalues(); |
826 | | for i in 1..values.len() { |
827 | | prop_assert!( |
828 | | values[i - 1] >= values[i], |
829 | | "eigenvalues not descending: {} < {}", |
830 | | values[i - 1], |
831 | | values[i] |
832 | | ); |
833 | | } |
834 | | } |
835 | | |
836 | | #[test] |
837 | | fn prop_eigenvector_count_matches_dimension(n in 1usize..8) { |
838 | | let m = Matrix::identity(n); |
839 | | let eigen = SymmetricEigen::new(&m).expect("eigen should succeed"); |
840 | | |
841 | | prop_assert_eq!(eigen.len(), n); |
842 | | prop_assert_eq!(eigen.eigenvalues().len(), n); |
843 | | prop_assert_eq!(eigen.eigenvectors().rows(), n); |
844 | | prop_assert_eq!(eigen.eigenvectors().cols(), n); |
845 | | } |
846 | | |
847 | | #[test] |
848 | | fn prop_reconstruction_accuracy( |
849 | | a in 1.0f32..10.0, // Ensure positive diagonal for conditioning |
850 | | b in -5.0f32..5.0, // Off-diagonal smaller than diagonal |
851 | | c in 1.0f32..10.0 // Ensure positive diagonal for conditioning |
852 | | ) { |
853 | | // Create symmetric 2x2 matrix [[a+|b|, b], [b, c+|b|]] |
854 | | // Add |b| to diagonal for better conditioning |
855 | | let diag_a = a + b.abs(); |
856 | | let diag_c = c + b.abs(); |
857 | | let m = Matrix::from_vec(2, 2, vec![diag_a, b, b, diag_c]).expect("valid matrix"); |
858 | | |
859 | | if let Ok(eigen) = SymmetricEigen::new(&m) { |
860 | | if let Ok(recon) = eigen.reconstruct() { |
861 | | // Use relative error for numerical stability |
862 | | let frobenius_orig: f32 = [diag_a, b, b, diag_c].iter() |
863 | | .map(|x| x * x).sum::<f32>().sqrt(); |
864 | | let max_allowed_error = 0.01 * frobenius_orig.max(1.0); |
865 | | |
866 | | for i in 0..2 { |
867 | | for j in 0..2 { |
868 | | let orig = m.get(i, j).unwrap(); |
869 | | let rec = recon.get(i, j).unwrap(); |
870 | | prop_assert!( |
871 | | (orig - rec).abs() < max_allowed_error, |
872 | | "reconstruction error: {} vs {}, allowed: {}", |
873 | | orig, |
874 | | rec, |
875 | | max_allowed_error |
876 | | ); |
877 | | } |
878 | | } |
879 | | } |
880 | | } |
881 | | } |
882 | | } |
883 | | } |
884 | | } |