Skip to main content

proof_engine/stochastic/
random_matrix.rs

1//! Random matrix theory: GOE, GUE, Wishart matrices, eigenvalue computation,
2//! and spectral distribution analysis (Wigner semicircle, Marchenko-Pastur).
3
4use super::brownian::Rng;
5use super::monte_carlo::Histogram;
6use glam::Vec2;
7
8// ---------------------------------------------------------------------------
9// RandomMatrix
10// ---------------------------------------------------------------------------
11
12/// A dense matrix stored in row-major order.
13pub struct RandomMatrix {
14    pub rows: usize,
15    pub cols: usize,
16    pub entries: Vec<f64>,
17}
18
19impl RandomMatrix {
20    /// Create a zero matrix.
21    pub fn zeros(rows: usize, cols: usize) -> Self {
22        Self {
23            rows,
24            cols,
25            entries: vec![0.0; rows * cols],
26        }
27    }
28
29    /// Access element (i, j).
30    pub fn get(&self, i: usize, j: usize) -> f64 {
31        self.entries[i * self.cols + j]
32    }
33
34    /// Set element (i, j).
35    pub fn set(&mut self, i: usize, j: usize, val: f64) {
36        self.entries[i * self.cols + j] = val;
37    }
38
39    /// Create an identity matrix.
40    pub fn identity(n: usize) -> Self {
41        let mut m = Self::zeros(n, n);
42        for i in 0..n {
43            m.set(i, i, 1.0);
44        }
45        m
46    }
47
48    /// Matrix multiplication.
49    pub fn mul(&self, other: &RandomMatrix) -> RandomMatrix {
50        assert_eq!(self.cols, other.rows);
51        let mut result = RandomMatrix::zeros(self.rows, other.cols);
52        for i in 0..self.rows {
53            for j in 0..other.cols {
54                let mut sum = 0.0;
55                for k in 0..self.cols {
56                    sum += self.get(i, k) * other.get(k, j);
57                }
58                result.set(i, j, sum);
59            }
60        }
61        result
62    }
63
64    /// Transpose.
65    pub fn transpose(&self) -> RandomMatrix {
66        let mut result = RandomMatrix::zeros(self.cols, self.rows);
67        for i in 0..self.rows {
68            for j in 0..self.cols {
69                result.set(j, i, self.get(i, j));
70            }
71        }
72        result
73    }
74
75    /// Frobenius norm.
76    pub fn frobenius_norm(&self) -> f64 {
77        self.entries.iter().map(|x| x * x).sum::<f64>().sqrt()
78    }
79
80    /// Trace (sum of diagonal elements).
81    pub fn trace(&self) -> f64 {
82        let n = self.rows.min(self.cols);
83        (0..n).map(|i| self.get(i, i)).sum()
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Random matrix ensembles
89// ---------------------------------------------------------------------------
90
91/// Gaussian Orthogonal Ensemble: symmetric matrix with N(0,1) off-diagonal
92/// and N(0,2) diagonal entries, divided by sqrt(2n).
93pub fn goe(n: usize, rng: &mut Rng) -> RandomMatrix {
94    let mut m = RandomMatrix::zeros(n, n);
95    let scale = 1.0 / (2.0 * n as f64).sqrt();
96    for i in 0..n {
97        m.set(i, i, rng.normal() * (2.0_f64).sqrt() * scale);
98        for j in (i + 1)..n {
99            let val = rng.normal() * scale;
100            m.set(i, j, val);
101            m.set(j, i, val);
102        }
103    }
104    m
105}
106
107/// Gaussian Unitary Ensemble (real approximation): symmetric matrix similar
108/// to GOE but with different normalization. For a real approximation, we
109/// use the same structure as GOE with scale 1/sqrt(n).
110pub fn gue(n: usize, rng: &mut Rng) -> RandomMatrix {
111    let mut m = RandomMatrix::zeros(n, n);
112    let scale = 1.0 / (n as f64).sqrt();
113    for i in 0..n {
114        m.set(i, i, rng.normal() * scale);
115        for j in (i + 1)..n {
116            let val = rng.normal() * scale / (2.0_f64).sqrt();
117            m.set(i, j, val);
118            m.set(j, i, val);
119        }
120    }
121    m
122}
123
124/// Wishart matrix: W = X^T * X / n where X is a p x n matrix with iid N(0,1).
125pub fn wishart(n: usize, p: usize, rng: &mut Rng) -> RandomMatrix {
126    let mut x = RandomMatrix::zeros(p, n);
127    for i in 0..p {
128        for j in 0..n {
129            x.set(i, j, rng.normal());
130        }
131    }
132    let xt = x.transpose();
133    let mut w = xt.mul(&x);
134    // Scale by 1/n
135    for v in w.entries.iter_mut() {
136        *v /= n as f64;
137    }
138    w
139}
140
141/// Generate a random correlation matrix of size n.
142pub fn random_correlation(n: usize, rng: &mut Rng) -> RandomMatrix {
143    // Use random vectors and compute their correlations
144    let samples = 5 * n;
145    let mut data = RandomMatrix::zeros(n, samples);
146    for i in 0..n {
147        for j in 0..samples {
148            data.set(i, j, rng.normal());
149        }
150    }
151    // Compute correlation matrix
152    let mut corr = RandomMatrix::zeros(n, n);
153    for i in 0..n {
154        let mean_i: f64 = (0..samples).map(|j| data.get(i, j)).sum::<f64>() / samples as f64;
155        let std_i: f64 = ((0..samples).map(|j| (data.get(i, j) - mean_i).powi(2)).sum::<f64>() / samples as f64).sqrt();
156        for k in i..n {
157            let mean_k: f64 = (0..samples).map(|j| data.get(k, j)).sum::<f64>() / samples as f64;
158            let std_k: f64 = ((0..samples).map(|j| (data.get(k, j) - mean_k).powi(2)).sum::<f64>() / samples as f64).sqrt();
159            let cov: f64 = (0..samples)
160                .map(|j| (data.get(i, j) - mean_i) * (data.get(k, j) - mean_k))
161                .sum::<f64>() / samples as f64;
162            let r = if std_i > 1e-10 && std_k > 1e-10 { cov / (std_i * std_k) } else { 0.0 };
163            corr.set(i, k, r);
164            corr.set(k, i, r);
165        }
166    }
167    corr
168}
169
170// ---------------------------------------------------------------------------
171// Eigenvalue computation (QR algorithm for real symmetric matrices)
172// ---------------------------------------------------------------------------
173
174/// Compute eigenvalues of a real symmetric matrix using the QR algorithm
175/// with Wilkinson shifts.
176pub fn eigenvalues(matrix: &RandomMatrix) -> Vec<f64> {
177    assert_eq!(matrix.rows, matrix.cols, "matrix must be square");
178    let n = matrix.rows;
179    if n == 0 {
180        return Vec::new();
181    }
182    if n == 1 {
183        return vec![matrix.get(0, 0)];
184    }
185
186    // First, reduce to tridiagonal form via Householder reflections.
187    let (diag, off_diag) = tridiagonalize(matrix);
188
189    // Then apply implicit QR on the tridiagonal matrix.
190    qr_tridiagonal(diag, off_diag)
191}
192
193/// Householder tridiagonalization of a symmetric matrix.
194/// Returns (diagonal, sub-diagonal) vectors.
195fn tridiagonalize(m: &RandomMatrix) -> (Vec<f64>, Vec<f64>) {
196    let n = m.rows;
197    let mut a = m.entries.clone();
198    let cols = n;
199
200    let get = |a: &[f64], i: usize, j: usize| a[i * cols + j];
201    let set = |a: &mut [f64], i: usize, j: usize, v: f64| a[i * cols + j] = v;
202
203    for k in 0..n.saturating_sub(2) {
204        // Compute Householder vector for column k, rows k+1..n
205        let mut x: Vec<f64> = (k + 1..n).map(|i| get(&a, i, k)).collect();
206        let x_norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
207        if x_norm < 1e-15 {
208            continue;
209        }
210        let sign = if x[0] >= 0.0 { 1.0 } else { -1.0 };
211        x[0] += sign * x_norm;
212        let x_new_norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
213        if x_new_norm < 1e-15 {
214            continue;
215        }
216        for v in x.iter_mut() {
217            *v /= x_new_norm;
218        }
219
220        // Apply P = I - 2*v*v^T from left and right: A <- P * A * P
221        let m_size = n - k - 1;
222
223        // Compute p = A_sub * v
224        let mut p = vec![0.0; m_size];
225        for i in 0..m_size {
226            for j in 0..m_size {
227                p[i] += get(&a, i + k + 1, j + k + 1) * x[j];
228            }
229        }
230
231        // K = 2, q = 2*p - 2*(v^T * p)*v
232        let vtp: f64 = x.iter().zip(p.iter()).map(|(a, b)| a * b).sum();
233        let mut q = vec![0.0; m_size];
234        for i in 0..m_size {
235            q[i] = 2.0 * p[i] - 2.0 * vtp * x[i];
236        }
237
238        // A_sub -= v*q^T + q*v^T
239        for i in 0..m_size {
240            for j in 0..m_size {
241                let val = get(&a, i + k + 1, j + k + 1) - 2.0 * (x[i] * q[j] + q[i] * x[j]) / 2.0;
242                // Correction: A <- A - 2*v*(p - (v^T*p)*v)^T - 2*(p - (v^T*p)*v)*v^T
243                // Actually let's use the simpler formula
244                let new_val = get(&a, i + k + 1, j + k + 1)
245                    - 2.0 * x[i] * p[j]
246                    - 2.0 * p[i] * x[j]
247                    + 4.0 * vtp * x[i] * x[j];
248                set(&mut a, i + k + 1, j + k + 1, new_val);
249            }
250        }
251
252        // Update the border elements
253        // A[k, k+1..n] and A[k+1..n, k]
254        let mut border: Vec<f64> = (0..m_size).map(|i| get(&a, k, i + k + 1)).collect();
255        let bv: f64 = border.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
256        for i in 0..m_size {
257            border[i] -= 2.0 * bv * x[i];
258        }
259        for i in 0..m_size {
260            set(&mut a, k, i + k + 1, border[i]);
261            set(&mut a, i + k + 1, k, border[i]);
262        }
263    }
264
265    let diag: Vec<f64> = (0..n).map(|i| get(&a, i, i)).collect();
266    let off_diag: Vec<f64> = (0..n.saturating_sub(1)).map(|i| get(&a, i, i + 1)).collect();
267    (diag, off_diag)
268}
269
270/// Implicit QR iteration on a tridiagonal matrix.
271fn qr_tridiagonal(mut diag: Vec<f64>, mut off: Vec<f64>) -> Vec<f64> {
272    let n = diag.len();
273    if n <= 1 {
274        return diag;
275    }
276
277    let max_iter = 100 * n;
278    let mut m = n;
279
280    for _ in 0..max_iter {
281        if m <= 1 {
282            break;
283        }
284
285        // Deflation: if off[m-2] is small, reduce problem size
286        while m > 1 && off[m - 2].abs() < 1e-12 * (diag[m - 2].abs() + diag[m - 1].abs()).max(1e-15) {
287            m -= 1;
288        }
289        if m <= 1 {
290            break;
291        }
292
293        // Wilkinson shift
294        let d = (diag[m - 2] - diag[m - 1]) / 2.0;
295        let sign_d = if d >= 0.0 { 1.0 } else { -1.0 };
296        let shift = diag[m - 1] - off[m - 2] * off[m - 2] / (d + sign_d * (d * d + off[m - 2] * off[m - 2]).sqrt());
297
298        // QR step with shift
299        let mut x = diag[0] - shift;
300        let mut z = off[0];
301
302        for k in 0..m - 1 {
303            // Givens rotation to zero out z
304            let r = (x * x + z * z).sqrt();
305            let c = x / r;
306            let s = z / r;
307
308            if k > 0 {
309                off[k - 1] = r;
310            }
311
312            let d1 = diag[k];
313            let d2 = diag[k + 1];
314            let e = off[k];
315
316            diag[k] = c * c * d1 + 2.0 * c * s * e + s * s * d2;
317            diag[k + 1] = s * s * d1 - 2.0 * c * s * e + c * c * d2;
318            off[k] = c * s * (d2 - d1) + (c * c - s * s) * e;
319
320            if k + 1 < m - 1 {
321                x = off[k + 1] * c + 0.0;
322                // Actually:
323                let old_off_next = off[k + 1];
324                x = off[k];
325                z = -s * old_off_next;
326                off[k + 1] = c * old_off_next;
327                x = off[k]; // re-read after modification
328            }
329        }
330    }
331
332    diag.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
333    diag
334}
335
336/// Compute eigenvalues using a simpler Jacobi rotation method for small symmetric matrices.
337/// More robust than QR for our purposes.
338pub fn eigenvalues_jacobi(matrix: &RandomMatrix) -> Vec<f64> {
339    assert_eq!(matrix.rows, matrix.cols);
340    let n = matrix.rows;
341    if n == 0 {
342        return Vec::new();
343    }
344
345    let mut a = matrix.entries.clone();
346    let max_iter = 100 * n * n;
347
348    for _ in 0..max_iter {
349        // Find largest off-diagonal element
350        let mut max_val = 0.0;
351        let mut p = 0;
352        let mut q = 1;
353        for i in 0..n {
354            for j in (i + 1)..n {
355                let v = a[i * n + j].abs();
356                if v > max_val {
357                    max_val = v;
358                    p = i;
359                    q = j;
360                }
361            }
362        }
363        if max_val < 1e-12 {
364            break;
365        }
366
367        // Compute rotation angle
368        let app = a[p * n + p];
369        let aqq = a[q * n + q];
370        let apq = a[p * n + q];
371
372        let theta = if (app - aqq).abs() < 1e-15 {
373            std::f64::consts::FRAC_PI_4
374        } else {
375            0.5 * (2.0 * apq / (app - aqq)).atan()
376        };
377
378        let c = theta.cos();
379        let s = theta.sin();
380
381        // Apply rotation
382        let mut new_a = a.clone();
383        for i in 0..n {
384            // Row/col p
385            let aip = a[i * n + p];
386            let aiq = a[i * n + q];
387            new_a[i * n + p] = c * aip + s * aiq;
388            new_a[i * n + q] = -s * aip + c * aiq;
389            new_a[p * n + i] = new_a[i * n + p];
390            new_a[q * n + i] = new_a[i * n + q];
391        }
392        new_a[p * n + p] = c * c * app + 2.0 * s * c * apq + s * s * aqq;
393        new_a[q * n + q] = s * s * app - 2.0 * s * c * apq + c * c * aqq;
394        new_a[p * n + q] = 0.0;
395        new_a[q * n + p] = 0.0;
396
397        a = new_a;
398    }
399
400    let mut eigs: Vec<f64> = (0..n).map(|i| a[i * n + i]).collect();
401    eigs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
402    eigs
403}
404
405// ---------------------------------------------------------------------------
406// Spectral analysis
407// ---------------------------------------------------------------------------
408
409/// Wigner semicircle law: eigenvalues of GOE(n) should follow
410/// rho(x) = (2/(pi*R^2)) * sqrt(R^2 - x^2) for |x| <= R, where R = 2.
411pub fn wigner_semicircle(eigenvalues: &[f64]) -> Histogram {
412    Histogram::from_samples_bounded(eigenvalues, 50, -2.5, 2.5)
413}
414
415/// Wigner semicircle theoretical density at x with radius R.
416pub fn semicircle_density(x: f64, r: f64) -> f64 {
417    if x.abs() > r {
418        return 0.0;
419    }
420    2.0 / (std::f64::consts::PI * r * r) * (r * r - x * x).sqrt()
421}
422
423/// Marchenko-Pastur distribution: eigenvalues of large Wishart matrices.
424/// For ratio gamma = p/n, support is [(1-sqrt(gamma))^2, (1+sqrt(gamma))^2].
425pub fn marchenko_pastur(eigenvalues: &[f64], ratio: f64) -> Histogram {
426    let lambda_min = (1.0 - ratio.sqrt()).powi(2);
427    let lambda_max = (1.0 + ratio.sqrt()).powi(2);
428    Histogram::from_samples_bounded(eigenvalues, 50, lambda_min * 0.5, lambda_max * 1.5)
429}
430
431/// Marchenko-Pastur theoretical density.
432pub fn marchenko_pastur_density(x: f64, ratio: f64) -> f64 {
433    let lambda_min = (1.0 - ratio.sqrt()).powi(2);
434    let lambda_max = (1.0 + ratio.sqrt()).powi(2);
435    if x < lambda_min || x > lambda_max || x <= 0.0 {
436        return 0.0;
437    }
438    let num = ((lambda_max - x) * (x - lambda_min)).sqrt();
439    num / (2.0 * std::f64::consts::PI * ratio * x)
440}
441
442/// Level spacing ratio: r_i = min(s_i, s_{i+1}) / max(s_i, s_{i+1})
443/// where s_i = lambda_{i+1} - lambda_i.
444pub fn level_spacing_ratios(eigenvalues: &[f64]) -> Vec<f64> {
445    if eigenvalues.len() < 3 {
446        return Vec::new();
447    }
448    let spacings: Vec<f64> = eigenvalues.windows(2).map(|w| w[1] - w[0]).collect();
449    spacings
450        .windows(2)
451        .map(|w| w[0].min(w[1]) / w[0].max(w[1]).max(1e-15))
452        .collect()
453}
454
455// ---------------------------------------------------------------------------
456// EigenvalueRenderer
457// ---------------------------------------------------------------------------
458
459/// Render eigenvalue distribution as a glyph histogram.
460pub struct EigenvalueRenderer {
461    pub bar_character: char,
462    pub bar_color: [f32; 4],
463    pub theory_character: char,
464    pub theory_color: [f32; 4],
465    pub x_scale: f32,
466    pub y_scale: f32,
467}
468
469impl EigenvalueRenderer {
470    pub fn new() -> Self {
471        Self {
472            bar_character: '█',
473            bar_color: [0.4, 0.6, 1.0, 0.8],
474            theory_character: '·',
475            theory_color: [1.0, 0.3, 0.3, 1.0],
476            x_scale: 0.3,
477            y_scale: 2.0,
478        }
479    }
480
481    /// Render empirical histogram.
482    pub fn render_histogram(&self, hist: &Histogram) -> Vec<(Vec2, char, [f32; 4])> {
483        let mut glyphs = Vec::new();
484        let max_count = hist.max_count().max(1) as f32;
485        for (i, &count) in hist.bins.iter().enumerate() {
486            let x = (i as f32 - hist.bin_count as f32 / 2.0) * self.x_scale;
487            let height = (count as f32 / max_count * 15.0) as usize;
488            for h in 0..height {
489                glyphs.push((
490                    Vec2::new(x, h as f32 * self.y_scale * 0.1),
491                    self.bar_character,
492                    self.bar_color,
493                ));
494            }
495        }
496        glyphs
497    }
498
499    /// Render with semicircle overlay.
500    pub fn render_with_semicircle(&self, hist: &Histogram, radius: f64) -> Vec<(Vec2, char, [f32; 4])> {
501        let mut glyphs = self.render_histogram(hist);
502
503        // Overlay semicircle
504        for i in 0..100 {
505            let x = -radius + 2.0 * radius * i as f64 / 100.0;
506            let density = semicircle_density(x, radius);
507            let pos = Vec2::new(x as f32 * self.x_scale * (hist.bin_count as f32 / (2.0 * radius as f32)),
508                                density as f32 * self.y_scale);
509            glyphs.push((pos, self.theory_character, self.theory_color));
510        }
511        glyphs
512    }
513}
514
515impl Default for EigenvalueRenderer {
516    fn default() -> Self {
517        Self::new()
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Tests
523// ---------------------------------------------------------------------------
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_goe_symmetric() {
531        let mut rng = Rng::new(42);
532        let m = goe(10, &mut rng);
533        for i in 0..10 {
534            for j in 0..10 {
535                assert!(
536                    (m.get(i, j) - m.get(j, i)).abs() < 1e-14,
537                    "GOE should be symmetric"
538                );
539            }
540        }
541    }
542
543    #[test]
544    fn test_gue_symmetric() {
545        let mut rng = Rng::new(42);
546        let m = gue(10, &mut rng);
547        for i in 0..10 {
548            for j in 0..10 {
549                assert!(
550                    (m.get(i, j) - m.get(j, i)).abs() < 1e-14,
551                    "GUE (real approx) should be symmetric"
552                );
553            }
554        }
555    }
556
557    #[test]
558    fn test_wishart_positive_semidefinite() {
559        // Wishart matrices should have non-negative eigenvalues
560        let mut rng = Rng::new(42);
561        let w = wishart(20, 10, &mut rng);
562        let eigs = eigenvalues_jacobi(&w);
563        for &e in &eigs {
564            assert!(
565                e > -0.1,
566                "Wishart eigenvalue should be >= 0, got {}",
567                e
568            );
569        }
570    }
571
572    #[test]
573    fn test_eigenvalues_identity() {
574        let id = RandomMatrix::identity(5);
575        let eigs = eigenvalues_jacobi(&id);
576        for &e in &eigs {
577            assert!((e - 1.0).abs() < 1e-6, "identity eigenvalue should be 1, got {}", e);
578        }
579    }
580
581    #[test]
582    fn test_eigenvalues_diagonal() {
583        let mut m = RandomMatrix::zeros(3, 3);
584        m.set(0, 0, 1.0);
585        m.set(1, 1, 3.0);
586        m.set(2, 2, 5.0);
587        let eigs = eigenvalues_jacobi(&m);
588        assert!((eigs[0] - 1.0).abs() < 1e-6);
589        assert!((eigs[1] - 3.0).abs() < 1e-6);
590        assert!((eigs[2] - 5.0).abs() < 1e-6);
591    }
592
593    #[test]
594    fn test_eigenvalues_2x2() {
595        // [[2, 1], [1, 2]] has eigenvalues 1 and 3
596        let mut m = RandomMatrix::zeros(2, 2);
597        m.set(0, 0, 2.0);
598        m.set(0, 1, 1.0);
599        m.set(1, 0, 1.0);
600        m.set(1, 1, 2.0);
601        let eigs = eigenvalues_jacobi(&m);
602        assert!((eigs[0] - 1.0).abs() < 1e-6, "got {}", eigs[0]);
603        assert!((eigs[1] - 3.0).abs() < 1e-6, "got {}", eigs[1]);
604    }
605
606    #[test]
607    fn test_wigner_semicircle_approximate() {
608        // Generate many GOE matrices, collect eigenvalues
609        let mut rng = Rng::new(42);
610        let mut all_eigs = Vec::new();
611        let n = 50;
612        let samples = 100;
613        for _ in 0..samples {
614            let m = goe(n, &mut rng);
615            let eigs = eigenvalues_jacobi(&m);
616            all_eigs.extend(eigs);
617        }
618
619        // Most eigenvalues should be within [-2, 2] (approximately)
620        let in_range = all_eigs.iter().filter(|&&e| e.abs() < 2.5).count();
621        let fraction = in_range as f64 / all_eigs.len() as f64;
622        assert!(
623            fraction > 0.9,
624            "most GOE eigenvalues should be in [-2.5, 2.5], fraction = {}",
625            fraction
626        );
627
628        let hist = wigner_semicircle(&all_eigs);
629        assert_eq!(hist.bins.len(), 50);
630    }
631
632    #[test]
633    fn test_semicircle_density() {
634        // Density at 0 should be 2/(pi*R^2) * R = 2/(pi*R)
635        let r = 2.0;
636        let d0 = semicircle_density(0.0, r);
637        let expected = 2.0 / (std::f64::consts::PI * r);
638        assert!((d0 - expected).abs() < 1e-10);
639
640        // Density outside support should be 0
641        assert_eq!(semicircle_density(3.0, 2.0), 0.0);
642    }
643
644    #[test]
645    fn test_matrix_operations() {
646        let mut a = RandomMatrix::zeros(2, 2);
647        a.set(0, 0, 1.0);
648        a.set(0, 1, 2.0);
649        a.set(1, 0, 3.0);
650        a.set(1, 1, 4.0);
651
652        assert!((a.trace() - 5.0).abs() < 1e-10);
653
654        let at = a.transpose();
655        assert!((at.get(0, 1) - 3.0).abs() < 1e-10);
656        assert!((at.get(1, 0) - 2.0).abs() < 1e-10);
657
658        let prod = a.mul(&RandomMatrix::identity(2));
659        assert!((prod.get(0, 0) - 1.0).abs() < 1e-10);
660    }
661
662    #[test]
663    fn test_marchenko_pastur_density() {
664        let ratio = 0.5;
665        let lambda_min = (1.0 - ratio.sqrt()).powi(2);
666        let lambda_max = (1.0 + ratio.sqrt()).powi(2);
667        // Density should be 0 outside support
668        assert_eq!(marchenko_pastur_density(0.0, ratio), 0.0);
669        assert_eq!(marchenko_pastur_density(lambda_max + 1.0, ratio), 0.0);
670        // Density should be positive inside support
671        let mid = (lambda_min + lambda_max) / 2.0;
672        assert!(marchenko_pastur_density(mid, ratio) > 0.0);
673    }
674
675    #[test]
676    fn test_renderer() {
677        let mut rng = Rng::new(42);
678        let m = goe(10, &mut rng);
679        let eigs = eigenvalues_jacobi(&m);
680        let hist = wigner_semicircle(&eigs);
681        let renderer = EigenvalueRenderer::new();
682        let glyphs = renderer.render_histogram(&hist);
683        assert!(!glyphs.is_empty());
684    }
685}