Skip to main content

horon_engine/
tensor_network.rs

1//! tensor_network.rs - Hyperbolic Tensor Network for Hierarchical Data
2//!
3//! This module implements the core data network over hyperbolic space:
4//!
5//! - Nodes embedded in the Poincaré disk with parent-child geometric relationships
6//! - Exact storage of node data (metadata + raw value bytes)
7//! - Spatial queries (nearest, k-nearest, range) delegated to `cell_index`
8
9use std::collections::HashMap;
10use std::collections::BinaryHeap;
11use std::fmt::{self, Debug, Formatter};
12use std::ops::Range;
13use std::sync::Mutex;
14use dashmap::DashMap;
15use g_math::fixed_point::{FixedPoint, FixedVector};
16use super::hyperbolic_geometry::{PoincareDisk, HyperbolicPoint};
17use super::hash_table::GeometricSignature;
18use crate::constants;
19use crate::cell_index::CellIndex;
20use crate::metric_tree::EuclideanMetric;
21use crate::semantic_index::SemanticIndexCache;
22
23/// Exact metadata stored per node. Never lossy-compressed.
24#[derive(Clone, Debug)]
25pub struct NodeMetadata {
26    /// Node key (path)
27    pub key: String,
28    /// Content type
29    pub content_type: Option<String>,
30    /// User-defined metadata
31    pub metadata: HashMap<String, String>,
32    /// Creation timestamp (seconds since epoch)
33    pub created_at: u64,
34    /// Last update timestamp
35    pub updated_at: u64,
36}
37
38impl NodeMetadata {
39    /// Create new metadata with current timestamp.
40    pub fn new(key: String, content_type: Option<String>) -> Self {
41        let now = std::time::SystemTime::now()
42            .duration_since(std::time::UNIX_EPOCH)
43            .unwrap_or_default()
44            .as_secs();
45
46        Self {
47            key,
48            content_type,
49            metadata: HashMap::new(),
50            created_at: now,
51            updated_at: now,
52        }
53    }
54
55    /// Touch the updated_at timestamp.
56    pub fn touch(&mut self) {
57        self.updated_at = std::time::SystemTime::now()
58            .duration_since(std::time::UNIX_EPOCH)
59            .unwrap_or_default()
60            .as_secs();
61    }
62}
63
64/// Siblings per rainbow band: children beyond this cascade to the next
65/// concentric ring. Derived from signature quantization capacity (see
66/// `create_child_point`); 256 keeps a wide margin below the ~700-sibling
67/// single-ring collision threshold.
68pub const RAINBOW_BAND_CAPACITY: u32 = 256;
69/// Each band steps outward by τ / RAINBOW_BAND_STEP_DIV.
70const RAINBOW_BAND_STEP_DIV: i32 = 64;
71/// Warn when fan-out reaches this many bands (spacing degradation).
72const RAINBOW_BAND_WARN: u32 = 32;
73
74/// A node in the hyperbolic tensor network.
75///
76/// Stores exact metadata and raw value bytes. Despite the historical name,
77/// no compression is performed — the name is retained for API stability and
78/// will be revisited before a 1.0 release.
79#[derive(Clone, Debug)]
80pub struct CompressedNode {
81    /// Exact metadata (key, content_type, user metadata, timestamps)
82    node_metadata: NodeMetadata,
83    /// Raw value bytes (exact, no compression)
84    value: Vec<u8>,
85    /// Child node references by their geometric signatures
86    children: Vec<GeometricSignature>,
87    /// Semantic coordinates — raw Q64.64 bytes (16 bytes per dimension).
88    /// Empty if no semantic dimensions are set.
89    semantic_coords: Vec<u8>,
90}
91
92impl CompressedNode {
93    /// Create a new node.
94    pub fn new(metadata: NodeMetadata, value: Vec<u8>) -> Self {
95        Self {
96            node_metadata: metadata,
97            value,
98            children: Vec::new(),
99            semantic_coords: Vec::new(),
100        }
101    }
102
103    /// Get the node metadata.
104    pub fn metadata(&self) -> &NodeMetadata {
105        &self.node_metadata
106    }
107
108    /// Get a mutable reference to the metadata.
109    pub fn metadata_mut(&mut self) -> &mut NodeMetadata {
110        &mut self.node_metadata
111    }
112
113    /// Get the raw value bytes.
114    pub fn value(&self) -> &[u8] {
115        &self.value
116    }
117
118    /// Update the value bytes.
119    pub fn update_value(&mut self, value: Vec<u8>) {
120        self.value = value;
121        self.node_metadata.touch();
122    }
123
124    /// Add a child node reference.
125    pub fn add_child(&mut self, signature: GeometricSignature) {
126        self.children.push(signature);
127    }
128
129    /// Remove a child reference by unique id (used when the child is deleted).
130    pub fn remove_child(&mut self, unique_id: &str) {
131        self.children.retain(|sig| sig.unique_id() != unique_id);
132    }
133
134    /// Get the child node references.
135    pub fn children(&self) -> &[GeometricSignature] {
136        &self.children
137    }
138
139    /// Check if this node has any children.
140    pub fn has_children(&self) -> bool {
141        !self.children.is_empty()
142    }
143
144    /// Get the number of children.
145    pub fn child_count(&self) -> usize {
146        self.children.len()
147    }
148
149    /// Get the semantic coordinates (raw Q64.64 bytes, 16 bytes per dimension).
150    pub fn semantic_coords(&self) -> &[u8] {
151        &self.semantic_coords
152    }
153
154    /// Set the semantic coordinates (raw Q64.64 bytes).
155    pub fn set_semantic_coords(&mut self, coords: Vec<u8>) {
156        self.semantic_coords = coords;
157    }
158}
159
160/// Hyperbolic Tensor Network for tree data representation.
161///
162/// Embeds hierarchical data into the Poincaré disk model using Sarkar's
163/// cone-based construction. Each node occupies a point in hyperbolic space,
164/// with children placed at hyperbolic distance τ from their parent using
165/// Möbius reflections to preserve the tree structure as a Delaunay graph.
166///
167/// Internal maps use DashMap for lock-free concurrent reads, preparing
168/// for multi-threaded access in later phases.
169pub struct HyperbolicTensorNetwork {
170    /// The disk these points live in: dimension and the origin. Geometry
171    /// only — it holds no nodes.
172    poincare_disk: PoincareDisk,
173    /// The spatial index. Every spatial read is answered here, exactly, by
174    /// expanding rings until a proven lower bound rules out the rest.
175    cell_index: CellIndex,
176    /// Nodes mapped by their unique_id
177    nodes: DashMap<String, CompressedNode>,
178    /// Points in the Poincaré disk for each node (keyed by unique_id)
179    point_map: DashMap<String, HyperbolicPoint>,
180    /// Root node signature (Mutex: set once during root insert)
181    root_signature: Mutex<Option<GeometricSignature>>,
182    /// Sarkar embedding scale factor: parent-child hyperbolic distance
183    tau: FixedPoint,
184    /// Per-parent child count for Sarkar cone angular placement
185    child_counts: DashMap<String, u32>,
186    /// Lazy per-slice VP-tree cache for semantic KNN, invalidated by a
187    /// semantic-epoch counter (see `docs/SEMANTIC_INDEX.md`)
188    semantic_index: SemanticIndexCache,
189}
190
191impl HyperbolicTensorNetwork {
192    /// Create a new hyperbolic tensor network with the given Sarkar scale factor τ.
193    pub fn new(dimension: usize, tau: FixedPoint) -> Self {
194        // Semantic coordinates and persisted geometry assume a 16-byte Q64.64
195        // FixedPoint. GMATH_PROFILE is a build-time env var, so a rebuild under
196        // a different profile would silently reinterpret those bytes — fail
197        // loudly instead.
198        assert_eq!(
199            FixedPoint::raw_byte_len(), 16,
200            "horon-engine requires the 16-byte Q64.64 g_math profile (GMATH_PROFILE=embedded); \
201             rebuild with the correct profile"
202        );
203
204        Self {
205            poincare_disk: PoincareDisk::new(dimension),
206            cell_index: CellIndex::default(),
207            nodes: DashMap::new(),
208            point_map: DashMap::new(),
209            root_signature: Mutex::new(None),
210            tau,
211            child_counts: DashMap::new(),
212            semantic_index: SemanticIndexCache::new(),
213        }
214    }
215
216    /// Add a node to the DashMap without computing geometric embedding.
217    ///
218    /// Creates a key-derived unique_id for path_map/id_to_path lookups.
219    /// Semantic queries (nearest_semantic, neighbors_semantic, get/set)
220    /// work normally. Spatial queries (nearest, neighbors) will not
221    /// find this node until it is embedded.
222    pub fn add_node_data_only(&self, metadata: NodeMetadata, value: Vec<u8>, _level: u32) -> String {
223        use sha3::{Sha3_256, Digest as _};
224        let mut hasher = Sha3_256::new();
225        hasher.update(b"data_only:");
226        hasher.update(metadata.key.as_bytes());
227        let unique_id = hex::encode(&hasher.finalize()[..16]);
228
229        let node = CompressedNode::new(metadata, value);
230        self.nodes.insert(unique_id.clone(), node);
231        // Fresh nodes carry no semantic coords, but bump anyway: free
232        // insurance against future insert-with-coords paths.
233        self.semantic_index.bump();
234        unique_id
235    }
236
237    /// Add a node to the tensor network.
238    ///
239    /// Computes a position in the Poincaré disk using Sarkar's cone construction:
240    /// children are placed at hyperbolic distance τ from their parent, at
241    /// golden-angle-spaced angles in the parent's reflected frame.
242    pub fn add_node(&self,
243                    metadata: NodeMetadata,
244                    value: Vec<u8>,
245                    parent_signature: Option<&GeometricSignature>,
246                    level: u32) -> Option<GeometricSignature> {
247        self.add_node_inner(metadata, value, parent_signature, level, None)
248    }
249
250    /// Add a node with an explicit child_index for deterministic Sarkar reconstruction.
251    ///
252    /// Used during snapshot replay: the stored child_index ensures the node gets
253    /// the same geometric position regardless of replay order.
254    pub fn add_node_positioned(&self,
255                    metadata: NodeMetadata,
256                    value: Vec<u8>,
257                    parent_signature: Option<&GeometricSignature>,
258                    level: u32,
259                    child_index: u32) -> Option<GeometricSignature> {
260        self.add_node_inner(metadata, value, parent_signature, level, Some(child_index))
261    }
262
263    fn add_node_inner(&self,
264                    metadata: NodeMetadata,
265                    value: Vec<u8>,
266                    parent_signature: Option<&GeometricSignature>,
267                    level: u32,
268                    child_index_hint: Option<u32>) -> Option<GeometricSignature> {
269        // Q64.64 precision supports depth ≈ 44/τ before sibling separation
270        // degrades near the disk boundary. Warn as inserts approach the
271        // budget rather than silently losing angular precision.
272        if self.tau > FixedPoint::from_int(0) {
273            let depth_budget = (FixedPoint::from_int(44) / self.tau).to_int() as u32;
274            if level.saturating_mul(10) >= depth_budget.saturating_mul(9) {
275                log::warn!(
276                    "insert '{}' at depth {} approaches the Q64.64 precision budget (~{} levels at tau={}); sibling positions may lose separation",
277                    metadata.key, level, depth_budget, self.tau.to_f64()
278                );
279            }
280        }
281        // Resolve the child index and geometric point WITHOUT yet advancing
282        // the parent's sibling counter — the counter is committed only after
283        // the insert is known to succeed (below), so a refused collision or
284        // any other early return can never leave a gap in the index sequence
285        // (which would make placement depend on transient failed inserts).
286        //
287        // The requested slot may already be occupied by a DIFFERENT key, and
288        // that is not always a precision failure. Not every insert path feeds
289        // the sibling counter: data-only nodes (`add_node_data_only`) and
290        // ancestors auto-created during replay take positions without
291        // reserving an index, so a counter-assigned index and an index
292        // recorded in `_child_index` can name the same point. Whichever node
293        // is replayed second then lands on an occupied slot.
294        //
295        // Refusing outright made the file unopenable — the failure surfaced as
296        // `Failed to add node to tensor network` on reopen, after a
297        // `put_data_only` + `compact()`, with no way to recover the data. So
298        // probe forward for the next free slot instead. A file whose slots do
299        // not collide is unaffected: the first probe succeeds, placement is
300        // unchanged, and on-disk geometry stays bit-identical.
301        const MAX_PROBE: u32 = 1024;
302        let mut probe = child_index_hint;
303        let mut resolved = None;
304
305        for _ in 0..MAX_PROBE {
306            let (point, child_index) = match parent_signature {
307                Some(parent_sig) => self.compute_child_placement(parent_sig, probe),
308                None => (self.poincare_disk.origin(), 0),
309            };
310
311            // Refuse placement past the radius where the distance kernel
312            // stops being faithful. Beyond it queries do not get slower, they
313            // get wrong — every node saturates to the same distance and
314            // ranking becomes arbitrary — so this is an error, not a warning.
315            // One subtraction and a compare; see `constants::max_safe_radius`.
316            if crate::constants::min_safe_disk_gap()
317                > FixedPoint::from_int(1) - point.coords().length_squared()
318            {
319                log::error!(
320                    "refusing to place '{}' at level {}: hyperbolic radius exceeds {} \
321                     (max_safe_radius), where the Q64.64 distance kernel saturates. \
322                     Depth limit is floor(max_safe_radius / tau) = {} at tau = {}.",
323                    metadata.key,
324                    level,
325                    crate::constants::max_safe_radius().to_f64(),
326                    (crate::constants::max_safe_radius() / self.tau).to_int(),
327                    self.tau.to_f64(),
328                );
329                return None;
330            }
331
332            let signature =
333                GeometricSignature::embedded(&point, self.poincare_disk.dimension(), level);
334            let unique_id = signature.unique_id();
335
336            // Re-inserting the same key is an update, not a collision.
337            let taken_by_other = self
338                .nodes
339                .get(&unique_id)
340                .map(|existing| existing.metadata().key != metadata.key)
341                .unwrap_or(false);
342
343            if !taken_by_other {
344                resolved = Some((point, child_index, signature, unique_id));
345                break;
346            }
347
348            // The root has exactly one slot (the origin); there is nothing to
349            // probe, and a genuine clash there is a real error.
350            if parent_signature.is_none() {
351                break;
352            }
353            probe = Some(child_index.saturating_add(1));
354        }
355
356        let Some((point, child_index, signature, unique_id)) = resolved else {
357            log::error!(
358                "could not place '{}': no free sibling slot within {} probes — \
359                 precision budget exceeded (depth/fan-out); insert refused",
360                metadata.key, MAX_PROBE
361            );
362            return None;
363        };
364
365        // Commit the sibling-counter advance now that the insert will succeed.
366        // Callers hold the parent stripe lock, so peek-then-commit is atomic
367        // with respect to other inserts under the same parent.
368        if let Some(parent_sig) = parent_signature {
369            // Commit against the slot actually taken, not the one requested —
370            // they differ when the probe above had to step past an occupied
371            // position. `commit_child_index` takes the max, so for a
372            // first-probe hit this is identical to the previous behaviour.
373            self.commit_child_index(&parent_sig.unique_id(), Some(child_index), child_index);
374        }
375
376        let node = CompressedNode::new(metadata, value);
377        self.nodes.insert(unique_id.clone(), node);
378        // Fresh nodes carry no semantic coords, but bump anyway: free
379        // insurance against future insert-with-coords paths.
380        self.semantic_index.bump();
381
382        // Store child_index in metadata for deterministic snapshot reconstruction
383        if let Some(mut node_ref) = self.nodes.get_mut(&unique_id) {
384            node_ref.metadata_mut().metadata.insert(
385                "_child_index".to_string(),
386                child_index.to_string(),
387            );
388        }
389        self.point_map.insert(unique_id.clone(), point.clone());
390
391        self.cell_index.insert(&unique_id, &point);
392
393        if parent_signature.is_none() {
394            let mut root = self.root_signature.lock().unwrap_or_else(|e| e.into_inner());
395            if root.is_none() {
396                *root = Some(signature.clone());
397            }
398        }
399
400        if let Some(parent_sig) = parent_signature {
401            if let Some(mut parent_node) = self.nodes.get_mut(&parent_sig.unique_id()) {
402                parent_node.add_child(signature.clone());
403            }
404        }
405
406        Some(signature)
407    }
408
409    /// Place a child node using Sarkar's cone construction.
410    ///
411    /// 1. Look up parent's position in the Poincaré disk
412    /// 2. Compute child angle: child_count × golden_angle (irrational spacing)
413    /// 3. Create child at origin frame: (r·cos θ, r·sin θ, 0, …) where r = tanh(τ/2)
414    /// 4. Möbius-reflect from origin to parent's position
415    ///
416    /// This produces embeddings where the tree IS its own Delaunay triangulation
417    /// (Sarkar 2011), with (1+ε) distance distortion for any tree.
418    /// Resolve the child index and Poincaré-disk point for a new child of
419    /// `parent_signature`, **without** mutating the parent's sibling counter.
420    ///
421    /// The returned index is what the child *would* receive; the caller
422    /// commits the counter advance via [`Self::commit_child_index`] once the
423    /// insert is certain to succeed. Splitting peek from commit keeps the
424    /// sibling-index sequence gap-free across refused/failed inserts, which
425    /// is what makes placement independent of transient failures.
426    fn compute_child_placement(&self, parent_signature: &GeometricSignature, child_index_hint: Option<u32>) -> (HyperbolicPoint, u32) {
427        let parent_id = parent_signature.unique_id();
428        let dimension = self.poincare_disk.dimension();
429
430        // Get parent position (root is at origin)
431        let parent_point = self.point_map.get(&parent_id)
432            .map(|r| r.value().clone())
433            .unwrap_or_else(|| HyperbolicPoint::origin(dimension));
434
435        // Peek the child index: explicit hint (snapshot replay) or the current
436        // auto-increment counter. No mutation here.
437        let child_index = child_index_hint
438            .unwrap_or_else(|| self.child_counts.get(&parent_id).map(|r| *r.value()).unwrap_or(0));
439
440        // Rainbow bands: siblings fill concentric rings instead of exhausting
441        // one circle. Band 0 sits at the classic Sarkar distance τ —
442        // bit-identical to the historical placement, so existing trees keep
443        // their exact geometry. Each full band cascades outward by τ/64:
444        // angular quantization capacity is renewed per ring, so fan-out is
445        // collision-free by construction rather than guarded by warnings.
446        // The angle sequence runs continuously across bands (a discretized
447        // Vogel/phyllotaxis spiral).
448        //
449        // Capacity math: signatures quantize positions to 1e-3 cells; at
450        // ring radius tanh(τ/2) the golden-angle minimum chord falls below a
451        // cell around ~700 siblings (earlier for deep parents, which Möbius
452        // reflection compresses). 256 leaves a wide margin; the τ/64 radial
453        // step keeps adjacent bands ~6 cells apart.
454        let band = child_index / RAINBOW_BAND_CAPACITY;
455        if band >= RAINBOW_BAND_WARN {
456            log::warn!(
457                "parent of child '{}' reached rainbow band {} ({}+ siblings): placement \
458                 remains collision-free but subtree spacing is degrading — consider restructuring",
459                child_index, band, child_index
460            );
461        }
462        let effective_tau = self.tau
463            + self.tau * FixedPoint::from_int(band as i32)
464                / FixedPoint::from_int(RAINBOW_BAND_STEP_DIV);
465        let half_tau = effective_tau / FixedPoint::from_int(2);
466        let r = half_tau.tanh();
467
468        // Child angle: golden angle spacing ensures no clustering regardless of child count
469        let angle = FixedPoint::from_int(child_index as i32) * constants::golden_angle();
470
471        // Build child position in the origin frame
472        let mut child_at_origin = FixedVector::new(dimension);
473        if dimension >= 2 {
474            let (sin_a, cos_a) = angle.sincos();
475            child_at_origin[0] = r * cos_a;
476            child_at_origin[1] = r * sin_a;
477            // Higher dimensions stay at zero — children lie in a 2D geodesic submanifold
478        } else {
479            // 1D: alternate left/right
480            child_at_origin[0] = if child_index % 2 == 0 { r } else { -r };
481        }
482        let child_point = HyperbolicPoint::new(child_at_origin);
483
484        // Möbius-reflect from origin to parent's position
485        (child_point.reflect_from_origin(&parent_point), child_index)
486    }
487
488    /// Advance the parent's sibling counter after a successful insert.
489    ///
490    /// For an auto-increment insert this bumps the counter past `child_index`;
491    /// for a hinted (snapshot-replay) insert it tracks the running maximum so
492    /// later auto-increment inserts never collide with a replayed index.
493    fn commit_child_index(&self, parent_id: &str, child_index_hint: Option<u32>, child_index: u32) {
494        let next = match child_index_hint {
495            Some(hint) => {
496                let current = self.child_counts.get(parent_id).map(|r| *r.value()).unwrap_or(0);
497                current.max(hint + 1)
498            }
499            None => child_index + 1,
500        };
501        self.child_counts.insert(parent_id.to_string(), next);
502    }
503
504    /// Get a node by its signature (returns cloned value).
505    pub fn get_node_by_signature(&self, signature: &GeometricSignature) -> Option<CompressedNode> {
506        self.nodes.get(&signature.unique_id()).map(|r| r.value().clone())
507    }
508
509    /// Update the value of a node by its unique_id.
510    pub fn update_node_value(&self, unique_id: &str, value: Vec<u8>) -> bool {
511        if let Some(mut node) = self.nodes.get_mut(unique_id) {
512            node.update_value(value);
513            true
514        } else {
515            false
516        }
517    }
518
519    /// Set a metadata key-value pair on a node by its unique_id.
520    pub fn set_node_metadata_entry(&self, unique_id: &str, key: &str, val: &str) -> bool {
521        if let Some(mut node) = self.nodes.get_mut(unique_id) {
522            node.metadata_mut().metadata.insert(key.to_string(), val.to_string());
523            true
524        } else {
525            false
526        }
527    }
528
529    /// Set semantic coordinates on a node by its unique_id.
530    pub fn set_node_semantic(&self, unique_id: &str, coords: Vec<u8>) -> bool {
531        if let Some(mut node) = self.nodes.get_mut(unique_id) {
532            node.set_semantic_coords(coords);
533            drop(node); // release the shard before the epoch bump
534            // Mutation first, then bump: a builder that pre-read the old
535            // epoch tags its tree stale (see semantic_index.rs).
536            self.semantic_index.bump();
537            true
538        } else {
539            false
540        }
541    }
542
543    /// Get semantic coordinates for a node by its unique_id.
544    pub fn get_node_semantic(&self, unique_id: &str) -> Option<Vec<u8>> {
545        self.nodes.get(unique_id).map(|node| node.semantic_coords().to_vec())
546    }
547
548    /// Get the root node (cloned).
549    pub fn root_node(&self) -> Option<CompressedNode> {
550        let root = self.root_signature.lock().unwrap_or_else(|e| e.into_inner());
551        root.as_ref().and_then(|sig| {
552            self.get_node_by_signature(sig)
553        })
554    }
555
556    /// Get the root node signature (cloned).
557    pub fn root_signature(&self) -> Option<GeometricSignature> {
558        self.root_signature.lock().unwrap_or_else(|e| e.into_inner()).clone()
559    }
560
561    /// Get the children of a node by its signature (cloned).
562    pub fn children_of(&self, signature: &GeometricSignature) -> Vec<CompressedNode> {
563        let node = match self.nodes.get(&signature.unique_id()) {
564            Some(r) => r.value().clone(),
565            None => return Vec::new(),
566        };
567
568        let mut children = Vec::new();
569        for child_sig in node.children() {
570            if let Some(child) = self.nodes.get(&child_sig.unique_id()) {
571                children.push(child.value().clone());
572            }
573        }
574
575        children
576    }
577
578    /// Get the hyperbolic point for a node (cloned).
579    pub fn get_point(&self, unique_id: &str) -> Option<HyperbolicPoint> {
580        self.point_map.get(unique_id).map(|r| r.value().clone())
581    }
582
583    /// Monotone counter of semantic-relevant mutations (coordinate writes,
584    /// inserts, deletes). External caches — like the semantic disk's
585    /// derived-position index — use it exactly as the internal per-slice
586    /// cache does: tag on build, rebuild when it has advanced.
587    pub fn semantic_epoch(&self) -> u64 {
588        self.semantic_index.epoch()
589    }
590
591    /// The Sarkar scale factor: the hyperbolic distance from any node to each
592    /// of its children.
593    pub fn tau(&self) -> FixedPoint {
594        self.tau
595    }
596
597    /// The spatial index, for tests that compare it against brute force.
598    pub fn cell_index(&self) -> &CellIndex {
599        &self.cell_index
600    }
601
602    /// Get the number of nodes in the network.
603    pub fn node_count(&self) -> usize {
604        self.nodes.len()
605    }
606
607    /// Remove a node-map entry that has NO geometric registration — the
608    /// data-only entry retired by `embed_existing` after its embedded
609    /// replacement went live under a new signature-derived id. Not for
610    /// embedded nodes: those need [`Self::unregister_node_with_parent`].
611    pub fn remove_detached_node(&self, unique_id: &str) {
612        self.nodes.remove(unique_id);
613        // The candidate set changed shape (old id gone): invalidate the semantic index.
614        self.semantic_index.bump();
615    }
616
617    /// Unregister a node from the spatial index (for deletion).
618    ///
619    /// Prefer [`Self::unregister_node_with_parent`]: the parent is not derivable
620    /// here, so the parent's child list keeps a dangling entry.
621    pub fn unregister_node(&self, unique_id: &str) {
622        self.unregister_node_with_parent(unique_id, None)
623    }
624
625    /// Unregister a node, removing it from the node map and from its parent's
626    /// child list. The caller supplies `parent_uid`; it is resolved from the
627    /// path map, which is authoritative for parentage.
628    pub fn unregister_node_with_parent(&self, unique_id: &str, parent_uid: Option<&str>) {
629        self.child_counts.remove(unique_id);
630
631        self.cell_index.remove(unique_id);
632        self.point_map.remove(unique_id);
633
634        // Remove the node itself — a ghost entry would keep serving stale
635        // semantic coordinates to nearest_semantic and permanently fail
636        // validate()'s nodes↔point_map invariant.
637        self.nodes.remove(unique_id);
638        // Deletion changes the semantic candidate set: invalidate the semantic index.
639        self.semantic_index.bump();
640
641        // Drop the deleted node from its parent's child list.
642        if let Some(pid) = parent_uid {
643            if let Some(mut parent_node) = self.nodes.get_mut(pid) {
644                parent_node.remove_child(unique_id);
645            }
646        }
647
648        // If the root itself was deleted, clear the root signature.
649        let mut root = self.root_signature.lock().unwrap_or_else(|e| e.into_inner());
650        if root.as_ref().map(|s| s.unique_id()).as_deref() == Some(unique_id) {
651            *root = None;
652        }
653    }
654
655    /// Find all descendants of a node using the spatial index.
656    ///
657    /// Uses the parent's stored point + a τ-based radius to find all nodes
658    /// within the Sarkar cone. Radius = 3·τ covers ~3 levels of descendants.
659    pub fn find_descendants_spatial(&self, signature: &GeometricSignature) -> Vec<(String, FixedPoint)> {
660        let unique_id = signature.unique_id();
661        let point = match self.point_map.get(&unique_id) {
662            Some(r) => r.value().clone(),
663            None => return Vec::new(),
664        };
665
666        // Subtree radius: 3·τ — covers descendants within 3 levels of the Sarkar cone
667        let subtree_radius = FixedPoint::from_int(3) * self.tau;
668
669        self.cell_index.within_radius(&point, subtree_radius)
670            .into_iter()
671            .filter(|(uid, _)| *uid != unique_id)
672            .collect()
673    }
674
675    /// The nearest stored node to an arbitrary point, exactly.
676    ///
677    /// Delegates to the cell index: the cell is computed from the query's
678    /// coordinates, and the ring expands until a proven lower bound says
679    /// nothing closer remains. No candidate cap, no window, no count-based
680    /// stopping rule.
681    ///
682    /// **Complexity**: O(1) cell lookup plus a bounded ring. Measured on
683    /// 5 461 nodes: 1.7 cells and ~27 points scanned per k=1 query, against
684    /// 7 563 µs for the bucket layer this replaced.
685    pub fn nearest_neighbor_point(&self, query_poincare: &HyperbolicPoint) -> Option<(String, FixedPoint)> {
686        self.cell_index.knn(query_poincare, 1).into_iter().next()
687    }
688
689    /// The k nearest stored nodes to an arbitrary point, ascending by
690    /// `(distance, unique_id)`.
691    pub fn nearest_neighbor_point_k(&self, query_poincare: &HyperbolicPoint, k: usize) -> Vec<(String, FixedPoint)> {
692        self.cell_index.knn(query_poincare, k)
693    }
694
695    /// Every stored node within `radius` of `centre`, ascending by
696    /// `(distance, unique_id)`. Same expansion as `nearest_neighbor_point_k`
697    /// with a fixed threshold instead of a moving k-th distance.
698    pub fn nodes_in_radius(&self, centre: &HyperbolicPoint, radius: FixedPoint) -> Vec<(String, FixedPoint)> {
699        self.cell_index.within_radius(centre, radius)
700    }
701
702
703    // -----------------------------------------------------------------------
704    // Semantic dimensional distance queries
705    // -----------------------------------------------------------------------
706
707    /// Compute Euclidean distance between two semantic coordinate vectors
708    /// across a dimensional slice (specified dimension range).
709    ///
710    /// Each dimension is 16 bytes (i128 LE, Q64.64 fixed-point).
711    /// Dimensions outside the vectors are treated as zero.
712    ///
713    /// Uses gMath's fused kernel: differences, squares, and the accumulator
714    /// all live at the compute tier, so the sum cannot wrap the way a
715    /// storage-tier Q64.64 accumulator would for large coordinates or many
716    /// dimensions.
717    pub fn semantic_distance(
718        coords_a: &[u8],
719        coords_b: &[u8],
720        dim_range: &Range<usize>,
721    ) -> FixedPoint {
722        let a = Self::decode_semantic_slice(coords_a, dim_range);
723        let b = Self::decode_semantic_slice(coords_b, dim_range);
724        g_math::fixed_point::imperative::fused::euclidean_distance(&a, &b)
725    }
726
727    /// Decode a dimension slice of a raw Q64.64 coordinate vector.
728    ///
729    /// Dimensions beyond the end of `coords` decode as zero — short vectors
730    /// are zero-extended, matching [`Self::semantic_distance`] semantics.
731    pub fn decode_semantic_slice(coords: &[u8], dim_range: &Range<usize>) -> Vec<FixedPoint> {
732        dim_range
733            .clone()
734            .map(|dim| {
735                let start = dim * 16;
736                let end = start + 16;
737                if coords.len() >= end {
738                    FixedPoint::from_raw(i128::from_le_bytes(
739                        coords[start..end].try_into().unwrap(),
740                    ))
741                } else {
742                    FixedPoint::from_int(0)
743                }
744            })
745            .collect()
746    }
747
748    /// Find the k nearest nodes by Euclidean distance in semantic dimension space.
749    ///
750    /// `query_coords`: raw Q64.64 byte vector representing the query point.
751    /// `k`: number of nearest neighbors to return.
752    /// `dim_range`: which semantic dimensions to compare (the "dimensional slice").
753    ///
754    /// Returns `Vec<(key, distance)>` sorted ascending by `(distance, key)` —
755    /// ties break deterministically by the user-visible node key, both for
756    /// ordering and for which ties survive the k-boundary.
757    ///
758    /// Routing (`docs/SEMANTIC_INDEX.md`): stores below
759    /// [`constants::SEMANTIC_INDEX_MIN_NODES`] use the brute-force scan;
760    /// larger stores query a lazily built per-`dim_range` VP-tree, rebuilt
761    /// when the semantic epoch has advanced (any coord write, insert, or
762    /// delete). Warm-index queries are O(log n) expected on low-dimensional
763    /// slices; the first query for a slice after a mutation pays the
764    /// O(n log n) build. Results are identical to the scan path.
765    pub fn nearest_semantic(
766        &self,
767        query_coords: &[u8],
768        k: usize,
769        dim_range: &Range<usize>,
770    ) -> Vec<(String, FixedPoint)> {
771        if k == 0 {
772            return Vec::new();
773        }
774
775        if self.nodes.len() < constants::SEMANTIC_INDEX_MIN_NODES {
776            return self.nearest_semantic_scan(query_coords, k, dim_range);
777        }
778
779        let query = Self::decode_semantic_slice(query_coords, dim_range);
780        let index = self.semantic_index.get_or_build(dim_range, || {
781            self.nodes
782                .iter()
783                .filter(|entry| !entry.value().semantic_coords().is_empty())
784                .map(|entry| {
785                    (
786                        entry.value().metadata().key.clone(),
787                        Self::decode_semantic_slice(entry.value().semantic_coords(), dim_range),
788                    )
789                })
790                .collect()
791        });
792        index.tree.knn(&query, k, &EuclideanMetric)
793    }
794
795    /// Reference brute-force path for [`Self::nearest_semantic`]:
796    /// O(n × d) scan over every node with semantic coordinates.
797    ///
798    /// Same ordering contract as the indexed path — ascending
799    /// `(distance, key)`. Public so tests and benchmarks can compare
800    /// the two paths directly; prefer `nearest_semantic`, which picks.
801    pub fn nearest_semantic_scan(
802        &self,
803        query_coords: &[u8],
804        k: usize,
805        dim_range: &Range<usize>,
806    ) -> Vec<(String, FixedPoint)> {
807        if k == 0 {
808            return Vec::new();
809        }
810
811        // Max-heap of size k on (distance, key): the peek is the current
812        // worst candidate under the same total order the index uses, so
813        // ties at the k-boundary break by key on both paths.
814        let mut heap: BinaryHeap<(FixedPoint, String)> = BinaryHeap::new();
815
816        for entry in self.nodes.iter() {
817            let coords = entry.value().semantic_coords();
818
819            // Skip nodes with no semantic coordinates
820            if coords.is_empty() {
821                continue;
822            }
823
824            let dist = Self::semantic_distance(query_coords, coords, dim_range);
825            let key = entry.value().metadata().key.as_str();
826
827            if heap.len() < k {
828                heap.push((dist, key.to_string()));
829            } else if let Some(worst) = heap.peek() {
830                if (dist, key) < (worst.0, worst.1.as_str()) {
831                    heap.pop();
832                    heap.push((dist, key.to_string()));
833                }
834            }
835        }
836
837        // Extract and sort ascending by (distance, uid)
838        let mut results: Vec<(String, FixedPoint)> = heap
839            .into_iter()
840            .map(|(dist, uid)| (uid, dist))
841            .collect();
842        results.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
843        results
844    }
845
846    /// Check if the network has a valid structure.
847    ///
848    /// Verifies structural invariants across all internal data structures:
849    /// nodes, point_map, child_counts, and the spatial index.
850    pub fn validate_network(&self) -> bool {
851        if self.nodes.is_empty() {
852            return false;
853        }
854
855        let root_sig = self.root_signature.lock().unwrap_or_else(|e| e.into_inner());
856        if root_sig.is_none() {
857            return false;
858        }
859
860        let root_id = root_sig.as_ref().unwrap().unique_id();
861        drop(root_sig);
862        if !self.nodes.contains_key(&root_id) {
863            return false;
864        }
865
866        // Child signatures reference existing nodes
867        for entry in self.nodes.iter() {
868            let node = entry.value();
869            for child_sig in node.children() {
870                if !self.nodes.contains_key(&child_sig.unique_id()) {
871                    return false;
872                }
873            }
874        }
875
876        // Every node has a point_map entry
877        for entry in self.nodes.iter() {
878            if !self.point_map.contains_key(entry.key()) {
879                return false;
880            }
881        }
882
883        // point_map keys are a subset of nodes
884        for entry in self.point_map.iter() {
885            if !self.nodes.contains_key(entry.key()) {
886                return false;
887            }
888        }
889
890        // child_counts keys are a subset of nodes (no orphan entries)
891        for entry in self.child_counts.iter() {
892            if !self.nodes.contains_key(entry.key()) {
893                return false;
894            }
895        }
896
897        self.verify_index_locates_all_nodes()
898    }
899
900    /// **Functional** integrity: can the spatial index actually answer a query
901    /// about what it holds?
902    ///
903    /// Every other check in this file is
904    /// *referential* — it asks whether these maps point at things that exist.
905    /// A structure can pass all of them and still be unable to find anything,
906    /// which is exactly what happened: the bucket layer was referentially
907    /// perfect while `nearest` returned the wrong node for 25 of 42 nodes in a
908    /// deep tree. No check asked it to locate a node it had itself indexed.
909    ///
910    /// This one does. `point_map` is the authority on where a node is; the
911    /// index is derived from it. Querying at a node's own stored position must
912    /// return that node, because distance 0 is the global minimum of a metric
913    /// — an expected answer known without any oracle.
914    ///
915    /// Ties are respected: several nodes may share a position, so the check is
916    /// that *something* at distance zero comes back, not that a particular id
917    /// does.
918    ///
919    /// O(n) queries, so it is a diagnostic rather than a hot path.
920    pub fn verify_index_locates_all_nodes(&self) -> bool {
921        for entry in self.point_map.iter() {
922            let found = self.cell_index.knn(entry.value(), 1);
923            match found.first() {
924                // Distance is zero for the node itself, or for anything
925                // embedded at the same position.
926                Some((_, distance)) if *distance == FixedPoint::from_int(0) => {}
927                _ => {
928                    log::error!(
929                        "spatial index cannot locate node {} at its own stored position",
930                        entry.key()
931                    );
932                    return false;
933                }
934            }
935        }
936        true
937    }
938}
939
940impl Debug for HyperbolicTensorNetwork {
941    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
942        write!(f, "HyperbolicTensorNetwork(nodes={}, dimension={})",
943               self.nodes.len(),
944               self.poincare_disk.dimension())
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    #[test]
953    fn test_compressed_node() {
954        let metadata = NodeMetadata::new("test".to_string(), None);
955        let value = b"Node data".to_vec();
956
957        let node = CompressedNode::new(metadata, value.clone());
958
959        assert_eq!(node.metadata().key, "test");
960        assert_eq!(node.value(), &value[..]);
961        assert!(!node.has_children());
962        assert_eq!(node.child_count(), 0);
963    }
964
965    #[test]
966    fn test_tensor_network_creation() {
967        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
968
969        assert_eq!(network.node_count(), 0);
970        assert!(network.root_node().is_none());
971    }
972
973    #[test]
974    fn test_adding_nodes() {
975        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
976
977        let root_meta = NodeMetadata::new("/".to_string(), None);
978        let root_sig = network.add_node(
979            root_meta,
980            b"Root node data".to_vec(),
981            None,
982            0
983        ).unwrap();
984
985        assert_eq!(network.node_count(), 1);
986        assert!(network.root_node().is_some());
987
988        let child_meta = NodeMetadata::new("/child".to_string(), None);
989        let child_sig = network.add_node(
990            child_meta,
991            b"Child node data".to_vec(),
992            Some(&root_sig),
993            1
994        ).unwrap();
995
996        assert_eq!(network.node_count(), 2);
997
998        let root_node = network.get_node_by_signature(&root_sig).unwrap();
999        assert_eq!(root_node.child_count(), 1);
1000        assert_eq!(root_node.children()[0].unique_id(), child_sig.unique_id());
1001    }
1002
1003    #[test]
1004    fn test_network_validation() {
1005        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1006
1007        assert!(!network.validate_network());
1008
1009        let root_sig = network.add_node(
1010            NodeMetadata::new("/".to_string(), None),
1011            b"Root data".to_vec(),
1012            None,
1013            0
1014        ).unwrap();
1015
1016        assert!(network.validate_network());
1017
1018        network.add_node(
1019            NodeMetadata::new("/child1".to_string(), None),
1020            b"Child 1 data".to_vec(),
1021            Some(&root_sig),
1022            1
1023        ).unwrap();
1024
1025        network.add_node(
1026            NodeMetadata::new("/child2".to_string(), None),
1027            b"Child 2 data".to_vec(),
1028            Some(&root_sig),
1029            1
1030        ).unwrap();
1031
1032        assert!(network.validate_network());
1033    }
1034
1035    #[test]
1036    fn test_point_map() {
1037        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1038
1039        let root_sig = network.add_node(
1040            NodeMetadata::new("/".to_string(), None),
1041            b"root".to_vec(),
1042            None,
1043            0
1044        ).unwrap();
1045
1046        // Root should be at origin
1047        let root_point = network.get_point(&root_sig.unique_id()).unwrap();
1048        assert!(root_point.euclidean_norm() < constants::epsilon());
1049
1050        let child_sig = network.add_node(
1051            NodeMetadata::new("/child".to_string(), None),
1052            b"child".to_vec(),
1053            Some(&root_sig),
1054            1
1055        ).unwrap();
1056
1057        // Child should be away from origin
1058        let child_point = network.get_point(&child_sig.unique_id()).unwrap();
1059        assert!(child_point.euclidean_norm() > constants::epsilon());
1060    }
1061
1062    #[test]
1063    fn test_spatial_descendants() {
1064        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1065
1066        let root_sig = network.add_node(
1067            NodeMetadata::new("/".to_string(), None),
1068            b"root".to_vec(),
1069            None,
1070            0
1071        ).unwrap();
1072
1073        let child_sig = network.add_node(
1074            NodeMetadata::new("/child".to_string(), None),
1075            b"child".to_vec(),
1076            Some(&root_sig),
1077            1
1078        ).unwrap();
1079
1080        let _grandchild_sig = network.add_node(
1081            NodeMetadata::new("/child/grandchild".to_string(), None),
1082            b"grandchild".to_vec(),
1083            Some(&child_sig),
1084            2
1085        ).unwrap();
1086
1087        // Root's spatial descendants should include child and grandchild
1088        let descendants = network.find_descendants_spatial(&root_sig);
1089        assert!(descendants.len() >= 2,
1090            "Expected at least 2 descendants, got {}", descendants.len());
1091    }
1092
1093    #[test]
1094    fn test_sarkar_child_distance() {
1095        // Children should be at exactly hyperbolic distance τ from parent
1096        let tau = constants::default_tau();
1097        let network = HyperbolicTensorNetwork::new(2, tau);
1098
1099        let root_sig = network.add_node(
1100            NodeMetadata::new("/".to_string(), None),
1101            b"root".to_vec(),
1102            None,
1103            0
1104        ).unwrap();
1105
1106        let child_sig = network.add_node(
1107            NodeMetadata::new("/child".to_string(), None),
1108            b"child".to_vec(),
1109            Some(&root_sig),
1110            1
1111        ).unwrap();
1112
1113        let root_point = network.get_point(&root_sig.unique_id()).unwrap();
1114        let child_point = network.get_point(&child_sig.unique_id()).unwrap();
1115
1116        let dist = root_point.hyperbolic_distance(&child_point);
1117        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
1118        assert!((dist - tau).abs() < tolerance,
1119            "Child should be at distance τ={} from parent, got {}", tau, dist);
1120    }
1121
1122    #[test]
1123    fn test_sarkar_sibling_separation() {
1124        // Multiple children of the same parent should be at distinct positions
1125        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1126
1127        let root_sig = network.add_node(
1128            NodeMetadata::new("/".to_string(), None),
1129            b"root".to_vec(),
1130            None,
1131            0
1132        ).unwrap();
1133
1134        let mut child_sigs = Vec::new();
1135        for i in 0..5 {
1136            let sig = network.add_node(
1137                NodeMetadata::new(format!("/child{}", i), None),
1138                format!("child{}", i).into_bytes(),
1139                Some(&root_sig),
1140                1
1141            ).unwrap();
1142            child_sigs.push(sig);
1143        }
1144
1145        // All children should be at the same distance from root
1146        let tau = constants::default_tau();
1147        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
1148        let root_point = network.get_point(&root_sig.unique_id()).unwrap();
1149
1150        for sig in &child_sigs {
1151            let child_point = network.get_point(&sig.unique_id()).unwrap();
1152            let dist = root_point.hyperbolic_distance(&child_point);
1153            assert!((dist - tau).abs() < tolerance,
1154                "All children should be at distance τ from parent");
1155        }
1156
1157        // All siblings should be pairwise distinct (non-zero distance)
1158        for i in 0..child_sigs.len() {
1159            for j in (i+1)..child_sigs.len() {
1160                let pi = network.get_point(&child_sigs[i].unique_id()).unwrap();
1161                let pj = network.get_point(&child_sigs[j].unique_id()).unwrap();
1162                let dist = pi.hyperbolic_distance(&pj);
1163                assert!(dist > constants::epsilon(),
1164                    "Siblings {} and {} should be at distinct positions", i, j);
1165            }
1166        }
1167    }
1168
1169    #[test]
1170    fn test_nearest_neighbor_point_finds_self() {
1171        // Query at a node's own position should return that node
1172        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1173
1174        let root_sig = network.add_node(
1175            NodeMetadata::new("/".to_string(), None),
1176            b"root".to_vec(),
1177            None,
1178            0
1179        ).unwrap();
1180
1181        let child_sig = network.add_node(
1182            NodeMetadata::new("/child".to_string(), None),
1183            b"child".to_vec(),
1184            Some(&root_sig),
1185            1
1186        ).unwrap();
1187
1188        // Query at child's position should find child
1189        let child_point = network.get_point(&child_sig.unique_id()).unwrap();
1190        let (nn_id, nn_dist) = network.nearest_neighbor_point(&child_point).unwrap();
1191
1192        assert_eq!(nn_id, child_sig.unique_id(),
1193            "Nearest neighbor at child's position should be child itself");
1194        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
1195        assert!(nn_dist < tolerance,
1196            "Distance to self should be ~0, got {}", nn_dist);
1197    }
1198
1199    #[test]
1200    fn test_delete_removes_node_from_index() {
1201        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1202
1203        let root_sig = network.add_node(
1204            NodeMetadata::new("/".to_string(), None),
1205            b"root".to_vec(),
1206            None,
1207            0
1208        ).unwrap();
1209
1210        let child_sig = network.add_node(
1211            NodeMetadata::new("/child".to_string(), None),
1212            b"child".to_vec(),
1213            Some(&root_sig),
1214            1
1215        ).unwrap();
1216
1217        let child_id = child_sig.unique_id();
1218        let child_point = network.get_point(&child_id).unwrap();
1219
1220        network.unregister_node_with_parent(&child_id, Some(&root_sig.unique_id()));
1221
1222        assert!(network.get_point(&child_id).is_none(),
1223            "deleted node should leave point_map");
1224
1225        // And the spatial index must stop returning it: querying at the
1226        // deleted node's own position may only find the root now.
1227        let (nn_id, _) = network.nearest_neighbor_point(&child_point).unwrap();
1228        assert_ne!(nn_id, child_id,
1229            "spatial index still returns a deleted node");
1230    }
1231
1232    #[test]
1233    fn test_semantic_distance_identical() {
1234        // Identical coordinates → distance = 0
1235        let coords = {
1236            let mut v = vec![0u8; 3 * 16]; // 3 dims
1237            let val = FixedPoint::from_f64(0.5).raw().to_le_bytes();
1238            v[0..16].copy_from_slice(&val);
1239            v[16..32].copy_from_slice(&val);
1240            v[32..48].copy_from_slice(&val);
1241            v
1242        };
1243        let dist = HyperbolicTensorNetwork::semantic_distance(&coords, &coords, &(0..3));
1244        assert!(dist < constants::epsilon(), "Distance to self should be ~0, got {}", dist);
1245    }
1246
1247    #[test]
1248    fn test_semantic_distance_known_value() {
1249        // dim0: (1.0, 0.0), dim1: (0.0, 0.0) → distance = 1.0
1250        let mut a = vec![0u8; 2 * 16];
1251        let one = FixedPoint::from_f64(1.0).raw().to_le_bytes();
1252        a[0..16].copy_from_slice(&one);
1253        // dim1 stays zero
1254
1255        let b = vec![0u8; 2 * 16]; // all zero
1256
1257        let dist = HyperbolicTensorNetwork::semantic_distance(&a, &b, &(0..2));
1258        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
1259        assert!((dist - FixedPoint::from_int(1)).abs() < tolerance,
1260            "Distance should be 1.0, got {}", dist);
1261    }
1262
1263    #[test]
1264    fn test_semantic_distance_dimensional_slice() {
1265        // Only compare dim 1, ignore dim 0
1266        let mut a = vec![0u8; 2 * 16];
1267        let one = FixedPoint::from_f64(1.0).raw().to_le_bytes();
1268        a[0..16].copy_from_slice(&one); // dim 0 = 1.0
1269
1270        let b = vec![0u8; 2 * 16]; // all zero
1271
1272        // Slice dim 1 only → both are 0.0 at dim 1 → distance = 0
1273        let dist = HyperbolicTensorNetwork::semantic_distance(&a, &b, &(1..2));
1274        assert!(dist < constants::epsilon(),
1275            "Slicing only dim 1 should give distance ~0, got {}", dist);
1276
1277        // Slice dim 0 only → (1.0 vs 0.0) → distance = 1.0
1278        let dist_full = HyperbolicTensorNetwork::semantic_distance(&a, &b, &(0..1));
1279        let tolerance = FixedPoint::from_int(1) / FixedPoint::from_int(100);
1280        assert!((dist_full - FixedPoint::from_int(1)).abs() < tolerance,
1281            "Slicing dim 0 should give distance 1.0, got {}", dist_full);
1282    }
1283
1284    #[test]
1285    fn test_nearest_semantic_basic() {
1286        let network = HyperbolicTensorNetwork::new(2, constants::default_tau());
1287
1288        // Add 3 nodes with semantic coords in 2 dims
1289        let root_sig = network.add_node(
1290            NodeMetadata::new("/".to_string(), None),
1291            b"root".to_vec(), None, 0,
1292        ).unwrap();
1293
1294        let a_sig = network.add_node(
1295            NodeMetadata::new("/a".to_string(), None),
1296            b"a".to_vec(), Some(&root_sig), 1,
1297        ).unwrap();
1298
1299        let b_sig = network.add_node(
1300            NodeMetadata::new("/b".to_string(), None),
1301            b"b".to_vec(), Some(&root_sig), 1,
1302        ).unwrap();
1303
1304        let c_sig = network.add_node(
1305            NodeMetadata::new("/c".to_string(), None),
1306            b"c".to_vec(), Some(&root_sig), 1,
1307        ).unwrap();
1308
1309        // Set semantic coords: /a at (0.8, 0.1), /b at (0.7, 0.2), /c at (0.1, 0.9)
1310        let make_coords = |d0: f64, d1: f64| -> Vec<u8> {
1311            let mut v = vec![0u8; 2 * 16];
1312            v[0..16].copy_from_slice(&FixedPoint::from_f64(d0).raw().to_le_bytes());
1313            v[16..32].copy_from_slice(&FixedPoint::from_f64(d1).raw().to_le_bytes());
1314            v
1315        };
1316
1317        network.set_node_semantic(&a_sig.unique_id(), make_coords(0.8, 0.1));
1318        network.set_node_semantic(&b_sig.unique_id(), make_coords(0.7, 0.2));
1319        network.set_node_semantic(&c_sig.unique_id(), make_coords(0.1, 0.9));
1320
1321        // Query near /a's position → /b should be closest, /c farthest
1322        let query = make_coords(0.8, 0.1);
1323        let results = network.nearest_semantic(&query, 3, &(0..2));
1324
1325        assert!(!results.is_empty());
1326
1327        // First result should be /a (distance ~0)
1328        let first_dist = results[0].1;
1329        assert!(first_dist < FixedPoint::from_f64(0.01),
1330            "Nearest to (0.8,0.1) should be /a at ~0 distance, got {}", first_dist);
1331
1332        // /c should be much farther than /b
1333        if results.len() >= 3 {
1334            assert!(results[2].1 > results[1].1,
1335                "Third result should be farther than second");
1336        }
1337    }
1338}