Skip to main content

geographdb_core/algorithms/
infogeo.rs

1use crate::algorithms::four_d::GraphNode4D;
2use std::collections::HashMap;
3
4#[derive(Debug, Clone)]
5pub struct InfoNode {
6    pub id: u64,
7    pub entropy: f32,
8}
9
10#[derive(Debug, Clone)]
11pub struct InfoEdge {
12    pub src: u64,
13    pub dst: u64,
14    /// D_KL(μ_src ∥ μ_dst)
15    pub kl_uv: f32,
16    /// D_KL(μ_dst ∥ μ_src)
17    pub kl_vu: f32,
18    /// Jensen-Shannon divergence (symmetric, bounded in [0, ln 2])
19    pub js_div: f32,
20    /// Fisher-Rao geodesic distance on the probability simplex
21    pub fisher_rao: f32,
22    pub entropy_src: f32,
23    pub entropy_dst: f32,
24}
25
26/// Information-geometric measures for every node and undirected edge.
27///
28/// For each node u the lazy random walk measure μ_u^α is built:
29///   μ_u(u) = α,  μ_u(v) = (1−α)/deg(u) for each out-neighbour v.
30///
31/// For each edge (u,v) this function computes KL divergence (both directions),
32/// Jensen-Shannon divergence, and the Fisher-Rao geodesic distance between
33/// μ_u^α and μ_v^α.
34pub fn info_geometry(nodes: &[GraphNode4D], alpha: f32) -> (Vec<InfoNode>, Vec<InfoEdge>) {
35    let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|n| (n.id, n)).collect();
36
37    let info_nodes = nodes
38        .iter()
39        .map(|node| {
40            let mu = build_measure(&node_map, node.id, alpha);
41            InfoNode {
42                id: node.id,
43                entropy: entropy(&mu),
44            }
45        })
46        .collect();
47
48    let mut info_edges = Vec::new();
49    for node in nodes {
50        let u = node.id;
51        for edge in &node.successors {
52            let v = edge.dst;
53            if v <= u {
54                continue; // each undirected edge once
55            }
56            let mu = build_measure(&node_map, u, alpha);
57            let nu = build_measure(&node_map, v, alpha);
58            info_edges.push(InfoEdge {
59                src: u,
60                dst: v,
61                kl_uv: kl_divergence(&mu, &nu),
62                kl_vu: kl_divergence(&nu, &mu),
63                js_div: js_divergence(&mu, &nu),
64                fisher_rao: fisher_rao_dist(&mu, &nu),
65                entropy_src: entropy(&mu),
66                entropy_dst: entropy(&nu),
67            });
68        }
69    }
70
71    (info_nodes, info_edges)
72}
73
74/// Lazy random walk measure (same construction as in ricci.rs).
75fn build_measure(node_map: &HashMap<u64, &GraphNode4D>, id: u64, alpha: f32) -> Vec<(u64, f32)> {
76    let node = match node_map.get(&id) {
77        Some(n) => n,
78        None => return vec![(id, 1.0)],
79    };
80    let deg = node.successors.len();
81    if deg == 0 {
82        return vec![(id, 1.0)];
83    }
84    let mut measure = Vec::with_capacity(deg + 1);
85    measure.push((id, alpha));
86    let w = (1.0 - alpha) / deg as f32;
87    for e in &node.successors {
88        measure.push((e.dst, w));
89    }
90    measure
91}
92
93/// Shannon entropy H(μ) = −Σ p(x) ln p(x).
94fn entropy(mu: &[(u64, f32)]) -> f32 {
95    mu.iter()
96        .filter(|&&(_, p)| p > 0.0)
97        .map(|&(_, p)| -p * p.ln())
98        .sum()
99}
100
101/// KL divergence D_KL(p ∥ q) = Σ p(x) ln(p(x)/q(x)).
102/// Returns f32::INFINITY if q(x) = 0 for any x where p(x) > 0.
103fn kl_divergence(p: &[(u64, f32)], q: &[(u64, f32)]) -> f32 {
104    let q_map: HashMap<u64, f32> = q.iter().cloned().collect();
105    let mut kl = 0.0_f32;
106    for &(id, pi) in p {
107        if pi <= 0.0 {
108            continue;
109        }
110        let qi = q_map.get(&id).copied().unwrap_or(0.0);
111        if qi <= 0.0 {
112            return f32::INFINITY;
113        }
114        kl += pi * (pi / qi).ln();
115    }
116    kl
117}
118
119/// Fisher-Rao geodesic distance on the probability simplex:
120///   d_FR(p,q) = 2 · arccos(Σ_x √(p(x)·q(x)))
121///
122/// Equals 0 when p = q and π when supports are disjoint.
123fn fisher_rao_dist(p: &[(u64, f32)], q: &[(u64, f32)]) -> f32 {
124    let q_map: HashMap<u64, f32> = q.iter().cloned().collect();
125    let bc: f32 = p
126        .iter()
127        .filter(|&&(_, pi)| pi > 0.0)
128        .map(|&(id, pi)| {
129            let qi = q_map.get(&id).copied().unwrap_or(0.0);
130            (pi * qi).sqrt()
131        })
132        .sum();
133    2.0 * bc.clamp(0.0, 1.0).acos()
134}
135
136/// Jensen-Shannon divergence JSD(p,q) = H(m) − ½(H(p)+H(q))  where m = (p+q)/2.
137fn js_divergence(p: &[(u64, f32)], q: &[(u64, f32)]) -> f32 {
138    let mut m_map: HashMap<u64, f32> = HashMap::new();
139    for &(id, pi) in p {
140        *m_map.entry(id).or_insert(0.0) += pi * 0.5;
141    }
142    for &(id, qi) in q {
143        *m_map.entry(id).or_insert(0.0) += qi * 0.5;
144    }
145    let m: Vec<(u64, f32)> = m_map.into_iter().collect();
146    (entropy(&m) - (entropy(p) + entropy(q)) * 0.5).max(0.0)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::algorithms::four_d::{GraphNode4D, GraphProperties, TemporalEdge};
153
154    fn node(id: u64, neighbors: &[u64]) -> GraphNode4D {
155        GraphNode4D {
156            id,
157            x: id as f32,
158            y: 0.0,
159            z: 0.0,
160            begin_ts: 0,
161            end_ts: 0,
162            properties: GraphProperties::default(),
163            successors: neighbors
164                .iter()
165                .map(|&dst| TemporalEdge {
166                    dst,
167                    weight: 1.0,
168                    begin_ts: 0,
169                    end_ts: 0,
170                })
171                .collect(),
172        }
173    }
174
175    // ── entropy ───────────────────────────────────────────────────────────────
176
177    #[test]
178    fn test_entropy_uniform_four_items() {
179        // Uniform over 4 items → H = ln(4)
180        let mu = vec![(0u64, 0.25), (1, 0.25), (2, 0.25), (3, 0.25)];
181        let h = entropy(&mu);
182        let expected = (4.0_f32).ln();
183        assert!(
184            (h - expected).abs() < 1e-5,
185            "H(uniform4) expected {expected:.6}, got {h:.6}"
186        );
187    }
188
189    #[test]
190    fn test_entropy_point_mass_is_zero() {
191        let mu = vec![(0u64, 1.0)];
192        assert!(entropy(&mu).abs() < 1e-6, "H(point mass) must be 0");
193    }
194
195    // ── KL divergence ─────────────────────────────────────────────────────────
196
197    #[test]
198    fn test_kl_identical_distributions_zero() {
199        let p = vec![(0u64, 0.5), (1, 0.3), (2, 0.2)];
200        assert!(kl_divergence(&p, &p).abs() < 1e-5, "D_KL(p‖p) must be 0");
201    }
202
203    #[test]
204    fn test_kl_orthogonal_distributions_infinite() {
205        let p = vec![(0u64, 0.5), (1, 0.5)];
206        let q = vec![(2u64, 0.5), (3, 0.5)];
207        assert!(
208            kl_divergence(&p, &q).is_infinite(),
209            "D_KL with disjoint support must be ∞"
210        );
211    }
212
213    #[test]
214    fn test_kl_triangle_measures_positive() {
215        // μ_0 = {0:0.5, 1:0.25, 2:0.25}, μ_1 = {0:0.25, 1:0.5, 2:0.25}
216        // D_KL = 0.25·ln(2) ≈ 0.1733
217        let mu0 = vec![(0u64, 0.5), (1, 0.25), (2, 0.25)];
218        let mu1 = vec![(1u64, 0.5), (0, 0.25), (2, 0.25)];
219        let kl = kl_divergence(&mu0, &mu1);
220        let expected = 0.25 * 2.0_f32.ln(); // ≈ 0.1733
221        assert!(
222            (kl - expected).abs() < 1e-4,
223            "D_KL(μ_0‖μ_1) for triangle expected {expected:.4}, got {kl:.4}"
224        );
225    }
226
227    // ── Fisher-Rao distance ───────────────────────────────────────────────────
228
229    #[test]
230    fn test_fisher_rao_identical_distributions_zero() {
231        let p = vec![(0u64, 0.5), (1, 0.5)];
232        assert!(fisher_rao_dist(&p, &p).abs() < 1e-5, "d_FR(p,p) must be 0");
233    }
234
235    #[test]
236    fn test_fisher_rao_orthogonal_distributions_pi() {
237        // Disjoint support → Bhattacharyya coefficient = 0 → d_FR = 2·arccos(0) = π
238        let p = vec![(0u64, 1.0)];
239        let q = vec![(1u64, 1.0)];
240        let d = fisher_rao_dist(&p, &q);
241        assert!(
242            (d - std::f32::consts::PI).abs() < 1e-5,
243            "d_FR with disjoint support expected π, got {d:.6}"
244        );
245    }
246
247    // ── Jensen-Shannon divergence ─────────────────────────────────────────────
248
249    #[test]
250    fn test_js_divergence_symmetry() {
251        let p = vec![(0u64, 0.6), (1, 0.4)];
252        let q = vec![(0u64, 0.2), (1, 0.8)];
253        let jspq = js_divergence(&p, &q);
254        let jsqp = js_divergence(&q, &p);
255        assert!(
256            (jspq - jsqp).abs() < 1e-6,
257            "JSD(p,q) and JSD(q,p) must be equal; got {jspq:.6} vs {jsqp:.6}"
258        );
259    }
260
261    // ── info_geometry integration ─────────────────────────────────────────────
262
263    #[test]
264    fn test_info_geometry_two_node_identical_measures() {
265        // Two-node graph 0↔1: α=0.5 → μ_0 = {0:0.5, 1:0.5} = μ_1
266        let nodes = vec![node(0, &[1]), node(1, &[0])];
267        let (info_nodes, info_edges) = info_geometry(&nodes, 0.5);
268        assert_eq!(info_nodes.len(), 2);
269        assert_eq!(info_edges.len(), 1);
270        let e = &info_edges[0];
271        assert!(
272            e.kl_uv.abs() < 1e-5,
273            "identical measures → kl_uv=0, got {}",
274            e.kl_uv
275        );
276        assert!(
277            e.fisher_rao.abs() < 1e-5,
278            "identical measures → FR=0, got {}",
279            e.fisher_rao
280        );
281        assert!(
282            e.js_div.abs() < 1e-5,
283            "identical measures → JS=0, got {}",
284            e.js_div
285        );
286    }
287
288    #[test]
289    fn test_info_geometry_triangle_positive_divergences() {
290        let nodes = vec![node(0, &[1, 2]), node(1, &[0, 2]), node(2, &[0, 1])];
291        let (info_nodes, info_edges) = info_geometry(&nodes, 0.5);
292        assert_eq!(info_nodes.len(), 3);
293        assert_eq!(info_edges.len(), 3);
294        // Measures are not identical → all divergences > 0
295        for e in &info_edges {
296            assert!(e.kl_uv > 0.0, "triangle kl_uv must be > 0");
297            assert!(e.fisher_rao > 0.0, "triangle fisher_rao must be > 0");
298            assert!(e.fisher_rao < std::f32::consts::PI, "fisher_rao < π");
299            assert!(e.js_div > 0.0, "triangle js_div must be > 0");
300        }
301        // Entropy of each node: H({self:0.5, n1:0.25, n2:0.25}) = 3/2·ln(2) ≈ 1.0397
302        for n in &info_nodes {
303            let expected = 1.5 * 2.0_f32.ln();
304            assert!(
305                (n.entropy - expected).abs() < 1e-4,
306                "triangle node entropy expected {expected:.4}, got {:.4}",
307                n.entropy
308            );
309        }
310    }
311}