1#![deny(unsafe_code)]
13
14use std::fmt;
15
16#[derive(Debug, Clone, PartialEq)]
21pub enum HodgeError {
22 EmptyMatrix,
23 SingularMatrix,
24 DimensionMismatch { expected: usize, actual: usize },
25 NoConvergence(usize),
26}
27
28impl fmt::Display for HodgeError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 HodgeError::EmptyMatrix => write!(f, "empty matrix"),
32 HodgeError::SingularMatrix => write!(f, "singular matrix"),
33 HodgeError::DimensionMismatch { expected, actual } => {
34 write!(f, "dimension mismatch: expected {expected}, got {actual}")
35 }
36 HodgeError::NoConvergence(iters) => {
37 write!(f, "no convergence after {iters} iterations")
38 }
39 }
40 }
41}
42
43impl std::error::Error for HodgeError {}
44
45pub type Result<T> = std::result::Result<T, HodgeError>;
46
47#[derive(Debug, Clone, PartialEq)]
52pub struct Matrix {
53 pub rows: usize,
54 pub cols: usize,
55 pub data: Vec<f64>,
56}
57
58impl Matrix {
59 pub fn zeros(rows: usize, cols: usize) -> Self {
60 Matrix {
61 rows,
62 cols,
63 data: vec![0.0; rows * cols],
64 }
65 }
66
67 pub fn identity(n: usize) -> Self {
68 let mut m = Self::zeros(n, n);
69 for i in 0..n {
70 m.data[i * n + i] = 1.0;
71 }
72 m
73 }
74
75 pub fn from_row_slice(rows: usize, cols: usize, data: &[f64]) -> Self {
76 Matrix {
77 rows,
78 cols,
79 data: data.to_vec(),
80 }
81 }
82
83 pub fn from_diag(diag: &[f64]) -> Self {
84 let n = diag.len();
85 let mut m = Self::zeros(n, n);
86 for (i, &v) in diag.iter().enumerate() {
87 m.data[i * n + i] = v;
88 }
89 m
90 }
91
92 #[inline]
93 pub fn get(&self, i: usize, j: usize) -> f64 {
94 self.data[i * self.cols + j]
95 }
96
97 #[inline]
98 pub fn set(&mut self, i: usize, j: usize, val: f64) {
99 self.data[i * self.cols + j] = val;
100 }
101
102 pub fn transpose(&self) -> Self {
103 let mut result = Self::zeros(self.cols, self.rows);
104 for i in 0..self.rows {
105 for j in 0..self.cols {
106 result.data[j * self.rows + i] = self.data[i * self.cols + j];
107 }
108 }
109 result
110 }
111
112 pub fn mul(&self, other: &Self) -> Self {
113 assert_eq!(self.cols, other.rows, "matrix multiply dimension mismatch");
114 let mut result = Self::zeros(self.rows, other.cols);
115 for i in 0..self.rows {
116 for j in 0..other.cols {
117 let mut sum = 0.0;
118 for k in 0..self.cols {
119 sum += self.get(i, k) * other.get(k, j);
120 }
121 result.set(i, j, sum);
122 }
123 }
124 result
125 }
126
127 pub fn scale(&self, s: f64) -> Self {
128 Matrix {
129 rows: self.rows,
130 cols: self.cols,
131 data: self.data.iter().map(|x| x * s).collect(),
132 }
133 }
134
135 pub fn add(&self, other: &Self) -> Self {
136 assert_eq!(self.rows, other.rows);
137 assert_eq!(self.cols, other.cols);
138 Matrix {
139 rows: self.rows,
140 cols: self.cols,
141 data: self
142 .data
143 .iter()
144 .zip(other.data.iter())
145 .map(|(a, b)| a + b)
146 .collect(),
147 }
148 }
149
150 pub fn sub(&self, other: &Self) -> Self {
151 assert_eq!(self.rows, other.rows);
152 assert_eq!(self.cols, other.cols);
153 Matrix {
154 rows: self.rows,
155 cols: self.cols,
156 data: self
157 .data
158 .iter()
159 .zip(other.data.iter())
160 .map(|(a, b)| a - b)
161 .collect(),
162 }
163 }
164
165 pub fn mul_vec(&self, v: &[f64]) -> Vec<f64> {
166 assert_eq!(self.cols, v.len());
167 self.data
168 .chunks_exact(self.cols)
169 .map(|row| row.iter().zip(v.iter()).map(|(a, b)| a * b).sum())
170 .collect()
171 }
172
173 pub fn frobenius_norm(&self) -> f64 {
174 self.data.iter().map(|x| x * x).sum::<f64>().sqrt()
175 }
176
177 pub fn trace(&self) -> f64 {
178 (0..self.rows.min(self.cols)).map(|i| self.get(i, i)).sum()
179 }
180
181 pub fn column(&self, j: usize) -> Vec<f64> {
182 (0..self.rows).map(|i| self.get(i, j)).collect()
183 }
184
185 pub fn symmetric_eigenvalues(&self) -> Vec<f64> {
187 let (eigenvalues, _) = self.symmetric_eigen();
188 eigenvalues
189 }
190
191 pub fn symmetric_eigen(&self) -> (Vec<f64>, Self) {
194 let n = self.rows;
195 if n == 0 {
196 return (vec![], Self::zeros(0, 0));
197 }
198
199 let mut a = self.data.clone();
200 let mut v = Matrix::identity(n).data;
201
202 let max_sweeps = 100;
203 let tol = 1e-12;
204
205 for _ in 0..max_sweeps {
206 let mut max_off = 0.0_f64;
208 let mut p = 0;
209 let mut q = 1;
210 for i in 0..n {
211 for j in (i + 1)..n {
212 let val = a[i * n + j].abs();
213 if val > max_off {
214 max_off = val;
215 p = i;
216 q = j;
217 }
218 }
219 }
220
221 if max_off < tol {
222 break;
223 }
224
225 let app = a[p * n + p];
227 let aqq = a[q * n + q];
228 let apq = a[p * n + q];
229
230 let theta = if (app - aqq).abs() < 1e-30 {
231 std::f64::consts::PI / 4.0
232 } else {
233 0.5 * (2.0 * apq / (app - aqq)).atan()
234 };
235
236 let c = theta.cos();
237 let s = theta.sin();
238
239 for i in 0..n {
241 if i != p && i != q {
242 let aip = a[i * n + p];
243 let aiq = a[i * n + q];
244 a[i * n + p] = c * aip + s * aiq;
245 a[p * n + i] = c * aip + s * aiq;
246 a[i * n + q] = -s * aip + c * aiq;
247 a[q * n + i] = -s * aip + c * aiq;
248 }
249 }
250
251 a[p * n + p] = c * c * app + 2.0 * s * c * apq + s * s * aqq;
252 a[q * n + q] = s * s * app - 2.0 * s * c * apq + c * c * aqq;
253 a[p * n + q] = 0.0;
254 a[q * n + p] = 0.0;
255
256 for i in 0..n {
258 let vip = v[i * n + p];
259 let viq = v[i * n + q];
260 v[i * n + p] = c * vip + s * viq;
261 v[i * n + q] = -s * vip + c * viq;
262 }
263 }
264
265 let eigenvalues: Vec<f64> = (0..n).map(|i| a[i * n + i]).collect();
266 let eigenvectors = Matrix {
267 rows: n,
268 cols: n,
269 data: v,
270 };
271
272 let mut indices: Vec<usize> = (0..n).collect();
274 indices.sort_by(|&i, &j| eigenvalues[i].partial_cmp(&eigenvalues[j]).unwrap());
275
276 let sorted_eigenvalues: Vec<f64> = indices.iter().map(|&i| eigenvalues[i]).collect();
277 let mut sorted_eigenvectors = Matrix::zeros(n, n);
278 for (col, &idx) in indices.iter().enumerate() {
279 for row in 0..n {
280 sorted_eigenvectors.set(row, col, eigenvectors.get(row, idx));
281 }
282 }
283
284 (sorted_eigenvalues, sorted_eigenvectors)
285 }
286}
287
288pub fn vec_dot(a: &[f64], b: &[f64]) -> f64 {
293 a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
294}
295
296pub fn vec_norm(v: &[f64]) -> f64 {
297 v.iter().map(|x| x * x).sum::<f64>().sqrt()
298}
299
300pub fn vec_add(a: &[f64], b: &[f64]) -> Vec<f64> {
301 a.iter().zip(b.iter()).map(|(x, y)| x + y).collect()
302}
303
304pub fn vec_sub(a: &[f64], b: &[f64]) -> Vec<f64> {
305 a.iter().zip(b.iter()).map(|(x, y)| x - y).collect()
306}
307
308pub fn vec_scale(v: &[f64], s: f64) -> Vec<f64> {
309 v.iter().map(|x| x * s).collect()
310}
311
312pub fn path_laplacian(n: usize) -> Matrix {
318 let mut l = Matrix::zeros(n, n);
319 for i in 0..n {
320 if i > 0 {
321 l.set(i, i, l.get(i, i) + 1.0);
322 l.set(i, i - 1, -1.0);
323 }
324 if i < n - 1 {
325 l.set(i, i, l.get(i, i) + 1.0);
326 l.set(i, i + 1, -1.0);
327 }
328 }
329 l
330}
331
332pub fn cycle_laplacian(n: usize) -> Matrix {
334 let mut l = Matrix::zeros(n, n);
335 for i in 0..n {
336 l.set(i, i, 2.0);
337 l.set(i, (i + 1) % n, -1.0);
338 l.set(i, (i + n - 1) % n, -1.0);
339 }
340 l
341}
342
343pub fn complete_laplacian(n: usize) -> Matrix {
345 let mut l = Matrix::zeros(n, n);
346 for i in 0..n {
347 l.set(i, i, (n - 1) as f64);
348 for j in 0..n {
349 if i != j {
350 l.set(i, j, -1.0);
351 }
352 }
353 }
354 l
355}
356
357#[derive(Debug, Clone)]
363pub struct HodgeDecomposition {
364 pub signal: Vec<f64>,
366 pub exact: Vec<f64>,
368 pub coexact: Vec<f64>,
370 pub harmonic: Vec<f64>,
372 pub laplacian: Matrix,
374 pub eigenvalues: Vec<f64>,
376 pub eigenvectors: Matrix,
378}
379
380impl HodgeDecomposition {
381 #[allow(clippy::needless_range_loop)]
394 pub fn decompose(laplacian: &Matrix, signal: &[f64]) -> Result<Self> {
395 let n = laplacian.rows;
396 if n == 0 {
397 return Err(HodgeError::EmptyMatrix);
398 }
399 if signal.len() != n {
400 return Err(HodgeError::DimensionMismatch {
401 expected: n,
402 actual: signal.len(),
403 });
404 }
405
406 let (eigenvalues, eigenvectors) = laplacian.symmetric_eigen();
407
408 let mut harmonic = vec![0.0; n];
410 let mut non_harmonic = vec![0.0; n];
411
412 for k in 0..n {
413 let ev = &eigenvalues[k];
414 let col = eigenvectors.column(k);
415 let proj = vec_dot(signal, &col);
416
417 if ev.abs() < 1e-6 {
418 for i in 0..n {
420 harmonic[i] += proj * col[i];
421 }
422 } else {
423 for i in 0..n {
425 non_harmonic[i] += proj * col[i];
426 }
427 }
428 }
429
430 let exact = non_harmonic.clone();
435 let coexact = vec![0.0; n];
436
437 Ok(HodgeDecomposition {
438 signal: signal.to_vec(),
439 exact,
440 coexact,
441 harmonic,
442 laplacian: laplacian.clone(),
443 eigenvalues,
444 eigenvectors,
445 })
446 }
447
448 pub fn verify_orthogonality(&self) -> bool {
450 let dot_eh = vec_dot(&self.exact, &self.harmonic).abs();
451 let dot_ch = vec_dot(&self.coexact, &self.harmonic).abs();
452 let dot_ec = vec_dot(&self.exact, &self.coexact).abs();
453 let tol = self.signal.len() as f64 * 1e-6;
454 dot_eh < tol && dot_ch < tol && dot_ec < tol
455 }
456
457 pub fn verify_reconstruction(&self) -> bool {
459 let reconstructed = vec_add(&vec_add(&self.exact, &self.coexact), &self.harmonic);
460 let diff = vec_sub(&self.signal, &reconstructed);
461 vec_norm(&diff) < 1e-6 * vec_norm(&self.signal).max(1.0)
462 }
463
464 pub fn energy_fractions(&self) -> (f64, f64, f64) {
466 let total = vec_norm(&self.signal).powi(2);
467 if total < 1e-15 {
468 return (0.0, 0.0, 0.0);
469 }
470 let exact_frac = vec_norm(&self.exact).powi(2) / total;
471 let coexact_frac = vec_norm(&self.coexact).powi(2) / total;
472 let harmonic_frac = vec_norm(&self.harmonic).powi(2) / total;
473 (exact_frac, coexact_frac, harmonic_frac)
474 }
475}
476
477#[derive(Debug, Clone)]
483pub struct HodgeRealization {
484 pub laplacian: Matrix,
485 pub greens_function: Matrix,
486 pub idempotent: Matrix,
487 pub harmonic_dimension: usize,
488 pub spectral_gap: f64,
489 pub eigenvalues: Vec<f64>,
490}
491
492impl HodgeRealization {
493 pub fn from_laplacian(laplacian: Matrix) -> Self {
495 let n = laplacian.rows;
496 let (eigenvalues, eigenvectors) = laplacian.symmetric_eigen();
497
498 let spectral_gap = eigenvalues
500 .iter()
501 .filter(|&&v| v > 1e-6)
502 .cloned()
503 .fold(f64::INFINITY, f64::min);
504 let spectral_gap = if spectral_gap == f64::INFINITY {
505 0.0
506 } else {
507 spectral_gap
508 };
509
510 let harmonic_dim = eigenvalues.iter().filter(|&&v| v.abs() < 1e-6).count();
512
513 let mut pinv_diag = vec![0.0; n];
515 for (i, &val) in eigenvalues.iter().enumerate() {
516 pinv_diag[i] = if val.abs() < 1e-6 { 0.0 } else { 1.0 / val };
517 }
518 let pinv_matrix = Matrix::from_diag(&pinv_diag);
519 let greens_function = eigenvectors
520 .mul(&pinv_matrix)
521 .mul(&eigenvectors.transpose());
522
523 let identity = Matrix::identity(n);
525 let idempotent = identity.sub(&laplacian.mul(&greens_function));
526
527 HodgeRealization {
528 laplacian,
529 greens_function,
530 idempotent,
531 harmonic_dimension: harmonic_dim,
532 spectral_gap,
533 eigenvalues,
534 }
535 }
536
537 pub fn project(&self, v: &[f64]) -> Vec<f64> {
539 self.idempotent.mul_vec(v)
540 }
541
542 pub fn non_harmonic_projection(&self, v: &[f64]) -> Vec<f64> {
544 vec_sub(v, &self.project(v))
545 }
546
547 pub fn verify_idempotence(&self) -> bool {
549 let ee = self.idempotent.mul(&self.idempotent);
550 let diff = ee.sub(&self.idempotent);
551 diff.data.iter().all(|x| x.abs() < 1e-6)
552 }
553
554 pub fn is_harmonic(&self, v: &[f64]) -> bool {
556 let lv = self.laplacian.mul_vec(v);
557 lv.iter().all(|x| x.abs() < 1e-6)
558 }
559
560 pub fn transient_cost(&self, t: f64) -> f64 {
562 (-self.spectral_gap * t).exp()
563 }
564
565 pub fn harmonic_basis(&self) -> Vec<Vec<f64>> {
567 let (_, eigenvectors) = self.laplacian.symmetric_eigen();
568 self.eigenvalues
569 .iter()
570 .enumerate()
571 .filter(|(_, &v)| v.abs() < 1e-6)
572 .map(|(k, _)| eigenvectors.column(k))
573 .collect()
574 }
575}
576
577#[derive(Debug, Clone)]
583pub struct BeliefState {
584 pub graph_size: usize,
585 pub beliefs: Vec<f64>,
586}
587
588impl BeliefState {
589 pub fn uniform(n: usize) -> Self {
591 BeliefState {
592 graph_size: n,
593 beliefs: vec![1.0 / n as f64; n],
594 }
595 }
596
597 pub fn from_vec(beliefs: Vec<f64>) -> Self {
599 let n = beliefs.len();
600 let sum: f64 = beliefs.iter().sum();
601 let beliefs = if sum > 1e-15 {
602 beliefs.iter().map(|x| x / sum).collect()
603 } else {
604 beliefs
605 };
606 BeliefState {
607 graph_size: n,
608 beliefs,
609 }
610 }
611
612 pub fn entropy(&self) -> f64 {
614 self.beliefs
615 .iter()
616 .filter(|&&x| x > 1e-15)
617 .map(|&x| -x * x.ln())
618 .sum()
619 }
620
621 pub fn kl_divergence(&self, other: &BeliefState) -> f64 {
623 self.beliefs
624 .iter()
625 .zip(other.beliefs.iter())
626 .filter(|(&x, _)| x > 1e-15)
627 .map(|(x, y)| {
628 let y_safe = if *y > 1e-15 { *y } else { 1e-15 };
629 x * (x / y_safe).ln()
630 })
631 .sum()
632 }
633
634 pub fn total_variation(&self, other: &BeliefState) -> f64 {
636 self.beliefs
637 .iter()
638 .zip(other.beliefs.iter())
639 .map(|(a, b)| (a - b).abs())
640 .sum::<f64>()
641 / 2.0
642 }
643}
644
645#[derive(Debug, Clone)]
647pub struct DecomposedBelief {
648 pub original: BeliefState,
649 pub harmonic_belief: BeliefState,
650 pub gradient_belief: BeliefState,
651 pub decomposition: HodgeDecomposition,
652}
653
654impl DecomposedBelief {
655 pub fn decompose(laplacian: &Matrix, belief: &BeliefState) -> Result<Self> {
657 let decomp = HodgeDecomposition::decompose(laplacian, &belief.beliefs)?;
658
659 let harmonic_belief = BeliefState {
661 graph_size: belief.graph_size,
662 beliefs: decomp.harmonic.clone(),
663 };
664
665 let gradient_belief = BeliefState {
666 graph_size: belief.graph_size,
667 beliefs: decomp.exact.clone(),
668 };
669
670 Ok(DecomposedBelief {
671 original: belief.clone(),
672 harmonic_belief,
673 gradient_belief,
674 decomposition: decomp,
675 })
676 }
677
678 pub fn interpret(&self) -> BeliefInterpretation {
680 let (exact_frac, coexact_frac, harmonic_frac) = self.decomposition.energy_fractions();
681
682 BeliefInterpretation {
683 consensus_fraction: harmonic_frac,
684 disagreement_fraction: exact_frac + coexact_frac,
685 consensus_belief: self.harmonic_belief.beliefs.clone(),
686 disagreement_pattern: self.gradient_belief.beliefs.clone(),
687 }
688 }
689}
690
691#[derive(Debug, Clone)]
693pub struct BeliefInterpretation {
694 pub consensus_fraction: f64,
696 pub disagreement_fraction: f64,
698 pub consensus_belief: Vec<f64>,
700 pub disagreement_pattern: Vec<f64>,
702}
703
704pub fn stability_analysis(
710 laplacian: &Matrix,
711 perturbation_scale: f64,
712 num_trials: usize,
713) -> StabilityReport {
714 let n = laplacian.rows;
715 let realization = HodgeRealization::from_laplacian(laplacian.clone());
716
717 let mut max_harmonic_shift = 0.0_f64;
718 let mut max_exact_shift = 0.0_f64;
719
720 for trial in 0..num_trials {
721 let signal: Vec<f64> = (0..n)
723 .map(|i| ((i * 7 + trial * 13 + 1) as f64).sin() * 10.0)
724 .collect();
725
726 let decomp = HodgeDecomposition::decompose(laplacian, &signal).unwrap();
727
728 let perturbed: Vec<f64> = signal
730 .iter()
731 .enumerate()
732 .map(|(i, x)| x + ((i * 11 + trial * 3) as f64).cos() * perturbation_scale)
733 .collect();
734
735 let decomp_p = HodgeDecomposition::decompose(laplacian, &perturbed).unwrap();
736
737 let h_shift = vec_norm(&vec_sub(&decomp.harmonic, &decomp_p.harmonic));
738 let e_shift = vec_norm(&vec_sub(&decomp.exact, &decomp_p.exact));
739
740 max_harmonic_shift = max_harmonic_shift.max(h_shift);
741 max_exact_shift = max_exact_shift.max(e_shift);
742 }
743
744 StabilityReport {
745 perturbation_scale,
746 max_harmonic_shift,
747 max_exact_shift,
748 spectral_gap: realization.spectral_gap,
749 harmonic_dimension: realization.harmonic_dimension,
750 }
751}
752
753#[derive(Debug, Clone)]
755pub struct StabilityReport {
756 pub perturbation_scale: f64,
757 pub max_harmonic_shift: f64,
758 pub max_exact_shift: f64,
759 pub spectral_gap: f64,
760 pub harmonic_dimension: usize,
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
770 fn test_matrix_identity() {
771 let m = Matrix::identity(3);
772 assert!((m.get(0, 0) - 1.0).abs() < 1e-10);
773 assert!((m.get(1, 1) - 1.0).abs() < 1e-10);
774 assert!((m.get(0, 1)).abs() < 1e-10);
775 }
776
777 #[test]
778 fn test_matrix_mul_identity() {
779 let a = Matrix::from_row_slice(2, 2, &[1.0, 2.0, 3.0, 4.0]);
780 let i = Matrix::identity(2);
781 let c = a.mul(&i);
782 assert!((c.get(0, 0) - 1.0).abs() < 1e-10);
783 assert!((c.get(0, 1) - 2.0).abs() < 1e-10);
784 }
785
786 #[test]
787 fn test_matrix_transpose() {
788 let m = Matrix::from_row_slice(2, 3, &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
789 let t = m.transpose();
790 assert_eq!(t.rows, 3);
791 assert_eq!(t.cols, 2);
792 assert!((t.get(0, 1) - 4.0).abs() < 1e-10);
793 }
794
795 #[test]
796 fn test_matrix_symmetric_eigenvalues_path() {
797 let lap = path_laplacian(5);
798 let eigenvalues = lap.symmetric_eigenvalues();
799 assert!(eigenvalues[0].abs() < 0.2, "First eigenvalue should be ~0");
800 for &ev in &eigenvalues {
801 assert!(ev >= -0.5, "eigenvalue {ev} should be non-negative");
802 }
803 }
804
805 #[test]
806 fn test_matrix_symmetric_eigenvalues_complete() {
807 let lap = complete_laplacian(4);
808 let eigenvalues = lap.symmetric_eigenvalues();
809 assert!(eigenvalues[0].abs() < 0.5);
811 assert!(eigenvalues[1] > 1.0);
812 }
813
814 #[test]
817 fn test_path_laplacian_row_sums() {
818 let lap = path_laplacian(5);
819 for i in 0..5 {
820 let row_sum: f64 = (0..5).map(|j| lap.get(i, j)).sum();
821 assert!(row_sum.abs() < 1e-10, "Row {i} sum = {row_sum}, expected 0");
822 }
823 }
824
825 #[test]
826 fn test_cycle_laplacian_row_sums() {
827 let lap = cycle_laplacian(4);
828 for i in 0..4 {
829 let row_sum: f64 = (0..4).map(|j| lap.get(i, j)).sum();
830 assert!(row_sum.abs() < 1e-10);
831 }
832 }
833
834 #[test]
835 fn test_complete_laplacian_row_sums() {
836 let lap = complete_laplacian(5);
837 for i in 0..5 {
838 let row_sum: f64 = (0..5).map(|j| lap.get(i, j)).sum();
839 assert!(row_sum.abs() < 1e-10);
840 }
841 }
842
843 #[test]
846 fn test_hodge_decomposition_orthogonality() {
847 let lap = cycle_laplacian(4);
848 let signal = vec![1.0, 3.0, 2.0, -1.0];
849 let decomp = HodgeDecomposition::decompose(&lap, &signal).unwrap();
850 assert!(
851 decomp.verify_orthogonality(),
852 "Exact and harmonic should be orthogonal"
853 );
854 }
855
856 #[test]
857 fn test_hodge_decomposition_reconstruction() {
858 let lap = cycle_laplacian(4);
859 let signal = vec![1.0, 3.0, 2.0, -1.0];
860 let decomp = HodgeDecomposition::decompose(&lap, &signal).unwrap();
861 assert!(
862 decomp.verify_reconstruction(),
863 "Signal should reconstruct from components"
864 );
865 }
866
867 #[test]
868 fn test_hodge_constant_signal_is_harmonic() {
869 let lap = cycle_laplacian(4);
870 let signal = vec![5.0; 4];
871 let decomp = HodgeDecomposition::decompose(&lap, &signal).unwrap();
872 assert!(
874 vec_norm(&decomp.exact) < 1.0,
875 "Constant signal exact component should be small"
876 );
877 }
878
879 #[test]
880 fn test_hodge_varying_signal_has_exact() {
881 let lap = path_laplacian(5);
882 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
883 let decomp = HodgeDecomposition::decompose(&lap, &signal).unwrap();
884 assert!(
885 vec_norm(&decomp.exact) > 0.1,
886 "Varying signal should have non-trivial exact component"
887 );
888 }
889
890 #[test]
891 fn test_hodge_energy_fractions_sum_to_one() {
892 let lap = cycle_laplacian(5);
893 let signal = vec![1.0, 3.0, 2.0, -1.0, 0.0];
894 let decomp = HodgeDecomposition::decompose(&lap, &signal).unwrap();
895 let (exact, coexact, harmonic) = decomp.energy_fractions();
896 let total = exact + coexact + harmonic;
897 assert!(
898 (total - 1.0).abs() < 0.2,
899 "Energy fractions should sum to ~1, got {total}"
900 );
901 }
902
903 #[test]
904 fn test_hodge_path_graph() {
905 let lap = path_laplacian(5);
906 let signal = vec![3.0, -1.0, 4.0, 2.0, -7.0];
907 let decomp = HodgeDecomposition::decompose(&lap, &signal).unwrap();
908 assert!(decomp.verify_reconstruction());
909 }
910
911 #[test]
914 fn test_hodge_realization_path() {
915 let lap = path_laplacian(5);
916 let hodge = HodgeRealization::from_laplacian(lap);
917 assert_eq!(
918 hodge.harmonic_dimension, 1,
919 "Connected graph has 1 harmonic form (constant)"
920 );
921 assert!(hodge.spectral_gap > 0.0);
922 }
923
924 #[test]
925 fn test_hodge_realization_cycle() {
926 let lap = cycle_laplacian(4);
927 let hodge = HodgeRealization::from_laplacian(lap);
928 assert_eq!(hodge.harmonic_dimension, 1, "Cycle has 1 harmonic form");
929 }
930
931 #[test]
932 fn test_hodge_realization_complete() {
933 let lap = complete_laplacian(4);
934 let hodge = HodgeRealization::from_laplacian(lap);
935 assert_eq!(
936 hodge.harmonic_dimension, 1,
937 "Complete graph has 1 harmonic form"
938 );
939 }
940
941 #[test]
942 fn test_hodge_idempotence_path() {
943 let hodge = HodgeRealization::from_laplacian(path_laplacian(5));
944 assert!(hodge.verify_idempotence(), "e∘e = e should hold");
945 }
946
947 #[test]
948 fn test_hodge_idempotence_cycle() {
949 let hodge = HodgeRealization::from_laplacian(cycle_laplacian(4));
950 assert!(hodge.verify_idempotence());
951 }
952
953 #[test]
954 fn test_hodge_is_harmonic_constant() {
955 let hodge = HodgeRealization::from_laplacian(cycle_laplacian(4));
956 let constant = vec![1.0; 4];
957 assert!(hodge.is_harmonic(&constant));
958 }
959
960 #[test]
961 fn test_hodge_not_harmonic() {
962 let hodge = HodgeRealization::from_laplacian(path_laplacian(5));
963 let v = vec![1.0, 0.0, 0.0, 0.0, 0.0];
964 assert!(!hodge.is_harmonic(&v));
965 }
966
967 #[test]
968 fn test_hodge_harmonic_projection_cycle() {
969 let hodge = HodgeRealization::from_laplacian(cycle_laplacian(3));
970 let constant = vec![5.0; 3];
971 let harm = hodge.project(&constant);
972 assert!(
974 vec_norm(&vec_sub(&constant, &harm)) < 1e-4,
975 "Constant should be preserved as harmonic: diff = {:?}",
976 vec_sub(&constant, &harm)
977 );
978 }
979
980 #[test]
981 fn test_hodge_project_idempotent() {
982 let hodge = HodgeRealization::from_laplacian(path_laplacian(5));
983 let v = vec![3.0, -1.0, 4.0, 2.0, -7.0];
984 let p1 = hodge.project(&v);
985 let p2 = hodge.project(&p1);
986 assert!(
987 vec_norm(&vec_sub(&p1, &p2)) < 1e-6,
988 "Projecting twice should give same result"
989 );
990 }
991
992 #[test]
993 fn test_hodge_spectral_gap_path_vs_complete() {
994 let path = HodgeRealization::from_laplacian(path_laplacian(10));
995 let complete = HodgeRealization::from_laplacian(complete_laplacian(10));
996 assert!(
997 complete.spectral_gap > path.spectral_gap,
998 "Complete graph spectral gap should be larger"
999 );
1000 }
1001
1002 #[test]
1003 fn test_transient_cost_decreases() {
1004 let hodge = HodgeRealization::from_laplacian(path_laplacian(5));
1005 assert!(hodge.transient_cost(1.0) > hodge.transient_cost(2.0));
1006 assert!(hodge.transient_cost(2.0) > hodge.transient_cost(5.0));
1007 }
1008
1009 #[test]
1012 fn test_belief_uniform() {
1013 let b = BeliefState::uniform(4);
1014 assert_eq!(b.beliefs.len(), 4);
1015 for &x in &b.beliefs {
1016 assert!((x - 0.25).abs() < 1e-10);
1017 }
1018 }
1019
1020 #[test]
1021 fn test_belief_from_vec_normalized() {
1022 let b = BeliefState::from_vec(vec![1.0, 1.0, 2.0]);
1023 assert!((b.beliefs[0] - 0.25).abs() < 1e-10);
1024 assert!((b.beliefs[1] - 0.25).abs() < 1e-10);
1025 assert!((b.beliefs[2] - 0.5).abs() < 1e-10);
1026 }
1027
1028 #[test]
1029 fn test_belief_entropy() {
1030 let uniform = BeliefState::uniform(4);
1031 let concentrated = BeliefState::from_vec(vec![1.0, 0.0, 0.0, 0.0]);
1032 assert!(uniform.entropy() > concentrated.entropy());
1033 }
1034
1035 #[test]
1036 fn test_belief_kl_divergence() {
1037 let b1 = BeliefState::from_vec(vec![0.5, 0.5]);
1038 let b2 = BeliefState::from_vec(vec![0.5, 0.5]);
1039 let kl = b1.kl_divergence(&b2);
1040 assert!(
1041 kl.abs() < 1e-10,
1042 "KL divergence of identical distributions should be 0"
1043 );
1044 }
1045
1046 #[test]
1047 fn test_belief_total_variation() {
1048 let b1 = BeliefState::from_vec(vec![1.0, 0.0]);
1049 let b2 = BeliefState::from_vec(vec![0.0, 1.0]);
1050 let tv = b1.total_variation(&b2);
1051 assert!(
1052 (tv - 1.0).abs() < 1e-10,
1053 "TV distance of disjoint distributions should be 1"
1054 );
1055 }
1056
1057 #[test]
1060 fn test_decompose_belief() {
1061 let lap = cycle_laplacian(4);
1062 let belief = BeliefState::from_vec(vec![1.0, 3.0, 2.0, 0.5]);
1063 let decomp = DecomposedBelief::decompose(&lap, &belief).unwrap();
1064 assert!(
1065 decomp.decomposition.verify_reconstruction(),
1066 "Belief decomposition should reconstruct"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_belief_interpretation() {
1072 let lap = cycle_laplacian(4);
1073 let belief = BeliefState::from_vec(vec![1.0, 3.0, 2.0, 0.5]);
1074 let decomp = DecomposedBelief::decompose(&lap, &belief).unwrap();
1075 let interp = decomp.interpret();
1076 assert!(
1077 (interp.consensus_fraction + interp.disagreement_fraction - 1.0).abs() < 0.2,
1078 "Fractions should sum to ~1"
1079 );
1080 }
1081
1082 #[test]
1085 fn test_stability_analysis() {
1086 let lap = cycle_laplacian(4);
1087 let report = stability_analysis(&lap, 0.1, 10);
1088 assert!(report.max_harmonic_shift >= 0.0);
1089 assert!(report.max_exact_shift >= 0.0);
1090 assert!(report.spectral_gap > 0.0);
1091 }
1092
1093 #[test]
1094 fn test_stability_small_perturbation() {
1095 let lap = cycle_laplacian(4);
1096 let report = stability_analysis(&lap, 0.01, 10);
1097 assert!(
1099 report.max_exact_shift < 5.0,
1100 "Small perturbation should cause small shifts"
1101 );
1102 }
1103
1104 #[test]
1107 fn test_harmonic_basis_cycle() {
1108 let hodge = HodgeRealization::from_laplacian(cycle_laplacian(4));
1109 let basis = hodge.harmonic_basis();
1110 assert_eq!(basis.len(), 1, "Cycle should have 1 harmonic basis vector");
1111 }
1112
1113 #[test]
1114 fn test_harmonic_basis_path() {
1115 let hodge = HodgeRealization::from_laplacian(path_laplacian(5));
1116 let basis = hodge.harmonic_basis();
1117 assert_eq!(basis.len(), 1, "Path graph has 1 harmonic form (constant)");
1118 }
1119}