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