Skip to main content

horon_engine/
hyperbolic_geometry.rs

1//! hyperbolic_geometry.rs - Poincaré Disk Model Implementation for horon-engine
2//! # Hyperbolic Geometry Implementation
3//!
4//! This module implements the Poincaré disk model of hyperbolic geometry,
5//! providing the mathematical foundation for Hyperbolic Tree Tensors (HTT).
6//!
7//! ## Key Features:
8//!
9//! - **Poincaré Disk Model**: Represents hyperbolic space within a unit disk
10//! - **Fixed-Point Arithmetic**: Ensures deterministic results across platforms
11//! - **Möbius Transformations**: Efficient operations for manipulating points
12//! - **Hyperbolic Distance Calculations**: Accurate measurement in hyperbolic space
13//!
14//! The Poincaré disk is ideal for representing hierarchical tree structures
15//! because it visually emphasizes the exponential growth characteristic of
16//! hyperbolic space, making it perfect for HTT's hierarchical representations.
17
18use std::fmt::{self, Debug, Formatter};
19use g_math::fixed_point::{FixedPoint, FixedVector};
20use crate::constants;
21
22/// A point in the Poincaré disk model of hyperbolic space.
23///
24/// The Poincaré disk model represents hyperbolic space within a unit disk,
25/// where geodesics are represented by arcs of circles orthogonal to the boundary,
26/// and hyperbolic distance is distorted in a way that makes the model ideal
27/// for representing hierarchical tree structures.
28#[derive(Clone)]
29pub struct HyperbolicPoint {
30    /// Coordinates in the Poincaré disk using fixed-point representation
31    /// The disk has radius 1, so all valid points must have norm < 1
32    coords: FixedVector,
33}
34
35impl HyperbolicPoint {
36    /// Create a new point in the Poincaré disk with the given coordinates.
37    ///
38    /// Coordinates are automatically projected into the disk if they're outside.
39    pub fn new(coords: FixedVector) -> Self {
40        let mut point = Self { coords };
41        point.ensure_in_disk();
42        point
43    }
44
45    /// Create a new point from a slice of f32 values (user-facing boundary API).
46    pub fn from_f32_slice(values: &[f32]) -> Self {
47        Self::new(FixedVector::from_f32_slice(values))
48    }
49
50    /// Create a point from exact fixed-point coordinates — the lossless
51    /// entry point, and the one the public API uses.
52    pub fn from_slice(values: &[FixedPoint]) -> Self {
53        let mut v = FixedVector::new(values.len());
54        for (i, &c) in values.iter().enumerate() {
55            v[i] = c;
56        }
57        Self::new(v)
58    }
59
60    /// Create a new point at the origin of the disk.
61    pub fn origin(dimension: usize) -> Self {
62        Self {
63            coords: FixedVector::new(dimension),
64        }
65    }
66
67    /// Ensure the point is inside the Poincaré disk.
68    ///
69    /// Projects the point onto the disk if it's outside.
70    fn ensure_in_disk(&mut self) {
71        let squared_norm = self.coords.length_squared();
72        let one = FixedPoint::from_int(1);
73
74        // If point is outside the disk or very close to the boundary, project it in
75        if squared_norm >= one || (one - squared_norm) < constants::boundary_margin() {
76            let norm = self.coords.length_fused();
77            let scale_factor = constants::near_boundary() / norm;
78
79            for i in 0..self.coords.len() {
80                self.coords[i] = self.coords[i] * scale_factor;
81            }
82        }
83    }
84
85    /// Get a reference to the underlying coordinates.
86    pub fn coords(&self) -> &FixedVector {
87        &self.coords
88    }
89
90    /// Get a mutable reference to the underlying coordinates.
91    pub fn coords_mut(&mut self) -> &mut FixedVector {
92        &mut self.coords
93    }
94
95    /// Get the dimension of the point.
96    pub fn dimension(&self) -> usize {
97        self.coords.len()
98    }
99
100    /// Calculate the Euclidean norm of the point.
101    pub fn euclidean_norm(&self) -> FixedPoint {
102        self.coords.length_fused()
103    }
104
105    /// Calculate the hyperbolic distance between this point and another.
106    ///
107    /// The hyperbolic distance in the Poincaré disk model is given by:
108    /// d(p, q) = 2 * atanh(|p-q| / |1-p̄q|)
109    /// where p̄ is the complex conjugate of p and |x| is the Euclidean norm.
110    ///
111    /// Computed in squared space (one-sqrt form): `r = √(|p−q|² / |1−p̄q|²)` with
112    /// `|p−q|² = |p|² + |q|² − 2⟨p,q⟩`, so the kernel pays **one** sqrt
113    /// instead of the previous four (|p−q|, |p|, |q|, and the denominator —
114    /// two of which were norms immediately squared back). Measured: the
115    /// four-sqrt form cost ~78 µs/pair; fixed-point sqrt is ~15 µs each.
116    /// Same guards, same clamps, same saturation semantics; outputs may
117    /// differ from the old form in the last ULPs (different-but-still-
118    /// deterministic rounding sequence).
119    pub fn hyperbolic_distance(&self, other: &Self) -> FixedPoint {
120        ratio_to_distance(self.hyperbolic_ratio(other))
121    }
122
123    /// Compute the Möbius ratio |p-q| / |1-p̄q| without the atanh transcendental.
124    ///
125    /// Since atanh is strictly monotonic on [0,1), comparing ratios is
126    /// equivalent to comparing hyperbolic distances:
127    ///   d(p,q) < d(p,r)  ⟺  ratio(p,q) < ratio(p,r)
128    ///
129    /// Computed in squared space with a single sqrt (one-sqrt form):
130    /// `r = √( (|p|² + |q|² − 2⟨p,q⟩) / (1 − 2⟨p,q⟩ + |p|²·|q|²) )` — one
131    /// dot product, two squared norms (no norm→square round-trips), one
132    /// division, one sqrt. The algebra now matches the proxy scorer
133    /// (`metric_tree::HyperbolicMetric::proxy` is exactly the pre-sqrt
134    /// value), so proxy and exact orderings share one computation path.
135    /// Guards are the squared-space equivalents of the previous ones:
136    /// origin when |p|² < small_epsilon², degenerate when den² < epsilon².
137    pub fn hyperbolic_ratio(&self, other: &Self) -> FixedPoint {
138        assert_eq!(self.dimension(), other.dimension(),
139                  "Points must have the same dimension for ratio calculation");
140
141        let zero = FixedPoint::from_int(0);
142        let one = FixedPoint::from_int(1);
143        let two = FixedPoint::from_int(2);
144
145        let self_norm_sq = self.coords.length_squared();
146        let other_norm_sq = other.coords.length_squared();
147
148        // Origin special cases: ratio(0, q) = |q|, ratio(p, 0) = |p| —
149        // one sqrt, same as the general path below.
150        let eps_sq = constants::small_epsilon() * constants::small_epsilon();
151        if self_norm_sq < eps_sq {
152            return clamp_ratio(other_norm_sq.sqrt());
153        }
154        if other_norm_sq < eps_sq {
155            return clamp_ratio(self_norm_sq.sqrt());
156        }
157
158        // Deliberately storage-tier with a shared dot product (fused-kernel adoption
159        // evaluated 2026-07-11 and rejected by measurement: the fused
160        // kernels cost +22% here — 35.4 → 43.1 µs — recomputing the dot
161        // and norms per kernel at compute tier, for ULPs the interior-
162        // bounded inputs (|x| < 1, sums < 4, wrap impossible) never need).
163        let dot_product = self.coords.dot(&other.coords);
164
165        // |p−q|² expanded through the dot product the denominator needs
166        // anyway; rounding can nudge a coincident pair slightly negative.
167        let mut dist_sq = self_norm_sq + other_norm_sq - two * dot_product;
168        if dist_sq < zero {
169            dist_sq = zero;
170        }
171
172        // Division safety only. For points at equal radius this term is
173        // `(1 − ‖p‖²)² + ‖p − q‖²`, which shrinks quadratically with depth —
174        // ordinary sibling geometry reaches 1e-9 around depth 11 and 1e-17
175        // around depth 21. The threshold must therefore sit at the
176        // representation floor, not at a "small number": anything higher
177        // returns the saturation value for legitimate points, making every
178        // node equidistant and nearest-neighbour ranking arbitrary.
179        let denominator_sq = one - two * dot_product + self_norm_sq * other_norm_sq;
180        if denominator_sq < constants::min_safe_denominator() {
181            return constants::near_boundary();
182        }
183
184        clamp_ratio((dist_sq / denominator_sq).sqrt())
185    }
186
187    /// Apply the disk isometry that sends `a` to `b` (through the origin) to
188    /// this point.
189    ///
190    /// Concretely this composes two gyro-translations:
191    ///   `T(z) = b ⊕ ((−a) ⊕ z)`
192    /// The first factor maps `a` to the origin, the second maps the origin to
193    /// `b`; each is a Poincaré-disk isometry, so their composition is one too,
194    /// in **any** dimension. In particular `T(a) = b` and distances are
195    /// preserved: `d(T(x), T(y)) = d(x, y)`.
196    ///
197    /// This replaces an earlier real-scalar formula `(z − a)/(1 − z·a)` that
198    /// only reduced to a valid Möbius map when the disk was treated as the 1-D
199    /// complex plane; for d ≥ 2 it distorted distances. Prefer [`mobius_add`],
200    /// [`reflect_to_origin`], and [`reflect_from_origin`] directly when you
201    /// need just one of these factors.
202    ///
203    /// [`mobius_add`]: Self::mobius_add
204    /// [`reflect_to_origin`]: Self::reflect_to_origin
205    /// [`reflect_from_origin`]: Self::reflect_from_origin
206    pub fn mobius_transform(&self, a: &Self, b: &Self) -> Self {
207        // z ↦ (−a) ⊕ z  (maps a to origin), then w ↦ b ⊕ w (maps origin to b).
208        let centered = self.reflect_to_origin(a);
209        centered.reflect_from_origin(b)
210    }
211
212    /// Möbius addition in the Poincaré ball model: a ⊕ z.
213    ///
214    /// The correct d-dimensional formula (Ungar's gyroaddition):
215    ///   a ⊕ z = ((1 + 2⟨a,z⟩ + ‖z‖²)·a + (1 − ‖a‖²)·z) / (1 + 2⟨a,z⟩ + ‖a‖²·‖z‖²)
216    ///
217    /// Key properties:
218    /// - Left identity: 0 ⊕ z = z
219    /// - Right identity: a ⊕ 0 = a
220    /// - Left inverse: (−a) ⊕ a = 0
221    /// - Left cancellation: (−a) ⊕ (a ⊕ b) = b
222    /// - Isometry: d(a⊕x, a⊕y) = d(x, y)
223    pub fn mobius_add(a: &Self, z: &Self) -> Self {
224        let dimension = a.dimension();
225        assert_eq!(dimension, z.dimension(), "Points must have the same dimension");
226
227        let a_dot_z = a.coords.dot(&z.coords);
228        let z_norm_sq = z.coords.length_squared();
229        let a_norm_sq = a.coords.length_squared();
230
231        let one = FixedPoint::from_int(1);
232        let two = FixedPoint::from_int(2);
233
234        let coeff_a = one + two * a_dot_z + z_norm_sq;
235        let coeff_z = one - a_norm_sq;
236        let denom = one + two * a_dot_z + a_norm_sq * z_norm_sq;
237
238        if denom.abs() < constants::epsilon() {
239            return Self::origin(dimension);
240        }
241
242        let inv_denom = one / denom;
243        let mut result = FixedVector::new(dimension);
244        for i in 0..dimension {
245            result[i] = (coeff_a * a.coords[i] + coeff_z * z.coords[i]) * inv_denom;
246        }
247
248        Self::new(result)
249    }
250
251    /// Reflect a point to the origin frame via Möbius addition: (−center) ⊕ self.
252    /// Maps center to the origin while preserving hyperbolic distances.
253    pub fn reflect_to_origin(&self, center: &Self) -> Self {
254        let dimension = center.dimension();
255        let mut neg_coords = FixedVector::new(dimension);
256        for i in 0..dimension {
257            neg_coords[i] = -center.coords[i];
258        }
259        // neg_center has same norm as center (already in disk); skip ensure_in_disk
260        let neg_center = Self { coords: neg_coords };
261        Self::mobius_add(&neg_center, self)
262    }
263
264    /// Reflect a point from the origin frame to center's frame: center ⊕ self.
265    /// Maps the origin to center's position while preserving hyperbolic distances.
266    pub fn reflect_from_origin(&self, center: &Self) -> Self {
267        Self::mobius_add(center, self)
268    }
269
270    /// Calculate the hyperbolic midpoint between this point and another.
271    pub fn hyperbolic_midpoint(&self, other: &Self) -> Self {
272        let dimension = self.dimension();
273        assert_eq!(dimension, other.dimension(), "Points must have the same dimension");
274
275        // Shortcut: if one point is the origin, the midpoint lies on the line to the other point
276        // In the Poincaré disk: midpoint at |m| = tanh(atanh(|p|)/2) in direction of p
277        if self.euclidean_norm() < constants::small_epsilon() {
278            let r = other.euclidean_norm();
279            if r < constants::small_epsilon() {
280                return Self::origin(dimension);
281            }
282            let half_hyp_dist = constants::safe_atanh(r) / FixedPoint::from_int(2);
283            let m_norm = half_hyp_dist.tanh();
284            let scale = m_norm / r;
285            let mut midpoint = FixedVector::new(dimension);
286            for i in 0..dimension {
287                midpoint[i] = other.coords[i] * scale;
288            }
289            return Self::new(midpoint);
290        }
291
292        if other.euclidean_norm() < constants::small_epsilon() {
293            let r = self.euclidean_norm();
294            if r < constants::small_epsilon() {
295                return Self::origin(dimension);
296            }
297            let half_hyp_dist = constants::safe_atanh(r) / FixedPoint::from_int(2);
298            let m_norm = half_hyp_dist.tanh();
299            let scale = m_norm / r;
300            let mut midpoint = FixedVector::new(dimension);
301            for i in 0..dimension {
302                midpoint[i] = self.coords[i] * scale;
303            }
304            return Self::new(midpoint);
305        }
306
307        // General case: follow the geodesic between the two points using the
308        // gyrovector reflection pair (Ungar), which is a true isometry in any
309        // dimension — unlike `mobius_transform`, whose real-scalar denominator
310        // `1 − z·a` is only the 1-D complex Möbius map and distorts distances
311        // for d ≥ 2.
312        //
313        // 1. Reflect `other` into the frame where `self` is the origin:
314        //        q0 = (−self) ⊕ other
315        // 2. In that frame the midpoint is radial: the point at half the
316        //    hyperbolic distance to q0, i.e. |m0| = tanh(atanh(|q0|)/2) along
317        //    the direction of q0.
318        // 3. Reflect the radial midpoint back out of `self`'s frame:
319        //        m = self ⊕ m0
320        let q0 = other.reflect_to_origin(self);
321
322        let r = q0.euclidean_norm();
323        if r < constants::small_epsilon() {
324            // The points coincide — the midpoint is the point itself.
325            return self.clone();
326        }
327
328        let half_hyp_dist = constants::safe_atanh(r) / FixedPoint::from_int(2);
329        let m0_norm = half_hyp_dist.tanh();
330        let scale = m0_norm / r;
331
332        let mut m0 = FixedVector::new(dimension);
333        for i in 0..dimension {
334            m0[i] = q0.coords[i] * scale;
335        }
336
337        Self::new(m0).reflect_from_origin(self)
338    }
339
340    /// Create a point at a specified distance and direction from this point.
341    ///
342    /// The direction is specified as a Euclidean vector that gets normalized.
343    pub fn point_at_distance(&self, direction: &FixedVector, distance: FixedPoint) -> Self {
344        let dimension = self.dimension();
345        assert_eq!(dimension, direction.len(), "Direction vector must have the same dimension");
346
347        // Normalize direction vector
348        let mut normalized = direction.clone();
349        normalized.normalize();
350
351        // Special case: starting from origin
352        if self.euclidean_norm() < constants::epsilon() {
353            // From the origin: |p| = tanh(d/2)
354            let half_dist = distance / FixedPoint::from_int(2);
355            let tanh_half_dist = half_dist.tanh();
356
357            let mut new_coords = FixedVector::new(dimension);
358            for i in 0..dimension {
359                new_coords[i] = normalized[i] * tanh_half_dist;
360            }
361
362            return Self::new(new_coords);
363        }
364
365        // General case: Möbius-reflect from self to origin, place point, reflect back
366        let _origin = Self::origin(dimension);
367
368        let half_dist = distance / FixedPoint::from_int(2);
369        let tanh_half_dist = half_dist.tanh();
370
371        let mut new_coords = FixedVector::new(dimension);
372        for i in 0..dimension {
373            new_coords[i] = normalized[i] * tanh_half_dist;
374        }
375
376        let new_point = Self::new(new_coords);
377
378        // Möbius-reflect from origin frame back to self's position
379        new_point.reflect_from_origin(self)
380    }
381}
382
383impl Debug for HyperbolicPoint {
384    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
385        write!(f, "HyperbolicPoint(dim={}, norm={})",
386               self.dimension(), self.euclidean_norm())
387    }
388}
389
390/// Convert a hyperbolic distance to its corresponding ratio threshold.
391///
392/// ratio = tanh(distance / 2)
393///
394/// Useful for converting a radius value to a ratio for comparison
395/// with `hyperbolic_ratio` results.
396pub fn distance_to_ratio(distance: FixedPoint) -> FixedPoint {
397    let half = constants::half();
398    (distance * half).tanh()
399}
400
401/// Clamp a Möbius ratio at the boundary safety margin (boundary saturation).
402#[inline]
403fn clamp_ratio(ratio: FixedPoint) -> FixedPoint {
404    if ratio > constants::near_boundary() {
405        constants::near_boundary()
406    } else {
407        ratio
408    }
409}
410
411/// Convert a Möbius ratio back to its hyperbolic distance.
412///
413/// Inverse of [`distance_to_ratio`]: `distance = 2 · atanh(ratio)`. Since
414/// `hyperbolic_ratio` and `hyperbolic_distance` share the same ratio term,
415/// this recovers the exact distance a `hyperbolic_ratio` result stands for —
416/// no second point lookup or full distance recomputation needed. As of the one-sqrt kernel
417/// it IS the exact kernel's final step: `hyperbolic_distance ≡
418/// ratio_to_distance(hyperbolic_ratio)` by construction.
419pub fn ratio_to_distance(ratio: FixedPoint) -> FixedPoint {
420    FixedPoint::from_int(2) * constants::safe_atanh(ratio)
421}
422
423/// The Poincaré disk model of hyperbolic space.
424///
425/// This structure represents the hyperbolic space itself and provides
426/// operations for working with hyperbolic points.
427#[derive(Clone, Debug)]
428pub struct PoincareDisk {
429    /// Dimension of the hyperbolic space
430    dimension: usize,
431    /// Curvature of the hyperbolic space (standard Poincaré disk has -1)
432    curvature: FixedPoint,
433}
434
435impl PoincareDisk {
436    /// Create a new Poincaré disk with the given dimension.
437    pub fn new(dimension: usize) -> Self {
438        Self {
439            dimension,
440            curvature: FixedPoint::from_int(-1),
441        }
442    }
443
444    /// Get the dimension of the hyperbolic space.
445    pub fn dimension(&self) -> usize {
446        self.dimension
447    }
448
449    /// Get the curvature of the hyperbolic space.
450    pub fn curvature(&self) -> FixedPoint {
451        self.curvature
452    }
453
454    /// Create a point at the origin of the disk.
455    pub fn origin(&self) -> HyperbolicPoint {
456        HyperbolicPoint::origin(self.dimension)
457    }
458
459    /// Create a new point from Euclidean coordinates, projecting to the disk.
460    pub fn point_from_euclidean(&self, coords: FixedVector) -> HyperbolicPoint {
461        assert_eq!(coords.len(), self.dimension, "Coordinates must match disk dimension");
462        HyperbolicPoint::new(coords)
463    }
464
465    /// Create a new point from FixedPoint coordinates, projecting to the disk.
466    pub fn point_from_coords(&self, coords: FixedVector) -> HyperbolicPoint {
467        assert_eq!(coords.len(), self.dimension, "Coordinates must match disk dimension");
468        HyperbolicPoint::new(coords)
469    }
470
471    /// Create a new point from f32 coordinates, projecting to the disk (boundary API).
472    pub fn point_from_f32_slice(&self, coords: &[f32]) -> HyperbolicPoint {
473        assert_eq!(coords.len(), self.dimension, "Coordinates must match disk dimension");
474        HyperbolicPoint::from_f32_slice(coords)
475    }
476
477    /// Project a Euclidean point to the hyperbolic space.
478    pub fn project(&self, point: &FixedVector) -> HyperbolicPoint {
479        self.point_from_euclidean(point.clone())
480    }
481
482    /// Compute the hyperbolic distance between two points.
483    pub fn distance(&self, p1: &HyperbolicPoint, p2: &HyperbolicPoint) -> FixedPoint {
484        p1.hyperbolic_distance(p2)
485    }
486
487    /// Find the hyperbolic midpoint between two points.
488    pub fn midpoint(&self, p1: &HyperbolicPoint, p2: &HyperbolicPoint) -> HyperbolicPoint {
489        p1.hyperbolic_midpoint(p2)
490    }
491
492    /// Create a point at the specified radial distance from the origin.
493    pub fn point_at_distance_from_origin(&self, direction: &FixedVector, distance: FixedPoint) -> HyperbolicPoint {
494        self.origin().point_at_distance(direction, distance)
495    }
496
497    /// Create a point at the specified hyperbolic coordinates.
498    ///
499    /// Hyperbolic coordinates are specified as (r, θ₁, θ₂, ..., θₙ₋₁) where:
500    /// - r is the hyperbolic distance from the origin
501    /// - θᵢ are angular coordinates (similar to spherical coordinates)
502    pub fn point_from_hyperbolic_coords(&self, r: FixedPoint, angles: &[FixedPoint]) -> HyperbolicPoint {
503        assert_eq!(angles.len(), self.dimension - 1,
504                  "Need exactly dimension-1 angles for hyperbolic coordinates");
505
506        let mut coords = FixedVector::new(self.dimension);
507
508        // Shortcut: r=0 is the origin
509        if r < constants::epsilon() {
510            return self.origin();
511        }
512
513        // |p| = tanh(r/2)
514        let half_r = r / FixedPoint::from_int(2);
515        let rho = half_r.tanh();
516
517        // Convert from spherical to Cartesian coordinates
518        let (sin_0, cos_0) = angles[0].sincos();
519        coords[0] = rho * cos_0;
520
521        let mut sin_product = sin_0;
522
523        for i in 1..self.dimension - 1 {
524            let (sin_i, cos_i) = angles[i].sincos();
525            coords[i] = rho * sin_product * cos_i;
526            sin_product = sin_product * sin_i;
527        }
528
529        if self.dimension > 1 {
530            coords[self.dimension - 1] = rho * sin_product;
531        }
532
533        HyperbolicPoint::new(coords)
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::constants;
541
542    fn fp_approx_eq(a: FixedPoint, b: FixedPoint, tolerance: FixedPoint) -> bool {
543        (a - b).abs() < tolerance
544    }
545
546    #[test]
547    fn test_poincare_disk_creation() {
548        let disk = PoincareDisk::new(2);
549        assert_eq!(disk.dimension(), 2);
550        assert_eq!(disk.curvature().to_int(), -1);
551    }
552
553    #[test]
554    fn test_origin_creation() {
555        let disk = PoincareDisk::new(2);
556        let origin = disk.origin();
557
558        assert_eq!(origin.dimension(), 2);
559        assert!(origin.euclidean_norm() < constants::epsilon());
560    }
561
562    #[test]
563    fn test_point_creation() {
564        let disk = PoincareDisk::new(2);
565        let point = disk.point_from_f32_slice(&[0.5, 0.0]);
566
567        assert_eq!(point.dimension(), 2);
568        assert!(fp_approx_eq(point.coords()[0], constants::half(), constants::epsilon()));
569        assert!(point.coords()[1].abs() < constants::epsilon());
570    }
571
572    #[test]
573    fn test_boundary_projection() {
574        let disk = PoincareDisk::new(2);
575
576        // Try to create a point outside the disk
577        let point = disk.point_from_f32_slice(&[1.5, 0.0]);
578
579        // Point should be projected to inside the disk
580        assert!(point.euclidean_norm() < FixedPoint::from_int(1));
581    }
582
583    #[test]
584    fn test_hyperbolic_distance() {
585        let disk = PoincareDisk::new(2);
586        let origin = disk.origin();
587        let point = disk.point_from_f32_slice(&[0.5, 0.0]);
588
589        // Distance from origin in Poincaré disk: d(0,p) = 2*atanh(|p|)
590        let expected = FixedPoint::from_int(2) * constants::safe_atanh(constants::half());
591        let actual = disk.distance(&origin, &point);
592        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
593
594        assert!(fp_approx_eq(actual, expected, tolerance));
595    }
596
597    #[test]
598    fn test_point_at_distance() {
599        let disk = PoincareDisk::new(2);
600        let origin = disk.origin();
601
602        // Create a direction vector along the x-axis
603        let direction = FixedVector::from_f32_slice(&[1.0, 0.0]);
604
605        // Create a point at distance 1.0 from origin in the x direction
606        let distance = FixedPoint::from_int(1);
607        let point = origin.point_at_distance(&direction, distance);
608
609        // Check the distance is as expected
610        let actual_distance = disk.distance(&origin, &point);
611        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
612        assert!(fp_approx_eq(actual_distance, FixedPoint::from_int(1), tolerance));
613
614        // Check the direction is along the x-axis
615        let expected_x = (distance / FixedPoint::from_int(2)).tanh();
616        assert!(fp_approx_eq(point.coords()[0], expected_x, tolerance));
617        assert!(point.coords()[1].abs() < tolerance);
618    }
619
620    #[test]
621    fn test_hyperbolic_midpoint() {
622        let disk = PoincareDisk::new(2);
623        let origin = disk.origin();
624        let point = disk.point_from_f32_slice(&[0.5, 0.0]);
625
626        let midpoint = disk.midpoint(&origin, &point);
627
628        let d1 = disk.distance(&origin, &midpoint);
629        let d2 = disk.distance(&midpoint, &point);
630        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
631
632        // The distances should be approximately equal
633        assert!(fp_approx_eq(d1, d2, tolerance));
634
635        // The sum of distances should equal the total distance
636        let total_distance = disk.distance(&origin, &point);
637        assert!(fp_approx_eq(d1 + d2, total_distance, tolerance));
638    }
639
640    #[test]
641    fn test_hyperbolic_midpoint_general_case() {
642        // Neither point is the origin — exercises the general geodesic path,
643        // not the radial origin shortcut. The midpoint must be equidistant
644        // from both endpoints and split the total distance exactly in half.
645        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
646
647        let cases: [(&[f32], &[f32]); 3] = [
648            (&[0.5, 0.0], &[0.0, 0.5]),
649            (&[0.3, 0.2], &[-0.4, 0.1]),
650            (&[0.1, -0.3], &[0.25, 0.35]),
651        ];
652
653        for (pa, pb) in cases {
654            let p = HyperbolicPoint::from_f32_slice(pa);
655            let q = HyperbolicPoint::from_f32_slice(pb);
656            let m = p.hyperbolic_midpoint(&q);
657
658            let d_pm = p.hyperbolic_distance(&m);
659            let d_mq = m.hyperbolic_distance(&q);
660            let d_pq = p.hyperbolic_distance(&q);
661
662            assert!(
663                fp_approx_eq(d_pm, d_mq, tolerance),
664                "midpoint not equidistant for {:?}/{:?}: d(p,m)={} d(m,q)={}",
665                pa, pb, d_pm, d_mq
666            );
667            assert!(
668                fp_approx_eq(d_pm + d_mq, d_pq, tolerance),
669                "midpoint off the geodesic for {:?}/{:?}: d(p,m)+d(m,q)={} vs d(p,q)={}",
670                pa, pb, d_pm + d_mq, d_pq
671            );
672        }
673    }
674
675    #[test]
676    fn test_hyperbolic_midpoint_4d_general_case() {
677        // Same invariants in 4D (the default HTT dimension), where the old
678        // real-scalar Möbius formula was most wrong.
679        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
680        let p = HyperbolicPoint::from_f32_slice(&[0.3, 0.2, -0.1, 0.15]);
681        let q = HyperbolicPoint::from_f32_slice(&[-0.2, 0.1, 0.25, -0.05]);
682        let m = p.hyperbolic_midpoint(&q);
683
684        let d_pm = p.hyperbolic_distance(&m);
685        let d_mq = m.hyperbolic_distance(&q);
686        let d_pq = p.hyperbolic_distance(&q);
687        assert!(fp_approx_eq(d_pm, d_mq, tolerance),
688            "4D midpoint not equidistant: {} vs {}", d_pm, d_mq);
689        assert!(fp_approx_eq(d_pm + d_mq, d_pq, tolerance),
690            "4D midpoint off geodesic: {} vs {}", d_pm + d_mq, d_pq);
691    }
692
693    #[test]
694    fn test_mobius_transformation() {
695        let disk = PoincareDisk::new(2);
696        let origin = disk.origin();
697        let point = disk.point_from_f32_slice(&[0.5, 0.0]);
698        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
699
700        // Identity transformation (a=0, b=0)
701        let transformed = point.mobius_transform(&origin, &origin);
702        assert!(fp_approx_eq(transformed.coords()[0], point.coords()[0], tolerance));
703        assert!(fp_approx_eq(transformed.coords()[1], point.coords()[1], tolerance));
704
705        let a = disk.point_from_f32_slice(&[0.3, 0.2]);
706        let b = disk.point_from_f32_slice(&[-0.1, 0.4]);
707
708        // Stays inside the disk.
709        let boundary_point = disk.point_from_f32_slice(&[0.95, 0.0]);
710        let transformed_boundary = boundary_point.mobius_transform(&a, &b);
711        assert!(transformed_boundary.euclidean_norm() < FixedPoint::from_int(1));
712
713        // Maps a to b.
714        let a_image = a.mobius_transform(&a, &b);
715        assert!(fp_approx_eq(a_image.coords()[0], b.coords()[0], tolerance));
716        assert!(fp_approx_eq(a_image.coords()[1], b.coords()[1], tolerance));
717
718        // Is an isometry: distances are preserved under the transform.
719        let x = disk.point_from_f32_slice(&[0.1, -0.25]);
720        let y = disk.point_from_f32_slice(&[0.4, 0.15]);
721        let d_before = x.hyperbolic_distance(&y);
722        let d_after = x.mobius_transform(&a, &b).hyperbolic_distance(&y.mobius_transform(&a, &b));
723        assert!(
724            fp_approx_eq(d_before, d_after, tolerance),
725            "mobius_transform not an isometry: d_before={} d_after={}",
726            d_before, d_after
727        );
728    }
729
730    #[test]
731    fn test_mobius_add_properties() {
732        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
733
734        // Property 1: 0 ⊕ z = z (left identity)
735        let origin = HyperbolicPoint::origin(2);
736        let p = HyperbolicPoint::from_f32_slice(&[0.3, 0.2]);
737        let result = HyperbolicPoint::mobius_add(&origin, &p);
738        assert!(fp_approx_eq(result.coords()[0], p.coords()[0], tolerance));
739        assert!(fp_approx_eq(result.coords()[1], p.coords()[1], tolerance));
740
741        // Property 2: a ⊕ 0 = a (right identity)
742        let a = HyperbolicPoint::from_f32_slice(&[0.4, -0.3]);
743        let result2 = HyperbolicPoint::mobius_add(&a, &origin);
744        assert!(fp_approx_eq(result2.coords()[0], a.coords()[0], tolerance));
745        assert!(fp_approx_eq(result2.coords()[1], a.coords()[1], tolerance));
746
747        // Property 3: (-a) ⊕ a = 0 (left inverse)
748        let neg_a = HyperbolicPoint::from_f32_slice(&[-0.4, 0.3]);
749        let result3 = HyperbolicPoint::mobius_add(&neg_a, &a);
750        assert!(result3.euclidean_norm() < tolerance,
751            "(-a) ⊕ a should be origin, got norm {}", result3.euclidean_norm());
752
753        // Property 4: round-trip reflect (left cancellation)
754        let child = HyperbolicPoint::from_f32_slice(&[0.2, 0.1]);
755        let center = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
756        let reflected = child.reflect_from_origin(&center);
757        let back = reflected.reflect_to_origin(&center);
758        assert!(fp_approx_eq(back.coords()[0], child.coords()[0], tolerance));
759        assert!(fp_approx_eq(back.coords()[1], child.coords()[1], tolerance));
760
761        // Property 5: isometry d(0, z) = d(a, a ⊕ z)
762        let disk = PoincareDisk::new(2);
763        let z = HyperbolicPoint::from_f32_slice(&[0.2, -0.1]);
764        let a2 = HyperbolicPoint::from_f32_slice(&[0.3, 0.2]);
765        let a_plus_z = HyperbolicPoint::mobius_add(&a2, &z);
766        let d_origin_z = disk.distance(&origin, &z);
767        let d_a_az = disk.distance(&a2, &a_plus_z);
768        assert!(fp_approx_eq(d_origin_z, d_a_az, tolerance),
769            "Isometry violated: d(0,z)={} vs d(a,a⊕z)={}", d_origin_z, d_a_az);
770    }
771
772    #[test]
773    fn test_mobius_add_higher_dimensions() {
774        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
775
776        // Test in 4D — the default HTT dimension
777        let origin = HyperbolicPoint::origin(4);
778        let a = HyperbolicPoint::from_f32_slice(&[0.3, 0.2, -0.1, 0.15]);
779        let z = HyperbolicPoint::from_f32_slice(&[0.1, -0.2, 0.15, -0.05]);
780
781        // Round-trip: reflect out then back
782        let reflected = z.reflect_from_origin(&a);
783        let back = reflected.reflect_to_origin(&a);
784        for i in 0..4 {
785            assert!(fp_approx_eq(back.coords()[i], z.coords()[i], tolerance),
786                "4D round-trip failed at dim {}: {} vs {}", i, back.coords()[i], z.coords()[i]);
787        }
788
789        // Isometry in 4D
790        let a_plus_z = HyperbolicPoint::mobius_add(&a, &z);
791        let d_oz = origin.hyperbolic_distance(&z);
792        let d_a_az = a.hyperbolic_distance(&a_plus_z);
793        assert!(fp_approx_eq(d_oz, d_a_az, tolerance),
794            "4D isometry violated: d(0,z)={} vs d(a,a⊕z)={}", d_oz, d_a_az);
795    }
796
797    #[test]
798    fn test_hyperbolic_coordinates() {
799        let disk = PoincareDisk::new(2);
800
801        let r = FixedPoint::from_int(1);
802        let theta = FixedPoint::from_int(0); // Along the x-axis
803
804        let point = disk.point_from_hyperbolic_coords(r, &[theta]);
805
806        let distance = disk.distance(&disk.origin(), &point);
807        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
808        assert!(fp_approx_eq(distance, FixedPoint::from_int(1), tolerance));
809
810        // Direction should be along x-axis
811        assert!(point.coords()[0] > FixedPoint::from_int(0));
812        assert!(point.coords()[1].abs() < tolerance);
813    }
814
815    #[test]
816    fn test_ratio_ordering_matches_distance() {
817        let query = HyperbolicPoint::from_f32_slice(&[0.1, 0.1, 0.0, 0.0]);
818        let points = vec![
819            HyperbolicPoint::from_f32_slice(&[0.2, 0.0, 0.0, 0.0]),
820            HyperbolicPoint::from_f32_slice(&[0.5, 0.3, 0.0, 0.0]),
821            HyperbolicPoint::from_f32_slice(&[-0.3, 0.1, 0.0, 0.0]),
822            HyperbolicPoint::from_f32_slice(&[0.0, 0.6, 0.0, 0.0]),
823            HyperbolicPoint::from_f32_slice(&[0.7, -0.2, 0.0, 0.0]),
824        ];
825
826        let mut by_dist: Vec<(usize, FixedPoint)> = points.iter().enumerate()
827            .map(|(i, p)| (i, query.hyperbolic_distance(p)))
828            .collect();
829        by_dist.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
830
831        let mut by_ratio: Vec<(usize, FixedPoint)> = points.iter().enumerate()
832            .map(|(i, p)| (i, query.hyperbolic_ratio(p)))
833            .collect();
834        by_ratio.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
835
836        let dist_order: Vec<usize> = by_dist.iter().map(|(i, _)| *i).collect();
837        let ratio_order: Vec<usize> = by_ratio.iter().map(|(i, _)| *i).collect();
838        assert_eq!(dist_order, ratio_order,
839            "Ratio ordering must match distance ordering");
840    }
841
842    #[test]
843    fn test_ratio_origin_cases() {
844        let origin = HyperbolicPoint::origin(4);
845        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.3, 0.0, 0.0]);
846        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(1000);
847
848        let p_norm = p.euclidean_norm();
849        let r1 = origin.hyperbolic_ratio(&p);
850        let r2 = p.hyperbolic_ratio(&origin);
851
852        assert!(fp_approx_eq(r1, p_norm, tol),
853            "ratio(origin, p) should equal |p|: {} vs {}", r1, p_norm);
854        assert!(fp_approx_eq(r2, p_norm, tol),
855            "ratio(p, origin) should equal |p|: {} vs {}", r2, p_norm);
856    }
857
858    #[test]
859    fn test_ratio_symmetry() {
860        let p = HyperbolicPoint::from_f32_slice(&[0.3, 0.2, -0.1, 0.0]);
861        let q = HyperbolicPoint::from_f32_slice(&[0.5, -0.1, 0.2, 0.0]);
862        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(10000);
863
864        let r_pq = p.hyperbolic_ratio(&q);
865        let r_qp = q.hyperbolic_ratio(&p);
866        assert!(fp_approx_eq(r_pq, r_qp, tol),
867            "Ratio should be symmetric: {} vs {}", r_pq, r_qp);
868    }
869
870    #[test]
871    fn test_ratio_self_is_zero() {
872        let p = HyperbolicPoint::from_f32_slice(&[0.4, 0.3, 0.0, 0.0]);
873        let r = p.hyperbolic_ratio(&p);
874        assert!(r < constants::epsilon(),
875            "ratio(p, p) should be ~0: got {}", r);
876    }
877
878    #[test]
879    fn test_distance_to_ratio_roundtrip() {
880        let p = HyperbolicPoint::from_f32_slice(&[0.3, 0.1, 0.0, 0.0]);
881        let q = HyperbolicPoint::from_f32_slice(&[0.5, -0.2, 0.0, 0.0]);
882        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(1000);
883
884        let dist = p.hyperbolic_distance(&q);
885        let ratio_from_dist = super::distance_to_ratio(dist);
886        let ratio_direct = p.hyperbolic_ratio(&q);
887
888        assert!(fp_approx_eq(ratio_from_dist, ratio_direct, tol),
889            "distance_to_ratio(d(p,q)) should equal ratio(p,q): {} vs {}",
890            ratio_from_dist, ratio_direct);
891    }
892}