1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![allow(clippy::doc_markdown)]
4#![allow(clippy::large_stack_arrays)]
5#![allow(clippy::large_const_arrays)]
6#![allow(clippy::module_name_repetitions)]
7
8use num_rational::Ratio;
9use num_traits::{One, Zero};
10use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
11use std::fmt;
12
13pub type Rational = Ratio<i64>;
15
16const ATLAS_VERTEX_COUNT: usize = 96;
22
23const ATLAS_EDGE_COUNT: usize = 256;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct Label {
35 pub e1: u8,
37 pub e2: u8,
39 pub e3: u8,
41 pub d45: i8,
43 pub e6: u8,
45 pub e7: u8,
47}
48
49impl Label {
50 #[must_use]
56 pub const fn new(e1: u8, e2: u8, e3: u8, d45: i8, e6: u8, e7: u8) -> Self {
57 assert!(
58 e1 <= 1 && e2 <= 1 && e3 <= 1 && e6 <= 1 && e7 <= 1,
59 "Binary coordinates must be 0 or 1"
60 );
61 assert!(d45 >= -1 && d45 <= 1, "d45 must be in {{-1, 0, 1}}");
62 Self { e1, e2, e3, d45, e6, e7 }
63 }
64
65 #[must_use]
67 pub const fn mirror(&self) -> Self {
68 Self {
69 e1: self.e1,
70 e2: self.e2,
71 e3: self.e3,
72 d45: self.d45,
73 e6: self.e6,
74 e7: 1 - self.e7,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
92pub struct Atlas {
93 labels: Vec<Label>,
94 #[allow(dead_code)]
95 label_index: HashMap<Label, usize>,
96 adjacency: Vec<HashSet<usize>>,
97}
98
99impl Atlas {
100 #[must_use]
109 pub fn new() -> Self {
110 let labels = Self::generate_labels();
111 let label_index: HashMap<Label, usize> =
112 labels.iter().enumerate().map(|(i, &lab)| (lab, i)).collect();
113 let adjacency = Self::build_adjacency(&labels, &label_index);
114
115 let atlas = Self { labels, label_index, adjacency };
116 atlas.verify_invariants();
117 atlas
118 }
119
120 #[must_use]
122 pub fn num_vertices(&self) -> usize {
123 self.labels.len()
124 }
125
126 #[must_use]
128 pub fn num_edges(&self) -> usize {
129 let sum: usize = self.adjacency.iter().map(HashSet::len).sum();
130 sum / 2
131 }
132
133 #[must_use]
135 pub fn degree(&self, vertex: usize) -> usize {
136 self.adjacency[vertex].len()
137 }
138
139 #[must_use]
141 pub fn neighbors(&self, vertex: usize) -> &HashSet<usize> {
142 &self.adjacency[vertex]
143 }
144
145 #[must_use]
147 pub fn label(&self, vertex: usize) -> Label {
148 self.labels[vertex]
149 }
150
151 fn generate_labels() -> Vec<Label> {
154 let mut labels = Vec::with_capacity(ATLAS_VERTEX_COUNT);
155 for e1 in 0..=1u8 {
156 for e2 in 0..=1u8 {
157 for e3 in 0..=1u8 {
158 for e6 in 0..=1u8 {
159 for e7 in 0..=1u8 {
160 for d45 in -1..=1i8 {
161 labels.push(Label::new(e1, e2, e3, d45, e6, e7));
162 }
163 }
164 }
165 }
166 }
167 }
168 assert_eq!(labels.len(), ATLAS_VERTEX_COUNT);
169 labels
170 }
171
172 fn build_adjacency(
173 labels: &[Label],
174 label_index: &HashMap<Label, usize>,
175 ) -> Vec<HashSet<usize>> {
176 let mut adjacency = vec![HashSet::new(); labels.len()];
177 for (i, label) in labels.iter().enumerate() {
178 for neighbor_label in Self::compute_neighbors(*label) {
179 if let Some(&j) = label_index.get(&neighbor_label) {
180 if i != j {
181 adjacency[i].insert(j);
182 }
183 }
184 }
185 }
186 adjacency
187 }
188
189 fn compute_neighbors(label: Label) -> Vec<Label> {
190 let Label { e1, e2, e3, d45, e6, e7 } = label;
191 vec![
192 Label::new(1 - e1, e2, e3, d45, e6, e7),
193 Label::new(e1, 1 - e2, e3, d45, e6, e7),
194 Label::new(e1, e2, 1 - e3, d45, e6, e7),
195 Label::new(e1, e2, e3, d45, 1 - e6, e7),
196 Label::new(e1, e2, e3, Self::flip_d45_by_e4(d45), e6, e7),
197 Label::new(e1, e2, e3, Self::flip_d45_by_e5(d45), e6, e7),
198 ]
199 }
200
201 fn flip_d45_by_e4(d: i8) -> i8 {
203 match d {
204 -1 | 1 => 0,
205 0 => 1,
206 _ => panic!("d45 must be in {{-1, 0, 1}}"),
207 }
208 }
209
210 fn flip_d45_by_e5(d: i8) -> i8 {
212 match d {
213 -1 | 1 => 0,
214 0 => -1,
215 _ => panic!("d45 must be in {{-1, 0, 1}}"),
216 }
217 }
218
219 fn verify_invariants(&self) {
220 assert_eq!(self.labels.len(), ATLAS_VERTEX_COUNT, "Atlas must have 96 vertices");
221 assert_eq!(self.num_edges(), ATLAS_EDGE_COUNT, "Atlas must have 256 edges");
222 }
223}
224
225impl Default for Atlas {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231const HEMISPHERE_VERTICES: usize = 48;
237
238const Q4_VERTICES: usize = 16;
240
241const Q4_DEGREE: usize = 4;
243
244const Q4_EDGES: usize = 32;
246
247const Q4_SPECTRUM: [(i64, usize); 5] = [
252 (0, 1), (2, 4), (4, 6), (6, 4), (8, 1), ];
258
259#[derive(Clone)]
264struct DenseRatMatrix {
265 n: usize,
266 data: Vec<Rational>,
267}
268
269impl DenseRatMatrix {
270 fn new(n: usize) -> Self {
271 Self { n, data: vec![Rational::zero(); n * n] }
272 }
273
274 fn get(&self, i: usize, j: usize) -> Rational {
275 self.data[i * self.n + j]
276 }
277
278 fn set(&mut self, i: usize, j: usize, val: Rational) {
279 self.data[i * self.n + j] = val;
280 }
281
282 fn trace(&self) -> Rational {
283 (0..self.n).map(|i| self.get(i, i)).fold(Rational::zero(), |a, b| a + b)
284 }
285
286 fn is_symmetric(&self) -> bool {
287 for i in 0..self.n {
288 for j in (i + 1)..self.n {
289 if self.get(i, j) != self.get(j, i) {
290 return false;
291 }
292 }
293 }
294 true
295 }
296
297 fn row_sum(&self, i: usize) -> Rational {
298 (0..self.n).map(|j| self.get(i, j)).fold(Rational::zero(), |a, b| a + b)
299 }
300
301 fn frobenius_squared(&self) -> Rational {
302 self.data.iter().fold(Rational::zero(), |a, &v| a + v * v)
303 }
304
305 fn has_nonneg_diagonal(&self) -> bool {
306 (0..self.n).all(|i| self.get(i, i) >= Rational::zero())
307 }
308
309 fn has_nonpos_off_diagonal(&self) -> bool {
310 for i in 0..self.n {
311 for j in 0..self.n {
312 if i != j && self.get(i, j) > Rational::zero() {
313 return false;
314 }
315 }
316 }
317 true
318 }
319
320 fn from_atlas_subgraph(atlas: &Atlas, vertices: &[usize]) -> Self {
321 let n = vertices.len();
322 let mut mat = Self::new(n);
323 let index_map: HashMap<usize, usize> = vertices
324 .iter()
325 .enumerate()
326 .map(|(local, &atlas_idx)| (atlas_idx, local))
327 .collect();
328
329 for (i, &v) in vertices.iter().enumerate() {
330 let mut degree = 0i64;
331 for &neighbor in atlas.neighbors(v) {
332 if let Some(&j) = index_map.get(&neighbor) {
333 mat.set(i, j, -Rational::one());
334 degree += 1;
335 }
336 }
337 mat.set(i, i, Rational::from_integer(degree));
338 }
339 mat
340 }
341}
342
343impl fmt::Debug for DenseRatMatrix {
344 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345 write!(f, "DenseRatMatrix({}×{})", self.n, self.n)
346 }
347}
348
349fn det_3x3(m: &[[Rational; 3]; 3]) -> Rational {
355 m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
356 - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
357 + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
358}
359
360fn block_tridiagonal_matrix(nu: i64) -> [[Rational; 3]; 3] {
368 let n = Rational::from_integer(nu);
369 let one = Rational::one();
370 let two = Rational::new(2, 1);
371 let neg = -one;
372 let zero = Rational::zero();
373 [
374 [n + one, neg, zero],
375 [neg, n + two, neg],
376 [zero, neg, n + one],
377 ]
378}
379
380fn verify_block_eigenvalues(nu: i64) -> [Rational; 3] {
384 let m = block_tridiagonal_matrix(nu);
385 let nu_r = Rational::from_integer(nu);
386 let eigenvalues = [
387 nu_r,
388 nu_r + Rational::one(),
389 nu_r + Rational::new(3, 1),
390 ];
391 for &lambda in &eigenvalues {
392 let shifted = [
393 [m[0][0] - lambda, m[0][1], m[0][2]],
394 [m[1][0], m[1][1] - lambda, m[1][2]],
395 [m[2][0], m[2][1], m[2][2] - lambda],
396 ];
397 let det = det_3x3(&shifted);
398 assert!(det.is_zero(), "det(M_{nu} - {lambda}·I) must be 0, got {det}");
399 }
400 eigenvalues
401}
402
403fn decompose_hemispheres(atlas: &Atlas) -> [Vec<usize>; 2] {
408 let mut h0 = Vec::with_capacity(HEMISPHERE_VERTICES);
409 let mut h1 = Vec::with_capacity(HEMISPHERE_VERTICES);
410 for v in 0..atlas.num_vertices() {
411 if atlas.label(v).e7 == 0 { h0.push(v); } else { h1.push(v); }
412 }
413 assert_eq!(h0.len(), HEMISPHERE_VERTICES);
414 assert_eq!(h1.len(), HEMISPHERE_VERTICES);
415 [h0, h1]
416}
417
418fn verify_no_cross_hemisphere_edges(atlas: &Atlas, hemispheres: &[Vec<usize>; 2]) {
419 let h0_set: HashSet<usize> = hemispheres[0].iter().copied().collect();
420 for &v in &hemispheres[0] {
421 for &n in atlas.neighbors(v) {
422 assert!(h0_set.contains(&n), "Cross-hemisphere edge: {v} ~ {n}");
423 }
424 }
425}
426
427fn decompose_blocks(atlas: &Atlas, hemisphere: &[usize]) -> [Vec<usize>; 3] {
428 let mut b_neg = Vec::with_capacity(Q4_VERTICES);
429 let mut b_zero = Vec::with_capacity(Q4_VERTICES);
430 let mut b_pos = Vec::with_capacity(Q4_VERTICES);
431 for &v in hemisphere {
432 match atlas.label(v).d45 {
433 -1 => b_neg.push(v),
434 0 => b_zero.push(v),
435 1 => b_pos.push(v),
436 d => panic!("Invalid d45 value: {d}"),
437 }
438 }
439 assert_eq!(b_neg.len(), Q4_VERTICES);
440 assert_eq!(b_zero.len(), Q4_VERTICES);
441 assert_eq!(b_pos.len(), Q4_VERTICES);
442 [b_neg, b_zero, b_pos]
443}
444
445fn verify_q4_block(atlas: &Atlas, block: &[usize]) {
446 assert_eq!(block.len(), Q4_VERTICES);
447 let block_set: HashSet<usize> = block.iter().copied().collect();
448
449 for &v in block {
450 let within_degree = atlas.neighbors(v).iter().filter(|n| block_set.contains(n)).count();
451 assert_eq!(within_degree, Q4_DEGREE, "Q4 vertex {v} degree {within_degree} != {Q4_DEGREE}");
452 }
453
454 let mut visited = HashSet::new();
456 let mut queue = VecDeque::new();
457 visited.insert(block[0]);
458 queue.push_back(block[0]);
459 while let Some(v) = queue.pop_front() {
460 for &n in atlas.neighbors(v) {
461 if block_set.contains(&n) && visited.insert(n) {
462 queue.push_back(n);
463 }
464 }
465 }
466 assert_eq!(visited.len(), Q4_VERTICES, "Q4 block not connected");
467
468 for &v in block {
470 let vl = atlas.label(v);
471 let v_parity = (vl.e1 + vl.e2 + vl.e3 + vl.e6) % 2;
472 for &n in atlas.neighbors(v) {
473 if block_set.contains(&n) {
474 let nl = atlas.label(n);
475 let n_parity = (nl.e1 + nl.e2 + nl.e3 + nl.e6) % 2;
476 assert_ne!(v_parity, n_parity, "Q4 bipartite violation: {v} ~ {n}");
477 }
478 }
479 }
480}
481
482fn verify_inter_block_structure(atlas: &Atlas, blocks: &[Vec<usize>; 3]) {
483 let block_sets: [HashSet<usize>; 3] = [
484 blocks[0].iter().copied().collect(),
485 blocks[1].iter().copied().collect(),
486 blocks[2].iter().copied().collect(),
487 ];
488 verify_identity_matching(atlas, &blocks[0], &block_sets[1]);
489 verify_identity_matching(atlas, &blocks[2], &block_sets[1]);
490
491 for &v in &blocks[0] {
493 for &n in atlas.neighbors(v) {
494 assert!(!block_sets[2].contains(&n), "Skip edge: {v} ~ {n}");
495 }
496 }
497}
498
499fn verify_identity_matching(atlas: &Atlas, source: &[usize], target: &HashSet<usize>) {
500 for &v in source {
501 let inter: Vec<usize> = atlas.neighbors(v).iter().filter(|n| target.contains(n)).copied().collect();
502 assert_eq!(inter.len(), 1, "Vertex {v}: {} inter-block neighbors", inter.len());
503 let vl = atlas.label(v);
504 let nl = atlas.label(inter[0]);
505 assert_eq!(vl.e1, nl.e1);
506 assert_eq!(vl.e2, nl.e2);
507 assert_eq!(vl.e3, nl.e3);
508 assert_eq!(vl.e6, nl.e6);
509 }
510}
511
512#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
513fn verify_q4_spectrum(atlas: &Atlas, block: &[usize]) {
514 let block_set: HashSet<usize> = block.iter().copied().collect();
515 let mut degree_sum: usize = 0;
516 let mut degree_sq_sum: usize = 0;
517 for &v in block {
518 let deg = atlas.neighbors(v).iter().filter(|n| block_set.contains(n)).count();
519 degree_sum += deg;
520 degree_sq_sum += deg * deg;
521 }
522 let edge_count = degree_sum / 2;
523 assert_eq!(degree_sum, Q4_VERTICES * Q4_DEGREE);
524 assert_eq!(edge_count, Q4_EDGES);
525 let trace = degree_sum;
526 let expected_trace: usize = Q4_SPECTRUM.iter().map(|&(ev, mult)| ev as usize * mult).sum();
527 assert_eq!(trace, expected_trace);
528 let trace_sq = degree_sq_sum + 2 * edge_count;
529 let expected_trace_sq: usize = Q4_SPECTRUM.iter().map(|&(ev, mult)| (ev as usize) * (ev as usize) * mult).sum();
530 assert_eq!(trace_sq, expected_trace_sq);
531}
532
533fn compute_hemisphere_spectrum() -> Vec<(Rational, usize)> {
534 let mut spectrum_map: BTreeMap<Rational, usize> = BTreeMap::new();
535 for &(nu, q4_mult) in &Q4_SPECTRUM {
536 let block_eigs = verify_block_eigenvalues(nu);
537 for &eig in &block_eigs {
538 *spectrum_map.entry(eig).or_insert(0) += q4_mult;
539 }
540 }
541 spectrum_map.into_iter().collect()
542}
543
544#[derive(Debug, Clone)]
578pub struct SpectralAnalysis {
579 hemisphere_spectrum: Vec<(Rational, usize)>,
580 full_spectrum: Vec<(Rational, usize)>,
581 spectral_gap: Rational,
582 hemisphere_trace: Rational,
583 hemisphere_trace_squared: Rational,
584 max_eigenvalue: Rational,
585 #[allow(dead_code)]
586 hemisphere_laplacian: DenseRatMatrix,
587}
588
589impl SpectralAnalysis {
590 #[must_use]
600 pub fn from_atlas(atlas: &Atlas) -> Self {
601 assert_eq!(atlas.num_vertices(), 96);
602 assert_eq!(atlas.num_edges(), 256);
603
604 let hemispheres = decompose_hemispheres(atlas);
605 verify_no_cross_hemisphere_edges(atlas, &hemispheres);
606
607 let blocks = decompose_blocks(atlas, &hemispheres[0]);
608 for block in &blocks {
609 verify_q4_block(atlas, block);
610 verify_q4_spectrum(atlas, block);
611 }
612 verify_inter_block_structure(atlas, &blocks);
613
614 let hemisphere_spectrum = compute_hemisphere_spectrum();
615 let total_mult: usize = hemisphere_spectrum.iter().map(|(_, m)| *m).sum();
616 assert_eq!(total_mult, HEMISPHERE_VERTICES);
617
618 let spectral_gap = Self::find_spectral_gap(&hemisphere_spectrum);
619 let hemisphere_trace = Self::compute_trace(&hemisphere_spectrum);
620 let hemisphere_trace_squared = Self::compute_trace_squared(&hemisphere_spectrum);
621 let max_eigenvalue = Self::find_max_eigenvalue(&hemisphere_spectrum);
622
623 let hemisphere_laplacian = DenseRatMatrix::from_atlas_subgraph(atlas, &hemispheres[0]);
624
625 assert_eq!(hemisphere_laplacian.trace(), hemisphere_trace);
627 assert_eq!(hemisphere_laplacian.frobenius_squared(), hemisphere_trace_squared);
628 assert!(hemisphere_laplacian.is_symmetric());
629 for i in 0..HEMISPHERE_VERTICES {
630 assert!(hemisphere_laplacian.row_sum(i).is_zero());
631 }
632 assert!(hemisphere_laplacian.has_nonneg_diagonal());
633 assert!(hemisphere_laplacian.has_nonpos_off_diagonal());
634
635 let full_spectrum: Vec<(Rational, usize)> =
636 hemisphere_spectrum.iter().map(|&(ev, mult)| (ev, mult * 2)).collect();
637
638 Self {
639 hemisphere_spectrum,
640 full_spectrum,
641 spectral_gap,
642 hemisphere_trace,
643 hemisphere_trace_squared,
644 max_eigenvalue,
645 hemisphere_laplacian,
646 }
647 }
648
649 #[must_use]
653 pub const fn spectral_gap(&self) -> Rational {
654 self.spectral_gap
655 }
656
657 #[must_use]
665 pub fn hemisphere_spectrum(&self) -> &[(Rational, usize)] {
666 &self.hemisphere_spectrum
667 }
668
669 #[must_use]
673 pub fn full_spectrum(&self) -> &[(Rational, usize)] {
674 &self.full_spectrum
675 }
676
677 #[must_use]
679 pub const fn hemisphere_trace(&self) -> Rational {
680 self.hemisphere_trace
681 }
682
683 #[must_use]
685 pub const fn hemisphere_trace_squared(&self) -> Rational {
686 self.hemisphere_trace_squared
687 }
688
689 #[must_use]
691 pub const fn max_eigenvalue(&self) -> Rational {
692 self.max_eigenvalue
693 }
694
695 #[must_use]
697 pub fn num_distinct_eigenvalues(&self) -> usize {
698 self.hemisphere_spectrum.len()
699 }
700
701 #[must_use]
703 pub fn gershgorin_upper_bound(&self) -> Rational {
704 Rational::from_integer(12)
705 }
706
707 #[must_use]
709 pub fn all_eigenvalues_integer(&self) -> bool {
710 self.hemisphere_spectrum.iter().all(|(ev, _)| *ev.denom() == 1)
711 }
712
713 #[must_use]
719 pub fn block_matrix(&self, nu: i64) -> [[Rational; 3]; 3] {
720 assert!(Q4_SPECTRUM.iter().any(|&(ev, _)| ev == nu), "ν = {nu} is not a Q4 eigenvalue");
721 block_tridiagonal_matrix(nu)
722 }
723
724 fn find_spectral_gap(spectrum: &[(Rational, usize)]) -> Rational {
725 spectrum.iter().find(|(ev, _)| !ev.is_zero()).map(|(ev, _)| *ev).expect("no nonzero eigenvalue")
726 }
727
728 #[allow(clippy::cast_possible_wrap)]
729 fn compute_trace(spectrum: &[(Rational, usize)]) -> Rational {
730 spectrum.iter().fold(Rational::zero(), |acc, &(ev, mult)| acc + ev * Rational::from_integer(mult as i64))
731 }
732
733 #[allow(clippy::cast_possible_wrap)]
734 fn compute_trace_squared(spectrum: &[(Rational, usize)]) -> Rational {
735 spectrum.iter().fold(Rational::zero(), |acc, &(ev, mult)| acc + ev * ev * Rational::from_integer(mult as i64))
736 }
737
738 fn find_max_eigenvalue(spectrum: &[(Rational, usize)]) -> Rational {
739 spectrum.last().map_or_else(Rational::zero, |&(ev, _)| ev)
740 }
741}
742
743impl fmt::Display for SpectralAnalysis {
744 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
745 writeln!(f, "Atlas Spectral Analysis")?;
746 writeln!(f, "======================")?;
747 writeln!(f)?;
748 writeln!(f, "Spectral gap: λ₁ = {}", self.spectral_gap)?;
749 writeln!(f, "Max eigenvalue: λ_max = {}", self.max_eigenvalue)?;
750 writeln!(f, "Distinct eigenvalues: {}", self.num_distinct_eigenvalues())?;
751 writeln!(f)?;
752 writeln!(f, "Hemisphere spectrum (48 eigenvalues):")?;
753 for &(ev, mult) in &self.hemisphere_spectrum {
754 writeln!(f, " λ = {ev:>3} multiplicity = {mult}")?;
755 }
756 writeln!(f)?;
757 writeln!(f, "Trace: {}", self.hemisphere_trace)?;
758 writeln!(f, "Trace²: {}", self.hemisphere_trace_squared)?;
759 writeln!(f, "Gershgorin bound: [0, {}]", self.gershgorin_upper_bound())?;
760 Ok(())
761 }
762}
763
764#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn test_atlas_construction() {
774 let atlas = Atlas::new();
775 assert_eq!(atlas.num_vertices(), 96);
776 assert_eq!(atlas.num_edges(), 256);
777 }
778
779 #[test]
780 fn test_hemisphere_decomposition() {
781 let atlas = Atlas::new();
782 let hemispheres = decompose_hemispheres(&atlas);
783 assert_eq!(hemispheres[0].len(), 48);
784 assert_eq!(hemispheres[1].len(), 48);
785 verify_no_cross_hemisphere_edges(&atlas, &hemispheres);
786 }
787
788 #[test]
789 fn test_q4_blocks() {
790 let atlas = Atlas::new();
791 let hemispheres = decompose_hemispheres(&atlas);
792 let blocks = decompose_blocks(&atlas, &hemispheres[0]);
793 for block in &blocks {
794 assert_eq!(block.len(), 16);
795 verify_q4_block(&atlas, block);
796 verify_q4_spectrum(&atlas, block);
797 }
798 verify_inter_block_structure(&atlas, &blocks);
799 }
800
801 #[test]
802 fn test_block_eigenvalues() {
803 for &(nu, _) in &Q4_SPECTRUM {
804 let eigs = verify_block_eigenvalues(nu);
805 let nu_r = Rational::from_integer(nu);
806 assert_eq!(eigs[0], nu_r);
807 assert_eq!(eigs[1], nu_r + Rational::one());
808 assert_eq!(eigs[2], nu_r + Rational::new(3, 1));
809 }
810 }
811
812 #[test]
813 fn test_hemisphere_spectrum() {
814 let spectrum = compute_hemisphere_spectrum();
815 let expected: Vec<(Rational, usize)> = vec![
816 (Rational::zero(), 1),
817 (Rational::from_integer(1), 1),
818 (Rational::from_integer(2), 4),
819 (Rational::from_integer(3), 5),
820 (Rational::from_integer(4), 6),
821 (Rational::from_integer(5), 10),
822 (Rational::from_integer(6), 4),
823 (Rational::from_integer(7), 10),
824 (Rational::from_integer(8), 1),
825 (Rational::from_integer(9), 5),
826 (Rational::from_integer(11), 1),
827 ];
828 assert_eq!(spectrum, expected);
829 }
830
831 #[test]
832 fn test_spectral_gap_is_one() {
833 let atlas = Atlas::new();
834 let spectral = SpectralAnalysis::from_atlas(&atlas);
835 assert_eq!(spectral.spectral_gap(), Rational::from_integer(1));
836 assert_eq!(*spectral.spectral_gap().numer(), 1);
837 assert_eq!(*spectral.spectral_gap().denom(), 1);
838 }
839
840 #[test]
841 fn test_traces() {
842 let atlas = Atlas::new();
843 let spectral = SpectralAnalysis::from_atlas(&atlas);
844 assert_eq!(spectral.hemisphere_trace(), Rational::from_integer(256));
845 assert_eq!(spectral.hemisphere_trace_squared(), Rational::from_integer(1632));
846 }
847
848 #[test]
849 fn test_max_eigenvalue() {
850 let atlas = Atlas::new();
851 let spectral = SpectralAnalysis::from_atlas(&atlas);
852 assert_eq!(spectral.max_eigenvalue(), Rational::from_integer(11));
853 }
854
855 #[test]
856 fn test_all_eigenvalues_integer() {
857 let atlas = Atlas::new();
858 let spectral = SpectralAnalysis::from_atlas(&atlas);
859 assert!(spectral.all_eigenvalues_integer());
860 }
861
862 #[test]
863 fn test_full_spectrum() {
864 let atlas = Atlas::new();
865 let spectral = SpectralAnalysis::from_atlas(&atlas);
866 let total: usize = spectral.full_spectrum().iter().map(|(_, m)| m).sum();
867 assert_eq!(total, 96);
868 }
869
870 #[test]
871 fn test_gershgorin() {
872 let atlas = Atlas::new();
873 let spectral = SpectralAnalysis::from_atlas(&atlas);
874 let bound = spectral.gershgorin_upper_bound();
875 for &(ev, _) in spectral.hemisphere_spectrum() {
876 assert!(ev >= Rational::zero());
877 assert!(ev <= bound);
878 }
879 }
880
881 #[test]
882 fn test_display() {
883 let atlas = Atlas::new();
884 let spectral = SpectralAnalysis::from_atlas(&atlas);
885 let output = format!("{spectral}");
886 assert!(output.contains("Spectral gap"));
887 assert!(output.contains("λ₁ = 1"));
888 }
889}