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
443    #[test]
444    fn test_euclidean_to_poincare_zero() {
445        let result = euclidean_to_poincare(&[0.0, 0.0, 0.0], -1.0);
446        assert_eq!(result, vec![0.0, 0.0, 0.0]);
447    }
448
449    #[test]
450    fn test_euclidean_to_poincare_bounded() {
451        let c = -1.0;
452        // Large vector should be projected inside the ball
453        let result = euclidean_to_poincare(&[100.0, 100.0, 100.0], c);
454        let norm: f32 = result.iter().map(|v| v * v).sum::<f32>().sqrt();
455        let max_norm = 1.0 / c.abs().sqrt();
456        assert!(
457            norm < max_norm,
458            "Result should be inside the ball: norm={}, max={}",
459            norm,
460            max_norm
461        );
462    }
463
464    #[test]
465    fn test_hyperbolic_distance_same_point() {
466        let point = euclidean_to_poincare(&[0.5, 0.3], -1.0);
467        let dist = hyperbolic_distance(&point, &point, -1.0);
468        assert!(dist < 1e-5, "Distance from self should be ~0, got {}", dist);
469    }
470
471    #[test]
472    fn test_hyperbolic_distance_symmetry() {
473        let a = euclidean_to_poincare(&[1.0, 2.0], -1.0);
474        let b = euclidean_to_poincare(&[3.0, 1.0], -1.0);
475        let d_ab = hyperbolic_distance(&a, &b, -1.0);
476        let d_ba = hyperbolic_distance(&b, &a, -1.0);
477        assert!(
478            (d_ab - d_ba).abs() < 1e-4,
479            "Distance should be symmetric: {} vs {}",
480            d_ab,
481            d_ba
482        );
483    }
484
485    #[test]
486    fn test_hyperbolic_distance_triangle_inequality() {
487        let a = euclidean_to_poincare(&[1.0, 0.0], -1.0);
488        let b = euclidean_to_poincare(&[0.0, 1.0], -1.0);
489        let c = euclidean_to_poincare(&[2.0, 2.0], -1.0);
490
491        let d_ab = hyperbolic_distance(&a, &b, -1.0);
492        let d_bc = hyperbolic_distance(&b, &c, -1.0);
493        let d_ac = hyperbolic_distance(&a, &c, -1.0);
494
495        assert!(
496            d_ac <= d_ab + d_bc + 1e-4,
497            "Triangle inequality: d(a,c)={} should be <= d(a,b)+d(b,c)={}",
498            d_ac,
499            d_ab + d_bc
500        );
501    }
502
503    #[test]
504    fn test_mobius_add_identity() {
505        let a = euclidean_to_poincare(&[0.5, 0.3], -1.0);
506        let zero = vec![0.0, 0.0];
507        let result = mobius_add(&a, &zero, -1.0);
508        for (r, expected) in result.iter().zip(a.iter()) {
509            assert!((r - expected).abs() < 1e-4, "a ⊕ 0 should equal a");
510        }
511    }
512
513    #[test]
514    fn test_mobius_scalar_mul_zero() {
515        let v = euclidean_to_poincare(&[1.0, 2.0], -1.0);
516        let result = mobius_scalar_mul(0.0, &v, -1.0, 1e-5);
517        for r in &result {
518            assert!(r.abs() < 1e-4, "0 ⊗ v should be ~0, got {}", r);
519        }
520    }
521
522    #[test]
523    fn test_mobius_scalar_mul_one() {
524        let v = euclidean_to_poincare(&[1.0, 2.0], -1.0);
525        let result = mobius_scalar_mul(1.0, &v, -1.0, 1e-5);
526        for (r, expected) in result.iter().zip(v.iter()) {
527            assert!((r - expected).abs() < 1e-4, "1 ⊗ v should equal v");
528        }
529    }
530
531    #[test]
532    fn test_hyperbolic_embedding_add_and_search() {
533        let mut he = HyperbolicEmbedding::with_dimensions(3);
534
535        he.add("root", &[0.0, 0.0, 0.0]);
536        he.add("child_a", &[1.0, 0.0, 0.0]);
537        he.add("child_b", &[0.0, 1.0, 0.0]);
538        he.add("grandchild", &[1.0, 1.0, 0.0]);
539
540        assert_eq!(he.len(), 4);
541
542        // Nearest neighbor of child_a should be grandchild (closer in hierarchy)
543        let nn = he.nearest_neighbors("child_a", 2);
544        assert_eq!(nn.len(), 2);
545        // grandchild should be closer to child_a than child_b
546        let gc_dist = nn
547            .iter()
548            .find(|(name, _)| name == "grandchild")
549            .map(|(_, d)| *d);
550        let cb_dist = nn
551            .iter()
552            .find(|(name, _)| name == "child_b")
553            .map(|(_, d)| *d);
554        if let (Some(gc), Some(cb)) = (gc_dist, cb_dist) {
555            assert!(
556                gc < cb,
557                "grandchild should be closer to child_a than child_b"
558            );
559        }
560    }
561
562    #[test]
563    fn test_hyperbolic_embedding_depth() {
564        let mut he = HyperbolicEmbedding::with_dimensions(2);
565
566        he.add("root", &[0.0, 0.0]);
567        he.add("level1", &[0.5, 0.0]);
568        he.add("level2", &[1.0, 0.0]);
569
570        let root_depth = he.depth("root");
571        let l1_depth = he.depth("level1");
572        let l2_depth = he.depth("level2");
573
574        assert!(
575            root_depth < l1_depth,
576            "Root should be shallower: root={}, l1={}",
577            root_depth,
578            l1_depth
579        );
580        assert!(
581            l1_depth < l2_depth,
582            "Level1 should be shallower: l1={}, l2={}",
583            l1_depth,
584            l2_depth
585        );
586    }
587
588    #[test]
589    fn test_rank_by_depth() {
590        let mut he = HyperbolicEmbedding::with_dimensions(2);
591
592        he.add("leaf", &[2.0, 2.0]);
593        he.add("root", &[0.0, 0.0]);
594        he.add("mid", &[0.5, 0.5]);
595
596        let ranked = he.rank_by_depth();
597        assert_eq!(ranked[0].0, "root");
598        assert_eq!(ranked[1].0, "mid");
599        assert_eq!(ranked[2].0, "leaf");
600    }
601
602    #[test]
603    fn test_batch_conversion() {
604        let vectors = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![0.0, 0.0]];
605        let results = batch_euclidean_to_poincare(&vectors, -1.0);
606        assert_eq!(results.len(), 3);
607        // Last should be zero
608        assert_eq!(results[2], vec![0.0, 0.0]);
609    }
610
611    #[test]
612    fn test_curvature_effect() {
613        let v = [1.0, 1.0];
614
615        let p1 = euclidean_to_poincare(&v, -1.0);
616        let p2 = euclidean_to_poincare(&v, -2.0);
617
618        let norm1: f32 = p1.iter().map(|x| x * x).sum::<f32>().sqrt();
619        let norm2: f32 = p2.iter().map(|x| x * x).sum::<f32>().sqrt();
620
621        // Higher curvature magnitude → smaller ball → smaller norm
622        assert!(
623            norm2 < norm1,
624            "Higher curvature should produce smaller ball: {} vs {}",
625            norm2,
626            norm1
627        );
628    }
629
630    #[test]
631    fn test_add_child_hierarchy() {
632        let mut he = HyperbolicEmbedding::with_dimensions(3);
633
634        // Create a simple hierarchy
635        he.add("parent", &[1.0, 0.0, 0.0]);
636        he.add_child("parent", "child", &[0.5, 0.5, 0.0]);
637
638        assert_eq!(he.len(), 2);
639
640        // Both should exist
641        assert!(he.get("parent").is_some());
642        assert!(he.get("child").is_some());
643    }
644}