Skip to main content

horon_engine/
hash_table.rs

1//! hash_table.rs - O(1) Hyperbolic Space Lookup with VP-tree Spatial Index
2//! # Hyperbolic Hash Table
3//!
4//! Efficient O(1) lookups in hyperbolic space using geometric hashing techniques,
5//! with O(log n) spatial queries via per-bucket Vantage Point trees.
6//!
7//! This module implements a specialized hash table that leverages the Poincaré disk model
8//! of hyperbolic geometry to enable constant-time operations on hierarchical data structures.
9//!
10//! ## Key Features:
11//!
12//! - Geometric hashing for fast point-location in hyperbolic space
13//! - Locality-sensitive buckets for efficient similarity-based retrieval
14//! - VP-tree per bucket for O(log n) range and nearest-neighbor queries
15//! - Hierarchical organization supporting tree-like data structures
16//! - Fixed-point arithmetic for numerical stability
17
18use std::collections::{HashMap, HashSet};
19use std::fmt::{self, Debug, Formatter};
20use std::sync::Mutex;
21use dashmap::DashMap;
22use sha3::{Sha3_512, Digest};
23use g_math::fixed_point::{FixedPoint, FixedVector};
24use super::hyperbolic_geometry::{PoincareDisk, HyperbolicPoint};
25use crate::constants;
26
27// ---------------------------------------------------------------------------
28// GeometricSignature
29// ---------------------------------------------------------------------------
30
31/// A geometric signature for a node in hyperbolic space.
32///
33/// This signature uniquely identifies a point or region in the
34/// hyperbolic space, enabling O(1) lookups.
35#[derive(Clone, PartialEq, Eq, Hash)]
36pub struct GeometricSignature {
37    /// Hash value for O(1) lookup
38    hash: String,
39    /// Tree level for hierarchical navigation
40    level: u32,
41    /// Position signature in hyperbolic space
42    position_signature: Vec<i32>,
43}
44
45impl GeometricSignature {
46    /// Create a new geometric signature.
47    pub fn new(hash: String, level: u32, position_signature: Vec<i32>) -> Self {
48        Self {
49            hash,
50            level,
51            position_signature,
52        }
53    }
54
55    /// Get the hash value.
56    pub fn hash(&self) -> &str {
57        &self.hash
58    }
59
60    /// Get the tree level.
61    pub fn level(&self) -> u32 {
62        self.level
63    }
64
65    /// Get the position signature.
66    pub fn position_signature(&self) -> &[i32] {
67        &self.position_signature
68    }
69
70    /// Create a stub signature for data-only nodes (no geometric meaning).
71    pub fn stub(unique_id: &str) -> Self {
72        Self {
73            hash: unique_id.to_string(),
74            level: 0,
75            position_signature: Vec::new(),
76        }
77    }
78
79    /// Whether this is a stub signature (a data-only node with no geometric
80    /// embedding — upgradeable via `embed_existing`).
81    pub fn is_stub(&self) -> bool {
82        self.position_signature.is_empty()
83    }
84
85    /// Get a unique node identifier that includes level and position.
86    /// Unlike `hash()` which identifies a geometric bucket (shared by nearby points),
87    /// this produces a unique key suitable for node storage.
88    ///
89    /// For stub signatures (data-only nodes), returns the hash directly.
90    pub fn unique_id(&self) -> String {
91        if self.position_signature.is_empty() {
92            // Stub signature — hash IS the unique_id
93            return self.hash.clone();
94        }
95        use sha3::{Sha3_256, Digest as _};
96        let mut hasher = Sha3_256::new();
97        hasher.update(self.hash.as_bytes());
98        hasher.update(self.level.to_le_bytes());
99        for &v in &self.position_signature {
100            hasher.update(v.to_le_bytes());
101        }
102        hex::encode(&hasher.finalize()[..16])
103    }
104}
105
106impl Debug for GeometricSignature {
107    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
108        write!(f, "GeometricSignature(hash={}, level={})",
109               &self.hash[0..8], self.level)
110    }
111}
112
113// ---------------------------------------------------------------------------
114// HyperbolicRegion
115// ---------------------------------------------------------------------------
116
117/// Hyperbolic region in the Poincaré disk.
118///
119/// A region in hyperbolic space, used for locality-sensitive hashing.
120#[derive(Clone, Debug)]
121pub struct HyperbolicRegion {
122    /// Center point of the region
123    center: HyperbolicPoint,
124    /// Hyperbolic radius of the region
125    radius: FixedPoint,
126    /// Validation mask for fast verification
127    validation_mask: FixedVector,
128}
129
130impl HyperbolicRegion {
131    /// Create a new hyperbolic region.
132    pub fn new(center: HyperbolicPoint, radius: FixedPoint) -> Self {
133        let dimension = center.dimension();
134
135        // Create validation mask based on center point
136        let validation_mask = {
137            let one = FixedPoint::from_int(1);
138            let mut mask = FixedVector::new(dimension);
139            for i in 0..dimension {
140                let x = center.coords()[i];
141                mask[i] = x * (one + x.tanh());
142            }
143            mask
144        };
145
146        Self {
147            center,
148            radius,
149            validation_mask,
150        }
151    }
152
153    /// Check if a point is contained in this region.
154    pub fn contains(&self, point: &HyperbolicPoint, poincare_disk: &PoincareDisk) -> bool {
155        let distance = poincare_disk.distance(&self.center, point);
156        distance <= self.radius
157    }
158
159    /// Get the center of the region.
160    pub fn center(&self) -> &HyperbolicPoint {
161        &self.center
162    }
163
164    /// Get the radius of the region.
165    pub fn radius(&self) -> FixedPoint {
166        self.radius
167    }
168
169    /// Get the validation mask.
170    pub fn validation_mask(&self) -> &FixedVector {
171        &self.validation_mask
172    }
173
174    /// Quick validation check against a point.
175    /// Uses a generous threshold (positive similarity) as a fast heuristic.
176    pub fn quick_validate(&self, point: &HyperbolicPoint) -> bool {
177        let similarity = point.coords().dot(&self.validation_mask);
178        similarity > constants::epsilon()
179    }
180}
181
182// ---------------------------------------------------------------------------
183// BucketEntry
184// ---------------------------------------------------------------------------
185
186/// An entry in the spatial index tracking a node's position in a bucket.
187#[derive(Clone, Debug)]
188pub struct BucketEntry {
189    /// Node unique identifier
190    pub unique_id: String,
191    /// Position in the Poincaré disk
192    pub point: HyperbolicPoint,
193    /// Tree level
194    pub level: u32,
195}
196
197// ---------------------------------------------------------------------------
198// VP-tree: O(log n) spatial queries under the hyperbolic metric
199// ---------------------------------------------------------------------------
200
201/// Compare two FixedPoint values for sorting.
202fn cmp_fp(a: FixedPoint, b: FixedPoint) -> std::cmp::Ordering {
203    if a < b { std::cmp::Ordering::Less }
204    else if a > b { std::cmp::Ordering::Greater }
205    else { std::cmp::Ordering::Equal }
206}
207
208/// Euclidean squared distance between two hyperbolic points.
209/// Pure arithmetic (no transcendentals): O(d) multiply-adds, ~100ns.
210fn euclidean_distance_sq(a: &HyperbolicPoint, b: &HyperbolicPoint) -> FixedPoint {
211    // Deliberately storage-tier (fused-kernel adoption evaluated 2026-07-11 and
212    // rejected by measurement): inputs are Poincaré-interior (|x| < 1,
213    // d ≤ 4), so the accumulator is bounded by 4 and cannot wrap; the
214    // fused compute-tier kernel costs ~3× at this ns-scale call site for
215    // ULPs nothing downstream can observe.
216    let mut sum = FixedPoint::from_int(0);
217    let d = a.dimension().min(b.dimension());
218    for i in 0..d {
219        let diff = a.coords()[i] - b.coords()[i];
220        sum = sum + diff * diff;
221    }
222    sum
223}
224
225/// Buffered insertion threshold: rebuild VP-tree after this many pending inserts.
226const VP_BUFFER_THRESHOLD: usize = 32;
227
228/// Lazy deletion threshold: rebuild VP-tree after this many pending deletes.
229const VP_DELETE_THRESHOLD: usize = 32;
230
231/// Internal node of a Vantage Point tree.
232#[derive(Clone, Debug)]
233struct VPNode {
234    /// The vantage point entry.
235    entry: BucketEntry,
236    /// Median distance from vantage point to entries in subtrees.
237    median: FixedPoint,
238    /// Left subtree: entries closer than median distance.
239    left: Option<Box<VPNode>>,
240    /// Right subtree: entries at or beyond median distance.
241    right: Option<Box<VPNode>>,
242}
243
244/// Vantage Point tree for O(log n) spatial queries under the hyperbolic metric.
245///
246/// Uses a buffer + lazy-deletion strategy for efficient dynamic updates:
247/// - Insertions accumulate in a small buffer; the tree rebuilds when the buffer fills.
248/// - Deletions mark entries as dead; the tree rebuilds when too many are marked.
249/// - Queries search both the tree and the buffer, preserving correctness.
250///
251/// Within each hash table bucket, this replaces the previous linear scan
252/// (O(n/B) per bucket) with O(log(n/B)) queries.
253#[derive(Clone, Debug)]
254pub struct VPTree {
255    /// Root of the VP-tree (None if tree portion is empty).
256    root: Option<Box<VPNode>>,
257    /// Recent insertions not yet incorporated into the tree.
258    buffer: Vec<BucketEntry>,
259    /// Unique IDs of lazily deleted entries still in the tree.
260    deleted: HashSet<String>,
261    /// Number of entries in the tree structure (including lazily deleted ones).
262    tree_size: usize,
263}
264
265impl VPTree {
266    /// Create an empty VP-tree.
267    pub fn new() -> Self {
268        Self {
269            root: None,
270            buffer: Vec::new(),
271            deleted: HashSet::new(),
272            tree_size: 0,
273        }
274    }
275
276    /// Insert an entry. Duplicates (by unique_id) in the buffer are ignored.
277    pub fn insert(&mut self, entry: BucketEntry) {
278        if self.buffer.iter().any(|e| e.unique_id == entry.unique_id) {
279            return;
280        }
281        // If previously lazily deleted, un-delete
282        self.deleted.remove(&entry.unique_id);
283
284        self.buffer.push(entry);
285        if self.buffer.len() >= VP_BUFFER_THRESHOLD {
286            self.rebuild();
287        }
288    }
289
290    /// Remove an entry by unique_id.
291    pub fn remove(&mut self, unique_id: &str) {
292        // Try buffer first (cheaper than tree traversal)
293        let before = self.buffer.len();
294        self.buffer.retain(|e| e.unique_id != unique_id);
295        if self.buffer.len() < before {
296            return;
297        }
298
299        // Mark as lazily deleted in the tree
300        self.deleted.insert(unique_id.to_string());
301        if self.deleted.len() >= VP_DELETE_THRESHOLD {
302            self.rebuild();
303        }
304    }
305
306    /// Number of live entries (tree + buffer - deleted).
307    pub fn live_count(&self) -> usize {
308        let tree_live = self.tree_size.saturating_sub(self.deleted.len());
309        tree_live + self.buffer.len()
310    }
311
312    /// Whether the tree has any live entries.
313    pub fn is_empty(&self) -> bool {
314        self.live_count() == 0
315    }
316
317    /// Find all live entries within hyperbolic radius of center.
318    pub fn find_in_radius(&self, center: &HyperbolicPoint, radius: FixedPoint) -> Vec<(String, FixedPoint)> {
319        let mut results = Vec::new();
320
321        // Search the VP-tree (O(log n) with pruning)
322        if let Some(ref root) = self.root {
323            Self::search_radius(root, center, radius, &self.deleted, &mut results);
324        }
325
326        // Linear scan of the small buffer (bounded by VP_BUFFER_THRESHOLD)
327        for entry in &self.buffer {
328            let dist = center.hyperbolic_distance(&entry.point);
329            if dist <= radius {
330                results.push((entry.unique_id.clone(), dist));
331            }
332        }
333
334        results
335    }
336
337    /// Find the k nearest live entries to a point.
338    /// Returns results sorted by ascending distance.
339    pub fn find_nearest(&self, point: &HyperbolicPoint, k: usize) -> Vec<(String, FixedPoint)> {
340        if k == 0 { return Vec::new(); }
341
342        let mut candidates: Vec<(String, FixedPoint)> = Vec::with_capacity(k + 1);
343        // Initial search radius: any value at or above the largest distance
344        // hyperbolic_distance can return is safe (a too-small tau would prune
345        // real neighbours before k are found). The metric is bounded by the
346        // 0.99 boundary clamp — 2·atanh(0.99) ≈ 5.3 from the origin, and the
347        // antipodal sentinel is 100 — so 200 clears every reachable distance.
348        let mut tau = FixedPoint::from_int(200);
349
350        // Search the VP-tree (O(log n) with pruning)
351        if let Some(ref root) = self.root {
352            Self::search_knn(root, point, k, &self.deleted, &mut candidates, &mut tau);
353        }
354
355        // Linear scan of the small buffer
356        for entry in &self.buffer {
357            let dist = point.hyperbolic_distance(&entry.point);
358            if candidates.len() < k || dist < tau {
359                candidates.push((entry.unique_id.clone(), dist));
360                candidates.sort_by(|a, b| cmp_fp(a.1, b.1));
361                if candidates.len() > k {
362                    candidates.truncate(k);
363                }
364                if candidates.len() == k {
365                    tau = candidates.last().unwrap().1;
366                }
367            }
368        }
369
370        candidates
371    }
372
373    // ---- Internal VP-tree machinery ----
374
375    /// Rebuild the VP-tree from all live entries.
376    fn rebuild(&mut self) {
377        let mut entries = Vec::with_capacity(self.tree_size + self.buffer.len());
378
379        // Collect live entries from the existing tree
380        if let Some(root) = self.root.take() {
381            Self::collect_live(*root, &self.deleted, &mut entries);
382        }
383
384        // Drain the buffer
385        entries.append(&mut self.buffer);
386
387        self.deleted.clear();
388        self.tree_size = entries.len();
389        self.root = Self::build_tree(entries);
390    }
391
392    /// Recursively collect live entries from a VP-tree, consuming nodes.
393    fn collect_live(node: VPNode, deleted: &HashSet<String>, out: &mut Vec<BucketEntry>) {
394        if !deleted.contains(&node.entry.unique_id) {
395            out.push(node.entry);
396        }
397        if let Some(left) = node.left {
398            Self::collect_live(*left, deleted, out);
399        }
400        if let Some(right) = node.right {
401            Self::collect_live(*right, deleted, out);
402        }
403    }
404
405    /// The live entry whose hyperbolic distance from `center` is greatest,
406    /// as `(unique_id, distance)`, or `None` if there are no live entries.
407    ///
408    /// Linear in the number of live entries (tree + buffer). Callers use this
409    /// to recompute a bucket's effective pruning radius exactly after the
410    /// farthest node is removed, so the bound shrinks back under churn rather
411    /// than staying permanently inflated by a since-deleted outlier.
412    pub fn farthest_from(&self, center: &HyperbolicPoint) -> Option<(String, FixedPoint)> {
413        let mut best: Option<(String, FixedPoint)> = None;
414        let mut consider = |entry: &BucketEntry| {
415            let dist = center.hyperbolic_distance(&entry.point);
416            if best.as_ref().is_none_or(|(_, m)| dist > *m) {
417                best = Some((entry.unique_id.clone(), dist));
418            }
419        };
420        for entry in &self.buffer {
421            consider(entry);
422        }
423        if let Some(ref root) = self.root {
424            Self::visit_live(root, &self.deleted, &mut consider);
425        }
426        best
427    }
428
429    /// Visit each live (non-lazily-deleted) entry in the tree, borrowing.
430    fn visit_live<F: FnMut(&BucketEntry)>(node: &VPNode, deleted: &HashSet<String>, f: &mut F) {
431        if !deleted.contains(&node.entry.unique_id) {
432            f(&node.entry);
433        }
434        if let Some(ref left) = node.left {
435            Self::visit_live(left, deleted, f);
436        }
437        if let Some(ref right) = node.right {
438            Self::visit_live(right, deleted, f);
439        }
440    }
441
442    /// Build a balanced VP-tree from a set of entries.
443    ///
444    /// Algorithm (Yianilos 1993):
445    /// 1. Pick a vantage point (first entry for determinism)
446    /// 2. Compute distances from VP to all other entries
447    /// 3. Find the median distance
448    /// 4. Partition: entries closer than median go left, rest go right
449    /// 5. Recurse on each partition
450    fn build_tree(mut entries: Vec<BucketEntry>) -> Option<Box<VPNode>> {
451        if entries.is_empty() {
452            return None;
453        }
454
455        if entries.len() == 1 {
456            return Some(Box::new(VPNode {
457                entry: entries.remove(0),
458                median: FixedPoint::from_int(0),
459                left: None,
460                right: None,
461            }));
462        }
463
464        // Pick vantage point (first entry, deterministic)
465        let vp = entries.swap_remove(0);
466
467        // Compute distances from VP to all remaining entries
468        let mut with_dists: Vec<(BucketEntry, FixedPoint)> = entries
469            .into_iter()
470            .map(|e| {
471                let d = vp.point.hyperbolic_distance(&e.point);
472                (e, d)
473            })
474            .collect();
475
476        // Sort by distance to find median
477        with_dists.sort_by(|a, b| cmp_fp(a.1, b.1));
478
479        let median = with_dists[with_dists.len() / 2].1;
480
481        // Partition: strictly less than median -> left, rest -> right
482        let (left_vec, right_vec): (Vec<_>, Vec<_>) = with_dists
483            .into_iter()
484            .partition(|(_, d)| *d < median);
485
486        let left = Self::build_tree(left_vec.into_iter().map(|(e, _)| e).collect());
487        let right = Self::build_tree(right_vec.into_iter().map(|(e, _)| e).collect());
488
489        Some(Box::new(VPNode {
490            entry: vp,
491            median,
492            left,
493            right,
494        }))
495    }
496
497    /// Recursive range search on the VP-tree.
498    ///
499    /// At each node, computes distance d from center to vantage point, then:
500    /// - Check VP itself (d <= radius?)
501    /// - Prune left subtree if d - radius > median (all left entries too far)
502    /// - Prune right subtree if d + radius < median (all right entries too close to VP)
503    fn search_radius(
504        node: &VPNode,
505        center: &HyperbolicPoint,
506        radius: FixedPoint,
507        deleted: &HashSet<String>,
508        results: &mut Vec<(String, FixedPoint)>,
509    ) {
510        let d = center.hyperbolic_distance(&node.entry.point);
511
512        if d <= radius && !deleted.contains(&node.entry.unique_id) {
513            results.push((node.entry.unique_id.clone(), d));
514        }
515
516        // Left subtree: entries with dist_to_vp < median
517        // By triangle inequality, any left entry's distance to center is >= |d - dist_to_vp|
518        // Minimum possible: d - median (when dist_to_vp approaches median)
519        // Search left if: d - radius <= median
520        if let Some(ref left) = node.left {
521            if d - radius <= node.median {
522                Self::search_radius(left, center, radius, deleted, results);
523            }
524        }
525
526        // Right subtree: entries with dist_to_vp >= median
527        // Minimum possible distance to center: median - d (when dist_to_vp = median)
528        // Search right if: d + radius >= median
529        if let Some(ref right) = node.right {
530            if d + radius >= node.median {
531                Self::search_radius(right, center, radius, deleted, results);
532            }
533        }
534    }
535
536    /// Recursive KNN search on the VP-tree.
537    ///
538    /// Maintains a shrinking search radius `tau` (distance to k-th best candidate).
539    /// Searches the closer subtree first for better early pruning.
540    fn search_knn(
541        node: &VPNode,
542        center: &HyperbolicPoint,
543        k: usize,
544        deleted: &HashSet<String>,
545        candidates: &mut Vec<(String, FixedPoint)>,
546        tau: &mut FixedPoint,
547    ) {
548        let d = center.hyperbolic_distance(&node.entry.point);
549
550        // Consider the vantage point
551        if !deleted.contains(&node.entry.unique_id) {
552            if candidates.len() < k || d < *tau {
553                candidates.push((node.entry.unique_id.clone(), d));
554                candidates.sort_by(|a, b| cmp_fp(a.1, b.1));
555                if candidates.len() > k {
556                    candidates.truncate(k);
557                }
558                if candidates.len() == k {
559                    *tau = candidates.last().unwrap().1;
560                }
561            }
562        }
563
564        // Search the closer subtree first for tighter pruning
565        let search_left_first = d < node.median;
566
567        if search_left_first {
568            if let Some(ref left) = node.left {
569                if d - *tau <= node.median {
570                    Self::search_knn(left, center, k, deleted, candidates, tau);
571                }
572            }
573            if let Some(ref right) = node.right {
574                if d + *tau >= node.median {
575                    Self::search_knn(right, center, k, deleted, candidates, tau);
576                }
577            }
578        } else {
579            if let Some(ref right) = node.right {
580                if d + *tau >= node.median {
581                    Self::search_knn(right, center, k, deleted, candidates, tau);
582                }
583            }
584            if let Some(ref left) = node.left {
585                if d - *tau <= node.median {
586                    Self::search_knn(left, center, k, deleted, candidates, tau);
587                }
588            }
589        }
590    }
591}
592
593// ---------------------------------------------------------------------------
594// HyperbolicHashBucket
595// ---------------------------------------------------------------------------
596
597/// Hash bucket for the hyperbolic hash table.
598///
599/// Each bucket's VP-tree is protected by a Mutex for per-bucket
600/// concurrent spatial index access.
601#[derive(Debug)]
602pub struct HyperbolicHashBucket {
603    /// The hyperbolic region for this bucket
604    region: HyperbolicRegion,
605    /// Position signature for validation
606    position_signature: Vec<i32>,
607    /// Additional validation metrics
608    _metrics: Vec<FixedPoint>,
609    /// VP-tree spatial index for nodes in this bucket (per-bucket lock)
610    vp_tree: Mutex<VPTree>,
611    /// Effective pruning-radius bookkeeping. Deep nodes are assigned to the
612    /// nearest bucket even when they fall outside every bucket's nominal
613    /// region, so range/KNN pruning must widen past the nominal radius to
614    /// reach them (see `EffRadius`). Held under its own mutex.
615    eff: Mutex<EffRadius>,
616}
617
618/// Effective-radius bookkeeping for a bucket.
619///
620/// The pruning bound is the nominal region radius widened to reach the
621/// farthest live member. Unlike a monotone high-water mark, it shrinks back
622/// when that farthest member is removed: a bucket that briefly held a deep
623/// outlier does not keep scanning a stale-wide radius forever under churn.
624/// The shrink is exact — on removal of the bound-defining node the bound is
625/// recomputed from the remaining live members via `VPTree::farthest_from`.
626#[derive(Clone, Debug)]
627struct EffRadius {
628    /// The bucket's nominal region radius; the bound never drops below this.
629    nominal: FixedPoint,
630    /// Current effective radius: `max(nominal, farthest live-member distance)`.
631    current: FixedPoint,
632    /// `unique_id` of the member whose center-distance defines `current`, or
633    /// `None` when `current == nominal` (no out-of-region member). Tracking
634    /// the defining node lets removals recompute the bound only when the node
635    /// that set it is the one being removed — O(1) for every other removal.
636    max_uid: Option<String>,
637}
638
639impl EffRadius {
640    fn new(nominal: FixedPoint) -> Self {
641        Self { nominal, current: nominal, max_uid: None }
642    }
643}
644
645impl Clone for HyperbolicHashBucket {
646    fn clone(&self) -> Self {
647        // Acquire and release each bucket lock in turn — never hold both at
648        // once — so this can never form a lock cycle with `forget_node`
649        // (which holds `eff` while taking `vp_tree`).
650        let vp_tree = self.vp_tree.lock().unwrap_or_else(|e| e.into_inner()).clone();
651        let eff = self.eff.lock().unwrap_or_else(|e| e.into_inner()).clone();
652        Self {
653            region: self.region.clone(),
654            position_signature: self.position_signature.clone(),
655            _metrics: self._metrics.clone(),
656            vp_tree: Mutex::new(vp_tree),
657            eff: Mutex::new(eff),
658        }
659    }
660}
661
662impl HyperbolicHashBucket {
663    /// Create a new hyperbolic hash bucket.
664    pub fn new(region: HyperbolicRegion, position_signature: Vec<i32>) -> Self {
665        let mut metrics = Vec::new();
666
667        let center = region.center();
668        metrics.push(center.euclidean_norm());
669
670        let sum_squares = center.coords().iter().enumerate().fold(
671            FixedPoint::from_int(0),
672            |acc, (_i, &x)| acc + x * x
673        );
674        metrics.push(sum_squares);
675
676        let nominal_radius = region.radius();
677        Self {
678            region,
679            position_signature,
680            _metrics: metrics,
681            vp_tree: Mutex::new(VPTree::new()),
682            eff: Mutex::new(EffRadius::new(nominal_radius)),
683        }
684    }
685
686    /// The pruning radius for range/KNN queries: the nominal region radius,
687    /// widened to cover the farthest node currently registered in this bucket.
688    pub fn effective_radius(&self) -> FixedPoint {
689        self.eff.lock().unwrap_or_else(|e| e.into_inner()).current
690    }
691
692    /// Record the center-distance of a newly registered node, widening the
693    /// effective radius (and remembering the node) if it lies farther than the
694    /// current bound.
695    fn note_node_distance(&self, unique_id: &str, center_dist: FixedPoint) {
696        let mut e = self.eff.lock().unwrap_or_else(|e| e.into_inner());
697        if center_dist > e.current {
698            e.current = center_dist;
699            e.max_uid = Some(unique_id.to_string());
700        }
701    }
702
703    /// Drop a removed node's contribution to the effective radius.
704    ///
705    /// If the removed node is the one currently defining the bound, recompute
706    /// the exact bound from the remaining live members, so a deleted outlier
707    /// no longer inflates query pruning. The node must already be gone from
708    /// the VP-tree when this is called. O(bucket size) only on removal of the
709    /// bound-defining node; O(1) for every other removal.
710    ///
711    /// Locks `eff` then `vp_tree`; this is the only site that holds two bucket
712    /// locks at once, and it always takes them in this order (see `Clone`).
713    fn forget_node(&self, unique_id: &str) {
714        let mut e = self.eff.lock().unwrap_or_else(|e| e.into_inner());
715        if e.max_uid.as_deref() != Some(unique_id) {
716            return;
717        }
718        let tree = self.vp_tree.lock().unwrap_or_else(|e| e.into_inner());
719        match tree.farthest_from(self.region.center()) {
720            Some((uid, dist)) if dist > e.nominal => {
721                e.current = dist;
722                e.max_uid = Some(uid);
723            }
724            _ => {
725                e.current = e.nominal;
726                e.max_uid = None;
727            }
728        }
729    }
730
731    /// Check if a point belongs to this bucket.
732    pub fn contains(&self, point: &HyperbolicPoint, poincare_disk: &PoincareDisk) -> bool {
733        self.region.contains(point, poincare_disk)
734    }
735
736    /// Get the region for this bucket.
737    pub fn region(&self) -> &HyperbolicRegion {
738        &self.region
739    }
740
741    /// Get the position signature.
742    pub fn position_signature(&self) -> &[i32] {
743        &self.position_signature
744    }
745
746    /// Perform a quick validation check.
747    pub fn quick_validate(&self, point: &HyperbolicPoint) -> bool {
748        self.region.quick_validate(point)
749    }
750}
751
752// ---------------------------------------------------------------------------
753// HyperbolicHashTable
754// ---------------------------------------------------------------------------
755
756/// Hyperbolic Hash Table for O(1) lookups in hyperbolic space.
757///
758/// Buckets partition the Poincaré disk into ~61 fixed regions. Each bucket
759/// contains a VP-tree that provides O(log n) spatial queries within the bucket.
760/// Combined with the O(1) bucket selection, total query time is O(log(n/B))
761/// where B is the bucket count.
762#[derive(Clone)]
763pub struct HyperbolicHashTable {
764    /// Poincaré disk model
765    poincare_disk: PoincareDisk,
766    /// Hash buckets organized by hash value
767    buckets: HashMap<String, HyperbolicHashBucket>,
768    /// Map from position signature to hash
769    signature_map: HashMap<Vec<i32>, String>,
770    /// Reverse map: unique_id -> bucket_hash (for O(1) unregistration)
771    node_to_bucket: DashMap<String, String>,
772}
773
774impl HyperbolicHashTable {
775    /// Create a new hyperbolic hash table with the specified dimension.
776    pub fn new(dimension: usize) -> Self {
777        let poincare_disk = PoincareDisk::new(dimension);
778
779        let mut table = Self {
780            poincare_disk,
781            buckets: HashMap::new(),
782            signature_map: HashMap::new(),
783            node_to_bucket: DashMap::new(),
784        };
785
786        table.initialize_buckets();
787        table
788    }
789
790    /// Initialize buckets for locality-sensitive hashing.
791    fn initialize_buckets(&mut self) {
792        let dimension = self.poincare_disk.dimension();
793
794        // Distances from the origin (all as exact rationals)
795        let distances = [
796            FixedPoint::from_int(0),                                          // Origin
797            constants::half(),                                                 // 0.5
798            FixedPoint::from_int(1),                                          // 1.0
799            FixedPoint::from_int(3) / FixedPoint::from_int(2),               // 1.5
800            FixedPoint::from_int(2),                                          // 2.0
801        ];
802
803        let directions_per_distance = [
804            1,               // Origin (just 1 point)
805            dimension * 2,   // Close
806            dimension * 3,   // Medium
807            dimension * 4,   // Far
808            dimension * 5,   // Very far
809        ];
810
811        for (dist_idx, &distance) in distances.iter().enumerate() {
812            let num_directions = directions_per_distance[dist_idx];
813
814            // Special case for the origin
815            if dist_idx == 0 {
816                let origin = self.poincare_disk.origin();
817                let region = HyperbolicRegion::new(origin.clone(), constants::region_radius());
818
819                let position_signature = vec![0; dimension];
820
821                let bucket = HyperbolicHashBucket::new(region, position_signature.clone());
822                let signature = self.compute_geometric_signature(&origin);
823                let hash = self.compute_stable_hash(&signature);
824
825                self.buckets.insert(hash.clone(), bucket);
826                self.signature_map.insert(position_signature, hash);
827
828                continue;
829            }
830
831            for dir_idx in 0..num_directions {
832                let direction = self.generate_direction_vector(dir_idx, num_directions);
833
834                let center = self.poincare_disk.point_at_distance_from_origin(
835                    &direction, distance
836                );
837
838                // Radius: 1/5 + 1/10 * distance (pure FixedPoint)
839                let one_fifth = FixedPoint::from_int(1) / FixedPoint::from_int(5);
840                let one_tenth = FixedPoint::from_int(1) / FixedPoint::from_int(10);
841                let radius = one_fifth + one_tenth * distance;
842                let region = HyperbolicRegion::new(center.clone(), radius);
843
844                let position_signature = self.generate_position_signature(&center);
845
846                let bucket = HyperbolicHashBucket::new(region, position_signature.clone());
847                let signature = self.compute_geometric_signature(&center);
848                let hash = self.compute_stable_hash(&signature);
849
850                self.buckets.insert(hash.clone(), bucket);
851                self.signature_map.insert(position_signature, hash);
852            }
853        }
854    }
855
856    /// Generate a direction vector for bucket initialization.
857    fn generate_direction_vector(&self, index: usize, total: usize) -> FixedVector {
858        let dimension = self.poincare_disk.dimension();
859        let mut direction = FixedVector::new(dimension);
860
861        // For 2D, use angles evenly distributed around a circle
862        if dimension == 2 {
863            let angle = constants::two_pi()
864                * FixedPoint::from_int(index as i32)
865                / FixedPoint::from_int(total as i32);
866            let (sin_a, cos_a) = angle.sincos();
867            direction[0] = cos_a;
868            direction[1] = sin_a;
869            return direction;
870        }
871
872        // For higher dimensions, use golden spiral method
873        let phi = constants::golden_angle();
874
875        // Use (index + 1) to avoid zero vector when index == 0
876        let idx = FixedPoint::from_int((index + 1) as i32);
877        for i in 0..dimension {
878            let phase = idx * phi * FixedPoint::from_int((i + 1) as i32);
879            direction[i] = phase.sin();
880        }
881
882        let norm_sq = direction.dot(&direction);
883        if norm_sq > constants::epsilon() {
884            direction.normalize();
885        } else {
886            // Fallback: unit vector along first axis
887            direction[0] = FixedPoint::from_int(1);
888        }
889
890        direction
891    }
892
893    /// Generate a position signature for a point.
894    fn generate_position_signature(&self, point: &HyperbolicPoint) -> Vec<i32> {
895        let dimension = self.poincare_disk.dimension();
896        let mut signature = Vec::with_capacity(dimension);
897
898        for i in 0..dimension {
899            signature.push(constants::quantize_position(point.coords()[i]));
900        }
901
902        signature
903    }
904
905    /// Compute a geometric signature for a point.
906    /// Uses x * (1 + tanh(x)) to produce a sign-sensitive signature
907    /// (plain x*tanh(x) is even and loses sign information).
908    fn compute_geometric_signature(&self, point: &HyperbolicPoint) -> Vec<i32> {
909        let dimension = self.poincare_disk.dimension();
910        let mut signature = Vec::with_capacity(dimension);
911        let one = FixedPoint::from_int(1);
912
913        for i in 0..dimension {
914            let x = point.coords()[i];
915            let transformed = x * (one + x.tanh());
916            signature.push(constants::quantize_1000(transformed));
917        }
918
919        signature
920    }
921
922    /// Compute a stable hash value from a geometric signature.
923    fn compute_stable_hash(&self, signature: &[i32]) -> String {
924        let mut hasher = Sha3_512::new();
925
926        for &value in signature {
927            hasher.update(value.to_le_bytes());
928        }
929
930        let hash = hasher.finalize();
931        hex::encode(&hash[..16])
932    }
933
934    /// Find the bucket containing a point.
935    ///
936    /// Uses a three-pass strategy:
937    /// 1. Exact signature match (O(1) HashMap lookup)
938    /// 2. Euclidean-distance prefilter: sort buckets by cheap Euclidean²
939    ///    distance to their center, then check hyperbolic containment
940    ///    starting from the nearest. Typically finds the match in 1-3 checks
941    ///    (~20-60µs) instead of scanning all ~61 buckets (~1.2ms).
942    /// 3. quick_validate fallback for edge cases.
943    pub fn find_bucket(&self, point: &HyperbolicPoint) -> Option<String> {
944        // Pass 1: exact signature match (O(1))
945        let position_signature = self.generate_position_signature(point);
946        if let Some(hash) = self.signature_map.get(&position_signature) {
947            return Some(hash.clone());
948        }
949
950        // Pass 2: Euclidean prefilter — check nearest bucket centers first.
951        // Euclidean distance² is O(d) pure arithmetic (~100ns per bucket),
952        // sorting 61 entries is ~1µs. Then we check hyperbolic containment
953        // on the nearest candidates, typically matching on the 1st or 2nd.
954        let mut candidates: Vec<(&String, FixedPoint)> = self.buckets.iter()
955            .map(|(hash, bucket)| {
956                (hash, euclidean_distance_sq(point, bucket.region().center()))
957            })
958            .collect();
959        // Tie-break equal distances by hash so assignment is deterministic
960        // regardless of HashMap iteration order.
961        candidates.sort_unstable_by(|a, b| cmp_fp(a.1, b.1).then_with(|| a.0.cmp(b.0)));
962
963        for (hash, _) in &candidates {
964            if let Some(bucket) = self.buckets.get(*hash) {
965                if bucket.contains(point, &self.poincare_disk) {
966                    return Some((*hash).clone());
967                }
968            }
969        }
970
971        // Pass 3: quick_validate fallback (for points far from any bucket
972        // center). The pre-sorted candidate list keeps this pass deterministic
973        // too — HashMap iteration order must never pick the bucket.
974        for (hash, _) in &candidates {
975            if let Some(bucket) = self.buckets.get(*hash) {
976                if bucket.quick_validate(point) {
977                    return Some((*hash).clone());
978                }
979            }
980        }
981
982        None
983    }
984
985    /// Create a geometric signature for a point.
986    ///
987    /// Uses the point's actual coordinates for the position signature (not the
988    /// bucket center), ensuring unique signatures for distinct points even when
989    /// they fall in the same geometric bucket. The hash field identifies the
990    /// bucket for O(1) locality lookup.
991    pub fn create_signature(&self, point: &HyperbolicPoint, level: u32) -> Option<GeometricSignature> {
992        // Position signature from the actual point (unique per point)
993        let position_signature = self.generate_position_signature(point);
994
995        // Bucket hash for O(1) locality lookup
996        let hash = if let Some(bucket_hash) = self.find_bucket(point) {
997            bucket_hash
998        } else {
999            // Fallback: hash from geometric signature
1000            let geo_sig = self.compute_geometric_signature(point);
1001            self.compute_stable_hash(&geo_sig)
1002        };
1003
1004        Some(GeometricSignature::new(hash, level, position_signature))
1005    }
1006
1007    /// Check if a hyperbolic point is valid.
1008    pub fn validate_point(&self, point: &HyperbolicPoint) -> bool {
1009        let norm = point.euclidean_norm();
1010        if norm >= FixedPoint::from_int(1) {
1011            return false;
1012        }
1013
1014        self.find_bucket(point).is_some()
1015    }
1016
1017    /// Get the Poincaré disk.
1018    pub fn poincare_disk(&self) -> &PoincareDisk {
1019        &self.poincare_disk
1020    }
1021
1022    /// Get the number of buckets.
1023    pub fn bucket_count(&self) -> usize {
1024        self.buckets.len()
1025    }
1026
1027    /// Register a node in the spatial index.
1028    /// Returns the bucket hash the node was placed in.
1029    pub fn register_node(&self, point: &HyperbolicPoint, unique_id: &str, level: u32) -> Option<String> {
1030        self.register_node_with_hint(point, unique_id, level, None)
1031    }
1032
1033    /// Register a node in the spatial index with an optional bucket hash hint.
1034    ///
1035    /// When `bucket_hint` is provided (e.g. from a prior `create_signature` call),
1036    /// skips the expensive `find_bucket` lookup entirely. Falls back to `find_bucket`
1037    /// if the hint is invalid.
1038    pub fn register_node_with_hint(&self, point: &HyperbolicPoint, unique_id: &str, level: u32, bucket_hint: Option<&str>) -> Option<String> {
1039        // Prevent duplicate registration
1040        if self.node_to_bucket.contains_key(unique_id) {
1041            return self.node_to_bucket.get(unique_id).map(|r| r.value().clone());
1042        }
1043
1044        // Try the hint first (avoids second find_bucket call on the insert path)
1045        let bucket_hash = match bucket_hint {
1046            Some(hint) if self.buckets.contains_key(hint) => hint.to_string(),
1047            _ => self.find_bucket(point)?,
1048        };
1049
1050        if let Some(bucket) = self.buckets.get(&bucket_hash) {
1051            // Deep nodes land in buckets whose nominal region doesn't contain
1052            // them; widen the bucket's pruning radius so range/KNN queries
1053            // never skip the bucket that actually holds them.
1054            let center_dist = self.poincare_disk.distance(point, bucket.region.center());
1055            bucket.note_node_distance(unique_id, center_dist);
1056            bucket.vp_tree.lock().unwrap_or_else(|e| e.into_inner()).insert(BucketEntry {
1057                unique_id: unique_id.to_string(),
1058                point: point.clone(),
1059                level,
1060            });
1061        }
1062        self.node_to_bucket.insert(unique_id.to_string(), bucket_hash.clone());
1063        Some(bucket_hash)
1064    }
1065
1066    /// Remove a node from the spatial index.
1067    /// O(1) via node_to_bucket reverse map — only touches the correct bucket.
1068    pub fn unregister_node(&self, unique_id: &str) {
1069        if let Some((_, bucket_hash)) = self.node_to_bucket.remove(unique_id) {
1070            if let Some(bucket) = self.buckets.get(&bucket_hash) {
1071                // Remove from the spatial index first (releasing the vp_tree
1072                // lock), then let the bucket recompute its effective radius
1073                // from the remaining live members if this node defined it.
1074                bucket.vp_tree.lock().unwrap_or_else(|e| e.into_inner()).remove(unique_id);
1075                bucket.forget_node(unique_id);
1076            }
1077        }
1078    }
1079
1080    /// Find all nodes within a hyperbolic radius of a center point.
1081    ///
1082    /// 1. Quick-reject entire buckets whose centers are beyond radius + bucket_radius.
1083    /// 2. Within each candidate bucket, use the VP-tree's O(log n) range query.
1084    pub fn find_nodes_in_radius(&self, center: &HyperbolicPoint, radius: FixedPoint) -> Vec<(String, FixedPoint)> {
1085        let mut results = Vec::new();
1086
1087        for bucket in self.buckets.values() {
1088            // Quick reject: if every node the bucket can hold is too far, skip.
1089            // Uses the effective radius (widened by out-of-region nodes), not
1090            // the nominal region radius.
1091            let bucket_center_dist = self.poincare_disk.distance(
1092                center, bucket.region.center()
1093            );
1094            if bucket_center_dist > radius + bucket.effective_radius() {
1095                continue;
1096            }
1097
1098            // VP-tree range query within this bucket
1099            let bucket_results = bucket.vp_tree.lock().unwrap_or_else(|e| e.into_inner()).find_in_radius(center, radius);
1100            results.extend(bucket_results);
1101        }
1102
1103        results
1104    }
1105
1106    /// Find the k nearest nodes to a point.
1107    ///
1108    /// Sorts buckets by distance to query point, queries each bucket's VP-tree
1109    /// for its k-nearest, and merges results with proper early termination:
1110    /// stops when the next bucket's minimum possible distance exceeds the
1111    /// k-th candidate's distance.
1112    pub fn find_nearest_nodes(&self, point: &HyperbolicPoint, k: usize) -> Vec<(String, FixedPoint)> {
1113        if k == 0 { return Vec::new(); }
1114
1115        // Sort buckets by the minimum possible distance of any member node:
1116        // center distance minus the bucket's EFFECTIVE radius (widened by
1117        // out-of-region nodes), floored at zero. With heterogeneous radii,
1118        // center distance alone is not monotone in this bound, and the early
1119        // `break` below is only sound when buckets are ordered by it.
1120        // Tie-break by hash so tied buckets scan in a deterministic order.
1121        let zero = FixedPoint::from_int(0);
1122        let mut bucket_dists: Vec<(&String, FixedPoint)> = self.buckets.iter()
1123            .map(|(hash, bucket)| {
1124                let d = self.poincare_disk.distance(point, bucket.region.center());
1125                let r = bucket.effective_radius();
1126                let min_possible = if d > r { d - r } else { zero };
1127                (hash, min_possible)
1128            })
1129            .collect();
1130        bucket_dists.sort_by(|a, b| cmp_fp(a.1, b.1).then_with(|| a.0.cmp(b.0)));
1131
1132        let mut candidates: Vec<(String, FixedPoint)> = Vec::new();
1133
1134        for (hash, min_possible) in &bucket_dists {
1135            // Early termination: once we have k candidates, no later bucket
1136            // (sorted by min_possible) can contain anything closer.
1137            if candidates.len() >= k {
1138                let kth_dist = candidates.last().unwrap().1;
1139                if *min_possible > kth_dist {
1140                    break;
1141                }
1142            }
1143
1144            if let Some(bucket) = self.buckets.get(*hash) {
1145                // VP-tree KNN within this bucket
1146                let bucket_results = bucket.vp_tree.lock().unwrap_or_else(|e| e.into_inner()).find_nearest(point, k);
1147
1148                // Merge with global candidates
1149                for result in bucket_results {
1150                    candidates.push(result);
1151                }
1152
1153                // Sort and keep top k
1154                candidates.sort_by(|a, b| cmp_fp(a.1, b.1));
1155                candidates.truncate(k);
1156            }
1157        }
1158
1159        candidates
1160    }
1161
1162    /// Verify the integrity of the hash table.
1163    pub fn verify_integrity(&self) -> bool {
1164        if self.buckets.is_empty() {
1165            return false;
1166        }
1167
1168        for (sig, hash) in &self.signature_map {
1169            if !self.buckets.contains_key(hash) {
1170                return false;
1171            }
1172
1173            let bucket = &self.buckets[hash];
1174            if bucket.position_signature() != sig.as_slice() {
1175                return false;
1176            }
1177        }
1178
1179        true
1180    }
1181}
1182
1183impl Debug for HyperbolicHashTable {
1184    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1185        write!(f, "HyperbolicHashTable(dim={}, buckets={}, nodes={})",
1186               self.poincare_disk.dimension(), self.buckets.len(),
1187               self.node_to_bucket.len())
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    #[test]
1196    fn test_hash_table_creation() {
1197        let table = HyperbolicHashTable::new(2);
1198        assert_eq!(table.poincare_disk().dimension(), 2);
1199        assert!(table.bucket_count() > 0);
1200    }
1201
1202    #[test]
1203    fn test_geometric_signature() {
1204        let table = HyperbolicHashTable::new(2);
1205        let point = table.poincare_disk().point_from_f32_slice(&[0.5, 0.0]);
1206
1207        let signature = table.create_signature(&point, 0).unwrap();
1208        assert_eq!(signature.level(), 0);
1209        assert!(!signature.hash().is_empty());
1210        assert!(!signature.position_signature().is_empty());
1211    }
1212
1213    #[test]
1214    fn test_bucket_finding() {
1215        let table = HyperbolicHashTable::new(2);
1216        let origin = table.poincare_disk().origin();
1217
1218        let bucket_hash = table.find_bucket(&origin);
1219        assert!(bucket_hash.is_some());
1220    }
1221
1222    #[test]
1223    fn test_point_validation() {
1224        let table = HyperbolicHashTable::new(2);
1225
1226        let valid_point = table.poincare_disk().point_from_f32_slice(&[0.5, 0.0]);
1227        assert!(table.validate_point(&valid_point));
1228
1229        let projected_point = table.poincare_disk().point_from_f32_slice(&[1.5, 0.0]);
1230        assert!(table.validate_point(&projected_point));
1231    }
1232
1233    #[test]
1234    fn test_hyperbolic_region() {
1235        let disk = PoincareDisk::new(2);
1236        let center = disk.point_from_f32_slice(&[0.5, 0.0]);
1237        let radius = constants::half();
1238
1239        let region = HyperbolicRegion::new(center.clone(), radius);
1240
1241        assert!(region.contains(&center, &disk));
1242        assert!(!region.contains(&disk.origin(), &disk));
1243
1244        let far_point = disk.point_from_f32_slice(&[0.8, 0.0]);
1245        assert!(!region.contains(&far_point, &disk));
1246    }
1247
1248    #[test]
1249    fn test_hash_bucket() {
1250        let disk = PoincareDisk::new(2);
1251        let center = disk.point_from_f32_slice(&[0.5, 0.0]);
1252        let radius = constants::half();
1253
1254        let region = HyperbolicRegion::new(center.clone(), radius);
1255        let position_signature = vec![500, 0];
1256
1257        let bucket = HyperbolicHashBucket::new(region, position_signature);
1258
1259        assert!(bucket.contains(&center, &disk));
1260        assert!(bucket.quick_validate(&center));
1261    }
1262
1263    #[test]
1264    fn test_integrity_verification() {
1265        let table = HyperbolicHashTable::new(2);
1266        assert!(table.verify_integrity());
1267    }
1268
1269    // ---- VP-tree tests ----
1270
1271    #[test]
1272    fn test_vp_tree_empty() {
1273        let vp = VPTree::new();
1274        assert!(vp.is_empty());
1275        assert_eq!(vp.live_count(), 0);
1276
1277        let origin = HyperbolicPoint::origin(2);
1278        let results = vp.find_in_radius(&origin, FixedPoint::from_int(10));
1279        assert!(results.is_empty());
1280
1281        let nearest = vp.find_nearest(&origin, 5);
1282        assert!(nearest.is_empty());
1283    }
1284
1285    #[test]
1286    fn test_vp_tree_insert_and_find() {
1287        let disk = PoincareDisk::new(2);
1288        let mut vp = VPTree::new();
1289
1290        // Insert several points at different positions
1291        let points: Vec<(&str, [f32; 2])> = vec![
1292            ("a", [0.1, 0.0]),
1293            ("b", [0.2, 0.0]),
1294            ("c", [0.3, 0.0]),
1295            ("d", [0.0, 0.1]),
1296            ("e", [0.0, 0.2]),
1297        ];
1298
1299        for (id, coords) in &points {
1300            vp.insert(BucketEntry {
1301                unique_id: id.to_string(),
1302                point: disk.point_from_f32_slice(coords),
1303                level: 0,
1304            });
1305        }
1306
1307        assert_eq!(vp.live_count(), 5);
1308
1309        // Find nearest to origin — should return "a" and "d" first (closest)
1310        let origin = disk.origin();
1311        let nearest = vp.find_nearest(&origin, 2);
1312        assert_eq!(nearest.len(), 2);
1313        // Distances should be in ascending order
1314        assert!(nearest[0].1 <= nearest[1].1);
1315
1316        // Range query with large radius should find all
1317        let all = vp.find_in_radius(&origin, FixedPoint::from_int(10));
1318        assert_eq!(all.len(), 5);
1319
1320        // Range query with tiny radius should find none (or very few)
1321        let tiny = vp.find_in_radius(&origin, FixedPoint::from_int(1) / FixedPoint::from_int(10000));
1322        assert!(tiny.len() <= 1);
1323    }
1324
1325    #[test]
1326    fn test_vp_tree_remove() {
1327        let disk = PoincareDisk::new(2);
1328        let mut vp = VPTree::new();
1329
1330        vp.insert(BucketEntry {
1331            unique_id: "x".to_string(),
1332            point: disk.point_from_f32_slice(&[0.1, 0.0]),
1333            level: 0,
1334        });
1335        vp.insert(BucketEntry {
1336            unique_id: "y".to_string(),
1337            point: disk.point_from_f32_slice(&[0.2, 0.0]),
1338            level: 0,
1339        });
1340
1341        assert_eq!(vp.live_count(), 2);
1342
1343        vp.remove("x");
1344        assert_eq!(vp.live_count(), 1);
1345
1346        // "x" should not appear in results
1347        let origin = disk.origin();
1348        let results = vp.find_in_radius(&origin, FixedPoint::from_int(10));
1349        assert_eq!(results.len(), 1);
1350        assert_eq!(results[0].0, "y");
1351    }
1352
1353    #[test]
1354    fn test_vp_tree_rebuild_on_buffer_threshold() {
1355        let disk = PoincareDisk::new(2);
1356        let mut vp = VPTree::new();
1357
1358        // Insert more than VP_BUFFER_THRESHOLD entries to trigger a rebuild
1359        for i in 0..(VP_BUFFER_THRESHOLD + 5) {
1360            let angle = constants::two_pi()
1361                * FixedPoint::from_int(i as i32)
1362                / FixedPoint::from_int((VP_BUFFER_THRESHOLD + 5) as i32);
1363            let r = FixedPoint::from_int(3) / FixedPoint::from_int(10);
1364            let mut coords = FixedVector::new(2);
1365            let (sin_a, cos_a) = angle.sincos();
1366            coords[0] = r * cos_a;
1367            coords[1] = r * sin_a;
1368
1369            vp.insert(BucketEntry {
1370                unique_id: format!("node_{}", i),
1371                point: HyperbolicPoint::new(coords),
1372                level: 0,
1373            });
1374        }
1375
1376        // After rebuild, tree should be structured (root is Some)
1377        assert!(vp.root.is_some());
1378        assert_eq!(vp.live_count(), VP_BUFFER_THRESHOLD + 5);
1379
1380        // Queries should still work correctly
1381        let origin = disk.origin();
1382        let all = vp.find_in_radius(&origin, FixedPoint::from_int(10));
1383        assert_eq!(all.len(), VP_BUFFER_THRESHOLD + 5);
1384    }
1385
1386    #[test]
1387    fn test_vp_tree_knn_ordering() {
1388        let disk = PoincareDisk::new(2);
1389        let mut vp = VPTree::new();
1390
1391        // Insert points at known increasing distances from origin
1392        let distances = [0.05f32, 0.1, 0.2, 0.3, 0.5, 0.7];
1393        for (i, &d) in distances.iter().enumerate() {
1394            vp.insert(BucketEntry {
1395                unique_id: format!("p{}", i),
1396                point: disk.point_from_f32_slice(&[d, 0.0]),
1397                level: 0,
1398            });
1399        }
1400
1401        let origin = disk.origin();
1402        let nearest = vp.find_nearest(&origin, 3);
1403        assert_eq!(nearest.len(), 3);
1404
1405        // Verify ascending distance order
1406        for i in 1..nearest.len() {
1407            assert!(nearest[i].1 >= nearest[i - 1].1,
1408                "Results not sorted: {:?} >= {:?}", nearest[i].1, nearest[i - 1].1);
1409        }
1410
1411        // The closest 3 should be p0, p1, p2 (distances 0.05, 0.1, 0.2)
1412        let ids: Vec<&str> = nearest.iter().map(|(id, _)| id.as_str()).collect();
1413        assert!(ids.contains(&"p0"));
1414        assert!(ids.contains(&"p1"));
1415        assert!(ids.contains(&"p2"));
1416    }
1417
1418    #[test]
1419    fn test_register_unregister_with_vp_tree() {
1420        let table = HyperbolicHashTable::new(2);
1421        let disk_clone = table.poincare_disk().clone();
1422
1423        let p1 = disk_clone.point_from_f32_slice(&[0.1, 0.0]);
1424        let p2 = disk_clone.point_from_f32_slice(&[0.2, 0.0]);
1425        let p3 = disk_clone.point_from_f32_slice(&[0.3, 0.0]);
1426
1427        table.register_node(&p1, "node1", 0);
1428        table.register_node(&p2, "node2", 1);
1429        table.register_node(&p3, "node3", 1);
1430
1431        // Should find all three with large radius
1432        let origin = disk_clone.origin();
1433        let results = table.find_nodes_in_radius(&origin, FixedPoint::from_int(10));
1434        assert!(results.len() >= 3, "Expected at least 3, got {}", results.len());
1435
1436        // Unregister node2
1437        table.unregister_node("node2");
1438
1439        // Should no longer find node2
1440        let results = table.find_nodes_in_radius(&origin, FixedPoint::from_int(10));
1441        let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
1442        assert!(!ids.contains(&"node2"), "node2 should be unregistered");
1443        assert!(ids.contains(&"node1"));
1444        assert!(ids.contains(&"node3"));
1445    }
1446
1447    #[test]
1448    fn test_find_nearest_with_early_termination() {
1449        let table = HyperbolicHashTable::new(2);
1450        let disk_clone = table.poincare_disk().clone();
1451
1452        // Insert nodes at various distances
1453        let positions: Vec<(&str, [f32; 2])> = vec![
1454            ("close1", [0.05, 0.0]),
1455            ("close2", [0.0, 0.05]),
1456            ("mid1", [0.3, 0.0]),
1457            ("mid2", [0.0, 0.3]),
1458            ("far1", [0.7, 0.0]),
1459            ("far2", [0.0, 0.7]),
1460        ];
1461
1462        for (id, coords) in &positions {
1463            let point = disk_clone.point_from_f32_slice(coords);
1464            table.register_node(&point, id, 0);
1465        }
1466
1467        let origin = disk_clone.origin();
1468        let nearest = table.find_nearest_nodes(&origin, 2);
1469        assert_eq!(nearest.len(), 2);
1470
1471        // The two closest should be close1 and close2
1472        let ids: Vec<&str> = nearest.iter().map(|(id, _)| id.as_str()).collect();
1473        assert!(ids.contains(&"close1"));
1474        assert!(ids.contains(&"close2"));
1475
1476        // Verify ascending order
1477        assert!(nearest[0].1 <= nearest[1].1);
1478    }
1479
1480    #[test]
1481    fn test_duplicate_registration_prevented() {
1482        let table = HyperbolicHashTable::new(2);
1483        let point = table.poincare_disk().point_from_f32_slice(&[0.1, 0.0]);
1484
1485        let h1 = table.register_node(&point, "dup_node", 0);
1486        let h2 = table.register_node(&point, "dup_node", 0);
1487
1488        // Both should return the same bucket hash
1489        assert_eq!(h1, h2);
1490
1491        // Should only appear once in results
1492        let origin = table.poincare_disk().origin();
1493        let results = table.find_nodes_in_radius(&origin, FixedPoint::from_int(10));
1494        let count = results.iter().filter(|(id, _)| id == "dup_node").count();
1495        assert_eq!(count, 1, "Duplicate registration should be prevented");
1496    }
1497
1498    #[test]
1499    fn effective_radius_returns_to_nominal_when_lone_outlier_removed() {
1500        // A deep node lands in the nearest bucket even though it sits well
1501        // outside that bucket's nominal region, widening the bucket's pruning
1502        // radius. When it is removed, the bound must shrink back to nominal —
1503        // a stale-wide radius would make every later query over-scan forever.
1504        let table = HyperbolicHashTable::new(2);
1505        let disk = table.poincare_disk().clone();
1506
1507        let deep = disk.point_from_f32_slice(&[0.95, 0.0]);
1508        let bucket_hash = table.register_node(&deep, "deep", 5).unwrap();
1509
1510        let nominal = table.buckets.get(&bucket_hash).unwrap().region().radius();
1511        let inflated = table.buckets.get(&bucket_hash).unwrap().effective_radius();
1512        assert!(
1513            inflated > nominal,
1514            "deep node should widen the bucket past nominal (inflated={:?}, nominal={:?})",
1515            inflated, nominal
1516        );
1517
1518        table.unregister_node("deep");
1519
1520        let after = table.buckets.get(&bucket_hash).unwrap().effective_radius();
1521        assert_eq!(
1522            after, nominal,
1523            "with the only out-of-region member gone, the bound must return to nominal"
1524        );
1525    }
1526
1527    #[test]
1528    fn effective_radius_falls_to_second_farthest_not_nominal() {
1529        // Two out-of-region nodes in the same bucket: removing the farther one
1530        // must shrink the bound to the remaining one's distance — not all the
1531        // way to nominal (that would under-prune and drop it from queries),
1532        // and not stay at the removed node's distance (that would over-scan).
1533        let table = HyperbolicHashTable::new(2);
1534        let disk = table.poincare_disk().clone();
1535
1536        // Same radial direction so both map to the same outermost bucket.
1537        let near_deep = disk.point_from_f32_slice(&[0.85, 0.0]);
1538        let far_deep = disk.point_from_f32_slice(&[0.97, 0.0]);
1539
1540        let h_near = table.register_node(&near_deep, "near_deep", 4).unwrap();
1541        let h_far = table.register_node(&far_deep, "far_deep", 6).unwrap();
1542
1543        // The scenario only bites when both share a bucket; skip otherwise
1544        // rather than assert on placement details this test doesn't own.
1545        if h_near != h_far {
1546            return;
1547        }
1548
1549        let bucket = || table.buckets.get(&h_near).unwrap();
1550        let nominal = bucket().region().radius();
1551        let with_both = bucket().effective_radius();
1552
1553        // Distance from the bucket center to the node that should define the
1554        // bound after the farther node is removed.
1555        let center = bucket().region().center().clone();
1556        let near_dist = center.hyperbolic_distance(&near_deep);
1557
1558        table.unregister_node("far_deep");
1559        let after = bucket().effective_radius();
1560
1561        assert!(after < with_both, "removing the farther node must shrink the bound");
1562        assert!(after > nominal, "the remaining out-of-region node must keep the bound above nominal");
1563        assert_eq!(after, near_dist, "the bound must equal the remaining node's center distance");
1564    }
1565}