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}