Skip to main content

horon_engine/
cell_index.rs

1//! Hyperbolic cell index — a node's cell is *computed* from its coordinates.
2//!
3//! Replaces the fixed-bucket layer, which never located anything: its ~61
4//! regions were seeded on a `dimension`-D golden spiral while the Sarkar
5//! embedding is provably planar, so no bucket could contain any node and every
6//! point fell through to a half-space sign test. Queries then sorted all
7//! buckets with the exact 37.5 µs kernel — and the bucket count was
8//! `1 + 14 × dimension`, so the cost scaled with a configuration knob rather
9//! than with the data.
10//!
11//! # Why a computed cell
12//!
13//! Because the cell is a pure function of the point, it is O(1) with no search
14//! and no global structure, and `shard = f(CellId)` is computable on any node
15//! with no coordination — which is what a distributed deployment needs and
16//! what a globally-structured tree cannot offer.
17//!
18//! # The cost model this is designed to
19//!
20//! Measured in Q64.64: `mul` 5.2 ns, `div` 32 ns, `cosh` 96 ns, `sincos`
21//! 318 ns, `tanh` 2.8 µs, `atanh` 16.1 µs, `sqrt` 17.7 µs, **`atan2` 33.2 µs**,
22//! `hyperbolic_distance` 37.5 µs, squared-ratio proxy ≈150 ns.
23//!
24//! So: no transcendental on any per-node or per-cell path. `tanh`/`sinh`/`cosh`
25//! appear only in build-time tables, `atanh` and `atan2` appear nowhere, and
26//! `sqrt` is paid at most once per query.
27//!
28//! # Exactness
29//!
30//! The ring expands until the per-cell lower bound proves nothing closer
31//! remains. There is no window, no candidate cap and no count-based stopping
32//! rule; if a bound cannot be proven the search widens. Degradation is toward
33//! more work, never toward a plausible answer.
34
35use dashmap::DashMap;
36use g_math::fixed_point::FixedPoint;
37
38use crate::hyperbolic_geometry::HyperbolicPoint;
39use crate::metric_tree::{CachedNormPoint, HyperbolicMetric, Metric};
40
41/// Bands past this are unreachable: the Q64.64 distance kernel saturates
42/// around hyperbolic radius 22, and `MAX_BANDS × W` covers well beyond it.
43const MAX_BANDS: usize = 64;
44
45/// Ceiling on sectors per band.
46///
47/// `k(b)` grows like `sinh`, passing `i32::MAX` around band 39 (3.9e9 at band
48/// 40) — which silently wrapped the sector arithmetic negative and scattered
49/// deep nodes to unrelated sectors, so a query at depth 19 found shallow
50/// ancestors instead of its own sibling. Capping keeps every sector value
51/// inside the range the conversions can carry.
52///
53/// Nothing is lost by capping: sectors exist to keep cells small, and a band
54/// this far out holds at most a handful of nodes, so extra angular resolution
55/// there subdivides emptiness.
56const MAX_SECTORS: i64 = 1 << 28;
57
58/// A cell: radial band and angular sector.
59#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
60pub struct CellId {
61    /// Radial band — hyperbolic radius `[band·W, (band+1)·W)`.
62    pub band: i32,
63    /// Angular sector within the band, `0..sectors(band)`.
64    pub sector: i64,
65}
66
67/// One indexed node.
68#[derive(Clone)]
69struct Entry {
70    unique_id: String,
71    point: CachedNormPoint,
72}
73
74/// Per-band constants, computed once. Every transcendental in the query path
75/// is hoisted into here.
76#[derive(Clone)]
77struct Band {
78    cosh_lo: FixedPoint,
79    sinh_lo: FixedPoint,
80    tanh_lo: FixedPoint,
81    cosh_hi: FixedPoint,
82    sinh_hi: FixedPoint,
83    tanh_hi: FixedPoint,
84    /// Sector count, chosen so each sector subtends roughly constant
85    /// hyperbolic arc length — which is what keeps occupancy flat as the
86    /// space expands exponentially.
87    sectors: i64,
88}
89
90/// The index.
91pub struct CellIndex {
92    /// Squared-norm thresholds. Band `b` covers `[thresholds[b], thresholds[b+1])`.
93    ///
94    /// This table *is* the definition of a band, not an approximation of
95    /// `2·artanh(‖p‖)/W`. Cross-checking the two on 5 461 real positions gave
96    /// 7 disagreements at boundaries where they round differently — harmless
97    /// individually, fatal if insert used one route and query the other.
98    thresholds: Vec<FixedPoint>,
99    bands: Vec<Band>,
100    cells: DashMap<CellId, Vec<Entry>>,
101    /// `unique_id → CellId`, so removal touches one cell.
102    node_cell: DashMap<String, CellId>,
103    /// Live node count per band, so a query enumerates only occupied bands.
104    band_load: DashMap<i32, usize>,
105    /// Occupied sectors per band. A query walks *these*, never the sector
106    /// space: `sectors(b)` grows like `sinh`, reaching ~477 000 by band 22
107    /// (a depth-11 node at tau=1), so iterating the space costs ~150 ms per
108    /// band whenever nothing prunes — which is exactly the case when fewer
109    /// than k results have been found yet.
110    band_sectors: DashMap<i32, std::collections::BTreeSet<i64>>,
111    band_width: f64,
112    arc: f64,
113}
114
115impl Default for CellIndex {
116    fn default() -> Self {
117        Self::new(0.5, 0.5)
118    }
119}
120
121impl CellIndex {
122    /// Build an index with band width `w` and target sector arc `arc`, both in
123    /// hyperbolic units. Measured on 5 461 nodes: `0.5 / 0.5` gives 1 184 cells
124    /// with a largest cell of 43 (the fixed buckets gave 26 cells and 1 462).
125    /// Both are stored so cells stay reproducible.
126    pub fn new(w: f64, arc: f64) -> Self {
127        let thresholds = (0..=MAX_BANDS)
128            .map(|b| {
129                // ‖p‖ = tanh(r/2) at the band edge; compare squared norms so
130                // the query path needs neither artanh nor sqrt.
131                let edge = FixedPoint::from_f64((b as f64) * w / 2.0).tanh();
132                edge * edge
133            })
134            .collect();
135        let bands = (0..MAX_BANDS)
136            .map(|b| {
137                let lo = FixedPoint::from_f64((b as f64) * w);
138                let hi = FixedPoint::from_f64(((b + 1) as f64) * w);
139                let mid = ((b as f64) + 0.5) * w;
140                // sectors ≈ circumference / arc; sinh grows exponentially, but
141                // only *occupied* cells are ever materialised, so this is an
142                // index range rather than an allocation.
143                let ideal = 2.0 * std::f64::consts::PI * mid.sinh() / arc;
144                let sectors = if ideal >= MAX_SECTORS as f64 {
145                    MAX_SECTORS
146                } else {
147                    (ideal.ceil() as i64).clamp(1, MAX_SECTORS)
148                };
149                Band {
150                    cosh_lo: lo.cosh(),
151                    sinh_lo: lo.sinh(),
152                    tanh_lo: lo.tanh(),
153                    cosh_hi: hi.cosh(),
154                    sinh_hi: hi.sinh(),
155                    tanh_hi: hi.tanh(),
156                    sectors,
157                }
158            })
159            .collect();
160        Self {
161            thresholds,
162            bands,
163            cells: DashMap::new(),
164            node_cell: DashMap::new(),
165            band_load: DashMap::new(),
166            band_sectors: DashMap::new(),
167            band_width: w,
168            arc,
169        }
170    }
171
172    /// Band width and target arc this index was built with.
173    pub fn parameters(&self) -> (f64, f64) {
174        (self.band_width, self.arc)
175    }
176
177    /// Live node count.
178    pub fn len(&self) -> usize {
179        self.node_cell.len()
180    }
181
182    /// Whether the index holds no nodes.
183    pub fn is_empty(&self) -> bool {
184        self.node_cell.is_empty()
185    }
186
187    /// Number of materialised cells — occupancy diagnostics.
188    pub fn cell_count(&self) -> usize {
189        self.cells.len()
190    }
191
192    /// The cell a point belongs to. Comparisons and one division; no
193    /// transcendental.
194    pub fn cell_of(&self, point: &HyperbolicPoint) -> CellId {
195        let norm_sq = planar_norm_sq(point);
196        let band = self.band_of(norm_sq);
197        let sectors = self.bands[band as usize].sectors;
198        CellId { band, sector: self.sector_of(point, sectors) }
199    }
200
201    fn band_of(&self, norm_sq: FixedPoint) -> i32 {
202        // `partition_point` over the threshold table; the last band absorbs
203        // anything at or beyond the representable radius.
204        let idx = self.thresholds.partition_point(|t| *t <= norm_sq);
205        (idx.max(1) - 1).min(MAX_BANDS - 1) as i32
206    }
207
208    fn sector_of(&self, point: &HyperbolicPoint, sectors: i64) -> i64 {
209        let pseudo = pseudo_angle(point.coords()[0], point.coords()[1]);
210        sector_from_pseudo(pseudo.to_f64(), sectors)
211    }
212
213    /// Register a node. Re-registering an id moves it to its new cell.
214    pub fn insert(&self, unique_id: &str, point: &HyperbolicPoint) {
215        let cell = self.cell_of(point);
216        if let Some(previous) = self.node_cell.get(unique_id).map(|r| *r.value()) {
217            if previous == cell {
218                return;
219            }
220            self.detach(unique_id, previous);
221        }
222        self.cells.entry(cell).or_default().push(Entry {
223            unique_id: unique_id.to_string(),
224            point: CachedNormPoint::new(point.clone()),
225        });
226        self.node_cell.insert(unique_id.to_string(), cell);
227        *self.band_load.entry(cell.band).or_insert(0) += 1;
228        self.band_sectors.entry(cell.band).or_default().insert(cell.sector);
229    }
230
231    /// Drop a node.
232    pub fn remove(&self, unique_id: &str) {
233        if let Some((_, cell)) = self.node_cell.remove(unique_id) {
234            self.detach(unique_id, cell);
235        }
236    }
237
238    fn detach(&self, unique_id: &str, cell: CellId) {
239        let emptied = match self.cells.get_mut(&cell) {
240            Some(mut members) => {
241                members.retain(|e| e.unique_id != unique_id);
242                members.is_empty()
243            }
244            None => false,
245        };
246        if emptied {
247            self.cells.remove(&cell);
248            if let Some(mut sectors) = self.band_sectors.get_mut(&cell.band) {
249                sectors.remove(&cell.sector);
250            }
251        }
252        if let Some(mut load) = self.band_load.get_mut(&cell.band) {
253            *load = load.saturating_sub(1);
254        }
255    }
256
257    /// The k nearest nodes to `query`, ascending by `(distance, unique_id)`.
258    pub fn knn(&self, query: &HyperbolicPoint, k: usize) -> Vec<(String, FixedPoint)> {
259        if k == 0 {
260            return Vec::new();
261        }
262        let probe = CachedNormPoint::new(query.clone());
263        let mut best: Vec<(FixedPoint, String)> = Vec::with_capacity(k + 1);
264        // Threshold in cosh space, matching the bounds. `None` until k found.
265        // A `Cell` so the visitor can raise it while the walker reads it.
266        let ceiling = std::cell::Cell::new(None::<FixedPoint>);
267
268        self.expand(query, |cell| {
269            let Some(members) = self.cells.get(&cell) else { return };
270            for entry in members.iter() {
271                // Rank in squared-ratio proxy space; the exact kernel is paid
272                // only for what survives into the result.
273                let score = HyperbolicMetric.proxy(&probe, &entry.point);
274                best.push((score, entry.unique_id.clone()));
275            }
276            best.sort_by(|a, b| {
277                a.0.partial_cmp(&b.0)
278                    .unwrap_or(std::cmp::Ordering::Equal)
279                    .then_with(|| a.1.cmp(&b.1))
280            });
281            best.dedup_by(|a, b| a.1 == b.1);
282            best.truncate(k);
283            if best.len() == k {
284                ceiling.set(Some(cosh_from_proxy(best[k - 1].0)));
285            }
286        }, || ceiling.get());
287
288        best.into_iter()
289            .map(|(score, id)| (id, ratio_sq_to_distance(score)))
290            .collect()
291    }
292
293    /// Every node within `radius` (hyperbolic) of `centre`.
294    pub fn within_radius(&self, centre: &HyperbolicPoint, radius: FixedPoint) -> Vec<(String, FixedPoint)> {
295        let probe = CachedNormPoint::new(centre.clone());
296        // `radius` is caller-supplied and routinely means "everything" — horon
297        // passes 1000. `cosh(1000)` is not representable in Q64.64 and the
298        // infallible `cosh` panics on it, so ask for the ceiling and accept
299        // that it may not exist: a radius too large to express in cosh space
300        // is a radius that prunes nothing, and `None` is exactly how `expand`
301        // spells "no cell can be ruled out". Degrades to a full scan, never to
302        // a wrong answer.
303        let ceiling = radius.try_cosh().ok();
304        let mut found: Vec<(String, FixedPoint)> = Vec::new();
305        let limit = move || ceiling;
306        self.expand(centre, |cell| {
307            let Some(members) = self.cells.get(&cell) else { return };
308            for entry in members.iter() {
309                let distance = HyperbolicMetric.distance(&probe, &entry.point);
310                if distance <= radius {
311                    found.push((entry.unique_id.clone(), distance));
312                }
313            }
314        }, limit);
315        found.sort_by(|a, b| {
316            a.1.partial_cmp(&b.1)
317                .unwrap_or(std::cmp::Ordering::Equal)
318                .then_with(|| a.0.cmp(&b.0))
319        });
320        found
321    }
322
323    /// Walk cells outward from the query, calling `visit` on each that the
324    /// bound cannot rule out.
325    ///
326    /// Bands are ordered by their radial lower bound `cosh(|r_q − nearest band
327    /// edge|)`, which follows from `d ≥ |r_q − r_p|` through the origin. That
328    /// ordering is what makes the early break sound. Within a band, sectors are
329    /// walked outward from the query's own; both bounds grow monotonically with
330    /// their expansion index, so the first failure ends that direction.
331    fn expand<F, C>(&self, query: &HyperbolicPoint, mut visit: F, ceiling: C)
332    where
333        F: FnMut(CellId),
334        C: Fn() -> Option<FixedPoint>,
335    {
336        // Geometry is taken from the first two coordinates only. Structural
337        // placement is provably planar, so for stored nodes this *is* the norm.
338        // A caller may still hand `nearest` an off-plane query point; the plane
339        // through the origin is totally geodesic, so projection onto it is
340        // distance-decreasing and `d(q, p) >= d(proj(q), p)`. Bounding against
341        // the projection therefore stays a valid lower bound on the true
342        // distance, while scoring below still uses every coordinate.
343        let norm_sq = planar_norm_sq(query);
344        let pseudo_q = pseudo_angle(query.coords()[0], query.coords()[1]).to_f64();
345
346        let one = FixedPoint::from_int(1);
347        let outside = one - norm_sq;
348        if outside <= FixedPoint::from_int(0) {
349            return;
350        }
351        let cosh_q = (one + norm_sq) / outside;
352        // sinh r_q = 2‖q‖/(1−‖q‖²), computed WITHOUT squaring the denominator.
353        //
354        // The closed form 4‖q‖²/(1−‖q‖²)² looks cheaper — no sqrt — but
355        // squaring `outside` destroys it: at hyperbolic radius 20 that term is
356        // 5e-9, and its square, 2.5e-17, sits only ~460 units above Q64.64's
357        // 5.4e-20 resolution, leaving under three significant digits. The
358        // resulting `sinh_q` was wrong by ~6e5, so `cosh_q − sinh_q` (which
359        // must equal e^-r_q ≈ 1e-9) came out as 6e5 and every band bound was
360        // nonsense — a query at depth 19 ranked shallow ancestors first.
361        //
362        // `norm_sq` is near 1, so its sqrt is accurate, and dividing by
363        // `outside` once keeps the small quantity to the first power.
364        let sinh_q = FixedPoint::from_int(2) * norm_sq.sqrt() / outside;
365        let sinh_q_sq = sinh_q * sinh_q;
366
367        // Occupied bands, ordered by radial bound then index (deterministic).
368        let mut order: Vec<(FixedPoint, i32)> = self
369            .band_load
370            .iter()
371            .filter(|r| *r.value() > 0)
372            .map(|r| (self.radial_bound(*r.key(), cosh_q, sinh_q), *r.key()))
373            .collect();
374        order.sort_by(|a, b| {
375            a.0.partial_cmp(&b.0)
376                .unwrap_or(std::cmp::Ordering::Equal)
377                .then_with(|| a.1.cmp(&b.1))
378        });
379
380        for (radial, band) in order {
381            if let Some(limit) = ceiling() {
382                if radial > limit {
383                    break;
384                }
385            }
386            let data = &self.bands[band as usize];
387            let sectors = data.sectors;
388            let centre = sector_from_pseudo(pseudo_q, sectors);
389
390            // Occupied sectors only, ordered by circular distance from the
391            // query's sector. The bound grows with that distance, so the first
392            // failure ends the band; the sector index breaks ties so the walk
393            // stays deterministic. Iterating the *space* instead would be
394            // unbounded work in sparse outer bands, where nothing prunes
395            // because fewer than k results have been found.
396            let Some(occupied) = self.band_sectors.get(&band) else { continue };
397            let mut candidates: Vec<(i64, i64)> = occupied
398                .iter()
399                .map(|s| {
400                    let raw = (s - centre).rem_euclid(sectors);
401                    (raw.min(sectors - raw), *s)
402                })
403                .collect();
404            drop(occupied);
405            candidates.sort_unstable();
406            let sector_width = 4.0 / (sectors as f64);
407            for (offset, sector) in candidates {
408                if let Some(limit) = ceiling() {
409                    // Envelope: the smallest gap any sector at this offset can
410                    // have. Monotone in offset, so once it fails, every later
411                    // candidate fails too — unlike the per-sector bound, which
412                    // is not monotone and must only `continue`.
413                    let envelope_gap = ((offset - 1).max(0) as f64) * sector_width;
414                    let envelope =
415                        self.bound_for_gap(data, envelope_gap, cosh_q, sinh_q, sinh_q_sq);
416                    if envelope.exceeds(limit) {
417                        break;
418                    }
419                    let bound =
420                        self.cell_bound(data, sector, pseudo_q, cosh_q, sinh_q, sinh_q_sq);
421                    if bound.exceeds(limit) {
422                        continue;
423                    }
424                }
425                visit(CellId { band, sector });
426            }
427        }
428    }
429
430    /// `cosh` of the smallest possible distance from a query at `cosh_q` to
431    /// anything in `band` — from `d ≥ |r_q − r_p|`, so it needs no angle.
432    fn radial_bound(&self, band: i32, cosh_q: FixedPoint, sinh_q: FixedPoint) -> FixedPoint {
433        let data = &self.bands[band as usize];
434        let one = FixedPoint::from_int(1);
435        // cosh(r_q − r_edge) = cosh r_q · cosh r_edge − sinh r_q · sinh r_edge
436        let below = cosh_q * data.cosh_lo - sinh_q * data.sinh_lo;
437        let above = cosh_q * data.cosh_hi - sinh_q * data.sinh_hi;
438        if below < one && above < one {
439            one
440        } else if below >= one && above >= one {
441            if below < above { below } else { above }
442        } else {
443            one
444        }
445    }
446
447    /// Lower bound on `cosh d(query, cell)`.
448    ///
449    /// From the hyperbolic law of cosines with `A = cosh r_q`,
450    /// `B = sinh r_q · cos Δθ_min`, minimised over the band's radius range.
451    /// The interior case is `√(A² − B²)`, which is returned **squared** so no
452    /// `sqrt` is taken — and is computed as `1 + sinh²r_q · sin²Δθ`, an
453    /// identity (`cosh² − sinh² = 1`) that turns a difference of near-equal
454    /// terms into a sum of positive ones, so it cannot cancel in fixed point.
455    fn cell_bound(
456        &self,
457        data: &Band,
458        sector: i64,
459        pseudo_q: f64,
460        cosh_q: FixedPoint,
461        sinh_q: FixedPoint,
462        sinh_q_sq: FixedPoint,
463    ) -> Bound {
464        let sectors = data.sectors as f64;
465        let lo = (sector as f64) * 4.0 / sectors;
466        let hi = ((sector + 1) as f64) * 4.0 / sectors;
467        // Circular gap in pseudo-angle. Each end needs its own wrap: taking the
468        // linear gap and wrapping afterwards scores a query at 0.1 against
469        // sector [3,4) as 1.1 rather than 0.1.
470        let circular = |a: f64, b: f64| {
471            let d = (a - b).abs();
472            d.min(4.0 - d)
473        };
474        let gap = if pseudo_q >= lo && pseudo_q < hi {
475            0.0
476        } else {
477            circular(pseudo_q, lo).min(circular(pseudo_q, hi))
478        };
479        self.bound_for_gap(data, gap, cosh_q, sinh_q, sinh_q_sq)
480    }
481
482    /// The bound for a given pseudo-angle gap. Split out so the walk can also
483    /// ask for the *best possible* bound at an offset — an envelope that is
484    /// monotone in the offset even though individual sectors at the same
485    /// offset are not, because the query sits somewhere inside its own sector
486    /// rather than at its centre.
487    fn bound_for_gap(
488        &self,
489        data: &Band,
490        gap: f64,
491        cosh_q: FixedPoint,
492        sinh_q: FixedPoint,
493        sinh_q_sq: FixedPoint,
494    ) -> Bound {
495        // The diamond pseudo-angle has dp/dθ = 1/(cos θ + sin θ)², which is 1
496        // at θ = 0 and π/2 and ½ at π/4 — so max slope is exactly 1 and
497        // Δθ ≥ Δp. Using the *average* slope here instead would overestimate
498        // Δθ and break the bound.
499        let delta_theta = gap.min(std::f64::consts::PI);
500        let (sin_dt, cos_dt) = FixedPoint::from_f64(delta_theta).sincos();
501
502        let zero = FixedPoint::from_int(0);
503        if cos_dt <= zero {
504            // B ≤ 0: the expression increases with radius, so the inner edge.
505            return Bound::Plain(cosh_q * data.cosh_lo - sinh_q * cos_dt * data.sinh_lo);
506        }
507        let b_term = sinh_q * cos_dt;
508        // Locate the minimising radius by comparing against precomputed tanh of
509        // the band edges rather than taking atanh of B/A.
510        if b_term >= cosh_q * data.tanh_lo && b_term <= cosh_q * data.tanh_hi {
511            Bound::Squared(FixedPoint::from_int(1) + sinh_q_sq * sin_dt * sin_dt)
512        } else if b_term < cosh_q * data.tanh_lo {
513            Bound::Plain(cosh_q * data.cosh_lo - b_term * data.sinh_lo)
514        } else {
515            Bound::Plain(cosh_q * data.cosh_hi - b_term * data.sinh_hi)
516        }
517    }
518}
519
520/// A bound on `cosh d`, either directly or squared (the interior case, kept
521/// squared so the query path never takes a `sqrt`).
522enum Bound {
523    Plain(FixedPoint),
524    Squared(FixedPoint),
525}
526
527impl Bound {
528    /// Whether this bound rules out everything within `limit` (a `cosh d`).
529    ///
530    /// The squared case divides rather than squaring the limit. `v > limit²`
531    /// is the natural form, but `limit` is a `cosh d` and passes 3e9 around
532    /// hyperbolic radius 22, where `limit²` overflows Q64.64 and wraps to
533    /// nonsense — precisely the depth at which the engine is already at its
534    /// precision limit, so the failure would land where it is least visible.
535    /// `limit ≥ 1` always, so the division is safe, and it costs 32 ns against
536    /// the 17.7 µs a `sqrt` would.
537    fn exceeds(&self, limit: FixedPoint) -> bool {
538        match self {
539            Bound::Plain(v) => *v > limit,
540            Bound::Squared(v) => *v / limit > limit,
541        }
542    }
543}
544
545/// Sector index for a pseudo-angle in `[0, 4)`.
546///
547/// Done in `f64` deliberately: `sectors` reaches `MAX_SECTORS` (2.7e8), beyond
548/// what `FixedPoint::from_int`'s `i32` argument can carry, and an `f64` mantissa
549/// represents every value in that range exactly. Insert and query both route
550/// through here so they can never disagree about a node's sector.
551fn sector_from_pseudo(pseudo: f64, sectors: i64) -> i64 {
552    let scaled = (pseudo / 4.0) * sectors as f64;
553    (scaled.floor() as i64).rem_euclid(sectors)
554}
555
556/// Squared norm of a point's first two coordinates — the radius the cell
557/// geometry works in. Equal to the full norm for every stored node, since
558/// structural placement never leaves the plane.
559fn planar_norm_sq(point: &HyperbolicPoint) -> FixedPoint {
560    let x = point.coords()[0];
561    let y = point.coords()[1];
562    x * x + y * y
563}
564
565/// Diamond pseudo-angle in `[0, 4)` — monotone in `atan2` at one division
566/// instead of 33 µs. Not linear in θ; callers must use the exact max slope of
567/// 1 when converting a pseudo-gap to an angular gap.
568fn pseudo_angle(x: FixedPoint, y: FixedPoint) -> FixedPoint {
569    let zero = FixedPoint::from_int(0);
570    if x == zero && y == zero {
571        return zero;
572    }
573    let one = FixedPoint::from_int(1);
574    if y >= zero {
575        if x >= zero {
576            y / (x + y)
577        } else {
578            one - x / (y - x)
579        }
580    } else if x < zero {
581        FixedPoint::from_int(2) - y / (-x - y)
582    } else {
583        FixedPoint::from_int(3) + x / (x - y)
584    }
585}
586
587/// `cosh d` from a squared Möbius ratio `s = tanh²(d/2)`: `(1 + s)/(1 − s)`.
588fn cosh_from_proxy(s: FixedPoint) -> FixedPoint {
589    let one = FixedPoint::from_int(1);
590    let denominator = one - s;
591    if denominator <= FixedPoint::from_int(0) {
592        return crate::constants::near_boundary().cosh();
593    }
594    (one + s) / denominator
595}
596
597/// Exact distance from a squared ratio: `d = 2·atanh(√s)`.
598fn ratio_sq_to_distance(s: FixedPoint) -> FixedPoint {
599    crate::hyperbolic_geometry::ratio_to_distance(s.sqrt())
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn point(x: f64, y: f64) -> HyperbolicPoint {
607        HyperbolicPoint::from_slice(&[FixedPoint::from_f64(x), FixedPoint::from_f64(y)])
608    }
609
610    fn populated() -> (CellIndex, Vec<(String, HyperbolicPoint)>) {
611        let index = CellIndex::default();
612        let mut nodes = Vec::new();
613        for i in 0..400 {
614            let radius = 0.05 + 0.9 * ((i % 20) as f64) / 20.0;
615            let angle = 0.37 * i as f64;
616            let p = point(radius * angle.cos(), radius * angle.sin());
617            let id = format!("n{i}");
618            index.insert(&id, &p);
619            nodes.push((id, p));
620        }
621        (index, nodes)
622    }
623
624    fn brute_force(nodes: &[(String, HyperbolicPoint)], q: &HyperbolicPoint, k: usize) -> Vec<String> {
625        let mut all: Vec<(FixedPoint, String)> = nodes
626            .iter()
627            .map(|(id, p)| (q.hyperbolic_distance(p), id.clone()))
628            .collect();
629        all.sort_by(|a, b| {
630            a.0.partial_cmp(&b.0)
631                .unwrap_or(std::cmp::Ordering::Equal)
632                .then_with(|| a.1.cmp(&b.1))
633        });
634        all.into_iter().take(k).map(|(_, id)| id).collect()
635    }
636
637    #[test]
638    fn cell_assignment_is_stable_and_reproducible() {
639        let index = CellIndex::default();
640        let p = point(0.3, -0.4);
641        assert_eq!(index.cell_of(&p), index.cell_of(&p));
642        assert_eq!(index.parameters(), (0.5, 0.5));
643    }
644
645    #[test]
646    fn a_node_is_its_own_nearest_neighbour() {
647        let (index, nodes) = populated();
648        for (id, p) in &nodes {
649            let got = index.knn(p, 1);
650            assert_eq!(&got[0].0, id, "{id} did not find itself");
651        }
652    }
653
654    /// The aliasing defect found during design was invisible at k=1: at small
655    /// sector counts, offsets +m and −m name the same sector.
656    #[test]
657    fn matches_brute_force_for_k_greater_than_one() {
658        let (index, nodes) = populated();
659        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
660        let mut rand = || {
661            state ^= state << 13;
662            state ^= state >> 7;
663            state ^= state << 17;
664            (state >> 11) as f64 / (1u64 << 53) as f64
665        };
666        for k in [1usize, 5, 20] {
667            for _ in 0..40 {
668                let r = rand().sqrt() * 0.95;
669                let a = rand() * std::f64::consts::TAU;
670                let q = point(r * a.cos(), r * a.sin());
671                let got: Vec<String> = index.knn(&q, k).into_iter().map(|(id, _)| id).collect();
672                let want = brute_force(&nodes, &q, k);
673                assert_eq!(got, want, "k={k} disagreed with brute force");
674            }
675        }
676    }
677
678    #[test]
679    fn within_radius_matches_brute_force() {
680        let (index, nodes) = populated();
681        let q = point(0.2, 0.1);
682        for r in [0.5f64, 1.3, 2.7] {
683            let radius = FixedPoint::from_f64(r);
684            let mut got: Vec<String> =
685                index.within_radius(&q, radius).into_iter().map(|(id, _)| id).collect();
686            let mut want: Vec<String> = nodes
687                .iter()
688                .filter(|(_, p)| q.hyperbolic_distance(p) <= radius)
689                .map(|(id, _)| id.clone())
690                .collect();
691            got.sort();
692            want.sort();
693            assert_eq!(got, want, "radius {r} disagreed with brute force");
694        }
695    }
696
697    /// The index is shared across threads behind `&self`, so insert, query and
698    /// remove interleave with no outer lock. This asserts the three things that
699    /// could go wrong: a deadlock (the test would hang), a lost or duplicated
700    /// node, and a query returning something that was never inserted.
701    ///
702    /// Threads own disjoint id ranges, so each thread's own bookkeeping is
703    /// independent of the others and the assertions stay deterministic under
704    /// any interleaving.
705    ///
706    /// This existed only as a throwaway prototype while the index was being
707    /// built. It is permanent now: `cell_index` answers every spatial read, and
708    /// a sharded structure that loses a node under contention would do so
709    /// silently.
710    #[test]
711    fn concurrent_insert_query_and_remove_stay_coherent() {
712        use std::sync::Arc;
713        use std::thread;
714
715        const THREADS: usize = 8;
716        const PER_THREAD: usize = 200;
717
718        // Spread ids over distinct radii and angles so threads touch many
719        // different cells rather than piling into one.
720        fn site(t: usize, i: usize) -> HyperbolicPoint {
721            let radius = 0.05 + 0.9 * (((t * 7 + i) % 20) as f64) / 20.0;
722            let angle = 0.37 * (i as f64) + 0.11 * (t as f64);
723            point(radius * angle.cos(), radius * angle.sin())
724        }
725
726        let index = Arc::new(CellIndex::default());
727
728        // --- concurrent inserts ---------------------------------------------
729        let mut handles = Vec::new();
730        for t in 0..THREADS {
731            let idx = Arc::clone(&index);
732            handles.push(thread::spawn(move || {
733                for i in 0..PER_THREAD {
734                    idx.insert(&format!("t{t}n{i}"), &site(t, i));
735                }
736            }));
737        }
738        for h in handles {
739            h.join().expect("insert thread panicked");
740        }
741        assert_eq!(
742            index.len(),
743            THREADS * PER_THREAD,
744            "concurrent inserts lost or duplicated nodes",
745        );
746
747        // --- readers and writers together -----------------------------------
748        // Half the threads churn their own range; half only read. A reader must
749        // never see an id that was never inserted, whatever the writers do.
750        let mut handles = Vec::new();
751        for t in 0..THREADS {
752            let idx = Arc::clone(&index);
753            handles.push(thread::spawn(move || -> usize {
754                let mut seen = 0usize;
755                if t % 2 == 0 {
756                    // writer: remove its odd entries, then put them back
757                    for i in (1..PER_THREAD).step_by(2) {
758                        idx.remove(&format!("t{t}n{i}"));
759                    }
760                    for i in (1..PER_THREAD).step_by(2) {
761                        idx.insert(&format!("t{t}n{i}"), &site(t, i));
762                    }
763                } else {
764                    // reader: hammer both query paths while that happens
765                    for i in 0..PER_THREAD {
766                        let q = site(t, i);
767                        for (id, _) in idx.knn(&q, 5) {
768                            assert!(
769                                id.starts_with('t') && id.contains('n'),
770                                "query returned an id that was never inserted: {id}",
771                            );
772                            seen += 1;
773                        }
774                        let r = FixedPoint::from_f64(0.5);
775                        for (id, _) in idx.within_radius(&q, r) {
776                            assert!(
777                                id.starts_with('t'),
778                                "radius query returned a foreign id: {id}",
779                            );
780                        }
781                    }
782                }
783                seen
784            }));
785        }
786        let seen: usize = handles
787            .into_iter()
788            .map(|h| h.join().expect("worker thread panicked"))
789            .sum();
790        assert!(seen > 0, "readers observed nothing at all");
791
792        // Writers restored everything they removed, so the population is intact.
793        assert_eq!(
794            index.len(),
795            THREADS * PER_THREAD,
796            "churn under contention changed the population",
797        );
798
799        // --- every survivor is still locatable at its own position ----------
800        for t in 0..THREADS {
801            for i in (0..PER_THREAD).step_by(37) {
802                let q = site(t, i);
803                let hit = index.knn(&q, 1);
804                assert!(!hit.is_empty(), "t{t}n{i} vanished from the index");
805                assert_eq!(
806                    hit[0].1,
807                    FixedPoint::from_int(0),
808                    "querying at a node's own position must return distance 0",
809                );
810            }
811        }
812    }
813
814    /// A radius so large it cannot be expressed in `cosh` space must return
815    /// everything, not panic.
816    ///
817    /// `radius` is caller-supplied and "give me everything" is a normal way to
818    /// call this — `horon`'s own API-surface test passes 1000. `cosh(1000)`
819    /// overflows Q64.64 and the infallible `cosh` panics, which took the whole
820    /// query down. The bound's job is to prune; when it cannot be computed the
821    /// answer is "prune nothing", never "give up".
822    #[test]
823    fn a_radius_too_large_for_cosh_returns_everything() {
824        let (index, nodes) = populated();
825        let q = point(0.2, 0.1);
826        for r in [30, 100, 1_000, 100_000] {
827            let got = index.within_radius(&q, FixedPoint::from_int(r));
828            assert_eq!(
829                got.len(),
830                nodes.len(),
831                "radius {r} should sweep the whole index",
832            );
833        }
834    }
835
836    #[test]
837    fn removal_takes_a_node_out_of_results() {
838        let (index, nodes) = populated();
839        let (victim, at) = nodes[17].clone();
840        assert_eq!(index.knn(&at, 1)[0].0, victim);
841        index.remove(&victim);
842        assert_eq!(index.len(), nodes.len() - 1);
843        let ids: Vec<String> = index.knn(&at, 5).into_iter().map(|(id, _)| id).collect();
844        assert!(!ids.contains(&victim), "removed node came back: {ids:?}");
845    }
846
847    #[test]
848    fn reinsert_moves_a_node_between_cells() {
849        let index = CellIndex::default();
850        let start = point(0.1, 0.1);
851        let end = point(-0.8, 0.2);
852        index.insert("drifter", &start);
853        let first = index.cell_of(&start);
854        index.insert("drifter", &end);
855        assert_ne!(first, index.cell_of(&end));
856        assert_eq!(index.len(), 1, "moving a node must not duplicate it");
857        assert_eq!(index.knn(&end, 1)[0].0, "drifter");
858    }
859
860    /// `Store::nearest` accepts arbitrary coordinates, which may carry
861    /// components outside the plane every stored node lives in. The bound is
862    /// taken against the projection (distance-decreasing, so still a lower
863    /// bound) while scoring uses all coordinates — the answer must stay exact.
864    #[test]
865    fn off_plane_queries_stay_exact() {
866        // Mirror the engine: every point is `dimension`-wide, and structural
867        // placement leaves the extra coordinates at zero.
868        let index = CellIndex::default();
869        let mut nodes = Vec::new();
870        for i in 0..400 {
871            let radius = 0.05 + 0.9 * ((i % 20) as f64) / 20.0;
872            let angle = 0.37 * i as f64;
873            let p = HyperbolicPoint::from_slice(&[
874                FixedPoint::from_f64(radius * angle.cos()),
875                FixedPoint::from_f64(radius * angle.sin()),
876                FixedPoint::from_int(0),
877            ]);
878            let id = format!("n{i}");
879            index.insert(&id, &p);
880            nodes.push((id, p));
881        }
882        for (dx, dy, dz) in [(0.2, -0.1, 0.3), (-0.5, 0.25, 0.15), (0.0, 0.0, 0.6)] {
883            let q = HyperbolicPoint::from_slice(&[
884                FixedPoint::from_f64(dx),
885                FixedPoint::from_f64(dy),
886                FixedPoint::from_f64(dz),
887            ]);
888            let got: Vec<String> = index.knn(&q, 5).into_iter().map(|(id, _)| id).collect();
889            let want = brute_force(&nodes, &q, 5);
890            assert_eq!(got, want, "off-plane query ({dx},{dy},{dz}) was not exact");
891        }
892    }
893
894    /// **The correctness argument, asserted directly.**
895    ///
896    /// Ring expansion is exact only if a cell's bound is a true *lower* bound
897    /// on the distance to everything in that cell. Everything else is
898    /// bookkeeping; if this is ever wrong, queries return confidently
899    /// incorrect answers — the failure 0.5.2 had to fix.
900    ///
901    /// This assertion found three real defects during design that the
902    /// end-to-end query tests showed only as rare wrong answers, or not at all:
903    /// the pseudo-angle's *average* slope used where its maximum was required;
904    /// a circular gap computed linearly and wrapped afterwards; and sector
905    /// aliasing at small sector counts.
906    #[test]
907    fn every_cell_bound_is_a_true_lower_bound() {
908        let (index, nodes) = populated();
909        let mut state: u64 = 0x243F_6A88_85A3_08D3;
910        let mut rand = || {
911            state ^= state << 13;
912            state ^= state >> 7;
913            state ^= state << 17;
914            (state >> 11) as f64 / (1u64 << 53) as f64
915        };
916
917        // Deep nodes as well as shallow: the shallow-only fixture missed a
918        // defect where `sinh r_q` was computed by squaring `1 − ‖q‖²`, which
919        // loses most of its significant digits as that term shrinks and made
920        // every band bound nonsense.
921        //
922        // These sit at hyperbolic radius ≈16, ≈11 and ≈7 — deep enough that
923        // the squaring defect shows, but inside the ≈17.5 radius where the
924        // distance kernel is still faithful. **Beyond that there is no
925        // trustworthy oracle**: `hyperbolic_distance` saturates at 28.324
926        // (cosh ≈ 1e12), so a bound cannot be validated against it at all.
927        // That is a limit of the verification, not of the bound.
928        const DEEP_NORMS: [f64; 3] = [1.0 - 2.5e-7, 1.0 - 1e-5, 1.0 - 1e-3];
929        for (i, depth_norm) in DEEP_NORMS.iter().enumerate() {
930            let angle = 0.9 * i as f64;
931            index.insert(
932                &format!("deep{i}"),
933                &point(depth_norm * angle.cos(), depth_norm * angle.sin()),
934            );
935        }
936        let nodes: Vec<(String, HyperbolicPoint)> = nodes
937            .into_iter()
938            .chain((0..3).map(|i| {
939                let angle = 0.9 * i as f64;
940                let n = DEEP_NORMS[i];
941                (format!("deep{i}"), point(n * angle.cos(), n * angle.sin()))
942            }))
943            .collect();
944
945        let one = FixedPoint::from_int(1);
946        let mut checked = 0usize;
947        let mut saturated = 0usize;
948        for round in 0..120 {
949            // Every fourth query sits in the deep regime.
950            let r = if round % 4 == 0 {
951                1.0 - 2.5e-7 * (1.0 + rand())
952            } else {
953                rand().sqrt() * 0.96
954            };
955            let a = rand() * std::f64::consts::TAU;
956            let q = point(r * a.cos(), r * a.sin());
957
958            let norm_sq = planar_norm_sq(&q);
959            let outside = one - norm_sq;
960            let cosh_q = (one + norm_sq) / outside;
961            let sinh_q_sq = FixedPoint::from_int(4) * norm_sq / (outside * outside);
962            let sinh_q = sinh_q_sq.sqrt();
963            let pseudo_q = pseudo_angle(q.coords()[0], q.coords()[1]).to_f64();
964
965            for cell in index.cells.iter() {
966                let id = *cell.key();
967                let data = &index.bands[id.band as usize];
968                let bound = index.cell_bound(
969                    data, id.sector, pseudo_q, cosh_q, sinh_q, sinh_q_sq,
970                );
971                for entry in cell.value().iter() {
972                    let actual = &nodes
973                        .iter()
974                        .find(|(n, _)| *n == entry.unique_id)
975                        .expect("indexed node must exist in the fixture")
976                        .1;
977                    // cosh d, the unit the bounds are expressed in.
978                    let d = q.hyperbolic_distance(actual);
979                    let cosh_d = d.cosh();
980                    // The kernel saturates at 28.324; a saturated pair carries
981                    // no information, so it is skipped rather than compared
982                    // against a value that is already wrong.
983                    if d.to_f64() > 27.0 {
984                        saturated += 1;
985                        continue;
986                    }
987                    checked += 1;
988                    let holds = match bound {
989                        Bound::Plain(v) => cosh_d >= v,
990                        // Divided, not squared — `cosh_d²` overflows Q64.64
991                        // in the deep regime this test now covers.
992                        Bound::Squared(v) => cosh_d >= v / cosh_d,
993                    };
994                    assert!(
995                        holds,
996                        "cell {id:?} claimed a bound that exceeds a member's true \
997                         distance (cosh d = {}) — the ring expansion would prune \
998                         a genuine neighbour",
999                        cosh_d.to_f64()
1000                    );
1001                }
1002            }
1003        }
1004        assert!(checked > 10_000, "too few (query, point) pairs checked: {checked}");
1005        assert!(
1006            saturated * 4 < checked,
1007            "{saturated} of {} pairs saturated the distance kernel — the fixture \
1008             has drifted past the usable radius and is no longer testing anything",
1009            saturated + checked
1010        );
1011    }
1012
1013    #[test]
1014    fn occupancy_is_spread_not_concentrated() {
1015        let (index, nodes) = populated();
1016        assert!(
1017            index.cell_count() > nodes.len() / 20,
1018            "cells {} for {} nodes — occupancy collapsed",
1019            index.cell_count(),
1020            nodes.len()
1021        );
1022    }
1023}