Skip to main content

oxios_memory/memory/
hyperbolic.rs

1#![allow(missing_docs)]
2//! Hyperbolic embeddings using the Poincaré ball model.
3//!
4//! The Poincaré ball model embeds hierarchical data (trees, taxonomies,
5//! ontologies) in hyperbolic space where distances naturally encode
6//! hierarchical relationships. Nodes close to the root are near the
7//! origin; leaf nodes are near the boundary.
8//!
9//! Use cases in Oxios:
10//! - Persona hierarchy (parent → child relationships)
11//! - Skill graph (prerequisite chains)
12//! - Memory category taxonomy
13//!
14//! Reference: "Poincaré Embeddings for Learning Hierarchical
15//! Representations" (Nickel & Kiela, 2017)
16
17use serde::{Deserialize, Serialize};
18
19// ---------------------------------------------------------------------------
20// Configuration
21// ---------------------------------------------------------------------------
22
23/// Configuration for hyperbolic operations.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct HyperbolicConfig {
26    /// Curvature of the hyperbolic space.
27    /// Must be negative. Default: -1.0 (standard Poincaré ball).
28    pub curvature: f32,
29    /// Embedding dimensionality.
30    pub dimensions: usize,
31    /// Numerical stability epsilon.
32    pub epsilon: f32,
33}
34
35impl Default for HyperbolicConfig {
36    fn default() -> Self {
37        Self {
38            curvature: -1.0,
39            dimensions: 64,
40            epsilon: 1e-5,
41        }
42    }
43}
44
45impl HyperbolicConfig {
46    /// Create a new config with validation.
47    pub fn new(curvature: f32, dimensions: usize) -> Self {
48        assert!(
49            curvature < 0.0,
50            "Curvature must be negative for hyperbolic space"
51        );
52        Self {
53            curvature,
54            dimensions,
55            epsilon: 1e-5,
56        }
57    }
58
59    /// Returns the absolute value of curvature (c = |K|).
60    ///
61    /// All free functions in this module (e.g., `euclidean_to_poincare`,
62    /// `hyperbolic_distance`) already compute `curvature.abs()` internally,
63    /// so this accessor is not needed by callers of those functions.
64    /// It remains available for code that needs the positive curvature
65    /// value directly (e.g., computing ball radius `1/√c`).
66    #[allow(dead_code)]
67    fn c(&self) -> f32 {
68        self.curvature.abs()
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Poincaré ball operations
74// ---------------------------------------------------------------------------
75
76/// Convert a Euclidean vector to a point on the Poincaré ball.
77///
78/// Projects the vector onto the open unit ball with radius 1/√c.
79/// Points are clipped to stay strictly inside the ball.
80///
81/// # Arguments
82/// * `vector` - Euclidean vector
83/// * `curvature` - Negative curvature K (e.g., -1.0)
84///
85/// # Returns
86/// Point on the Poincaré ball
87pub fn euclidean_to_poincare(vector: &[f32], curvature: f32) -> Vec<f32> {
88    let c = curvature.abs();
89    let max_norm = 1.0 / c.sqrt();
90
91    // Compute Euclidean norm
92    let norm_sq: f32 = vector.iter().map(|v| v * v).sum();
93    let norm = norm_sq.sqrt();
94
95    if norm == 0.0 {
96        return vec![0.0; vector.len()];
97    }
98
99    // Map to ball: project and scale, keeping inside the boundary
100    // Use tanh-based mapping for smooth bounded projection
101    let scale = max_norm * norm.tanh() / norm;
102    vector.iter().map(|&v| v * scale).collect()
103}
104
105/// Batch-convert Euclidean vectors to Poincaré ball points.
106pub fn batch_euclidean_to_poincare(vectors: &[Vec<f32>], curvature: f32) -> Vec<Vec<f32>> {
107    vectors
108        .iter()
109        .map(|v| euclidean_to_poincare(v, curvature))
110        .collect()
111}
112
113/// Compute the hyperbolic distance between two points on the Poincaré ball.
114///
115/// d(x, y) = (1/√c) * arcosh(1 + 2c * δ(x, y) / ((1 - c||x||²)(1 - c||y||²)))
116///
117/// where δ(x, y) = ||x - y||²
118pub fn hyperbolic_distance(a: &[f32], b: &[f32], curvature: f32) -> f32 {
119    let c = curvature.abs();
120
121    let norm_a_sq: f32 = a.iter().map(|v| v * v).sum();
122    let norm_b_sq: f32 = b.iter().map(|v| v * v).sum();
123
124    let diff_sq: f32 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum();
125
126    let denominator = (1.0 - c * norm_a_sq) * (1.0 - c * norm_b_sq);
127
128    if denominator <= 0.0 {
129        // Points on or beyond the boundary — return max distance
130        return f32::MAX;
131    }
132
133    let arg = 1.0 + 2.0 * c * diff_sq / denominator;
134
135    if arg <= 1.0 {
136        // Same point or very close
137        return 0.0;
138    }
139
140    // arcosh(arg) = ln(arg + sqrt(arg² − 1)). The previous formula computed
141    // sqrt(ln(arg)), which is wrong (e.g. arg=2 → 0.833 instead of arcosh(2)
142    // ≈ 1.317), corrupting depth/hierarchy estimates in Poincaré embeddings.
143    (1.0 / c.sqrt()) * (arg + (arg * arg - 1.0).sqrt()).ln()
144}
145
146/// Möbius addition: the hyperbolic analog of vector addition.
147///
148/// a ⊕_c b = ((1 + 2c⟨a,b⟩ + c||b||²)a + (1 - c||a||²)b) /
149///           (1 + 2c⟨a,b⟩ + c²||a||²||b||²)
150pub fn mobius_add(a: &[f32], b: &[f32], curvature: f32) -> Vec<f32> {
151    let c = curvature.abs();
152
153    let norm_a_sq: f32 = a.iter().map(|v| v * v).sum();
154    let norm_b_sq: f32 = b.iter().map(|v| v * v).sum();
155    let dot_ab: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
156
157    let denominator = 1.0 + 2.0 * c * dot_ab + c * c * norm_a_sq * norm_b_sq;
158
159    if denominator.abs() < 1e-10 {
160        // Degenerate case: return origin (neutral element)
161        return vec![0.0; a.len()];
162    }
163
164    let scale_a = 1.0 + 2.0 * c * dot_ab + c * norm_b_sq;
165    let scale_b = 1.0 - c * norm_a_sq;
166
167    a.iter()
168        .zip(b)
169        .map(|(&ai, &bi)| (scale_a * ai + scale_b * bi) / denominator)
170        .collect()
171}
172
173/// Möbius scalar multiplication: scaling in hyperbolic space.
174///
175/// s ⊗_c v = (1/√c) * tanh(s * arctanh(√c * ||v||)) * v / ||v||
176///
177/// # Arguments
178/// * `scalar` - Multiplication factor
179/// * `v` - Vector on the Poincaré ball
180/// * `curvature` - Negative curvature K
181/// * `epsilon` - Numerical stability margin (e.g. 1e-5)
182pub fn mobius_scalar_mul(scalar: f32, v: &[f32], curvature: f32, epsilon: f32) -> Vec<f32> {
183    let c = curvature.abs();
184    let norm_sq: f32 = v.iter().map(|x| x * x).sum();
185    let norm = norm_sq.sqrt();
186
187    if norm < epsilon {
188        return vec![0.0; v.len()];
189    }
190
191    let c_sqrt = c.sqrt();
192    let w = c_sqrt * norm;
193
194    // Clamp w to strictly less than 1 for numerical stability
195    let w = w.min(1.0 - epsilon);
196    let result_norm = (1.0 / c_sqrt) * (scalar * w.atanh()).tanh();
197
198    let scale = result_norm / norm;
199    v.iter().map(|&vi| vi * scale).collect()
200}
201
202// ---------------------------------------------------------------------------
203// HyperbolicEmbedding — higher-level interface
204// ---------------------------------------------------------------------------
205
206/// Hyperbolic embedding manager for hierarchical data.
207///
208/// Provides a convenient interface for storing and querying
209/// hierarchical embeddings in Poincaré ball space.
210pub struct HyperbolicEmbedding {
211    config: HyperbolicConfig,
212    /// Named embeddings: id → Poincaré ball point.
213    embeddings: Vec<(String, Vec<f32>)>,
214}
215
216impl HyperbolicEmbedding {
217    /// Create a new hyperbolic embedding manager.
218    pub fn new(config: HyperbolicConfig) -> Self {
219        Self {
220            config,
221            embeddings: Vec::new(),
222        }
223    }
224
225    /// Build a `HyperbolicEmbedding` from pre-existing (id, vector) pairs.
226    ///
227    /// Useful for restoring from a serialized source (e.g. SQLite `dream_state`).
228    /// The vectors are stored as-is — call sites must ensure they were
229    /// previously projected to the Poincaré ball.
230    pub fn from_pairs(pairs: Vec<(String, Vec<f32>)>) -> Self {
231        // Default config; the actual curvature is encoded in the vectors
232        // themselves (they were projected to a specific ball).
233        Self {
234            config: HyperbolicConfig::default(),
235            embeddings: pairs,
236        }
237    }
238
239    /// Create with default configuration.
240    pub fn with_dimensions(dimensions: usize) -> Self {
241        let config = HyperbolicConfig {
242            dimensions,
243            ..Default::default()
244        };
245        Self::new(config)
246    }
247
248    /// Add a Euclidean vector as a named embedding.
249    ///
250    /// Converts to Poincaré ball coordinates.
251    pub fn add(&mut self, id: &str, euclidean: &[f32]) {
252        let poincare = euclidean_to_poincare(euclidean, self.config.curvature);
253        // Replace if exists
254        if let Some(pos) = self.embeddings.iter().position(|(name, _)| name == id) {
255            self.embeddings[pos] = (id.to_string(), poincare);
256        } else {
257            self.embeddings.push((id.to_string(), poincare));
258        }
259    }
260
261    /// Add a parent-child relationship using Möbius addition.
262    ///
263    /// The child is placed at `parent ⊕ child_euclidean`, which naturally
264    /// positions it farther from the origin along the parent's direction.
265    pub fn add_child(&mut self, parent_id: &str, child_id: &str, child_euclidean: &[f32]) {
266        let child_on_ball = euclidean_to_poincare(child_euclidean, self.config.curvature);
267
268        let child_point = if let Some((_, parent_vec)) =
269            self.embeddings.iter().find(|(name, _)| name == parent_id)
270        {
271            // Use Möbius addition: child = parent ⊕ child_offset
272            // This naturally places the child deeper in the hierarchy
273            mobius_add(parent_vec, &child_on_ball, self.config.curvature)
274        } else {
275            child_on_ball
276        };
277
278        if let Some(pos) = self
279            .embeddings
280            .iter()
281            .position(|(name, _)| name == child_id)
282        {
283            self.embeddings[pos] = (child_id.to_string(), child_point);
284        } else {
285            self.embeddings.push((child_id.to_string(), child_point));
286        }
287    }
288
289    /// Get the hyperbolic embedding for a given id.
290    pub fn get(&self, id: &str) -> Option<&[f32]> {
291        self.embeddings
292            .iter()
293            .find(|(name, _)| name == id)
294            .map(|(_, v)| v.as_slice())
295    }
296
297    /// Find the k nearest neighbors in hyperbolic space.
298    ///
299    /// Returns (id, distance) pairs sorted by distance.
300    pub fn nearest_neighbors(&self, query_id: &str, k: usize) -> Vec<(String, f32)> {
301        let query = match self.get(query_id) {
302            Some(v) => v.to_vec(),
303            None => return Vec::new(),
304        };
305
306        let mut results: Vec<(String, f32)> = self
307            .embeddings
308            .iter()
309            .filter(|(name, _)| name != query_id)
310            .map(|(name, vec)| {
311                let dist = hyperbolic_distance(&query, vec, self.config.curvature);
312                (name.clone(), dist)
313            })
314            .collect();
315
316        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
317        results.truncate(k);
318        results
319    }
320
321    /// Find nearest neighbors for an arbitrary Euclidean query.
322    pub fn search(&self, query: &[f32], k: usize) -> Vec<(String, f32)> {
323        let query_poincare = euclidean_to_poincare(query, self.config.curvature);
324
325        let mut results: Vec<(String, f32)> = self
326            .embeddings
327            .iter()
328            .map(|(name, vec)| {
329                let dist = hyperbolic_distance(&query_poincare, vec, self.config.curvature);
330                (name.clone(), dist)
331            })
332            .collect();
333
334        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
335        results.truncate(k);
336        results
337    }
338
339    /// Compute the hierarchical distance between two embeddings.
340    ///
341    /// In hierarchical data, nodes deeper in the tree are farther from
342    /// the origin. This function returns the hyperbolic distance plus
343    /// a depth penalty.
344    pub fn hierarchical_distance(&self, id_a: &str, id_b: &str) -> f32 {
345        let a = match self.get(id_a) {
346            Some(v) => v,
347            None => return f32::MAX,
348        };
349        let b = match self.get(id_b) {
350            Some(v) => v,
351            None => return f32::MAX,
352        };
353
354        hyperbolic_distance(a, b, self.config.curvature)
355    }
356
357    /// Returns the number of stored embeddings.
358    pub fn len(&self) -> usize {
359        self.embeddings.len()
360    }
361
362    /// Returns true if no embeddings stored.
363    pub fn is_empty(&self) -> bool {
364        self.embeddings.is_empty()
365    }
366
367    /// Returns all embedding ids.
368    pub fn ids(&self) -> Vec<&str> {
369        self.embeddings
370            .iter()
371            .map(|(name, _)| name.as_str())
372            .collect()
373    }
374
375    /// Returns all embeddings as (id, vector) pairs.
376    pub fn all_embeddings(&self) -> &[(String, Vec<f32>)] {
377        &self.embeddings
378    }
379
380    // ── Phase 5: SQLite Persistence ────────────────────────────────
381    // Moved to `crate::sqlite::hyperbolic_persist` (RFC-018 b.8).
382    // The cfg-gated SQLite-specific methods live there as free functions
383    // that take `SqliteMemoryStore` as a parameter. The pure-math core
384    // remains here in `HyperbolicEmbedding`.
385
386    /// Find memories near a query in hyperbolic space.
387    ///
388    /// Returns (memory_id, hyperbolic_distance) pairs.
389    /// Useful for hierarchical navigation: memories close to the
390    /// root are general; those near the boundary are specific.
391    pub fn search_memories(&self, query_euclidean: &[f32], k: usize) -> Vec<(String, f32)> {
392        self.search(query_euclidean, k)
393    }
394
395    /// Get the hierarchical depth rank of all memories.
396    ///
397    /// Memories closer to the origin are more general/root-level.
398    /// Memories farther from origin are more specific/leaf-level.
399    ///
400    /// Returns (id, depth) pairs sorted by depth ascending.
401    pub fn hierarchical_rank(&self) -> Vec<(String, f32)> {
402        self.rank_by_depth()
403    }
404
405    /// Get the hyperbolic distance of a point from the origin.
406    ///
407    /// Points closer to the origin are "higher" in the hierarchy.
408    pub fn depth(&self, id: &str) -> f32 {
409        match self.get(id) {
410            Some(v) => hyperbolic_distance(&vec![0.0; v.len()], v, self.config.curvature),
411            None => f32::MAX,
412        }
413    }
414
415    /// Rank all embeddings by depth (origin distance).
416    ///
417    /// Returns (id, depth) pairs sorted by depth ascending.
418    /// Items with lower depth are closer to the root of the hierarchy.
419    pub fn rank_by_depth(&self) -> Vec<(String, f32)> {
420        let mut ranked: Vec<(String, f32)> = self
421            .embeddings
422            .iter()
423            .map(|(name, vec)| {
424                let origin = vec![0.0; vec.len()];
425                let d = hyperbolic_distance(&origin, vec, self.config.curvature);
426                (name.clone(), d)
427            })
428            .collect();
429
430        ranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
431        ranked
432    }
433}
434
435// ---------------------------------------------------------------------------
436// Tests
437// ---------------------------------------------------------------------------
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use proptest::prelude::*;
443
444    // -----------------------------------------------------------------------
445    // Property tests — invariant-style checks proptest is built for.
446    // The inline #[test] cases above pin specific shapes; these exercise
447    // a wider input space and catch subtle floating-point regressions.
448    // -----------------------------------------------------------------------
449
450    proptest! {
451        #![proptest_config(ProptestConfig::with_cases(64))]
452
453        /// euclidean_to_poincare preserves length: input and output have
454        /// the same number of dimensions.
455        #[test]
456        fn prop_euclidean_to_poincare_preserves_dim(
457            v in proptest::collection::vec(-2.0_f32..2.0, 1..16),
458        ) {
459            let out = euclidean_to_poincare(&v, -1.0);
460            prop_assert_eq!(out.len(), v.len());
461        }
462
463        /// euclidean_to_poincare(zero) == zero for any dimension.
464        #[test]
465        fn prop_euclidean_to_poincare_zero_is_zero(dim in 1_usize..16) {
466            let v = vec![0.0_f32; dim];
467            let out = euclidean_to_poincare(&v, -1.0);
468            prop_assert_eq!(out, v);
469        }
470
471        /// Result always lies strictly inside the open ball: norm < 1/√c.
472        /// (Note: the function uses tanh so the norm is actually bounded
473        /// strictly less than max_norm for any non-zero input.)
474        #[test]
475        fn prop_euclidean_to_poincare_stays_in_ball(
476            v in proptest::collection::vec(-1.0_f32..1.0, 1..8),
477        ) {
478            let out = euclidean_to_poincare(&v, -1.0);
479            let norm_sq: f32 = out.iter().map(|x| x * x).sum();
480            let norm = norm_sq.sqrt();
481            let max_norm = 1.0 / 1.0_f32.sqrt(); // c = |-1.0| = 1.0
482            prop_assert!(norm <= max_norm + 1e-5, "norm {} > max {}", norm, max_norm);
483        }
484
485        /// hyperbolic_distance is non-negative for any two inputs.
486        #[test]
487        fn prop_distance_is_nonneg(
488            a in proptest::collection::vec(-0.5_f32..0.5, 1..8),
489            b in proptest::collection::vec(-0.5_f32..0.5, 1..8),
490        ) {
491            let d = hyperbolic_distance(&a, &b, -1.0);
492            prop_assert!(d >= 0.0, "distance must be non-negative, got {}", d);
493        }
494
495        /// hyperbolic_distance(a, a) == 0 (identity of indiscernibles).
496        #[test]
497        fn prop_distance_self_is_zero(
498            a in proptest::collection::vec(-0.5_f32..0.5, 1..8),
499        ) {
500            let d = hyperbolic_distance(&a, &a, -1.0);
501            // Allow tiny epsilon for floating-point noise near boundary.
502            prop_assert!(d < 1e-4, "distance(a, a) should be 0, got {}", d);
503        }
504
505        /// hyperbolic_distance is symmetric: d(a, b) == d(b, a).
506        #[test]
507        fn prop_distance_is_symmetric(
508            a in proptest::collection::vec(-0.5_f32..0.5, 1..8),
509            b in proptest::collection::vec(-0.5_f32..0.5, 1..8),
510        ) {
511            let d_ab = hyperbolic_distance(&a, &b, -1.0);
512            let d_ba = hyperbolic_distance(&b, &a, -1.0);
513            prop_assert!(
514                (d_ab - d_ba).abs() < 1e-4,
515                "d(a,b)={} d(b,a)={}",
516                d_ab,
517                d_ba
518            );
519        }
520
521        /// Triangle inequality: d(a, c) ≤ d(a, b) + d(b, c).
522        #[test]
523        fn prop_distance_triangle_inequality(
524            (a, b, c) in (1usize..6).prop_flat_map(|n| (
525                proptest::collection::vec(-0.4_f32..0.4, n.clone()),
526                proptest::collection::vec(-0.4_f32..0.4, n.clone()),
527                proptest::collection::vec(-0.4_f32..0.4, n),
528            )),
529        ) {
530            let d_ab = hyperbolic_distance(&a, &b, -1.0);
531            let d_bc = hyperbolic_distance(&b, &c, -1.0);
532            let d_ac = hyperbolic_distance(&a, &c, -1.0);
533            // f32::MAX sentinel for boundary points can break the inequality.
534            if d_ab < f32::MAX && d_bc < f32::MAX && d_ac < f32::MAX {
535                prop_assert!(
536                    d_ac <= d_ab + d_bc + 1e-2,
537                    "triangle inequality violated: d(a,c)={} d(a,b)={} d(b,c)={}",
538                    d_ac,
539                    d_ab,
540                    d_bc
541                );
542            }
543        }
544
545        /// mobius_add preserves dimension.
546        #[test]
547        fn prop_mobius_add_preserves_dim(
548            (a, b) in (1usize..8).prop_flat_map(|n| (
549                proptest::collection::vec(-0.4_f32..0.4, n.clone()),
550                proptest::collection::vec(-0.4_f32..0.4, n),
551            )),
552        ) {
553            let sum = mobius_add(&a, &b, -1.0);
554            prop_assert_eq!(sum.len(), a.len());
555        }
556
557        /// mobius_add(a, 0) == a (zero is the additive identity).
558        #[test]
559        fn prop_mobius_add_zero_identity(
560            a in proptest::collection::vec(-0.4_f32..0.4, 1..8),
561        ) {
562            let zero = vec![0.0_f32; a.len()];
563            let sum = mobius_add(&a, &zero, -1.0);
564            for (got, want) in sum.iter().zip(a.iter()) {
565                prop_assert!((got - want).abs() < 1e-4, "{} vs {}", got, want);
566            }
567        }
568
569        /// mobius_scalar_mul(0, v) == 0 (zero scalar kills the vector).
570        #[test]
571        fn prop_mobius_scalar_mul_zero(
572            v in proptest::collection::vec(-0.4_f32..0.4, 1..8),
573        ) {
574            let r = mobius_scalar_mul(0.0, &v, -1.0, 1e-5);
575            for x in &r {
576                prop_assert!(x.abs() < 1e-4, "expected 0, got {}", x);
577            }
578        }
579    }
580    #[test]
581    fn test_euclidean_to_poincare_zero() {
582        let result = euclidean_to_poincare(&[0.0, 0.0, 0.0], -1.0);
583        assert_eq!(result, vec![0.0, 0.0, 0.0]);
584    }
585
586    #[test]
587    fn test_euclidean_to_poincare_bounded() {
588        let c = -1.0;
589        // Large vector should be projected inside the ball
590        let result = euclidean_to_poincare(&[100.0, 100.0, 100.0], c);
591        let norm: f32 = result.iter().map(|v| v * v).sum::<f32>().sqrt();
592        let max_norm = 1.0 / c.abs().sqrt();
593        assert!(
594            norm < max_norm,
595            "Result should be inside the ball: norm={}, max={}",
596            norm,
597            max_norm
598        );
599    }
600
601    #[test]
602    fn test_hyperbolic_distance_same_point() {
603        let point = euclidean_to_poincare(&[0.5, 0.3], -1.0);
604        let dist = hyperbolic_distance(&point, &point, -1.0);
605        assert!(dist < 1e-5, "Distance from self should be ~0, got {}", dist);
606    }
607
608    #[test]
609    fn test_hyperbolic_distance_symmetry() {
610        let a = euclidean_to_poincare(&[1.0, 2.0], -1.0);
611        let b = euclidean_to_poincare(&[3.0, 1.0], -1.0);
612        let d_ab = hyperbolic_distance(&a, &b, -1.0);
613        let d_ba = hyperbolic_distance(&b, &a, -1.0);
614        assert!(
615            (d_ab - d_ba).abs() < 1e-4,
616            "Distance should be symmetric: {} vs {}",
617            d_ab,
618            d_ba
619        );
620    }
621
622    #[test]
623    fn test_hyperbolic_distance_triangle_inequality() {
624        let a = euclidean_to_poincare(&[1.0, 0.0], -1.0);
625        let b = euclidean_to_poincare(&[0.0, 1.0], -1.0);
626        let c = euclidean_to_poincare(&[2.0, 2.0], -1.0);
627
628        let d_ab = hyperbolic_distance(&a, &b, -1.0);
629        let d_bc = hyperbolic_distance(&b, &c, -1.0);
630        let d_ac = hyperbolic_distance(&a, &c, -1.0);
631
632        assert!(
633            d_ac <= d_ab + d_bc + 1e-4,
634            "Triangle inequality: d(a,c)={} should be <= d(a,b)+d(b,c)={}",
635            d_ac,
636            d_ab + d_bc
637        );
638    }
639
640    #[test]
641    fn test_mobius_add_identity() {
642        let a = euclidean_to_poincare(&[0.5, 0.3], -1.0);
643        let zero = vec![0.0, 0.0];
644        let result = mobius_add(&a, &zero, -1.0);
645        for (r, expected) in result.iter().zip(a.iter()) {
646            assert!((r - expected).abs() < 1e-4, "a ⊕ 0 should equal a");
647        }
648    }
649
650    #[test]
651    fn test_mobius_scalar_mul_zero() {
652        let v = euclidean_to_poincare(&[1.0, 2.0], -1.0);
653        let result = mobius_scalar_mul(0.0, &v, -1.0, 1e-5);
654        for r in &result {
655            assert!(r.abs() < 1e-4, "0 ⊗ v should be ~0, got {}", r);
656        }
657    }
658
659    #[test]
660    fn test_mobius_scalar_mul_one() {
661        let v = euclidean_to_poincare(&[1.0, 2.0], -1.0);
662        let result = mobius_scalar_mul(1.0, &v, -1.0, 1e-5);
663        for (r, expected) in result.iter().zip(v.iter()) {
664            assert!((r - expected).abs() < 1e-4, "1 ⊗ v should equal v");
665        }
666    }
667
668    #[test]
669    fn test_hyperbolic_embedding_add_and_search() {
670        let mut he = HyperbolicEmbedding::with_dimensions(3);
671
672        he.add("root", &[0.0, 0.0, 0.0]);
673        he.add("child_a", &[1.0, 0.0, 0.0]);
674        he.add("child_b", &[0.0, 1.0, 0.0]);
675        he.add("grandchild", &[1.0, 1.0, 0.0]);
676
677        assert_eq!(he.len(), 4);
678
679        // Nearest neighbor of child_a should be grandchild (closer in hierarchy)
680        let nn = he.nearest_neighbors("child_a", 2);
681        assert_eq!(nn.len(), 2);
682        // grandchild should be closer to child_a than child_b
683        let gc_dist = nn
684            .iter()
685            .find(|(name, _)| name == "grandchild")
686            .map(|(_, d)| *d);
687        let cb_dist = nn
688            .iter()
689            .find(|(name, _)| name == "child_b")
690            .map(|(_, d)| *d);
691        if let (Some(gc), Some(cb)) = (gc_dist, cb_dist) {
692            assert!(
693                gc < cb,
694                "grandchild should be closer to child_a than child_b"
695            );
696        }
697    }
698
699    #[test]
700    fn test_hyperbolic_embedding_depth() {
701        let mut he = HyperbolicEmbedding::with_dimensions(2);
702
703        he.add("root", &[0.0, 0.0]);
704        he.add("level1", &[0.5, 0.0]);
705        he.add("level2", &[1.0, 0.0]);
706
707        let root_depth = he.depth("root");
708        let l1_depth = he.depth("level1");
709        let l2_depth = he.depth("level2");
710
711        assert!(
712            root_depth < l1_depth,
713            "Root should be shallower: root={}, l1={}",
714            root_depth,
715            l1_depth
716        );
717        assert!(
718            l1_depth < l2_depth,
719            "Level1 should be shallower: l1={}, l2={}",
720            l1_depth,
721            l2_depth
722        );
723    }
724
725    #[test]
726    fn test_rank_by_depth() {
727        let mut he = HyperbolicEmbedding::with_dimensions(2);
728
729        he.add("leaf", &[2.0, 2.0]);
730        he.add("root", &[0.0, 0.0]);
731        he.add("mid", &[0.5, 0.5]);
732
733        let ranked = he.rank_by_depth();
734        assert_eq!(ranked[0].0, "root");
735        assert_eq!(ranked[1].0, "mid");
736        assert_eq!(ranked[2].0, "leaf");
737    }
738
739    #[test]
740    fn test_batch_conversion() {
741        let vectors = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![0.0, 0.0]];
742        let results = batch_euclidean_to_poincare(&vectors, -1.0);
743        assert_eq!(results.len(), 3);
744        // Last should be zero
745        assert_eq!(results[2], vec![0.0, 0.0]);
746    }
747
748    #[test]
749    fn test_curvature_effect() {
750        let v = [1.0, 1.0];
751
752        let p1 = euclidean_to_poincare(&v, -1.0);
753        let p2 = euclidean_to_poincare(&v, -2.0);
754
755        let norm1: f32 = p1.iter().map(|x| x * x).sum::<f32>().sqrt();
756        let norm2: f32 = p2.iter().map(|x| x * x).sum::<f32>().sqrt();
757
758        // Higher curvature magnitude → smaller ball → smaller norm
759        assert!(
760            norm2 < norm1,
761            "Higher curvature should produce smaller ball: {} vs {}",
762            norm2,
763            norm1
764        );
765    }
766
767    #[test]
768    fn test_add_child_hierarchy() {
769        let mut he = HyperbolicEmbedding::with_dimensions(3);
770
771        // Create a simple hierarchy
772        he.add("parent", &[1.0, 0.0, 0.0]);
773        he.add_child("parent", "child", &[0.5, 0.5, 0.0]);
774
775        assert_eq!(he.len(), 2);
776
777        // Both should exist
778        assert!(he.get("parent").is_some());
779        assert!(he.get("child").is_some());
780    }
781}