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    (1.0 / c.sqrt()) * arg.ln().max(0.0).sqrt()
141}
142
143/// Möbius addition: the hyperbolic analog of vector addition.
144///
145/// a ⊕_c b = ((1 + 2c⟨a,b⟩ + c||b||²)a + (1 - c||a||²)b) /
146///           (1 + 2c⟨a,b⟩ + c²||a||²||b||²)
147pub fn mobius_add(a: &[f32], b: &[f32], curvature: f32) -> Vec<f32> {
148    let c = curvature.abs();
149
150    let norm_a_sq: f32 = a.iter().map(|v| v * v).sum();
151    let norm_b_sq: f32 = b.iter().map(|v| v * v).sum();
152    let dot_ab: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
153
154    let denominator = 1.0 + 2.0 * c * dot_ab + c * c * norm_a_sq * norm_b_sq;
155
156    if denominator.abs() < 1e-10 {
157        // Degenerate case: return origin (neutral element)
158        return vec![0.0; a.len()];
159    }
160
161    let scale_a = 1.0 + 2.0 * c * dot_ab + c * norm_b_sq;
162    let scale_b = 1.0 - c * norm_a_sq;
163
164    a.iter()
165        .zip(b)
166        .map(|(&ai, &bi)| (scale_a * ai + scale_b * bi) / denominator)
167        .collect()
168}
169
170/// Möbius scalar multiplication: scaling in hyperbolic space.
171///
172/// s ⊗_c v = (1/√c) * tanh(s * arctanh(√c * ||v||)) * v / ||v||
173///
174/// # Arguments
175/// * `scalar` - Multiplication factor
176/// * `v` - Vector on the Poincaré ball
177/// * `curvature` - Negative curvature K
178/// * `epsilon` - Numerical stability margin (e.g. 1e-5)
179pub fn mobius_scalar_mul(scalar: f32, v: &[f32], curvature: f32, epsilon: f32) -> Vec<f32> {
180    let c = curvature.abs();
181    let norm_sq: f32 = v.iter().map(|x| x * x).sum();
182    let norm = norm_sq.sqrt();
183
184    if norm < epsilon {
185        return vec![0.0; v.len()];
186    }
187
188    let c_sqrt = c.sqrt();
189    let w = c_sqrt * norm;
190
191    // Clamp w to strictly less than 1 for numerical stability
192    let w = w.min(1.0 - epsilon);
193    let result_norm = (1.0 / c_sqrt) * (scalar * w.atanh()).tanh();
194
195    let scale = result_norm / norm;
196    v.iter().map(|&vi| vi * scale).collect()
197}
198
199// ---------------------------------------------------------------------------
200// HyperbolicEmbedding — higher-level interface
201// ---------------------------------------------------------------------------
202
203/// Hyperbolic embedding manager for hierarchical data.
204///
205/// Provides a convenient interface for storing and querying
206/// hierarchical embeddings in Poincaré ball space.
207pub struct HyperbolicEmbedding {
208    config: HyperbolicConfig,
209    /// Named embeddings: id → Poincaré ball point.
210    embeddings: Vec<(String, Vec<f32>)>,
211}
212
213impl HyperbolicEmbedding {
214    /// Create a new hyperbolic embedding manager.
215    pub fn new(config: HyperbolicConfig) -> Self {
216        Self {
217            config,
218            embeddings: Vec::new(),
219        }
220    }
221
222    /// Build a `HyperbolicEmbedding` from pre-existing (id, vector) pairs.
223    ///
224    /// Useful for restoring from a serialized source (e.g. SQLite `dream_state`).
225    /// The vectors are stored as-is — call sites must ensure they were
226    /// previously projected to the Poincaré ball.
227    pub fn from_pairs(pairs: Vec<(String, Vec<f32>)>) -> Self {
228        // Default config; the actual curvature is encoded in the vectors
229        // themselves (they were projected to a specific ball).
230        Self {
231            config: HyperbolicConfig::default(),
232            embeddings: pairs,
233        }
234    }
235
236    /// Create with default configuration.
237    pub fn with_dimensions(dimensions: usize) -> Self {
238        let config = HyperbolicConfig {
239            dimensions,
240            ..Default::default()
241        };
242        Self::new(config)
243    }
244
245    /// Add a Euclidean vector as a named embedding.
246    ///
247    /// Converts to Poincaré ball coordinates.
248    pub fn add(&mut self, id: &str, euclidean: &[f32]) {
249        let poincare = euclidean_to_poincare(euclidean, self.config.curvature);
250        // Replace if exists
251        if let Some(pos) = self.embeddings.iter().position(|(name, _)| name == id) {
252            self.embeddings[pos] = (id.to_string(), poincare);
253        } else {
254            self.embeddings.push((id.to_string(), poincare));
255        }
256    }
257
258    /// Add a parent-child relationship using Möbius addition.
259    ///
260    /// The child is placed at `parent ⊕ child_euclidean`, which naturally
261    /// positions it farther from the origin along the parent's direction.
262    pub fn add_child(&mut self, parent_id: &str, child_id: &str, child_euclidean: &[f32]) {
263        let child_on_ball = euclidean_to_poincare(child_euclidean, self.config.curvature);
264
265        let child_point = if let Some((_, parent_vec)) =
266            self.embeddings.iter().find(|(name, _)| name == parent_id)
267        {
268            // Use Möbius addition: child = parent ⊕ child_offset
269            // This naturally places the child deeper in the hierarchy
270            mobius_add(parent_vec, &child_on_ball, self.config.curvature)
271        } else {
272            child_on_ball
273        };
274
275        if let Some(pos) = self
276            .embeddings
277            .iter()
278            .position(|(name, _)| name == child_id)
279        {
280            self.embeddings[pos] = (child_id.to_string(), child_point);
281        } else {
282            self.embeddings.push((child_id.to_string(), child_point));
283        }
284    }
285
286    /// Get the hyperbolic embedding for a given id.
287    pub fn get(&self, id: &str) -> Option<&[f32]> {
288        self.embeddings
289            .iter()
290            .find(|(name, _)| name == id)
291            .map(|(_, v)| v.as_slice())
292    }
293
294    /// Find the k nearest neighbors in hyperbolic space.
295    ///
296    /// Returns (id, distance) pairs sorted by distance.
297    pub fn nearest_neighbors(&self, query_id: &str, k: usize) -> Vec<(String, f32)> {
298        let query = match self.get(query_id) {
299            Some(v) => v.to_vec(),
300            None => return Vec::new(),
301        };
302
303        let mut results: Vec<(String, f32)> = self
304            .embeddings
305            .iter()
306            .filter(|(name, _)| name != query_id)
307            .map(|(name, vec)| {
308                let dist = hyperbolic_distance(&query, vec, self.config.curvature);
309                (name.clone(), dist)
310            })
311            .collect();
312
313        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
314        results.truncate(k);
315        results
316    }
317
318    /// Find nearest neighbors for an arbitrary Euclidean query.
319    pub fn search(&self, query: &[f32], k: usize) -> Vec<(String, f32)> {
320        let query_poincare = euclidean_to_poincare(query, self.config.curvature);
321
322        let mut results: Vec<(String, f32)> = self
323            .embeddings
324            .iter()
325            .map(|(name, vec)| {
326                let dist = hyperbolic_distance(&query_poincare, vec, self.config.curvature);
327                (name.clone(), dist)
328            })
329            .collect();
330
331        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
332        results.truncate(k);
333        results
334    }
335
336    /// Compute the hierarchical distance between two embeddings.
337    ///
338    /// In hierarchical data, nodes deeper in the tree are farther from
339    /// the origin. This function returns the hyperbolic distance plus
340    /// a depth penalty.
341    pub fn hierarchical_distance(&self, id_a: &str, id_b: &str) -> f32 {
342        let a = match self.get(id_a) {
343            Some(v) => v,
344            None => return f32::MAX,
345        };
346        let b = match self.get(id_b) {
347            Some(v) => v,
348            None => return f32::MAX,
349        };
350
351        hyperbolic_distance(a, b, self.config.curvature)
352    }
353
354    /// Returns the number of stored embeddings.
355    pub fn len(&self) -> usize {
356        self.embeddings.len()
357    }
358
359    /// Returns true if no embeddings stored.
360    pub fn is_empty(&self) -> bool {
361        self.embeddings.is_empty()
362    }
363
364    /// Returns all embedding ids.
365    pub fn ids(&self) -> Vec<&str> {
366        self.embeddings
367            .iter()
368            .map(|(name, _)| name.as_str())
369            .collect()
370    }
371
372    /// Returns all embeddings as (id, vector) pairs.
373    pub fn all_embeddings(&self) -> &[(String, Vec<f32>)] {
374        &self.embeddings
375    }
376
377    // ── Phase 5: SQLite Persistence ────────────────────────────────
378    // Moved to `crate::sqlite::hyperbolic_persist` (RFC-018 b.8).
379    // The cfg-gated SQLite-specific methods live there as free functions
380    // that take `SqliteMemoryStore` as a parameter. The pure-math core
381    // remains here in `HyperbolicEmbedding`.
382
383    /// Find memories near a query in hyperbolic space.
384    ///
385    /// Returns (memory_id, hyperbolic_distance) pairs.
386    /// Useful for hierarchical navigation: memories close to the
387    /// root are general; those near the boundary are specific.
388    pub fn search_memories(&self, query_euclidean: &[f32], k: usize) -> Vec<(String, f32)> {
389        self.search(query_euclidean, k)
390    }
391
392    /// Get the hierarchical depth rank of all memories.
393    ///
394    /// Memories closer to the origin are more general/root-level.
395    /// Memories farther from origin are more specific/leaf-level.
396    ///
397    /// Returns (id, depth) pairs sorted by depth ascending.
398    pub fn hierarchical_rank(&self) -> Vec<(String, f32)> {
399        self.rank_by_depth()
400    }
401
402    /// Get the hyperbolic distance of a point from the origin.
403    ///
404    /// Points closer to the origin are "higher" in the hierarchy.
405    pub fn depth(&self, id: &str) -> f32 {
406        match self.get(id) {
407            Some(v) => hyperbolic_distance(&vec![0.0; v.len()], v, self.config.curvature),
408            None => f32::MAX,
409        }
410    }
411
412    /// Rank all embeddings by depth (origin distance).
413    ///
414    /// Returns (id, depth) pairs sorted by depth ascending.
415    /// Items with lower depth are closer to the root of the hierarchy.
416    pub fn rank_by_depth(&self) -> Vec<(String, f32)> {
417        let mut ranked: Vec<(String, f32)> = self
418            .embeddings
419            .iter()
420            .map(|(name, vec)| {
421                let origin = vec![0.0; vec.len()];
422                let d = hyperbolic_distance(&origin, vec, self.config.curvature);
423                (name.clone(), d)
424            })
425            .collect();
426
427        ranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
428        ranked
429    }
430}
431
432// ---------------------------------------------------------------------------
433// Tests
434// ---------------------------------------------------------------------------
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn test_euclidean_to_poincare_zero() {
442        let result = euclidean_to_poincare(&[0.0, 0.0, 0.0], -1.0);
443        assert_eq!(result, vec![0.0, 0.0, 0.0]);
444    }
445
446    #[test]
447    fn test_euclidean_to_poincare_bounded() {
448        let c = -1.0;
449        // Large vector should be projected inside the ball
450        let result = euclidean_to_poincare(&[100.0, 100.0, 100.0], c);
451        let norm: f32 = result.iter().map(|v| v * v).sum::<f32>().sqrt();
452        let max_norm = 1.0 / c.abs().sqrt();
453        assert!(
454            norm < max_norm,
455            "Result should be inside the ball: norm={}, max={}",
456            norm,
457            max_norm
458        );
459    }
460
461    #[test]
462    fn test_hyperbolic_distance_same_point() {
463        let point = euclidean_to_poincare(&[0.5, 0.3], -1.0);
464        let dist = hyperbolic_distance(&point, &point, -1.0);
465        assert!(dist < 1e-5, "Distance from self should be ~0, got {}", dist);
466    }
467
468    #[test]
469    fn test_hyperbolic_distance_symmetry() {
470        let a = euclidean_to_poincare(&[1.0, 2.0], -1.0);
471        let b = euclidean_to_poincare(&[3.0, 1.0], -1.0);
472        let d_ab = hyperbolic_distance(&a, &b, -1.0);
473        let d_ba = hyperbolic_distance(&b, &a, -1.0);
474        assert!(
475            (d_ab - d_ba).abs() < 1e-4,
476            "Distance should be symmetric: {} vs {}",
477            d_ab,
478            d_ba
479        );
480    }
481
482    #[test]
483    fn test_hyperbolic_distance_triangle_inequality() {
484        let a = euclidean_to_poincare(&[1.0, 0.0], -1.0);
485        let b = euclidean_to_poincare(&[0.0, 1.0], -1.0);
486        let c = euclidean_to_poincare(&[2.0, 2.0], -1.0);
487
488        let d_ab = hyperbolic_distance(&a, &b, -1.0);
489        let d_bc = hyperbolic_distance(&b, &c, -1.0);
490        let d_ac = hyperbolic_distance(&a, &c, -1.0);
491
492        assert!(
493            d_ac <= d_ab + d_bc + 1e-4,
494            "Triangle inequality: d(a,c)={} should be <= d(a,b)+d(b,c)={}",
495            d_ac,
496            d_ab + d_bc
497        );
498    }
499
500    #[test]
501    fn test_mobius_add_identity() {
502        let a = euclidean_to_poincare(&[0.5, 0.3], -1.0);
503        let zero = vec![0.0, 0.0];
504        let result = mobius_add(&a, &zero, -1.0);
505        for (r, expected) in result.iter().zip(a.iter()) {
506            assert!((r - expected).abs() < 1e-4, "a ⊕ 0 should equal a");
507        }
508    }
509
510    #[test]
511    fn test_mobius_scalar_mul_zero() {
512        let v = euclidean_to_poincare(&[1.0, 2.0], -1.0);
513        let result = mobius_scalar_mul(0.0, &v, -1.0, 1e-5);
514        for r in &result {
515            assert!(r.abs() < 1e-4, "0 ⊗ v should be ~0, got {}", r);
516        }
517    }
518
519    #[test]
520    fn test_mobius_scalar_mul_one() {
521        let v = euclidean_to_poincare(&[1.0, 2.0], -1.0);
522        let result = mobius_scalar_mul(1.0, &v, -1.0, 1e-5);
523        for (r, expected) in result.iter().zip(v.iter()) {
524            assert!((r - expected).abs() < 1e-4, "1 ⊗ v should equal v");
525        }
526    }
527
528    #[test]
529    fn test_hyperbolic_embedding_add_and_search() {
530        let mut he = HyperbolicEmbedding::with_dimensions(3);
531
532        he.add("root", &[0.0, 0.0, 0.0]);
533        he.add("child_a", &[1.0, 0.0, 0.0]);
534        he.add("child_b", &[0.0, 1.0, 0.0]);
535        he.add("grandchild", &[1.0, 1.0, 0.0]);
536
537        assert_eq!(he.len(), 4);
538
539        // Nearest neighbor of child_a should be grandchild (closer in hierarchy)
540        let nn = he.nearest_neighbors("child_a", 2);
541        assert_eq!(nn.len(), 2);
542        // grandchild should be closer to child_a than child_b
543        let gc_dist = nn
544            .iter()
545            .find(|(name, _)| name == "grandchild")
546            .map(|(_, d)| *d);
547        let cb_dist = nn
548            .iter()
549            .find(|(name, _)| name == "child_b")
550            .map(|(_, d)| *d);
551        if let (Some(gc), Some(cb)) = (gc_dist, cb_dist) {
552            assert!(
553                gc < cb,
554                "grandchild should be closer to child_a than child_b"
555            );
556        }
557    }
558
559    #[test]
560    fn test_hyperbolic_embedding_depth() {
561        let mut he = HyperbolicEmbedding::with_dimensions(2);
562
563        he.add("root", &[0.0, 0.0]);
564        he.add("level1", &[0.5, 0.0]);
565        he.add("level2", &[1.0, 0.0]);
566
567        let root_depth = he.depth("root");
568        let l1_depth = he.depth("level1");
569        let l2_depth = he.depth("level2");
570
571        assert!(
572            root_depth < l1_depth,
573            "Root should be shallower: root={}, l1={}",
574            root_depth,
575            l1_depth
576        );
577        assert!(
578            l1_depth < l2_depth,
579            "Level1 should be shallower: l1={}, l2={}",
580            l1_depth,
581            l2_depth
582        );
583    }
584
585    #[test]
586    fn test_rank_by_depth() {
587        let mut he = HyperbolicEmbedding::with_dimensions(2);
588
589        he.add("leaf", &[2.0, 2.0]);
590        he.add("root", &[0.0, 0.0]);
591        he.add("mid", &[0.5, 0.5]);
592
593        let ranked = he.rank_by_depth();
594        assert_eq!(ranked[0].0, "root");
595        assert_eq!(ranked[1].0, "mid");
596        assert_eq!(ranked[2].0, "leaf");
597    }
598
599    #[test]
600    fn test_batch_conversion() {
601        let vectors = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![0.0, 0.0]];
602        let results = batch_euclidean_to_poincare(&vectors, -1.0);
603        assert_eq!(results.len(), 3);
604        // Last should be zero
605        assert_eq!(results[2], vec![0.0, 0.0]);
606    }
607
608    #[test]
609    fn test_curvature_effect() {
610        let v = [1.0, 1.0];
611
612        let p1 = euclidean_to_poincare(&v, -1.0);
613        let p2 = euclidean_to_poincare(&v, -2.0);
614
615        let norm1: f32 = p1.iter().map(|x| x * x).sum::<f32>().sqrt();
616        let norm2: f32 = p2.iter().map(|x| x * x).sum::<f32>().sqrt();
617
618        // Higher curvature magnitude → smaller ball → smaller norm
619        assert!(
620            norm2 < norm1,
621            "Higher curvature should produce smaller ball: {} vs {}",
622            norm2,
623            norm1
624        );
625    }
626
627    #[test]
628    fn test_add_child_hierarchy() {
629        let mut he = HyperbolicEmbedding::with_dimensions(3);
630
631        // Create a simple hierarchy
632        he.add("parent", &[1.0, 0.0, 0.0]);
633        he.add_child("parent", "child", &[0.5, 0.5, 0.0]);
634
635        assert_eq!(he.len(), 2);
636
637        // Both should exist
638        assert!(he.get("parent").is_some());
639        assert!(he.get("child").is_some());
640    }
641}