horon_engine/hash_table.rs
1//! hash_table.rs — geometric node identity
2//!
3//! [`GeometricSignature`] is a node's identity: its depth in the tree plus its
4//! position, quantised at 2^-20. `unique_id()` digests those two and nothing
5//! else, so identity is a pure function of where a node sits — independent of
6//! any index.
7//!
8//! That independence is the point. Until 0.6.0 this module held a table of
9//! geometric buckets, each with its own VP-tree, and `unique_id` digested the
10//! bucket hash as well — which made every node's name a function of which
11//! bucket the index happened to choose, so the index could not be replaced
12//! without renaming every node. The buckets are gone (see
13//! `docs/ARCHITECTURE.md`); spatial queries are answered by [`crate::cell_index`].
14//!
15//! The module keeps its name for one release so the public path
16//! `horon_engine::hash_table::GeometricSignature` does not move twice.
17
18use std::fmt::{self, Debug, Formatter};
19use super::hyperbolic_geometry::HyperbolicPoint;
20use crate::constants;
21
22// ---------------------------------------------------------------------------
23// GeometricSignature
24// ---------------------------------------------------------------------------
25
26/// A geometric signature for a node in hyperbolic space.
27///
28/// This signature uniquely identifies a point or region in the
29/// hyperbolic space, enabling O(1) lookups.
30#[derive(Clone, PartialEq, Eq, Hash)]
31pub struct GeometricSignature {
32 /// Hash value for O(1) lookup
33 hash: String,
34 /// Tree level for hierarchical navigation
35 level: u32,
36 /// Position signature in hyperbolic space
37 position_signature: Vec<i32>,
38}
39
40impl GeometricSignature {
41 /// Create a new geometric signature.
42 pub fn new(hash: String, level: u32, position_signature: Vec<i32>) -> Self {
43 Self {
44 hash,
45 level,
46 position_signature,
47 }
48 }
49
50 /// Get the hash value.
51 pub fn hash(&self) -> &str {
52 &self.hash
53 }
54
55 /// Get the tree level.
56 pub fn level(&self) -> u32 {
57 self.level
58 }
59
60 /// Get the position signature.
61 pub fn position_signature(&self) -> &[i32] {
62 &self.position_signature
63 }
64
65 /// Create a stub signature for data-only nodes (no geometric meaning).
66 pub fn stub(unique_id: &str) -> Self {
67 Self {
68 hash: unique_id.to_string(),
69 level: 0,
70 position_signature: Vec::new(),
71 }
72 }
73
74 /// Whether this is a stub signature (a data-only node with no geometric
75 /// embedding — upgradeable via `embed_existing`).
76 pub fn is_stub(&self) -> bool {
77 self.position_signature.is_empty()
78 }
79
80 /// Get a unique node identifier, derived from level and position alone.
81 ///
82 /// **Deliberately independent of the spatial index.** This digest used to
83 /// include `hash()` — the geometric bucket — which made every node's
84 /// identity a function of which bucket the index happened to choose, so
85 /// the index could not be changed without renaming every node. The bucket
86 /// hash is itself a function of the same position (it is the signature of
87 /// the containing bucket's centre), so it contributed no entropy: two
88 /// nodes sharing a level and a `position_signature` already shared a
89 /// bucket. Dropping it loses no discrimination and buys a swappable index.
90 ///
91 /// Uniqueness rests on `position_signature`, quantised at 2^-20 — the
92 /// resolution the hardening audit established (`quantize_1000` collided
93 /// for depth-2 cousins at a few hundred nodes; 2^-20 pushes the birthday
94 /// bound past millions).
95 ///
96 /// For stub signatures (data-only nodes), returns the hash directly.
97 pub fn unique_id(&self) -> String {
98 if self.position_signature.is_empty() {
99 // Stub signature — hash IS the unique_id
100 return self.hash.clone();
101 }
102 use sha3::{Sha3_256, Digest as _};
103 let mut hasher = Sha3_256::new();
104 hasher.update(self.level.to_le_bytes());
105 for &v in &self.position_signature {
106 hasher.update(v.to_le_bytes());
107 }
108 hex::encode(&hasher.finalize()[..16])
109 }
110}
111
112impl Debug for GeometricSignature {
113 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
114 write!(f, "GeometricSignature(hash={}, level={})",
115 &self.hash[0..8], self.level)
116 }
117}
118
119
120impl GeometricSignature {
121 /// Create the signature of an embedded node at `point`, `level` deep.
122 ///
123 /// The position signature is the point's first `dimension` coordinates
124 /// quantised at 2^-20; `hash` is set to the resulting `unique_id`, so for
125 /// an embedded signature the two agree. No index is consulted: this is a
126 /// pure function of position and depth.
127 pub fn embedded(point: &HyperbolicPoint, dimension: usize, level: u32) -> Self {
128 let position_signature: Vec<i32> = (0..dimension)
129 .map(|i| constants::quantize_position(point.coords()[i]))
130 .collect();
131 let mut signature = Self {
132 hash: String::new(),
133 level,
134 position_signature,
135 };
136 signature.hash = signature.unique_id();
137 signature
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::hyperbolic_geometry::PoincareDisk;
145
146 #[test]
147 fn embedded_signature_carries_level_and_position() {
148 let disk = PoincareDisk::new(2);
149 let point = disk.point_from_f32_slice(&[0.5, 0.0]);
150
151 let signature = GeometricSignature::embedded(&point, 2, 3);
152 assert_eq!(signature.level(), 3);
153 assert_eq!(signature.position_signature().len(), 2);
154 assert!(!signature.is_stub());
155 // For an embedded signature the hash is the id.
156 assert_eq!(signature.hash(), signature.unique_id());
157 }
158
159 #[test]
160 fn distinct_positions_get_distinct_ids() {
161 let disk = PoincareDisk::new(2);
162 let a = GeometricSignature::embedded(&disk.point_from_f32_slice(&[0.5, 0.0]), 2, 1);
163 let b = GeometricSignature::embedded(&disk.point_from_f32_slice(&[0.0, 0.5]), 2, 1);
164 assert_ne!(a.unique_id(), b.unique_id());
165 }
166
167 #[test]
168 fn same_position_at_different_levels_gets_distinct_ids() {
169 let disk = PoincareDisk::new(2);
170 let point = disk.point_from_f32_slice(&[0.25, 0.25]);
171 let a = GeometricSignature::embedded(&point, 2, 1);
172 let b = GeometricSignature::embedded(&point, 2, 2);
173 assert_ne!(a.unique_id(), b.unique_id());
174 }
175
176 #[test]
177 fn stub_signature_is_its_own_id() {
178 let stub = GeometricSignature::stub("data-only-node");
179 assert!(stub.is_stub());
180 assert_eq!(stub.unique_id(), "data-only-node");
181 }
182}