Skip to main content

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}