Skip to main content

horon_engine/
klein.rs

1//! klein.rs — Klein Projective Model + Nielsen Power Diagram
2//!
3//! Implements the Klein model of hyperbolic space and the Nielsen reduction
4//! (hyperbolic Voronoi = Euclidean power diagram).
5//!
6//! One limit is load-bearing for callers of this module:
7//! [`power_distance`] is the *Euclidean* power distance. It coincides with the
8//! hyperbolic Voronoi diagram only when every site shares one Klein norm; for
9//! sites at differing norms the exact reduction is
10//! `argmin_i (1 - <x, k_i>) * gamma_i` (see `semantic_disk::classify_point`).
11//!
12//! Spatial queries do not go through this module. They are answered by
13//! `cell_index`, which works in the Poincaré disk and decides with exact
14//! hyperbolic distance. An earlier uniform grid over this model held one owner
15//! per tile and was removed in 0.6.0: Sarkar placement drives cells below tile
16//! size within a few levels, so most nodes owned no tile at any affordable
17//! resolution.
18//!
19//! Mathematical foundation:
20//! - Klein map: x_K = 2·x_P / (1 + ||x_P||²)
21//! - Inverse:   x_P = x_K / (1 + √(1 - ||x_K||²))
22//! - Power weight: w_i = 1 - ||x_K_i||²
23//! - Power distance: pd(q, p_i) = ||q - p_i||² - w_i
24//! - Nielsen 2009: hyperbolic Voronoi cells = Euclidean power cells in Klein model
25
26use g_math::fixed_point::{FixedPoint, FixedVector};
27use crate::constants;
28use crate::hyperbolic_geometry::HyperbolicPoint;
29
30// ---------------------------------------------------------------------------
31// KleinPoint
32// ---------------------------------------------------------------------------
33
34/// A point in the Klein projective model of hyperbolic space.
35///
36/// In the Klein model, geodesics are Euclidean straight lines (chords),
37/// which makes power diagram bisectors into Euclidean hyperplanes.
38#[derive(Clone, Debug)]
39pub struct KleinPoint {
40    /// Klein disk coordinates (||coords|| < 1)
41    pub coords: FixedVector,
42    /// Power weight: w = 1 - ||coords||²
43    pub weight: FixedPoint,
44}
45
46impl KleinPoint {
47    /// Create a KleinPoint from raw coordinates, computing the weight.
48    pub fn new(coords: FixedVector) -> Self {
49        let weight = FixedPoint::from_int(1) - coords.length_squared();
50        Self { coords, weight }
51    }
52
53    /// Dimension of the point.
54    pub fn dimension(&self) -> usize {
55        self.coords.len()
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Poincaré ↔ Klein conversions
61// ---------------------------------------------------------------------------
62
63/// Convert a Poincaré disk point to a Klein disk point.
64///
65/// Formula: x_K = 2·x_P / (1 + ||x_P||²)
66/// Weight:  w = 1 - ||x_K||²
67pub fn poincare_to_klein(p: &HyperbolicPoint) -> KleinPoint {
68    let dim = p.dimension();
69    let norm_sq = p.coords().length_squared();
70    let one = FixedPoint::from_int(1);
71    let two = FixedPoint::from_int(2);
72
73    let denom = one + norm_sq; // 1 + ||x_P||²
74    let scale = two / denom;   // 2 / (1 + ||x_P||²)
75
76    let mut klein_coords = FixedVector::new(dim);
77    for i in 0..dim {
78        klein_coords[i] = p.coords()[i] * scale;
79    }
80
81    KleinPoint::new(klein_coords)
82}
83
84/// Convert a Klein disk point back to a Poincaré disk point.
85///
86/// Formula: x_P = x_K / (1 + √(1 - ||x_K||²))
87pub fn klein_to_poincare(k: &KleinPoint) -> HyperbolicPoint {
88    let dim = k.dimension();
89    let one = FixedPoint::from_int(1);
90    let norm_sq = k.coords.length_squared();
91
92    // Handle origin specially
93    if norm_sq < constants::small_epsilon() {
94        return HyperbolicPoint::origin(dim);
95    }
96
97    let sqrt_term = (one - norm_sq).sqrt(); // √(1 - ||x_K||²)
98    let denom = one + sqrt_term;
99    let inv_denom = one / denom;
100
101    let mut poincare_coords = FixedVector::new(dim);
102    for i in 0..dim {
103        poincare_coords[i] = k.coords[i] * inv_denom;
104    }
105
106    HyperbolicPoint::new(poincare_coords)
107}
108
109// ---------------------------------------------------------------------------
110// Weighted barycenter (Einstein midpoint) — semantic disk
111// ---------------------------------------------------------------------------
112
113/// Weighted hyperbolic barycenter of Klein-model sites (Einstein midpoint):
114///
115/// ```text
116/// m = Σ wᵢ·γᵢ·kᵢ / Σ wᵢ·γᵢ,   γᵢ = 1/√(1 − ||kᵢ||²)
117/// ```
118///
119/// Note `1 − ||kᵢ||²` is exactly [`KleinPoint::weight`], so γᵢ = 1/√weightᵢ.
120/// The result is a convex combination of points strictly inside the unit
121/// ball, hence itself strictly inside — no clamping needed.
122///
123/// Sites with non-positive weight are ignored. Returns `None` when no site
124/// carries positive weight (the caller's "no concept position" case).
125/// Scale-invariant in the weights: `(w₁..wₙ)` and `(c·w₁..c·wₙ)` produce the
126/// same point. Deterministic: fixed-point arithmetic, input-order defined
127/// accumulation (callers pass sites in a canonical order).
128pub fn weighted_barycenter(sites: &[(KleinPoint, FixedPoint)]) -> Option<KleinPoint> {
129    let zero = FixedPoint::from_int(0);
130    let one = FixedPoint::from_int(1);
131
132    let mut dim = 0;
133    let mut denom = zero;
134    let mut numer: Option<FixedVector> = None;
135
136    for (site, w) in sites {
137        if *w <= zero {
138            continue;
139        }
140        // γ = 1/√(1 − ||k||²); clamp the radicand away from zero so
141        // boundary-adjacent sites yield a large-but-finite factor instead
142        // of a division blow-up.
143        let radicand = if site.weight > constants::small_epsilon() {
144            site.weight
145        } else {
146            constants::small_epsilon()
147        };
148        let gamma = one / radicand.sqrt();
149        let coeff = *w * gamma;
150
151        if numer.is_none() {
152            dim = site.dimension();
153            numer = Some(FixedVector::new(dim));
154        }
155        let acc = numer.as_mut().unwrap();
156        for i in 0..dim {
157            acc[i] += site.coords[i] * coeff;
158        }
159        denom += coeff;
160    }
161
162    let numer = numer?;
163    if denom <= zero {
164        return None;
165    }
166    let inv = one / denom;
167    let mut coords = FixedVector::new(dim);
168    for i in 0..dim {
169        coords[i] = numer[i] * inv;
170    }
171    Some(KleinPoint::new(coords))
172}
173
174// ---------------------------------------------------------------------------
175// Power distance
176// ---------------------------------------------------------------------------
177
178/// Compute the power distance from a query point (in Klein coords) to a site.
179///
180/// pd(q, p_i) = ||q - p_i||² - w_i
181///
182/// The nearest neighbor in hyperbolic Voronoi = argmin of power distance.
183pub fn power_distance(query: &FixedVector, site: &KleinPoint) -> FixedPoint {
184    let dim = query.len();
185    assert_eq!(dim, site.dimension(), "Dimension mismatch");
186
187    // Deliberately storage-tier (fused-kernel adoption evaluated 2026-07-11 and
188    // rejected by measurement: 23.8 → 75.9 ns, 3.2×): Klein-interior
189    // inputs (|k| < 1, d ≤ 4) bound the accumulator below 4 — wrap is
190    // impossible — and this is the O(1) grid/nearest hot path where the
191    // fused kernel's upscale/downscale overhead dominates.
192    let mut dist_sq = FixedPoint::from_int(0);
193    for i in 0..dim {
194        let d = query[i] - site.coords[i];
195        dist_sq = dist_sq + d * d;
196    }
197
198    dist_sq - site.weight
199}
200
201/// Find the nearest neighbor by minimum power distance (brute force).
202///
203/// Returns (index, power_distance) of the nearest site.
204pub fn nearest_by_power_distance(query: &FixedVector, sites: &[KleinPoint]) -> Option<(usize, FixedPoint)> {
205    if sites.is_empty() {
206        return None;
207    }
208
209    let mut best_idx = 0;
210    let mut best_pd = power_distance(query, &sites[0]);
211
212    for (i, site) in sites.iter().enumerate().skip(1) {
213        let pd = power_distance(query, site);
214        if pd < best_pd {
215            best_pd = pd;
216            best_idx = i;
217        }
218    }
219
220    Some((best_idx, best_pd))
221}
222
223// ---------------------------------------------------------------------------
224// Tests
225// ---------------------------------------------------------------------------
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::constants;
231
232    fn fp(v: i32) -> FixedPoint {
233        FixedPoint::from_int(v)
234    }
235
236    fn fp_approx_eq(a: FixedPoint, b: FixedPoint, tol: FixedPoint) -> bool {
237        (a - b).abs() < tol
238    }
239
240    // ---- Weighted barycenter (semantic disk) ----
241
242    fn klein_at(x: f32, y: f32) -> KleinPoint {
243        poincare_to_klein(&HyperbolicPoint::from_f32_slice(&[x, y]))
244    }
245
246    #[test]
247    fn barycenter_single_site_is_identity() {
248        let site = klein_at(0.4, -0.2);
249        let m = weighted_barycenter(&[(site.clone(), fp(3))]).unwrap();
250        assert!(fp_approx_eq(m.coords[0], site.coords[0], constants::epsilon()));
251        assert!(fp_approx_eq(m.coords[1], site.coords[1], constants::epsilon()));
252    }
253
254    #[test]
255    fn barycenter_equal_weights_matches_verified_midpoint() {
256        // Two sites, equal weights: the Einstein midpoint must agree with
257        // the independently verified gyro hyperbolic_midpoint (independent oracle).
258        let pa = HyperbolicPoint::from_f32_slice(&[0.5, 0.1]);
259        let pb = HyperbolicPoint::from_f32_slice(&[-0.2, 0.4]);
260        let expected = pa.hyperbolic_midpoint(&pb);
261
262        let m = weighted_barycenter(&[
263            (poincare_to_klein(&pa), fp(1)),
264            (poincare_to_klein(&pb), fp(1)),
265        ])
266        .unwrap();
267        let got = klein_to_poincare(&m);
268
269        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(1000);
270        assert!(
271            fp_approx_eq(got.coords()[0], expected.coords()[0], tol)
272                && fp_approx_eq(got.coords()[1], expected.coords()[1], tol),
273            "einstein midpoint {:?} != gyro midpoint {:?}",
274            got, expected
275        );
276    }
277
278    #[test]
279    fn barycenter_is_weight_scale_invariant() {
280        let sites = [klein_at(0.3, 0.3), klein_at(-0.4, 0.1), klein_at(0.0, -0.5)];
281        let a = weighted_barycenter(&[
282            (sites[0].clone(), fp(1)),
283            (sites[1].clone(), fp(2)),
284            (sites[2].clone(), fp(3)),
285        ])
286        .unwrap();
287        let b = weighted_barycenter(&[
288            (sites[0].clone(), fp(7)),
289            (sites[1].clone(), fp(14)),
290            (sites[2].clone(), fp(21)),
291        ])
292        .unwrap();
293        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(100000);
294        assert!(fp_approx_eq(a.coords[0], b.coords[0], tol));
295        assert!(fp_approx_eq(a.coords[1], b.coords[1], tol));
296    }
297
298    #[test]
299    fn barycenter_stays_inside_disk_and_handles_zero_weights() {
300        // Skewed weights over spread-out sites: result strictly inside.
301        let m = weighted_barycenter(&[
302            (klein_at(0.9, 0.0), fp(100)),
303            (klein_at(-0.9, 0.0), fp(1)),
304        ])
305        .unwrap();
306        assert!(m.coords.length_squared() < FixedPoint::from_int(1));
307
308        // Non-positive weights are ignored; all-zero → None.
309        assert!(weighted_barycenter(&[(klein_at(0.5, 0.0), fp(0))]).is_none());
310        assert!(weighted_barycenter(&[]).is_none());
311        let only_positive = weighted_barycenter(&[
312            (klein_at(0.5, 0.0), fp(0)),
313            (klein_at(0.2, 0.2), fp(1)),
314            (klein_at(0.7, 0.0), fp(-2)),
315        ])
316        .unwrap();
317        let expected = klein_at(0.2, 0.2);
318        assert!(fp_approx_eq(only_positive.coords[0], expected.coords[0], constants::epsilon()));
319        assert!(fp_approx_eq(only_positive.coords[1], expected.coords[1], constants::epsilon()));
320    }
321
322    // ---- Step 1 tests: Klein conversions + power distance ----
323
324    #[test]
325    fn test_klein_origin_maps_to_origin() {
326        let origin = HyperbolicPoint::origin(2);
327        let k = poincare_to_klein(&origin);
328
329        assert!(k.coords[0].abs() < constants::epsilon());
330        assert!(k.coords[1].abs() < constants::epsilon());
331        // Weight at origin should be 1
332        assert!(fp_approx_eq(k.weight, fp(1), constants::epsilon()));
333    }
334
335    #[test]
336    fn test_klein_roundtrip() {
337        // P(0.5, 0) → K → P should return (0.5, 0) within ε
338        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
339        let k = poincare_to_klein(&p);
340        let p2 = klein_to_poincare(&k);
341
342        let tol = constants::epsilon();
343        assert!(fp_approx_eq(p.coords()[0], p2.coords()[0], tol),
344            "x roundtrip: {} vs {}", p.coords()[0], p2.coords()[0]);
345        assert!(fp_approx_eq(p.coords()[1], p2.coords()[1], tol),
346            "y roundtrip: {} vs {}", p.coords()[1], p2.coords()[1]);
347    }
348
349    #[test]
350    fn test_klein_roundtrip_multiple() {
351        // Test roundtrip for several points
352        let test_points: Vec<[f32; 2]> = vec![
353            [0.3, 0.2],
354            [-0.4, 0.1],
355            [0.0, 0.7],
356            [0.1, -0.5],
357            [0.8, 0.0],
358        ];
359
360        let tol = constants::epsilon();
361        for coords in &test_points {
362            let p = HyperbolicPoint::from_f32_slice(coords);
363            let k = poincare_to_klein(&p);
364            let p2 = klein_to_poincare(&k);
365
366            assert!(fp_approx_eq(p.coords()[0], p2.coords()[0], tol),
367                "Roundtrip failed for ({}, {})", coords[0], coords[1]);
368            assert!(fp_approx_eq(p.coords()[1], p2.coords()[1], tol),
369                "Roundtrip failed for ({}, {})", coords[0], coords[1]);
370        }
371    }
372
373    #[test]
374    fn test_klein_known_example() {
375        // P(0.5, 0) → K should give (0.8, 0), w = 0.36
376        // 2·0.5/(1+0.25) = 1.0/1.25 = 0.8
377        // w = 1 - 0.64 = 0.36
378        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
379        let k = poincare_to_klein(&p);
380
381        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(100);
382        let expected_x = FixedPoint::from_int(4) / FixedPoint::from_int(5); // 0.8
383        let expected_w = FixedPoint::from_int(36) / FixedPoint::from_int(100); // 0.36
384
385        assert!(fp_approx_eq(k.coords[0], expected_x, tol),
386            "Klein x: expected 0.8, got {}", k.coords[0]);
387        assert!(k.coords[1].abs() < tol,
388            "Klein y: expected 0, got {}", k.coords[1]);
389        assert!(fp_approx_eq(k.weight, expected_w, tol),
390            "Klein weight: expected 0.36, got {}", k.weight);
391    }
392
393    #[test]
394    fn test_klein_boundary_behavior() {
395        // As ||x_P|| → 1, ||x_K|| → 1
396        let near_boundary = HyperbolicPoint::from_f32_slice(&[0.95, 0.0]);
397        let k = poincare_to_klein(&near_boundary);
398
399        // ||x_K|| should be close to 1 (and closer than ||x_P||)
400        let k_norm = k.coords.length();
401        assert!(k_norm > FixedPoint::from_int(9) / FixedPoint::from_int(10),
402            "Klein norm should be near 1 for boundary point, got {}", k_norm);
403        assert!(k_norm < FixedPoint::from_int(1),
404            "Klein norm should be < 1, got {}", k_norm);
405    }
406
407    #[test]
408    fn test_power_distance_at_site_center() {
409        // pd(p_i, p_i) = ||p_i - p_i||² - w_i = -w_i = ||x_K||² - 1 < 0
410        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
411        let k = poincare_to_klein(&p);
412
413        let pd = power_distance(&k.coords, &k);
414        let expected = -k.weight; // = ||x_K||² - 1
415
416        let tol = constants::epsilon();
417        assert!(fp_approx_eq(pd, expected, tol),
418            "Power distance at site center should be -weight: {} vs {}", pd, expected);
419        assert!(pd < FixedPoint::from_int(0),
420            "Power distance at own site should be negative");
421    }
422
423    #[test]
424    fn test_power_distance_ordering_matches_hyperbolic() {
425        // For a query point and two sites, power distance ordering should
426        // match hyperbolic distance ordering
427        let query_p = HyperbolicPoint::from_f32_slice(&[0.1, 0.1]);
428        let site1_p = HyperbolicPoint::from_f32_slice(&[0.2, 0.0]);
429        let site2_p = HyperbolicPoint::from_f32_slice(&[0.6, 0.3]);
430
431        let query_k = poincare_to_klein(&query_p);
432        let site1_k = poincare_to_klein(&site1_p);
433        let site2_k = poincare_to_klein(&site2_p);
434
435        let pd1 = power_distance(&query_k.coords, &site1_k);
436        let pd2 = power_distance(&query_k.coords, &site2_k);
437
438        let hd1 = query_p.hyperbolic_distance(&site1_p);
439        let hd2 = query_p.hyperbolic_distance(&site2_p);
440
441        // If hd1 < hd2, then pd1 should be < pd2
442        if hd1 < hd2 {
443            assert!(pd1 < pd2,
444                "Power distance ordering should match hyperbolic: pd1={} pd2={}, hd1={} hd2={}",
445                pd1, pd2, hd1, hd2);
446        } else {
447            assert!(pd2 <= pd1,
448                "Power distance ordering should match hyperbolic: pd1={} pd2={}, hd1={} hd2={}",
449                pd1, pd2, hd1, hd2);
450        }
451    }
452
453    #[test]
454    fn test_nearest_by_power_distance() {
455        let sites = vec![
456            KleinPoint::new(FixedVector::from_f32_slice(&[0.2, 0.0])),
457            KleinPoint::new(FixedVector::from_f32_slice(&[0.8, 0.0])),
458            KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.5])),
459        ];
460
461        let query = FixedVector::from_f32_slice(&[0.1, 0.0]);
462
463        let (idx, _pd) = nearest_by_power_distance(&query, &sites).unwrap();
464
465        // Closest to (0.2, 0) should be site 0
466        assert_eq!(idx, 0, "Nearest should be site 0");
467    }
468
469    #[test]
470    fn test_klein_roundtrip_4d() {
471        // Test roundtrip in 4D (default HTT dimension)
472        let p = HyperbolicPoint::from_f32_slice(&[0.3, 0.2, -0.1, 0.15]);
473        let k = poincare_to_klein(&p);
474        let p2 = klein_to_poincare(&k);
475
476        let tol = constants::epsilon();
477        for i in 0..4 {
478            assert!(fp_approx_eq(p.coords()[i], p2.coords()[i], tol),
479                "4D roundtrip failed at dim {}: {} vs {}", i, p.coords()[i], p2.coords()[i]);
480        }
481    }
482
483    // ---- Step 5 validation tests ----
484
485    #[test]
486    fn test_power_distance_ordering_equidistant_sites() {
487        // The Nielsen reduction gives exact Voronoi equivalence for sites at
488        // equal Klein norm (common in Sarkar embeddings at same tree depth).
489        // For siblings at equal distance from parent, pd ordering = d_H ordering.
490        let tau = constants::default_tau();
491        let half_tau = tau * constants::half();
492        let r = half_tau.tanh(); // Sarkar radius in Poincaré disk
493
494        // Create 4 equidistant siblings (children of origin at distance τ)
495        let angles: Vec<FixedPoint> = vec![
496            FixedPoint::from_int(0),
497            FixedPoint::from_int(3) / FixedPoint::from_int(2),
498            FixedPoint::from_int(3),
499            FixedPoint::from_int(9) / FixedPoint::from_int(2),
500        ];
501        let sites_p: Vec<HyperbolicPoint> = angles.iter().map(|a| {
502            let mut v = FixedVector::new(2);
503            let (sin_a, cos_a) = a.sincos();
504            v[0] = r * cos_a;
505            v[1] = r * sin_a;
506            HyperbolicPoint::new(v)
507        }).collect();
508
509        let sites_k: Vec<KleinPoint> = sites_p.iter().map(|p| poincare_to_klein(p)).collect();
510
511        // Query points near each site — power NN should match hyperbolic NN
512        for (qi, site) in sites_p.iter().enumerate() {
513            // Query slightly perturbed from the site
514            let mut q_coords = site.coords().clone();
515            q_coords[0] = q_coords[0] + constants::epsilon();
516            let q_p = HyperbolicPoint::new(q_coords.clone());
517            let q_k = poincare_to_klein(&q_p);
518
519            let (pd_nn, _) = nearest_by_power_distance(&q_k.coords, &sites_k).unwrap();
520
521            let mut hyp_dists: Vec<(usize, FixedPoint)> = sites_p.iter().enumerate()
522                .map(|(i, s)| (i, q_p.hyperbolic_distance(s)))
523                .collect();
524            hyp_dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
525
526            assert_eq!(pd_nn, hyp_dists[0].0,
527                "Power NN should match hyperbolic NN near site {}", qi);
528        }
529    }
530}