Skip to main content

hodge_belief/
lib.rs

1//! # hodge-belief
2//!
3//! Hodge decomposition for belief states with interpretability.
4//!
5//! Implements:
6//! - **Hodge decomposition**: Split any signal into exact + coexact + harmonic components
7//! - **Belief states**: Represent and decompose multi-agent belief distributions
8//! - **Interpretability**: Understand why agents disagree by examining decomposition components
9//!
10//! Uses pure Rust with no external dependencies (implements sparse-aware dense linear algebra).
11
12#![deny(unsafe_code)]
13
14use std::fmt;
15
16// ============================================================
17// Error type
18// ============================================================
19
20#[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// ============================================================
48// Dense matrix
49// ============================================================
50
51#[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    /// Symmetric eigenvalues via Jacobi algorithm.
186    pub fn symmetric_eigenvalues(&self) -> Vec<f64> {
187        let (eigenvalues, _) = self.symmetric_eigen();
188        eigenvalues
189    }
190
191    /// Jacobi eigenvalue algorithm for symmetric matrices.
192    /// Much more accurate than power iteration for small dense matrices.
193    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            // Find largest off-diagonal element
207            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            // Compute rotation
226            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            // Apply rotation to A
240            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            // Apply rotation to eigenvectors
257            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        // Sort ascending
273        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
288// ============================================================
289// Vector operations
290// ============================================================
291
292pub 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
312// ============================================================
313// Graph Laplacian builders
314// ============================================================
315
316/// Build a path graph Laplacian.
317pub 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
332/// Build a cycle graph Laplacian.
333pub 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
343/// Build a complete graph Laplacian.
344pub 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// ============================================================
358// Hodge Decomposition
359// ============================================================
360
361/// Result of the Hodge decomposition of a signal on a graph.
362#[derive(Debug, Clone)]
363pub struct HodgeDecomposition {
364    /// Original signal.
365    pub signal: Vec<f64>,
366    /// Exact (gradient) component: f = δα for some α.
367    pub exact: Vec<f64>,
368    /// Coexact (curl) component: δβ for some β.
369    pub coexact: Vec<f64>,
370    /// Harmonic component: in kernel of Laplacian.
371    pub harmonic: Vec<f64>,
372    /// The Laplacian used.
373    pub laplacian: Matrix,
374    /// Eigenvalues of the Laplacian.
375    pub eigenvalues: Vec<f64>,
376    /// Eigenvectors (columns).
377    pub eigenvectors: Matrix,
378}
379
380impl HodgeDecomposition {
381    /// Decompose a signal using the graph Laplacian.
382    ///
383    /// signal = exact + coexact + harmonic
384    ///
385    /// where:
386    /// - harmonic = projection onto kernel of L (zero eigenvalues)
387    /// - exact + coexact = projection onto image of L (non-zero eigenvalues)
388    ///
389    /// For 0-forms on a graph:
390    /// - harmonic = constant component (for connected graph)
391    /// - exact = gradient component
392    /// - coexact = 0 for 0-forms
393    #[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        // Split eigenvectors into harmonic (zero eigenvalue) and non-harmonic
409        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                // Harmonic component
419                for i in 0..n {
420                    harmonic[i] += proj * col[i];
421                }
422            } else {
423                // Non-harmonic (exact + coexact for 0-forms, this is the "exact" part)
424                for i in 0..n {
425                    non_harmonic[i] += proj * col[i];
426                }
427            }
428        }
429
430        // For 0-forms on a graph, the decomposition is:
431        // signal = harmonic + exact (gradient)
432        // The coexact part is 0 for 0-forms.
433        // We split the non-harmonic part into exact using the Laplacian gradient
434        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    /// Verify orthogonality: exact · harmonic ≈ 0.
449    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    /// Verify reconstruction: signal ≈ exact + coexact + harmonic.
458    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    /// Energy fractions.
465    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// ============================================================
478// Hodge Realization
479// ============================================================
480
481/// Hodge realization: e = I - ΔG, where G is the Green's function.
482#[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    /// Build from a Laplacian matrix.
494    pub fn from_laplacian(laplacian: Matrix) -> Self {
495        let n = laplacian.rows;
496        let (eigenvalues, eigenvectors) = laplacian.symmetric_eigen();
497
498        // Spectral gap: smallest non-zero eigenvalue
499        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        // Harmonic dimension
511        let harmonic_dim = eigenvalues.iter().filter(|&&v| v.abs() < 1e-6).count();
512
513        // Pseudoinverse: G = V * diag(λ⁺) * V^T
514        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        // e = I - ΔG
524        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    /// Apply the idempotent (project onto harmonic space).
538    pub fn project(&self, v: &[f64]) -> Vec<f64> {
539        self.idempotent.mul_vec(v)
540    }
541
542    /// Non-harmonic projection (image of Δ).
543    pub fn non_harmonic_projection(&self, v: &[f64]) -> Vec<f64> {
544        vec_sub(v, &self.project(v))
545    }
546
547    /// Verify idempotence: e∘e = e.
548    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    /// Check if a vector is harmonic.
555    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    /// Transient cost at time t.
561    pub fn transient_cost(&self, t: f64) -> f64 {
562        (-self.spectral_gap * t).exp()
563    }
564
565    /// Get harmonic basis vectors.
566    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// ============================================================
578// Belief States
579// ============================================================
580
581/// A belief state over a graph.
582#[derive(Debug, Clone)]
583pub struct BeliefState {
584    pub graph_size: usize,
585    pub beliefs: Vec<f64>,
586}
587
588impl BeliefState {
589    /// Create a uniform belief state.
590    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    /// Create from a vector (will be normalized).
598    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    /// Entropy of the belief distribution.
613    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    /// KL divergence from self to other.
622    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    /// Total variation distance.
635    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/// Decomposed belief state with interpretability.
646#[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    /// Decompose a belief state using the graph Laplacian.
656    pub fn decompose(laplacian: &Matrix, belief: &BeliefState) -> Result<Self> {
657        let decomp = HodgeDecomposition::decompose(laplacian, &belief.beliefs)?;
658
659        // Extract harmonic and gradient components as belief states
660        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    /// Interpret the decomposition: what fraction of belief is consensus vs disagreement.
679    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/// Interpretation of a decomposed belief state.
692#[derive(Debug, Clone)]
693pub struct BeliefInterpretation {
694    /// Fraction of belief that is consensus (harmonic).
695    pub consensus_fraction: f64,
696    /// Fraction of belief that is disagreement (exact + coexact).
697    pub disagreement_fraction: f64,
698    /// The consensus belief (harmonic component).
699    pub consensus_belief: Vec<f64>,
700    /// The disagreement pattern (gradient component).
701    pub disagreement_pattern: Vec<f64>,
702}
703
704// ============================================================
705// Stability analysis
706// ============================================================
707
708/// Analyze stability of the Hodge decomposition under perturbation.
709pub 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        // Generate random signal
722        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        // Perturb signal
729        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/// Stability report.
754#[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    // ---- Matrix tests ----
768
769    #[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        // K4 eigenvalues: 0, 4, 4, 4
810        assert!(eigenvalues[0].abs() < 0.5);
811        assert!(eigenvalues[1] > 1.0);
812    }
813
814    // ---- Laplacian builder tests ----
815
816    #[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    // ---- Hodge decomposition tests ----
844
845    #[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        // Constant signal should be entirely harmonic
873        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    // ---- Hodge Realization tests ----
912
913    #[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        // Constant on a cycle should be entirely harmonic
973        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    // ---- Belief state tests ----
1010
1011    #[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    // ---- Decomposed belief tests ----
1058
1059    #[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    // ---- Stability tests ----
1083
1084    #[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        // Small perturbation should cause small shifts
1098        assert!(
1099            report.max_exact_shift < 5.0,
1100            "Small perturbation should cause small shifts"
1101        );
1102    }
1103
1104    // ---- Harmonic basis test ----
1105
1106    #[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}