Skip to main content

entropy_conservation/
persistence.rs

1//! Persistence homology of entropy landscapes.
2//!
3//! Computes Betti numbers and persistence diagrams for the
4//! verification space, revealing the topological structure of
5//! entropy across scales.
6
7/// A persistence diagram: collection of (birth, death) pairs
8/// and the resulting Betti numbers.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct PersistenceDiagram {
11    /// (birth, death) pairs for each feature.
12    pub points: Vec<(f64, f64)>,
13    /// Betti numbers β₀, β₁, β₂, … (connected components, cycles, voids, …).
14    pub betti_numbers: Vec<usize>,
15}
16
17struct UnionFind {
18    parent: Vec<usize>,
19    rank: Vec<usize>,
20}
21
22impl UnionFind {
23    fn new(n: usize) -> Self {
24        Self {
25            parent: (0..n).collect(),
26            rank: vec![0; n],
27        }
28    }
29
30    fn find(&mut self, x: usize) -> usize {
31        if self.parent[x] != x {
32            self.parent[x] = self.find(self.parent[x]);
33        }
34        self.parent[x]
35    }
36
37    fn union(&mut self, a: usize, b: usize) -> bool {
38        let ra = self.find(a);
39        let rb = self.find(b);
40        if ra == rb {
41            return false;
42        }
43        if self.rank[ra] < self.rank[rb] {
44            self.parent[ra] = rb;
45        } else if self.rank[ra] > self.rank[rb] {
46            self.parent[rb] = ra;
47        } else {
48            self.parent[rb] = ra;
49            self.rank[ra] += 1;
50        }
51        true
52    }
53}
54
55impl PersistenceDiagram {
56    /// Build a persistence diagram from a distance/similarity matrix.
57    ///
58    /// Uses a Vietoris-Rips filtration approach.
59    /// `dist` is a flattened n×n distance matrix (row-major).
60    /// `n` is the number of points.
61    pub fn from_distance_matrix(dist: &[f64], n: usize) -> Self {
62        if n <= 1 {
63            return Self {
64                points: vec![],
65                betti_numbers: vec![1],
66            };
67        }
68
69        let mut uf = UnionFind::new(n);
70
71        // Build edge list sorted by distance
72        let mut edges: Vec<(f64, usize, usize)> = Vec::new();
73        for i in 0..n {
74            for j in (i + 1)..n {
75                let d = dist.get(i * n + j).copied().unwrap_or(f64::INFINITY);
76                edges.push((d, i, j));
77            }
78        }
79        edges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
80
81        // H₀ persistence: track when components merge
82        let mut points = Vec::new();
83
84        for (d, i, j) in &edges {
85            if d.is_infinite() {
86                continue; // disconnected components never merge
87            }
88            if uf.find(*i) != uf.find(*j) {
89                // Component merges at distance d → death of one component
90                points.push((0.0, *d));
91                uf.union(*i, *j);
92            }
93        }
94
95        // β₀ = number of connected components
96        let mut roots = std::collections::HashSet::new();
97        for i in 0..n {
98            roots.insert(uf.find(i));
99        }
100        let beta0 = roots.len();
101
102        // Approximate β₁ = edges - vertices + components (for 1-complex)
103        let beta1 = if edges.len() + beta0 > n {
104            edges.len() - n + beta0
105        } else {
106            0
107        };
108
109        let betti_numbers = vec![beta0, beta1];
110
111        Self { points, betti_numbers }
112    }
113
114    /// Build from an n×n ndarray distance matrix.
115    pub fn from_ndarray_matrix(mat: &ndarray::Array2<f64>) -> Self {
116        let n = mat.nrows();
117        let dist: Vec<f64> = mat.iter().copied().collect();
118        Self::from_distance_matrix(&dist, n)
119    }
120
121    /// All persistence pairs where the feature survived (death > birth).
122    pub fn persistent_features(&self) -> Vec<(f64, f64)> {
123        self.points
124            .iter()
125            .filter(|(b, d)| d > b)
126            .copied()
127            .collect()
128    }
129
130    /// Total persistence: Σ |d - b|^p for each feature.
131    pub fn total_persistence(&self, p: f64) -> f64 {
132        self.points
133            .iter()
134            .filter(|(b, d)| d > b)
135            .map(|(b, d)| (d - b).powf(p))
136            .sum()
137    }
138
139    /// Check all points are above or on the diagonal (death ≥ birth).
140    pub fn is_valid(&self) -> bool {
141        self.points.iter().all(|(b, d)| *d >= *b - 1e-12)
142    }
143}
144
145/// Compute the entropy landscape: pairwise Jensen-Shannon distances
146/// between verification distributions for each module.
147pub fn entropy_landscape(distributions: &[Vec<f64>]) -> ndarray::Array2<f64> {
148    let n = distributions.len();
149    let mut dist_mat = ndarray::Array2::<f64>::zeros((n, n));
150
151    for i in 0..n {
152        for j in (i + 1)..n {
153            let d = crate::entropy::js_divergence(&distributions[i], &distributions[j]);
154            let d_sqrt = d.sqrt().max(0.0);
155            dist_mat[[i, j]] = d_sqrt;
156            dist_mat[[j, i]] = d_sqrt;
157        }
158    }
159    dist_mat
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn persistence_diagram_points_above_diagonal() {
168        let dist = vec![
169            0.0, 1.0, 2.0,
170            1.0, 0.0, 1.5,
171            2.0, 1.5, 0.0,
172        ];
173        let pd = PersistenceDiagram::from_distance_matrix(&dist, 3);
174        assert!(pd.is_valid(), "all points should be on or above diagonal");
175    }
176
177    #[test]
178    fn betti_number_single_component() {
179        let dist = vec![
180            0.0, 0.1, 0.2,
181            0.1, 0.0, 0.15,
182            0.2, 0.15, 0.0,
183        ];
184        let pd = PersistenceDiagram::from_distance_matrix(&dist, 3);
185        assert_eq!(pd.betti_numbers[0], 1, "connected graph → β₀ = 1");
186    }
187
188    #[test]
189    fn betti_number_two_components() {
190        let inf = f64::INFINITY;
191        let dist = vec![
192            0.0, 0.5, inf,
193            0.5, 0.0, inf,
194            inf, inf, 0.0,
195        ];
196        let pd = PersistenceDiagram::from_distance_matrix(&dist, 3);
197        assert_eq!(pd.betti_numbers[0], 2, "two components → β₀ = 2");
198    }
199
200    #[test]
201    fn total_persistence_non_negative() {
202        let dist = vec![
203            0.0, 1.0, 2.0,
204            1.0, 0.0, 1.0,
205            2.0, 1.0, 0.0,
206        ];
207        let pd = PersistenceDiagram::from_distance_matrix(&dist, 3);
208        let tp = pd.total_persistence(1.0);
209        assert!(tp >= 0.0);
210    }
211
212    #[test]
213    fn empty_diagram() {
214        let pd = PersistenceDiagram::from_distance_matrix(&[], 0);
215        assert!(pd.points.is_empty());
216    }
217
218    #[test]
219    fn entropy_landscape_shape() {
220        let dists = vec![
221            vec![0.5, 0.5],
222            vec![0.3, 0.7],
223            vec![0.1, 0.9],
224        ];
225        let mat = entropy_landscape(&dists);
226        assert_eq!(mat.nrows(), 3);
227        assert_eq!(mat.ncols(), 3);
228        for i in 0..3 {
229            assert!((mat[[i, i]]).abs() < 1e-12);
230        }
231        assert!((mat[[0, 1]] - mat[[1, 0]]).abs() < 1e-12);
232    }
233}