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, the Nielsen reduction
4//! (hyperbolic Voronoi = Euclidean power diagram), and a uniform grid used
5//! to propose candidates for point location.
6//!
7//! Two limits are load-bearing for callers of this module:
8//! - [`power_distance`] is the *Euclidean* power distance. It coincides with
9//!   the hyperbolic Voronoi diagram only when every site shares one Klein
10//!   norm; for sites at differing norms the exact reduction is
11//!   `argmin_i (1 - <x, k_i>) * gamma_i` (see `semantic_disk::classify_point`).
12//! - [`PointLocationGrid`] holds one owner per tile, and Sarkar placement
13//!   drives power cells below tile size within a few levels. A grid hit is a
14//!   candidate, never an answer; callers must decide with hyperbolic distance.
15//!
16//! Mathematical foundation:
17//! - Klein map: x_K = 2·x_P / (1 + ||x_P||²)
18//! - Inverse:   x_P = x_K / (1 + √(1 - ||x_K||²))
19//! - Power weight: w_i = 1 - ||x_K_i||²
20//! - Power distance: pd(q, p_i) = ||q - p_i||² - w_i
21//! - Nielsen 2009: hyperbolic Voronoi cells = Euclidean power cells in Klein model
22
23use g_math::fixed_point::{FixedPoint, FixedVector};
24use crate::constants;
25use crate::hyperbolic_geometry::HyperbolicPoint;
26
27// ---------------------------------------------------------------------------
28// KleinPoint
29// ---------------------------------------------------------------------------
30
31/// A point in the Klein projective model of hyperbolic space.
32///
33/// In the Klein model, geodesics are Euclidean straight lines (chords),
34/// which makes power diagram bisectors into Euclidean hyperplanes.
35#[derive(Clone, Debug)]
36pub struct KleinPoint {
37    /// Klein disk coordinates (||coords|| < 1)
38    pub coords: FixedVector,
39    /// Power weight: w = 1 - ||coords||²
40    pub weight: FixedPoint,
41}
42
43impl KleinPoint {
44    /// Create a KleinPoint from raw coordinates, computing the weight.
45    pub fn new(coords: FixedVector) -> Self {
46        let weight = FixedPoint::from_int(1) - coords.length_squared();
47        Self { coords, weight }
48    }
49
50    /// Dimension of the point.
51    pub fn dimension(&self) -> usize {
52        self.coords.len()
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Poincaré ↔ Klein conversions
58// ---------------------------------------------------------------------------
59
60/// Convert a Poincaré disk point to a Klein disk point.
61///
62/// Formula: x_K = 2·x_P / (1 + ||x_P||²)
63/// Weight:  w = 1 - ||x_K||²
64pub fn poincare_to_klein(p: &HyperbolicPoint) -> KleinPoint {
65    let dim = p.dimension();
66    let norm_sq = p.coords().length_squared();
67    let one = FixedPoint::from_int(1);
68    let two = FixedPoint::from_int(2);
69
70    let denom = one + norm_sq; // 1 + ||x_P||²
71    let scale = two / denom;   // 2 / (1 + ||x_P||²)
72
73    let mut klein_coords = FixedVector::new(dim);
74    for i in 0..dim {
75        klein_coords[i] = p.coords()[i] * scale;
76    }
77
78    KleinPoint::new(klein_coords)
79}
80
81/// Convert a Klein disk point back to a Poincaré disk point.
82///
83/// Formula: x_P = x_K / (1 + √(1 - ||x_K||²))
84pub fn klein_to_poincare(k: &KleinPoint) -> HyperbolicPoint {
85    let dim = k.dimension();
86    let one = FixedPoint::from_int(1);
87    let norm_sq = k.coords.length_squared();
88
89    // Handle origin specially
90    if norm_sq < constants::small_epsilon() {
91        return HyperbolicPoint::origin(dim);
92    }
93
94    let sqrt_term = (one - norm_sq).sqrt(); // √(1 - ||x_K||²)
95    let denom = one + sqrt_term;
96    let inv_denom = one / denom;
97
98    let mut poincare_coords = FixedVector::new(dim);
99    for i in 0..dim {
100        poincare_coords[i] = k.coords[i] * inv_denom;
101    }
102
103    HyperbolicPoint::new(poincare_coords)
104}
105
106// ---------------------------------------------------------------------------
107// Weighted barycenter (Einstein midpoint) — semantic disk
108// ---------------------------------------------------------------------------
109
110/// Weighted hyperbolic barycenter of Klein-model sites (Einstein midpoint):
111///
112/// ```text
113/// m = Σ wᵢ·γᵢ·kᵢ / Σ wᵢ·γᵢ,   γᵢ = 1/√(1 − ||kᵢ||²)
114/// ```
115///
116/// Note `1 − ||kᵢ||²` is exactly [`KleinPoint::weight`], so γᵢ = 1/√weightᵢ.
117/// The result is a convex combination of points strictly inside the unit
118/// ball, hence itself strictly inside — no clamping needed.
119///
120/// Sites with non-positive weight are ignored. Returns `None` when no site
121/// carries positive weight (the caller's "no concept position" case).
122/// Scale-invariant in the weights: `(w₁..wₙ)` and `(c·w₁..c·wₙ)` produce the
123/// same point. Deterministic: fixed-point arithmetic, input-order defined
124/// accumulation (callers pass sites in a canonical order).
125pub fn weighted_barycenter(sites: &[(KleinPoint, FixedPoint)]) -> Option<KleinPoint> {
126    let zero = FixedPoint::from_int(0);
127    let one = FixedPoint::from_int(1);
128
129    let mut dim = 0;
130    let mut denom = zero;
131    let mut numer: Option<FixedVector> = None;
132
133    for (site, w) in sites {
134        if *w <= zero {
135            continue;
136        }
137        // γ = 1/√(1 − ||k||²); clamp the radicand away from zero so
138        // boundary-adjacent sites yield a large-but-finite factor instead
139        // of a division blow-up.
140        let radicand = if site.weight > constants::small_epsilon() {
141            site.weight
142        } else {
143            constants::small_epsilon()
144        };
145        let gamma = one / radicand.sqrt();
146        let coeff = *w * gamma;
147
148        if numer.is_none() {
149            dim = site.dimension();
150            numer = Some(FixedVector::new(dim));
151        }
152        let acc = numer.as_mut().unwrap();
153        for i in 0..dim {
154            acc[i] += site.coords[i] * coeff;
155        }
156        denom += coeff;
157    }
158
159    let numer = numer?;
160    if denom <= zero {
161        return None;
162    }
163    let inv = one / denom;
164    let mut coords = FixedVector::new(dim);
165    for i in 0..dim {
166        coords[i] = numer[i] * inv;
167    }
168    Some(KleinPoint::new(coords))
169}
170
171// ---------------------------------------------------------------------------
172// Power distance
173// ---------------------------------------------------------------------------
174
175/// Compute the power distance from a query point (in Klein coords) to a site.
176///
177/// pd(q, p_i) = ||q - p_i||² - w_i
178///
179/// The nearest neighbor in hyperbolic Voronoi = argmin of power distance.
180pub fn power_distance(query: &FixedVector, site: &KleinPoint) -> FixedPoint {
181    let dim = query.len();
182    assert_eq!(dim, site.dimension(), "Dimension mismatch");
183
184    // Deliberately storage-tier (fused-kernel adoption evaluated 2026-07-11 and
185    // rejected by measurement: 23.8 → 75.9 ns, 3.2×): Klein-interior
186    // inputs (|k| < 1, d ≤ 4) bound the accumulator below 4 — wrap is
187    // impossible — and this is the O(1) grid/nearest hot path where the
188    // fused kernel's upscale/downscale overhead dominates.
189    let mut dist_sq = FixedPoint::from_int(0);
190    for i in 0..dim {
191        let d = query[i] - site.coords[i];
192        dist_sq = dist_sq + d * d;
193    }
194
195    dist_sq - site.weight
196}
197
198/// Find the nearest neighbor by minimum power distance (brute force).
199///
200/// Returns (index, power_distance) of the nearest site.
201pub fn nearest_by_power_distance(query: &FixedVector, sites: &[KleinPoint]) -> Option<(usize, FixedPoint)> {
202    if sites.is_empty() {
203        return None;
204    }
205
206    let mut best_idx = 0;
207    let mut best_pd = power_distance(query, &sites[0]);
208
209    for (i, site) in sites.iter().enumerate().skip(1) {
210        let pd = power_distance(query, site);
211        if pd < best_pd {
212            best_pd = pd;
213            best_idx = i;
214        }
215    }
216
217    Some((best_idx, best_pd))
218}
219
220// ---------------------------------------------------------------------------
221// Power Cell (Step 2)
222// ---------------------------------------------------------------------------
223
224/// A half-plane constraint defining one face of a power cell.
225///
226/// Represents: ⟨q, normal⟩ ≤ offset
227/// where q is on node i's side of the bisector with neighbor j.
228#[derive(Clone, Debug)]
229pub struct HalfPlane {
230    /// Normal vector (direction p_j - p_i in Klein space)
231    pub normal: FixedVector,
232    /// Offset threshold: ||p_j||² - ||p_i||²
233    pub offset: FixedPoint,
234    /// Which tree neighbor defines this boundary
235    pub neighbor_id: String,
236}
237
238/// A power cell — the Voronoi region of a single node in the Klein model.
239///
240/// Cell(i) = ∩_{j ∈ tree_neighbors(i)} { q : pd(q,i) ≤ pd(q,j) }
241/// By the Delaunay=Tree theorem, neighbors are exactly the tree neighbors.
242#[derive(Clone, Debug)]
243pub struct PowerCell {
244    /// Unique ID of the node owning this cell
245    pub node_id: String,
246    /// The site (Klein point) at the center of this cell
247    pub site: KleinPoint,
248    /// Half-plane constraints, one per tree neighbor
249    pub half_planes: Vec<HalfPlane>,
250}
251
252/// Compute the bisector half-plane between sites i and j.
253///
254/// The bisector {q : pd(q,i) = pd(q,j)} is a hyperplane:
255///   ⟨q, p_j - p_i⟩ = ||p_j||² - ||p_i||²
256///
257/// The half-plane for node i (closer to i than j):
258///   ⟨q, p_j - p_i⟩ ≤ ||p_j||² - ||p_i||²
259pub fn compute_bisector(site_i: &KleinPoint, site_j: &KleinPoint, neighbor_id: &str) -> HalfPlane {
260    let dim = site_i.dimension();
261    assert_eq!(dim, site_j.dimension(), "Dimension mismatch");
262
263    // normal = p_j - p_i
264    let mut normal = FixedVector::new(dim);
265    for i in 0..dim {
266        normal[i] = site_j.coords[i] - site_i.coords[i];
267    }
268
269    // offset = ||p_j||² - ||p_i||²
270    let offset = site_j.coords.length_squared() - site_i.coords.length_squared();
271
272    HalfPlane {
273        normal,
274        offset,
275        neighbor_id: neighbor_id.to_string(),
276    }
277}
278
279/// Test whether a query point lies inside a power cell.
280///
281/// Returns true if ⟨q, hp.normal⟩ ≤ hp.offset for all half-planes.
282pub fn point_in_cell(query: &FixedVector, cell: &PowerCell) -> bool {
283    for hp in &cell.half_planes {
284        let dot = query.dot(&hp.normal);
285        if dot > hp.offset {
286            return false;
287        }
288    }
289    true
290}
291
292// ---------------------------------------------------------------------------
293// Point Location Grid (Step 3)
294// ---------------------------------------------------------------------------
295
296/// Uniform grid over the Klein disk for O(1) point location.
297///
298/// The grid partitions the [-1, 1]² bounding box into resolution×resolution
299/// tiles. Each tile stores the ID of the power cell that owns its center.
300/// Query: map Klein coords → tile → owner ID → verify with half-plane check.
301pub struct PointLocationGrid {
302    /// Grid cells per axis
303    pub resolution: usize,
304    /// Dimension of the embedding space
305    dimension: usize,
306    /// Size of each grid tile: 2 / resolution
307    cell_size: FixedPoint,
308    /// Inverse cell size: resolution / 2 (precomputed for fast indexing)
309    inv_cell_size: FixedPoint,
310    /// Flat grid: grid[row * resolution + col] → Option<node_id>
311    grid: Vec<Option<String>>,
312    /// Inverted index: node_id → list of tile indices owned by that node.
313    /// Enables O(parent_tiles) insert updates instead of O(R²).
314    tile_owners: std::collections::HashMap<String, Vec<usize>>,
315}
316
317impl PointLocationGrid {
318    /// Create an empty grid with the given resolution and dimension.
319    /// For dim > 2, the grid projects onto the first 2 coordinates.
320    pub fn new(resolution: usize) -> Self {
321        Self::with_dimension(resolution, 2)
322    }
323
324    /// Create a grid for a specific embedding dimension.
325    ///
326    /// A `resolution` of 0 is treated as 1 (a single degenerate cell): the
327    /// grid spans `[-1, 1]` in each axis, so `cell_size = 2 / resolution`
328    /// would divide by zero. A 1×1 grid holds no useful spatial structure but
329    /// keeps the constructor total rather than panicking.
330    pub fn with_dimension(resolution: usize, dimension: usize) -> Self {
331        let resolution = resolution.max(1);
332        let res_fp = FixedPoint::from_int(resolution as i32);
333        let two = FixedPoint::from_int(2);
334        let cell_size = two / res_fp;
335        let inv_cell_size = res_fp / two;
336
337        Self {
338            resolution,
339            dimension,
340            cell_size,
341            inv_cell_size,
342            grid: vec![None; resolution * resolution],
343            tile_owners: std::collections::HashMap::new(),
344        }
345    }
346
347    /// Build the grid from a set of KleinPoints by brute-force nearest power distance.
348    ///
349    /// For each tile center inside the Klein disk, find the site with minimum
350    /// power distance and assign that tile to that site's node_id.
351    pub fn build(&mut self, sites: &[(String, KleinPoint)]) {
352        if sites.is_empty() {
353            return;
354        }
355
356        let one = FixedPoint::from_int(1);
357
358        for row in 0..self.resolution {
359            for col in 0..self.resolution {
360                let center = self.tile_center(row, col);
361
362                // Skip tiles outside the Klein disk
363                if center.length_squared() >= one {
364                    self.grid[row * self.resolution + col] = None;
365                    continue;
366                }
367
368                // Find site with minimum power distance
369                let mut best_id: Option<&str> = None;
370                let mut best_pd = FixedPoint::from_int(0);
371                let mut first = true;
372
373                for (id, site) in sites {
374                    let pd = power_distance(&center, site);
375                    if first || pd < best_pd {
376                        best_pd = pd;
377                        best_id = Some(id.as_str());
378                        first = false;
379                    }
380                }
381
382                self.grid[row * self.resolution + col] = best_id.map(|s| s.to_string());
383            }
384        }
385
386        // Build inverted index
387        self.tile_owners.clear();
388        for (idx, cell) in self.grid.iter().enumerate() {
389            if let Some(ref id) = cell {
390                self.tile_owners.entry(id.clone()).or_default().push(idx);
391            }
392        }
393    }
394
395    /// Query the grid for the node owning the tile containing the given Klein point.
396    ///
397    /// Returns None if the point is outside the disk or the tile is unassigned.
398    pub fn query(&self, query_klein: &FixedVector) -> Option<&str> {
399        // Clamp to valid range
400        let (row, col) = self.coords_to_tile(query_klein);
401
402        if row >= self.resolution || col >= self.resolution {
403            return None;
404        }
405
406        self.grid[row * self.resolution + col].as_deref()
407    }
408
409    /// Update the grid after inserting a new leaf node.
410    ///
411    /// Only tiles currently assigned to the parent need checking.
412    /// For each such tile, if the tile center is closer to the new leaf
413    /// in power distance, reassign it.
414    pub fn update_insert(&mut self, parent_id: &str, new_id: &str, new_site: &KleinPoint, parent_site: &KleinPoint) {
415        let one = FixedPoint::from_int(1);
416
417        // Get parent's tile indices via inverted index — O(parent_tiles) not O(R²)
418        let parent_tiles = match self.tile_owners.get(parent_id) {
419            Some(tiles) => tiles.clone(),
420            None => return,
421        };
422
423        let mut tiles_to_reassign = Vec::new();
424
425        for &idx in &parent_tiles {
426            let row = idx / self.resolution;
427            let col = idx % self.resolution;
428            let center = self.tile_center(row, col);
429
430            if center.length_squared() >= one {
431                continue;
432            }
433
434            let pd_parent = power_distance(&center, parent_site);
435            let pd_new = power_distance(&center, new_site);
436
437            if pd_new < pd_parent {
438                tiles_to_reassign.push(idx);
439            }
440        }
441
442        // Apply reassignments to grid
443        for &idx in &tiles_to_reassign {
444            self.grid[idx] = Some(new_id.to_string());
445        }
446
447        // Update inverted index: remove from parent, add to new node
448        if !tiles_to_reassign.is_empty() {
449            if let Some(parent_list) = self.tile_owners.get_mut(parent_id) {
450                parent_list.retain(|idx| !tiles_to_reassign.contains(idx));
451            }
452            self.tile_owners.entry(new_id.to_string())
453                .or_default()
454                .extend(&tiles_to_reassign);
455        }
456    }
457
458    /// Update the grid after deleting a leaf node.
459    ///
460    /// All tiles assigned to the deleted node are reassigned to the parent.
461    pub fn update_delete(&mut self, deleted_id: &str, parent_id: &str) {
462        // Get deleted node's tiles via inverted index — O(deleted_tiles) not O(R²)
463        let deleted_tiles = match self.tile_owners.remove(deleted_id) {
464            Some(tiles) => tiles,
465            None => return,
466        };
467
468        // Reassign to parent in grid
469        for &idx in &deleted_tiles {
470            self.grid[idx] = Some(parent_id.to_string());
471        }
472
473        // Add to parent's inverted index
474        self.tile_owners.entry(parent_id.to_string())
475            .or_default()
476            .extend(deleted_tiles);
477    }
478
479    /// Get the Klein-space center coordinates of a grid tile.
480    /// Returns a vector of dimension `self.dimension`, with higher dims = 0.
481    fn tile_center(&self, row: usize, col: usize) -> FixedVector {
482        let half = constants::half();
483        let one = FixedPoint::from_int(1);
484
485        // Klein disk spans [-1, 1]. Tile (row, col) maps to:
486        // x = -1 + (col + 0.5) * cell_size
487        // y = -1 + (row + 0.5) * cell_size
488        let col_fp = FixedPoint::from_int(col as i32);
489        let row_fp = FixedPoint::from_int(row as i32);
490
491        let x = -one + (col_fp + half) * self.cell_size;
492        let y = -one + (row_fp + half) * self.cell_size;
493
494        let mut v = FixedVector::new(self.dimension);
495        v[0] = x;
496        if self.dimension >= 2 {
497            v[1] = y;
498        }
499        // Higher dimensions stay at zero
500        v
501    }
502
503    /// Map Klein coordinates to grid tile indices (uses first 2 dims).
504    fn coords_to_tile(&self, coords: &FixedVector) -> (usize, usize) {
505        let one = FixedPoint::from_int(1);
506
507        // col = floor((x + 1) / cell_size), row = floor((y + 1) / cell_size)
508        let x = coords[0];
509        let y = if coords.len() >= 2 { coords[1] } else { FixedPoint::from_int(0) };
510        let col_fp = (x + one) * self.inv_cell_size;
511        let row_fp = (y + one) * self.inv_cell_size;
512
513        let col = col_fp.to_int().max(0) as usize;
514        let row = row_fp.to_int().max(0) as usize;
515
516        (row.min(self.resolution - 1), col.min(self.resolution - 1))
517    }
518
519    /// Get the number of assigned tiles (tiles inside the disk with an owner).
520    pub fn assigned_tile_count(&self) -> usize {
521        self.grid.iter().filter(|t| t.is_some()).count()
522    }
523
524    /// Get the grid resolution.
525    pub fn resolution(&self) -> usize {
526        self.resolution
527    }
528}
529
530impl std::fmt::Debug for PointLocationGrid {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        write!(f, "PointLocationGrid(resolution={}, assigned={})",
533               self.resolution, self.assigned_tile_count())
534    }
535}
536
537// ---------------------------------------------------------------------------
538// Tests
539// ---------------------------------------------------------------------------
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::constants;
545
546    fn fp(v: i32) -> FixedPoint {
547        FixedPoint::from_int(v)
548    }
549
550    fn fp_approx_eq(a: FixedPoint, b: FixedPoint, tol: FixedPoint) -> bool {
551        (a - b).abs() < tol
552    }
553
554    // ---- Weighted barycenter (semantic disk) ----
555
556    fn klein_at(x: f32, y: f32) -> KleinPoint {
557        poincare_to_klein(&HyperbolicPoint::from_f32_slice(&[x, y]))
558    }
559
560    #[test]
561    fn barycenter_single_site_is_identity() {
562        let site = klein_at(0.4, -0.2);
563        let m = weighted_barycenter(&[(site.clone(), fp(3))]).unwrap();
564        assert!(fp_approx_eq(m.coords[0], site.coords[0], constants::epsilon()));
565        assert!(fp_approx_eq(m.coords[1], site.coords[1], constants::epsilon()));
566    }
567
568    #[test]
569    fn barycenter_equal_weights_matches_verified_midpoint() {
570        // Two sites, equal weights: the Einstein midpoint must agree with
571        // the independently verified gyro hyperbolic_midpoint (independent oracle).
572        let pa = HyperbolicPoint::from_f32_slice(&[0.5, 0.1]);
573        let pb = HyperbolicPoint::from_f32_slice(&[-0.2, 0.4]);
574        let expected = pa.hyperbolic_midpoint(&pb);
575
576        let m = weighted_barycenter(&[
577            (poincare_to_klein(&pa), fp(1)),
578            (poincare_to_klein(&pb), fp(1)),
579        ])
580        .unwrap();
581        let got = klein_to_poincare(&m);
582
583        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(1000);
584        assert!(
585            fp_approx_eq(got.coords()[0], expected.coords()[0], tol)
586                && fp_approx_eq(got.coords()[1], expected.coords()[1], tol),
587            "einstein midpoint {:?} != gyro midpoint {:?}",
588            got, expected
589        );
590    }
591
592    #[test]
593    fn barycenter_is_weight_scale_invariant() {
594        let sites = [klein_at(0.3, 0.3), klein_at(-0.4, 0.1), klein_at(0.0, -0.5)];
595        let a = weighted_barycenter(&[
596            (sites[0].clone(), fp(1)),
597            (sites[1].clone(), fp(2)),
598            (sites[2].clone(), fp(3)),
599        ])
600        .unwrap();
601        let b = weighted_barycenter(&[
602            (sites[0].clone(), fp(7)),
603            (sites[1].clone(), fp(14)),
604            (sites[2].clone(), fp(21)),
605        ])
606        .unwrap();
607        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(100000);
608        assert!(fp_approx_eq(a.coords[0], b.coords[0], tol));
609        assert!(fp_approx_eq(a.coords[1], b.coords[1], tol));
610    }
611
612    #[test]
613    fn barycenter_stays_inside_disk_and_handles_zero_weights() {
614        // Skewed weights over spread-out sites: result strictly inside.
615        let m = weighted_barycenter(&[
616            (klein_at(0.9, 0.0), fp(100)),
617            (klein_at(-0.9, 0.0), fp(1)),
618        ])
619        .unwrap();
620        assert!(m.coords.length_squared() < FixedPoint::from_int(1));
621
622        // Non-positive weights are ignored; all-zero → None.
623        assert!(weighted_barycenter(&[(klein_at(0.5, 0.0), fp(0))]).is_none());
624        assert!(weighted_barycenter(&[]).is_none());
625        let only_positive = weighted_barycenter(&[
626            (klein_at(0.5, 0.0), fp(0)),
627            (klein_at(0.2, 0.2), fp(1)),
628            (klein_at(0.7, 0.0), fp(-2)),
629        ])
630        .unwrap();
631        let expected = klein_at(0.2, 0.2);
632        assert!(fp_approx_eq(only_positive.coords[0], expected.coords[0], constants::epsilon()));
633        assert!(fp_approx_eq(only_positive.coords[1], expected.coords[1], constants::epsilon()));
634    }
635
636    // ---- Step 1 tests: Klein conversions + power distance ----
637
638    #[test]
639    fn test_klein_origin_maps_to_origin() {
640        let origin = HyperbolicPoint::origin(2);
641        let k = poincare_to_klein(&origin);
642
643        assert!(k.coords[0].abs() < constants::epsilon());
644        assert!(k.coords[1].abs() < constants::epsilon());
645        // Weight at origin should be 1
646        assert!(fp_approx_eq(k.weight, fp(1), constants::epsilon()));
647    }
648
649    #[test]
650    fn test_klein_roundtrip() {
651        // P(0.5, 0) → K → P should return (0.5, 0) within ε
652        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
653        let k = poincare_to_klein(&p);
654        let p2 = klein_to_poincare(&k);
655
656        let tol = constants::epsilon();
657        assert!(fp_approx_eq(p.coords()[0], p2.coords()[0], tol),
658            "x roundtrip: {} vs {}", p.coords()[0], p2.coords()[0]);
659        assert!(fp_approx_eq(p.coords()[1], p2.coords()[1], tol),
660            "y roundtrip: {} vs {}", p.coords()[1], p2.coords()[1]);
661    }
662
663    #[test]
664    fn test_klein_roundtrip_multiple() {
665        // Test roundtrip for several points
666        let test_points: Vec<[f32; 2]> = vec![
667            [0.3, 0.2],
668            [-0.4, 0.1],
669            [0.0, 0.7],
670            [0.1, -0.5],
671            [0.8, 0.0],
672        ];
673
674        let tol = constants::epsilon();
675        for coords in &test_points {
676            let p = HyperbolicPoint::from_f32_slice(coords);
677            let k = poincare_to_klein(&p);
678            let p2 = klein_to_poincare(&k);
679
680            assert!(fp_approx_eq(p.coords()[0], p2.coords()[0], tol),
681                "Roundtrip failed for ({}, {})", coords[0], coords[1]);
682            assert!(fp_approx_eq(p.coords()[1], p2.coords()[1], tol),
683                "Roundtrip failed for ({}, {})", coords[0], coords[1]);
684        }
685    }
686
687    #[test]
688    fn test_klein_known_example() {
689        // P(0.5, 0) → K should give (0.8, 0), w = 0.36
690        // 2·0.5/(1+0.25) = 1.0/1.25 = 0.8
691        // w = 1 - 0.64 = 0.36
692        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
693        let k = poincare_to_klein(&p);
694
695        let tol = FixedPoint::from_int(1) / FixedPoint::from_int(100);
696        let expected_x = FixedPoint::from_int(4) / FixedPoint::from_int(5); // 0.8
697        let expected_w = FixedPoint::from_int(36) / FixedPoint::from_int(100); // 0.36
698
699        assert!(fp_approx_eq(k.coords[0], expected_x, tol),
700            "Klein x: expected 0.8, got {}", k.coords[0]);
701        assert!(k.coords[1].abs() < tol,
702            "Klein y: expected 0, got {}", k.coords[1]);
703        assert!(fp_approx_eq(k.weight, expected_w, tol),
704            "Klein weight: expected 0.36, got {}", k.weight);
705    }
706
707    #[test]
708    fn test_klein_boundary_behavior() {
709        // As ||x_P|| → 1, ||x_K|| → 1
710        let near_boundary = HyperbolicPoint::from_f32_slice(&[0.95, 0.0]);
711        let k = poincare_to_klein(&near_boundary);
712
713        // ||x_K|| should be close to 1 (and closer than ||x_P||)
714        let k_norm = k.coords.length();
715        assert!(k_norm > FixedPoint::from_int(9) / FixedPoint::from_int(10),
716            "Klein norm should be near 1 for boundary point, got {}", k_norm);
717        assert!(k_norm < FixedPoint::from_int(1),
718            "Klein norm should be < 1, got {}", k_norm);
719    }
720
721    #[test]
722    fn test_power_distance_at_site_center() {
723        // pd(p_i, p_i) = ||p_i - p_i||² - w_i = -w_i = ||x_K||² - 1 < 0
724        let p = HyperbolicPoint::from_f32_slice(&[0.5, 0.0]);
725        let k = poincare_to_klein(&p);
726
727        let pd = power_distance(&k.coords, &k);
728        let expected = -k.weight; // = ||x_K||² - 1
729
730        let tol = constants::epsilon();
731        assert!(fp_approx_eq(pd, expected, tol),
732            "Power distance at site center should be -weight: {} vs {}", pd, expected);
733        assert!(pd < FixedPoint::from_int(0),
734            "Power distance at own site should be negative");
735    }
736
737    #[test]
738    fn test_power_distance_ordering_matches_hyperbolic() {
739        // For a query point and two sites, power distance ordering should
740        // match hyperbolic distance ordering
741        let query_p = HyperbolicPoint::from_f32_slice(&[0.1, 0.1]);
742        let site1_p = HyperbolicPoint::from_f32_slice(&[0.2, 0.0]);
743        let site2_p = HyperbolicPoint::from_f32_slice(&[0.6, 0.3]);
744
745        let query_k = poincare_to_klein(&query_p);
746        let site1_k = poincare_to_klein(&site1_p);
747        let site2_k = poincare_to_klein(&site2_p);
748
749        let pd1 = power_distance(&query_k.coords, &site1_k);
750        let pd2 = power_distance(&query_k.coords, &site2_k);
751
752        let hd1 = query_p.hyperbolic_distance(&site1_p);
753        let hd2 = query_p.hyperbolic_distance(&site2_p);
754
755        // If hd1 < hd2, then pd1 should be < pd2
756        if hd1 < hd2 {
757            assert!(pd1 < pd2,
758                "Power distance ordering should match hyperbolic: pd1={} pd2={}, hd1={} hd2={}",
759                pd1, pd2, hd1, hd2);
760        } else {
761            assert!(pd2 <= pd1,
762                "Power distance ordering should match hyperbolic: pd1={} pd2={}, hd1={} hd2={}",
763                pd1, pd2, hd1, hd2);
764        }
765    }
766
767    #[test]
768    fn test_nearest_by_power_distance() {
769        let sites = vec![
770            KleinPoint::new(FixedVector::from_f32_slice(&[0.2, 0.0])),
771            KleinPoint::new(FixedVector::from_f32_slice(&[0.8, 0.0])),
772            KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.5])),
773        ];
774
775        let query = FixedVector::from_f32_slice(&[0.1, 0.0]);
776
777        let (idx, _pd) = nearest_by_power_distance(&query, &sites).unwrap();
778
779        // Closest to (0.2, 0) should be site 0
780        assert_eq!(idx, 0, "Nearest should be site 0");
781    }
782
783    // ---- Step 2 tests: Power cells ----
784
785    #[test]
786    fn test_compute_bisector_symmetry() {
787        let site_i = KleinPoint::new(FixedVector::from_f32_slice(&[0.2, 0.0]));
788        let site_j = KleinPoint::new(FixedVector::from_f32_slice(&[0.6, 0.0]));
789
790        let hp_ij = compute_bisector(&site_i, &site_j, "j");
791        let hp_ji = compute_bisector(&site_j, &site_i, "i");
792
793        // Normals should be opposite, offsets should be opposite
794        let tol = constants::epsilon();
795        assert!(fp_approx_eq(hp_ij.normal[0], -hp_ji.normal[0], tol));
796        assert!(fp_approx_eq(hp_ij.offset, -hp_ji.offset, tol));
797    }
798
799    #[test]
800    fn test_point_in_cell_at_site_center() {
801        // The site center should be inside its own cell
802        let site_i = KleinPoint::new(FixedVector::from_f32_slice(&[0.2, 0.0]));
803        let site_j = KleinPoint::new(FixedVector::from_f32_slice(&[0.6, 0.0]));
804
805        let hp = compute_bisector(&site_i, &site_j, "j");
806        let cell = PowerCell {
807            node_id: "i".to_string(),
808            site: site_i.clone(),
809            half_planes: vec![hp],
810        };
811
812        assert!(point_in_cell(&site_i.coords, &cell),
813            "Site center should be inside its own cell");
814    }
815
816    #[test]
817    fn test_bisector_midpoint_on_boundary() {
818        // The midpoint between two Klein sites should be on the bisector (within ε)
819        let site_i = KleinPoint::new(FixedVector::from_f32_slice(&[0.2, 0.0]));
820        let site_j = KleinPoint::new(FixedVector::from_f32_slice(&[0.6, 0.0]));
821
822        let hp = compute_bisector(&site_i, &site_j, "j");
823
824        // Midpoint in Klein space (Euclidean midpoint, since Klein is projective)
825        let mut midpoint = FixedVector::new(2);
826        midpoint[0] = (site_i.coords[0] + site_j.coords[0]) * constants::half();
827        midpoint[1] = (site_i.coords[1] + site_j.coords[1]) * constants::half();
828
829        // For the midpoint to be on the bisector: ⟨midpoint, normal⟩ should ≈ offset
830        // But only when the two sites have equal norm (symmetric case)
831        // In general: pd(mid, i) should ≈ pd(mid, j)
832        let _pd_i = power_distance(&midpoint, &site_i);
833        let _pd_j = power_distance(&midpoint, &site_j);
834
835        // The Euclidean midpoint is NOT generally on the power bisector.
836        // The actual bisector point is where pd(q, i) = pd(q, j).
837        // Let's verify: query the bisector condition directly.
838        // At the bisector: ⟨q, normal⟩ = offset
839        // Let's find the point on the x-axis where this holds:
840        // q = (x, 0), normal = (0.4, 0), offset = 0.36 - 0.04 = 0.32
841        // 0.4x = 0.32 → x = 0.8
842        let bisector_x = hp.offset / hp.normal[0];
843        let mut bisector_pt = FixedVector::new(2);
844        bisector_pt[0] = bisector_x;
845
846        let pd_i_bpt = power_distance(&bisector_pt, &site_i);
847        let pd_j_bpt = power_distance(&bisector_pt, &site_j);
848
849        let tol = constants::epsilon();
850        assert!(fp_approx_eq(pd_i_bpt, pd_j_bpt, tol),
851            "Bisector point should have equal power distances: {} vs {}", pd_i_bpt, pd_j_bpt);
852    }
853
854    #[test]
855    fn test_cell_membership_consistency() {
856        // Create two sites and verify every test point belongs to exactly one cell
857        let site_a = KleinPoint::new(FixedVector::from_f32_slice(&[0.3, 0.0]));
858        let site_b = KleinPoint::new(FixedVector::from_f32_slice(&[-0.3, 0.0]));
859
860        let hp_ab = compute_bisector(&site_a, &site_b, "b");
861        let hp_ba = compute_bisector(&site_b, &site_a, "a");
862
863        let cell_a = PowerCell {
864            node_id: "a".to_string(),
865            site: site_a.clone(),
866            half_planes: vec![hp_ab],
867        };
868        let cell_b = PowerCell {
869            node_id: "b".to_string(),
870            site: site_b.clone(),
871            half_planes: vec![hp_ba],
872        };
873
874        // Test points along x-axis
875        let test_xs: Vec<f32> = vec![-0.8, -0.5, -0.2, 0.0, 0.2, 0.5, 0.8];
876        for &x in &test_xs {
877            let q = FixedVector::from_f32_slice(&[x, 0.0]);
878            let in_a = point_in_cell(&q, &cell_a);
879            let in_b = point_in_cell(&q, &cell_b);
880
881            // Exactly one should be true (or both at boundary)
882            assert!(in_a || in_b,
883                "Point ({}, 0) should be in at least one cell", x);
884        }
885    }
886
887    // ---- Step 3 tests: Point Location Grid ----
888
889    #[test]
890    fn test_grid_single_site() {
891        // With a single site at origin, all disk tiles should point to it
892        let sites = vec![
893            ("root".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]))),
894        ];
895
896        let mut grid = PointLocationGrid::new(16);
897        grid.build(&sites);
898
899        // Query at various points — all should return "root"
900        let test_points: Vec<[f32; 2]> = vec![[0.0, 0.0], [0.5, 0.0], [0.0, -0.5], [0.3, 0.3]];
901        for coords in &test_points {
902            let q = FixedVector::from_f32_slice(coords);
903            let result = grid.query(&q);
904            assert_eq!(result, Some("root"), "Single site should own all tiles");
905        }
906    }
907
908    #[test]
909    fn test_grid_query_matches_brute_force() {
910        // Build a grid with multiple sites and verify it matches brute-force
911        let sites = vec![
912            ("a".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.3, 0.0]))),
913            ("b".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[-0.3, 0.0]))),
914            ("c".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.4]))),
915        ];
916
917        let mut grid = PointLocationGrid::new(32);
918        grid.build(&sites);
919
920        // Test random-ish points
921        let test_points: Vec<[f32; 2]> = vec![
922            [0.2, 0.0], [-0.2, 0.0], [0.0, 0.3],
923            [0.5, 0.1], [-0.4, -0.2], [0.1, 0.5],
924        ];
925
926        let klein_sites: Vec<KleinPoint> = sites.iter().map(|(_, s)| s.clone()).collect();
927        let ids: Vec<&str> = sites.iter().map(|(id, _)| id.as_str()).collect();
928
929        for coords in &test_points {
930            let q = FixedVector::from_f32_slice(coords);
931            if q.length_squared() >= FixedPoint::from_int(1) {
932                continue;
933            }
934
935            let grid_result = grid.query(&q);
936            let (brute_idx, _) = nearest_by_power_distance(&q, &klein_sites).unwrap();
937            let brute_result = ids[brute_idx];
938
939            assert_eq!(grid_result, Some(brute_result),
940                "Grid mismatch at ({}, {}): grid={:?} brute={}",
941                coords[0], coords[1], grid_result, brute_result);
942        }
943    }
944
945    #[test]
946    fn test_grid_insert_update() {
947        // Build with parent, then insert child, verify grid updated correctly
948        let parent_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]));
949        let sites = vec![
950            ("parent".to_string(), parent_site.clone()),
951        ];
952
953        let mut grid = PointLocationGrid::new(16);
954        grid.build(&sites);
955
956        // All tiles should be parent
957        let q = FixedVector::from_f32_slice(&[0.5, 0.0]);
958        assert_eq!(grid.query(&q), Some("parent"));
959
960        // Insert child near (0.5, 0)
961        let child_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.5, 0.0]));
962        grid.update_insert("parent", "child", &child_site, &parent_site);
963
964        // Query near child should now return child
965        let q_near_child = FixedVector::from_f32_slice(&[0.6, 0.0]);
966        let result = grid.query(&q_near_child);
967        assert_eq!(result, Some("child"),
968            "After insert, tile near child should be owned by child");
969
970        // Query near origin should still be parent
971        let q_origin = FixedVector::from_f32_slice(&[0.0, 0.0]);
972        assert_eq!(grid.query(&q_origin), Some("parent"),
973            "Origin tile should still be parent");
974    }
975
976    #[test]
977    fn test_grid_delete_update() {
978        let parent_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]));
979        let child_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.5, 0.0]));
980
981        let sites = vec![
982            ("parent".to_string(), parent_site.clone()),
983            ("child".to_string(), child_site.clone()),
984        ];
985
986        let mut grid = PointLocationGrid::new(16);
987        grid.build(&sites);
988
989        // Verify child owns some tiles
990        let q = FixedVector::from_f32_slice(&[0.6, 0.0]);
991        assert_eq!(grid.query(&q), Some("child"));
992
993        // Delete child → reassign to parent
994        grid.update_delete("child", "parent");
995
996        assert_eq!(grid.query(&q), Some("parent"),
997            "After delete, child's tiles should revert to parent");
998    }
999
1000    #[test]
1001    fn test_grid_tile_count() {
1002        // Tiles inside disk ≈ π/4 · resolution²
1003        let resolution = 64;
1004        let sites = vec![
1005            ("root".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]))),
1006        ];
1007
1008        let mut grid = PointLocationGrid::new(resolution);
1009        grid.build(&sites);
1010
1011        let assigned = grid.assigned_tile_count();
1012        let expected_approx = (std::f64::consts::PI / 4.0 * (resolution as f64).powi(2)) as usize;
1013
1014        // Allow 10% tolerance
1015        let lower = expected_approx * 9 / 10;
1016        let upper = expected_approx * 11 / 10;
1017        assert!(assigned >= lower && assigned <= upper,
1018            "Assigned tiles {} should be near π/4·{}² ≈ {} (range [{}, {}])",
1019            assigned, resolution, expected_approx, lower, upper);
1020    }
1021
1022    #[test]
1023    fn test_klein_roundtrip_4d() {
1024        // Test roundtrip in 4D (default HTT dimension)
1025        let p = HyperbolicPoint::from_f32_slice(&[0.3, 0.2, -0.1, 0.15]);
1026        let k = poincare_to_klein(&p);
1027        let p2 = klein_to_poincare(&k);
1028
1029        let tol = constants::epsilon();
1030        for i in 0..4 {
1031            assert!(fp_approx_eq(p.coords()[i], p2.coords()[i], tol),
1032                "4D roundtrip failed at dim {}: {} vs {}", i, p.coords()[i], p2.coords()[i]);
1033        }
1034    }
1035
1036    // ---- Step 5 validation tests ----
1037
1038    #[test]
1039    fn test_power_distance_ordering_equidistant_sites() {
1040        // The Nielsen reduction gives exact Voronoi equivalence for sites at
1041        // equal Klein norm (common in Sarkar embeddings at same tree depth).
1042        // For siblings at equal distance from parent, pd ordering = d_H ordering.
1043        let tau = constants::default_tau();
1044        let half_tau = tau * constants::half();
1045        let r = half_tau.tanh(); // Sarkar radius in Poincaré disk
1046
1047        // Create 4 equidistant siblings (children of origin at distance τ)
1048        let angles: Vec<FixedPoint> = vec![
1049            FixedPoint::from_int(0),
1050            FixedPoint::from_int(3) / FixedPoint::from_int(2),
1051            FixedPoint::from_int(3),
1052            FixedPoint::from_int(9) / FixedPoint::from_int(2),
1053        ];
1054        let sites_p: Vec<HyperbolicPoint> = angles.iter().map(|a| {
1055            let mut v = FixedVector::new(2);
1056            let (sin_a, cos_a) = a.sincos();
1057            v[0] = r * cos_a;
1058            v[1] = r * sin_a;
1059            HyperbolicPoint::new(v)
1060        }).collect();
1061
1062        let sites_k: Vec<KleinPoint> = sites_p.iter().map(|p| poincare_to_klein(p)).collect();
1063
1064        // Query points near each site — power NN should match hyperbolic NN
1065        for (qi, site) in sites_p.iter().enumerate() {
1066            // Query slightly perturbed from the site
1067            let mut q_coords = site.coords().clone();
1068            q_coords[0] = q_coords[0] + constants::epsilon();
1069            let q_p = HyperbolicPoint::new(q_coords.clone());
1070            let q_k = poincare_to_klein(&q_p);
1071
1072            let (pd_nn, _) = nearest_by_power_distance(&q_k.coords, &sites_k).unwrap();
1073
1074            let mut hyp_dists: Vec<(usize, FixedPoint)> = sites_p.iter().enumerate()
1075                .map(|(i, s)| (i, q_p.hyperbolic_distance(s)))
1076                .collect();
1077            hyp_dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1078
1079            assert_eq!(pd_nn, hyp_dists[0].0,
1080                "Power NN should match hyperbolic NN near site {}", qi);
1081        }
1082    }
1083
1084    #[test]
1085    fn test_grid_vs_brute_force_stress() {
1086        // Build a grid with many sites and verify grid matches brute-force
1087        // for a large number of random query points
1088        let site_coords: Vec<[f32; 2]> = vec![
1089            [0.0, 0.0], [0.3, 0.0], [-0.3, 0.0], [0.0, 0.3], [0.0, -0.3],
1090            [0.2, 0.2], [-0.2, 0.2], [0.2, -0.2], [-0.2, -0.2],
1091            [0.5, 0.1], [-0.4, 0.3], [0.1, 0.6], [-0.1, -0.5],
1092        ];
1093
1094        let sites: Vec<(String, KleinPoint)> = site_coords.iter().enumerate()
1095            .map(|(i, c)| {
1096                let p = HyperbolicPoint::from_f32_slice(c);
1097                let k = poincare_to_klein(&p);
1098                (format!("node_{}", i), k)
1099            })
1100            .collect();
1101
1102        let mut grid = PointLocationGrid::new(64);
1103        grid.build(&sites);
1104
1105        let klein_only: Vec<KleinPoint> = sites.iter().map(|(_, k)| k.clone()).collect();
1106        let ids: Vec<&str> = sites.iter().map(|(id, _)| id.as_str()).collect();
1107
1108        // Test a grid of query points
1109        let mut mismatches = 0;
1110        let mut total = 0;
1111        for xi in -9..=9 {
1112            for yi in -9..=9 {
1113                let x = xi as f32 / 10.0;
1114                let y = yi as f32 / 10.0;
1115                if x * x + y * y >= 0.99 {
1116                    continue;
1117                }
1118
1119                let q = FixedVector::from_f32_slice(&[x, y]);
1120                total += 1;
1121
1122                let grid_result = grid.query(&q);
1123                let (brute_idx, _) = nearest_by_power_distance(&q, &klein_only).unwrap();
1124                let brute_result = ids[brute_idx];
1125
1126                if grid_result != Some(brute_result) {
1127                    mismatches += 1;
1128                }
1129            }
1130        }
1131
1132        // Allow a tiny mismatch rate due to grid quantization at cell boundaries
1133        let mismatch_rate = mismatches as f64 / total as f64;
1134        assert!(mismatch_rate < 0.05,
1135            "Grid vs brute-force mismatch rate {} ({}/{}) exceeds 5%",
1136            mismatch_rate, mismatches, total);
1137    }
1138
1139    #[test]
1140    fn test_insert_preserves_grid_correctness() {
1141        // Incrementally insert nodes and verify grid correctness after each insert
1142        let mut sites: Vec<(String, KleinPoint)> = Vec::new();
1143        let mut grid = PointLocationGrid::new(32);
1144
1145        // Start with root at origin
1146        let root_k = KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]));
1147        sites.push(("root".to_string(), root_k.clone()));
1148        grid.build(&sites);
1149
1150        // Insert 20 nodes incrementally
1151        let child_coords: Vec<[f32; 2]> = vec![
1152            [0.3, 0.0], [-0.3, 0.0], [0.0, 0.3], [0.0, -0.3],
1153            [0.5, 0.1], [-0.4, 0.3], [0.1, 0.6], [-0.1, -0.5],
1154            [0.2, 0.2], [-0.2, 0.2], [0.2, -0.2], [-0.2, -0.2],
1155            [0.7, 0.0], [0.0, 0.7], [-0.6, 0.1], [0.3, -0.4],
1156            [0.4, 0.4], [-0.3, -0.3], [0.15, 0.15], [-0.15, 0.45],
1157        ];
1158
1159        for (i, coords) in child_coords.iter().enumerate() {
1160            let p = HyperbolicPoint::from_f32_slice(coords);
1161            let k = poincare_to_klein(&p);
1162            let new_id = format!("node_{}", i);
1163
1164            // Insert using incremental update (parent = root for simplicity)
1165            grid.update_insert("root", &new_id, &k, &root_k);
1166            sites.push((new_id, k));
1167
1168            // Every 5 inserts, verify a spot-check
1169            if (i + 1) % 5 == 0 {
1170                let klein_only: Vec<KleinPoint> = sites.iter().map(|(_, k)| k.clone()).collect();
1171                // Check a few query points
1172                let test_queries: Vec<[f32; 2]> = vec![[0.0, 0.0], [0.2, 0.1], [-0.3, 0.2]];
1173                for q_coords in &test_queries {
1174                    let q = FixedVector::from_f32_slice(q_coords);
1175                    if q.length_squared() >= fp(1) { continue; }
1176
1177                    let grid_r = grid.query(&q);
1178                    let (_brute_idx, _) = nearest_by_power_distance(&q, &klein_only).unwrap();
1179                    // Grid may lag behind brute force at boundaries; just verify it returns something
1180                    assert!(grid_r.is_some(),
1181                        "Grid should return a result for query inside disk");
1182                }
1183            }
1184        }
1185    }
1186
1187    #[test]
1188    fn test_empty_tree_grid() {
1189        // Empty grid should return None for all queries
1190        let grid = PointLocationGrid::new(16);
1191        let q = FixedVector::from_f32_slice(&[0.0, 0.0]);
1192        assert_eq!(grid.query(&q), None, "Empty grid should return None");
1193    }
1194
1195    #[test]
1196    fn test_query_outside_disk_clamped() {
1197        // Query outside Klein disk should not panic, should return something or None
1198        let sites = vec![
1199            ("root".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]))),
1200        ];
1201        let mut grid = PointLocationGrid::new(16);
1202        grid.build(&sites);
1203
1204        let q = FixedVector::from_f32_slice(&[1.5, 0.0]);
1205        // Should not panic — returns whatever tile it maps to
1206        let _result = grid.query(&q);
1207    }
1208
1209    #[test]
1210    fn test_inverted_index_consistency_after_build() {
1211        let sites = vec![
1212            ("a".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.3, 0.0]))),
1213            ("b".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[-0.3, 0.0]))),
1214            ("c".to_string(), KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.4]))),
1215        ];
1216
1217        let mut grid = PointLocationGrid::new(32);
1218        grid.build(&sites);
1219
1220        // Verify: every tile in tile_owners has matching grid entry
1221        for (id, tiles) in &grid.tile_owners {
1222            for &idx in tiles {
1223                assert_eq!(grid.grid[idx].as_deref(), Some(id.as_str()),
1224                    "tile_owners[{}] contains idx {} but grid[{}] = {:?}",
1225                    id, idx, idx, grid.grid[idx]);
1226            }
1227        }
1228
1229        // Verify: every assigned grid entry is in tile_owners
1230        for (idx, cell) in grid.grid.iter().enumerate() {
1231            if let Some(ref id) = cell {
1232                let tiles = grid.tile_owners.get(id).expect("grid has id not in tile_owners");
1233                assert!(tiles.contains(&idx),
1234                    "grid[{}] = {} but tile_owners[{}] doesn't contain {}", idx, id, id, idx);
1235            }
1236        }
1237    }
1238
1239    #[test]
1240    fn test_inverted_index_after_insert() {
1241        let parent_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]));
1242        let sites = vec![("parent".to_string(), parent_site.clone())];
1243
1244        let mut grid = PointLocationGrid::new(16);
1245        grid.build(&sites);
1246
1247        let parent_tiles_before = grid.tile_owners.get("parent").map(|v| v.len()).unwrap_or(0);
1248        assert!(parent_tiles_before > 0, "Parent should own tiles after build");
1249
1250        // Insert child
1251        let child_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.5, 0.0]));
1252        grid.update_insert("parent", "child", &child_site, &parent_site);
1253
1254        let parent_tiles_after = grid.tile_owners.get("parent").map(|v| v.len()).unwrap_or(0);
1255        let child_tiles = grid.tile_owners.get("child").map(|v| v.len()).unwrap_or(0);
1256
1257        assert!(child_tiles > 0, "Child should own some tiles");
1258        assert_eq!(parent_tiles_before, parent_tiles_after + child_tiles,
1259            "Total tiles should be conserved: {} != {} + {}",
1260            parent_tiles_before, parent_tiles_after, child_tiles);
1261
1262        // Verify consistency
1263        for (id, tiles) in &grid.tile_owners {
1264            for &idx in tiles {
1265                assert_eq!(grid.grid[idx].as_deref(), Some(id.as_str()));
1266            }
1267        }
1268    }
1269
1270    #[test]
1271    fn test_inverted_index_after_delete() {
1272        let parent_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.0, 0.0]));
1273        let child_site = KleinPoint::new(FixedVector::from_f32_slice(&[0.5, 0.0]));
1274
1275        let sites = vec![
1276            ("parent".to_string(), parent_site.clone()),
1277            ("child".to_string(), child_site.clone()),
1278        ];
1279
1280        let mut grid = PointLocationGrid::new(16);
1281        grid.build(&sites);
1282
1283        let total_before: usize = grid.tile_owners.values().map(|v| v.len()).sum();
1284        let child_tiles_before = grid.tile_owners.get("child").map(|v| v.len()).unwrap_or(0);
1285        assert!(child_tiles_before > 0, "Child should own tiles");
1286
1287        // Delete child
1288        grid.update_delete("child", "parent");
1289
1290        assert!(grid.tile_owners.get("child").is_none(),
1291            "Deleted node should be removed from tile_owners");
1292
1293        let parent_tiles_after = grid.tile_owners.get("parent").map(|v| v.len()).unwrap_or(0);
1294        assert_eq!(parent_tiles_after, total_before,
1295            "Parent should absorb all tiles: {} vs {}", parent_tiles_after, total_before);
1296
1297        // Verify consistency
1298        for (id, tiles) in &grid.tile_owners {
1299            for &idx in tiles {
1300                assert_eq!(grid.grid[idx].as_deref(), Some(id.as_str()));
1301            }
1302        }
1303    }
1304}