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