1use std::fmt::{self, Debug, Formatter};
19use g_math::fixed_point::{FixedPoint, FixedVector};
20use crate::constants;
21
22#[derive(Clone)]
29pub struct HyperbolicPoint {
30 coords: FixedVector,
33}
34
35impl HyperbolicPoint {
36 pub fn new(coords: FixedVector) -> Self {
40 let mut point = Self { coords };
41 point.ensure_in_disk();
42 point
43 }
44
45 pub fn from_f32_slice(values: &[f32]) -> Self {
47 Self::new(FixedVector::from_f32_slice(values))
48 }
49
50 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 pub fn origin(dimension: usize) -> Self {
62 Self {
63 coords: FixedVector::new(dimension),
64 }
65 }
66
67 fn ensure_in_disk(&mut self) {
71 let squared_norm = self.coords.length_squared();
72 let one = FixedPoint::from_int(1);
73
74 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 pub fn coords(&self) -> &FixedVector {
87 &self.coords
88 }
89
90 pub fn coords_mut(&mut self) -> &mut FixedVector {
92 &mut self.coords
93 }
94
95 pub fn dimension(&self) -> usize {
97 self.coords.len()
98 }
99
100 pub fn euclidean_norm(&self) -> FixedPoint {
102 self.coords.length_fused()
103 }
104
105 pub fn hyperbolic_distance(&self, other: &Self) -> FixedPoint {
120 ratio_to_distance(self.hyperbolic_ratio(other))
121 }
122
123 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 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 let dot_product = self.coords.dot(&other.coords);
164
165 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 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 pub fn mobius_transform(&self, a: &Self, b: &Self) -> Self {
207 let centered = self.reflect_to_origin(a);
209 centered.reflect_from_origin(b)
210 }
211
212 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 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 let neg_center = Self { coords: neg_coords };
261 Self::mobius_add(&neg_center, self)
262 }
263
264 pub fn reflect_from_origin(&self, center: &Self) -> Self {
267 Self::mobius_add(center, self)
268 }
269
270 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 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 let q0 = other.reflect_to_origin(self);
321
322 let r = q0.euclidean_norm();
323 if r < constants::small_epsilon() {
324 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 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 let mut normalized = direction.clone();
349 normalized.normalize();
350
351 if self.euclidean_norm() < constants::epsilon() {
353 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 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 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
390pub fn distance_to_ratio(distance: FixedPoint) -> FixedPoint {
397 let half = constants::half();
398 (distance * half).tanh()
399}
400
401#[inline]
403fn clamp_ratio(ratio: FixedPoint) -> FixedPoint {
404 if ratio > constants::near_boundary() {
405 constants::near_boundary()
406 } else {
407 ratio
408 }
409}
410
411pub fn ratio_to_distance(ratio: FixedPoint) -> FixedPoint {
420 FixedPoint::from_int(2) * constants::safe_atanh(ratio)
421}
422
423#[derive(Clone, Debug)]
428pub struct PoincareDisk {
429 dimension: usize,
431 curvature: FixedPoint,
433}
434
435impl PoincareDisk {
436 pub fn new(dimension: usize) -> Self {
438 Self {
439 dimension,
440 curvature: FixedPoint::from_int(-1),
441 }
442 }
443
444 pub fn dimension(&self) -> usize {
446 self.dimension
447 }
448
449 pub fn curvature(&self) -> FixedPoint {
451 self.curvature
452 }
453
454 pub fn origin(&self) -> HyperbolicPoint {
456 HyperbolicPoint::origin(self.dimension)
457 }
458
459 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 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 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 pub fn project(&self, point: &FixedVector) -> HyperbolicPoint {
479 self.point_from_euclidean(point.clone())
480 }
481
482 pub fn distance(&self, p1: &HyperbolicPoint, p2: &HyperbolicPoint) -> FixedPoint {
484 p1.hyperbolic_distance(p2)
485 }
486
487 pub fn midpoint(&self, p1: &HyperbolicPoint, p2: &HyperbolicPoint) -> HyperbolicPoint {
489 p1.hyperbolic_midpoint(p2)
490 }
491
492 pub fn point_at_distance_from_origin(&self, direction: &FixedVector, distance: FixedPoint) -> HyperbolicPoint {
494 self.origin().point_at_distance(direction, distance)
495 }
496
497 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 if r < constants::epsilon() {
510 return self.origin();
511 }
512
513 let half_r = r / FixedPoint::from_int(2);
515 let rho = half_r.tanh();
516
517 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 let point = disk.point_from_f32_slice(&[1.5, 0.0]);
578
579 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 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 let direction = FixedVector::from_f32_slice(&[1.0, 0.0]);
604
605 let distance = FixedPoint::from_int(1);
607 let point = origin.point_at_distance(&direction, distance);
608
609 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 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 assert!(fp_approx_eq(d1, d2, tolerance));
634
635 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 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 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 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 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 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 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 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 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 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 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(¢er);
757 let back = reflected.reflect_to_origin(¢er);
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 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 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 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 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); 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 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}