Skip to main content

horon_engine/
store.rs

1//! store.rs - Simple, ergonomic wrapper around HTTStorage
2//!
3//! Provides a dead-simple API that hides all internal types (FixedPoint,
4//! IntegrationError, geometric signatures, etc.) behind standard Rust types.
5//!
6//! # Quick Start
7//!
8//! ```
9//! use horon_engine::Store;
10//!
11//! let store = Store::new();
12//! store.put("/greeting", b"Hello, world!").unwrap();
13//! let data = store.get("/greeting").unwrap();
14//! assert_eq!(data, b"Hello, world!");
15//! ```
16
17use std::collections::HashMap;
18use std::fmt;
19use std::ops::Range;
20
21use g_math::fixed_point::FixedPoint;
22
23use super::config::HTTStorageConfig;
24use super::constants::{OUTLIER_KNN, OUTLIER_MIN_POPULATION};
25use super::metric_tree::{EuclideanMetric, MetricVpTree};
26use super::storage::HTTStorage;
27use super::tensor_network::HyperbolicTensorNetwork;
28use super::tree_tensor::IntegrationError;
29
30// ---------------------------------------------------------------------------
31// StoreError
32// ---------------------------------------------------------------------------
33
34/// Simplified error type for Store operations.
35#[derive(Debug)]
36pub enum StoreError {
37    /// The requested key was not found.
38    NotFound(String),
39    /// A key already exists (when an exclusive insert was expected).
40    AlreadyExists(String),
41    /// The operation was invalid (bad key, configuration error, etc.).
42    InvalidOperation(String),
43    /// An internal error occurred (lock poisoned, deserialization, etc.).
44    Internal(String),
45}
46
47impl fmt::Display for StoreError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            StoreError::NotFound(msg) => write!(f, "not found: {}", msg),
51            StoreError::AlreadyExists(msg) => write!(f, "already exists: {}", msg),
52            StoreError::InvalidOperation(msg) => write!(f, "invalid operation: {}", msg),
53            StoreError::Internal(msg) => write!(f, "internal error: {}", msg),
54        }
55    }
56}
57
58impl std::error::Error for StoreError {}
59
60/// A semantic outlier found by [`Store::find_outliers`]: a node whose
61/// average distance to its nearest peers is anomalously large relative to
62/// the population under the queried prefix.
63#[derive(Debug, Clone, PartialEq)]
64pub struct SemanticOutlier {
65    /// The outlying node's key.
66    pub key: String,
67    /// Its average distance to its k nearest peers in the population.
68    pub avg_knn_distance: FixedPoint,
69    /// How many standard deviations that average sits above the population
70    /// mean (always > the requested threshold).
71    pub z_score: FixedPoint,
72    /// The closest peer — "even its best match is this far away".
73    pub nearest_peer: String,
74    /// Distance to that closest peer.
75    pub nearest_distance: FixedPoint,
76}
77
78impl From<IntegrationError> for StoreError {
79    fn from(e: IntegrationError) -> Self {
80        match e {
81            IntegrationError::NotFound(msg) => StoreError::NotFound(msg),
82            IntegrationError::AlreadyExists(msg) => StoreError::AlreadyExists(msg),
83            IntegrationError::ValidationFailed(msg) | IntegrationError::ConfigurationError(msg) => {
84                StoreError::InvalidOperation(msg)
85            }
86            IntegrationError::OperationFailed(msg)
87            | IntegrationError::DeserializationError(msg)
88            | IntegrationError::LockError(msg) => StoreError::Internal(msg),
89        }
90    }
91}
92
93// ---------------------------------------------------------------------------
94// StoreConfig
95// ---------------------------------------------------------------------------
96
97/// Minimal configuration for a Store.
98///
99/// Power users who need control over dimension, grid resolution, or other
100/// internals should use [`HTTStorage`] directly.
101pub struct StoreConfig {
102    capacity: usize,
103    tau: FixedPoint,
104}
105
106impl StoreConfig {
107    /// Create a new config with default capacity (10,000 nodes).
108    pub fn new() -> Self {
109        Self { capacity: 10_000, tau: FixedPoint::from_int(0) }
110    }
111
112    /// Set the expected number of in-memory nodes.
113    ///
114    /// **Advisory only**: this sizes internal caches; it does not enforce a
115    /// limit. Inserts beyond `capacity` succeed and the store grows unbounded.
116    pub fn capacity(mut self, n: usize) -> Self {
117        self.capacity = n;
118        self
119    }
120
121    /// Set the Sarkar embedding scale factor τ.
122    ///
123    /// Controls the hyperbolic distance between parent and child nodes.
124    /// Default is 1.0. Smaller values allow deeper trees within the same
125    /// Q64.64 precision budget; larger values give better angular separation
126    /// between siblings.
127    pub fn tau(mut self, t: FixedPoint) -> Self {
128        self.tau = t;
129        self
130    }
131
132    fn to_htt_config(&self) -> HTTStorageConfig {
133        HTTStorageConfig {
134            dimension: 4,
135            max_memory_nodes: self.capacity,
136            cache_size: std::cmp::max(self.capacity / 10, 10),
137            storage_path: None,
138            flush_interval: 60,
139            optimize_on_shutdown: true,
140            tau: self.tau,
141        }
142    }
143}
144
145impl Default for StoreConfig {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151// ---------------------------------------------------------------------------
152// QueryAdapter
153// ---------------------------------------------------------------------------
154
155/// Result from a query adapter.
156#[derive(Debug, Clone)]
157pub enum QueryResult {
158    /// A single entry with key, data, and metadata.
159    Entry {
160        /// The entry's path key.
161        key: String,
162        /// The entry's raw data payload.
163        data: Vec<u8>,
164        /// The entry's key-value metadata.
165        meta: HashMap<String, String>,
166    },
167    /// A count of matching entries.
168    Count(usize),
169    /// A list of matching keys.
170    Keys(Vec<String>),
171}
172
173/// Trait for pluggable query adapters.
174///
175/// Adapters encapsulate query logic (e.g. search by metadata, semantic
176/// similarity, path patterns) and call `Store` methods internally.
177/// This is object-safe: `dyn QueryAdapter` works.
178pub trait QueryAdapter: Send + Sync {
179    /// Execute a query against the store.
180    fn execute(&self, store: &Store, query: &str) -> Result<Vec<QueryResult>, StoreError>;
181}
182
183// ---------------------------------------------------------------------------
184// Store
185// ---------------------------------------------------------------------------
186
187/// A simple, ergonomic hierarchical data store backed by Hyperbolic Tree Tensors.
188///
189/// All keys are path-like strings (e.g. `"/users/alice"`). Parent directories
190/// are created automatically. The root node `"/"` always exists.
191///
192/// # Examples
193///
194/// ```
195/// use horon_engine::Store;
196/// use g_math::fixed_point::FixedPoint;
197///
198/// let store = Store::new();
199///
200/// // Store and retrieve data
201/// store.put("/config/db", b"postgres://localhost").unwrap();
202/// assert_eq!(store.get("/config/db").unwrap(), b"postgres://localhost");
203///
204/// // Coordinates and distances are Q64.64 fixed point, never floats: the
205/// // same query returns bit-identical results on any platform. Converting
206/// // from a decimal literal is explicit, so the lossy step is visible at
207/// // the call site rather than hidden inside the API.
208/// let origin: Vec<FixedPoint> = (0..4).map(|_| FixedPoint::from_int(0)).collect();
209/// let (path, distance) = store.nearest(&origin).unwrap();
210/// ```
211pub struct Store {
212    inner: HTTStorage,
213}
214
215impl Store {
216    /// Create a new Store with default settings (capacity: 10,000).
217    pub fn new() -> Self {
218        Self::with_config(StoreConfig::new())
219    }
220
221    /// Create a new Store with custom configuration.
222    pub fn with_config(config: StoreConfig) -> Self {
223        Self {
224            inner: HTTStorage::new(config.to_htt_config()),
225        }
226    }
227
228    /// Store data at a key (upsert — inserts or updates).
229    ///
230    /// Parent directories are created automatically.
231    pub fn put(&self, key: &str, data: &[u8]) -> Result<(), StoreError> {
232        self.inner.store(key, data, None)?;
233        Ok(())
234    }
235
236    /// Store data without geometric embedding (data + semantic only).
237    ///
238    /// Much faster than `put()` for bulk loading — skips Sarkar embedding,
239    /// VP-tree, and power diagram construction. Semantic queries
240    /// (`nearest_semantic`, `neighbors_semantic`, `get_semantic`) work
241    /// normally. Spatial queries (`nearest`, `neighbors`) will not find
242    /// nodes loaded this way.
243    pub fn put_data_only(&self, key: &str, data: &[u8]) -> Result<(), StoreError> {
244        self.inner.store_data_only(key, data, None)?;
245        Ok(())
246    }
247
248    /// Store data with an explicit child_index for deterministic Sarkar reconstruction.
249    ///
250    /// Used during snapshot replay: the stored child_index ensures the node gets
251    /// the same geometric position regardless of replay order.
252    pub fn put_positioned(&self, key: &str, data: &[u8], child_index: u32) -> Result<(), StoreError> {
253        self.inner.store_positioned(key, data, None, child_index)?;
254        Ok(())
255    }
256
257    /// Retrieve data by key.
258    pub fn get(&self, key: &str) -> Result<Vec<u8>, StoreError> {
259        Ok(self.inner.retrieve(key)?)
260    }
261
262    /// Remove a key and its data.
263    pub fn remove(&self, key: &str) -> Result<(), StoreError> {
264        self.inner.delete(key)?;
265        Ok(())
266    }
267
268    /// Check if a key exists.
269    pub fn exists(&self, key: &str) -> bool {
270        self.inner.exists(key)
271    }
272
273    /// List immediate children of a path.
274    ///
275    /// Returns only direct children, not the full subtree.
276    pub fn children(&self, path: &str) -> Result<Vec<String>, StoreError> {
277        let htt = self.inner.shared_htt();
278        let nodes = htt.list_children(path)?;
279        Ok(nodes.into_iter().map(|n| n.metadata().key.clone()).collect())
280    }
281
282    /// List all keys under a prefix (full subtree).
283    pub fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
284        Ok(self.inner.list(prefix)?)
285    }
286
287    /// Set a metadata field on a key.
288    pub fn set_meta(&self, key: &str, name: &str, value: &str) -> Result<(), StoreError> {
289        self.inner.set_metadata(key, name, value)?;
290        Ok(())
291    }
292
293    /// Get all metadata for a key.
294    pub fn get_meta(&self, key: &str) -> Result<HashMap<String, String>, StoreError> {
295        Ok(self.inner.get_metadata(key)?)
296    }
297
298    /// Set semantic coordinates on a key (raw Q64.64 bytes, 16 bytes per dimension).
299    ///
300    /// Each coordinate is 16 bytes (i128 LE). For example, 2 semantic dimensions
301    /// requires 32 bytes. Use `FixedPoint::from_f64(value).raw().to_le_bytes()`
302    /// to encode each coordinate.
303    ///
304    /// Returns `InvalidOperation` if `coords` is not a multiple of 16 bytes —
305    /// a misaligned vector would otherwise have its trailing partial dimension
306    /// silently ignored by distance computations.
307    pub fn set_semantic(&self, key: &str, coords: Vec<u8>) -> Result<(), StoreError> {
308        if coords.len() % 16 != 0 {
309            return Err(StoreError::InvalidOperation(format!(
310                "semantic coordinates must be a multiple of 16 bytes (one Q64.64 value per dimension); got {} bytes",
311                coords.len()
312            )));
313        }
314        self.inner.set_semantic(key, coords)?;
315        Ok(())
316    }
317
318    /// Get semantic coordinates for a key (raw Q64.64 bytes).
319    ///
320    /// Returns empty Vec if no semantic coordinates have been set.
321    pub fn get_semantic(&self, key: &str) -> Result<Vec<u8>, StoreError> {
322        Ok(self.inner.get_semantic(key)?)
323    }
324
325    /// The deepest node this store can place, given its `tau`.
326    ///
327    /// A node sits at hyperbolic radius `depth × tau`, and Q64.64 stops
328    /// representing coordinate differences faithfully past a radius of about
329    /// 21 — beyond that every node saturates to the same distance and ranking
330    /// becomes arbitrary. Placement past the limit is **refused**, so this is
331    /// the number to design against rather than discover by failing.
332    ///
333    /// Raising `tau` for wider fan-out lowers this proportionally: the default
334    /// `tau = 1.0` gives 21, `tau = 2.0` gives 10.
335    ///
336    /// Full metric fidelity degrades before the hard limit — the step error
337    /// along a geodesic is 3.4e-8 at radius 16, 2.0e-4 at 20. See
338    /// `docs/ARCHITECTURE.md`.
339    pub fn max_depth(&self) -> u32 {
340        let tau = self.inner.shared_htt().tensor_network().tau();
341        (crate::constants::max_safe_radius() / tau).to_int().max(0) as u32
342    }
343
344    /// Find the nearest stored node to an arbitrary point in hyperbolic space.
345    ///
346    /// Coordinates are in the Poincare disk model (each component in `(-1, 1)`).
347    /// The answer is the true nearest node: every surviving candidate is
348    /// ranked by exact hyperbolic distance.
349    ///
350    /// **Cost**: the query's own cell, then rings outward until a proven lower
351    /// bound rules out every cell not yet visited. Exact — nothing is capped,
352    /// sampled or windowed. How many cells that takes depends on how the tree
353    /// is shaped, so this is not O(1); measured figures are in BENCHMARKS.md.
354    ///
355    /// Returns `(key, hyperbolic_distance)`.
356    pub fn nearest(&self, coords: &[FixedPoint]) -> Result<(String, FixedPoint), StoreError> {
357        Ok(self.inner.nearest_neighbor_point(coords)?)
358    }
359
360    /// Find the k nearest stored nodes to an arbitrary point in hyperbolic space.
361    ///
362    /// Like `nearest()` but returns multiple candidates, enabling the caller
363    /// to post-filter and still get results.
364    ///
365    /// **Cost**: as `nearest()` — the query's own cell, then rings outward until a proven lower
366    /// bound rules out every cell not yet visited. Exact — nothing is capped,
367    /// sampled or windowed. How many cells that takes depends on how the tree
368    /// is shaped, so this is not O(1); measured figures are in BENCHMARKS.md.
369    ///
370    /// Returns `(key, hyperbolic_distance)` sorted by ascending distance.
371    pub fn nearest_k(&self, coords: &[FixedPoint], k: usize) -> Result<Vec<(String, FixedPoint)>, StoreError> {
372        Ok(self.inner.nearest_neighbor_point_k(coords, k)?)
373    }
374
375    /// Find the k nearest neighbors of an existing node.
376    ///
377    /// Returns keys sorted by ascending hyperbolic distance.
378    /// The queried key itself is excluded from results.
379    ///
380    /// **Cost**: as `nearest_k()`, from the queried node's own position.
381    pub fn neighbors(&self, path: &str, k: usize) -> Result<Vec<String>, StoreError> {
382        Ok(self.inner.find_nearest(path, k)?)
383    }
384
385    // -----------------------------------------------------------------------
386    // Semantic dimensional distance queries
387    // -----------------------------------------------------------------------
388
389    /// Reject a dimension slice that cannot carry information.
390    ///
391    /// `decode_semantic_slice` zero-extends: a dimension past the end of a
392    /// stored vector reads as zero. That is deliberate and lets a short vector
393    /// compare against a long one. Two cases abuse it:
394    ///
395    /// - an **empty** range compares nothing, so every pair scores 0;
396    /// - a range starting past the end of the *query's own* coordinates means
397    ///   the query contributes zeros across the whole slice.
398    ///
399    /// Either way every candidate ties at distance zero, the deterministic
400    /// key tie-break picks k of them, and the caller receives a confident,
401    /// reproducible, information-free answer. That is the one thing this
402    /// engine refuses to do, so it is an error instead.
403    ///
404    /// A range that merely *extends past* the data is still fine — that is
405    /// zero-extension working as designed.
406    fn check_slice(dim_range: &Range<usize>, query_dims: usize, what: &str) -> Result<(), StoreError> {
407        if dim_range.start >= dim_range.end {
408            return Err(StoreError::InvalidOperation(format!(
409                "dimension range {}..{} is empty, so every node would tie at distance \
410                 zero and the ranking would be arbitrary",
411                dim_range.start, dim_range.end
412            )));
413        }
414        if dim_range.start >= query_dims {
415            return Err(StoreError::InvalidOperation(format!(
416                "dimension range {}..{} starts past the {} of the {}, which has {} \
417                 dimension(s); every value compared would be a zero-extension and the \
418                 ranking would be arbitrary",
419                dim_range.start, dim_range.end, "end", what, query_dims
420            )));
421        }
422        Ok(())
423    }
424
425    /// Find the k nearest nodes by Euclidean distance across a dimensional slice.
426    ///
427    /// A dimensional slice selects which semantic dimensions to compare.
428    /// For example, `16..33` compares only category preference axes,
429    /// ignoring operational dimensions. Different slices answer different
430    /// questions from the same data.
431    ///
432    /// `query_coords`: raw Q64.64 bytes (16 bytes per dimension).
433    /// `k`: number of nearest neighbors to return.
434    /// `dim_range`: which dimensions to include in the distance calculation.
435    ///
436    /// Returns `(key, distance)` sorted ascending by `(distance, key)` —
437    /// ties break deterministically.
438    /// Returns `InvalidOperation` if `query_coords` is not a multiple of 16 bytes.
439    ///
440    /// **Complexity** (`docs/SEMANTIC_INDEX.md`): stores below
441    /// `SEMANTIC_INDEX_MIN_NODES` use a brute-force O(n × d) scan. Larger
442    /// stores use a lazily built per-`dim_range` VP-tree: O(log n) expected
443    /// per warm query on low-dimensional slices; the first query for a slice
444    /// after any semantic write pays an O(n log n) rebuild. Results are
445    /// identical on both paths.
446    pub fn nearest_semantic(
447        &self,
448        query_coords: &[u8],
449        k: usize,
450        dim_range: Range<usize>,
451    ) -> Result<Vec<(String, FixedPoint)>, StoreError> {
452        if query_coords.len() % 16 != 0 {
453            return Err(StoreError::InvalidOperation(format!(
454                "semantic query coordinates must be a multiple of 16 bytes; got {} bytes",
455                query_coords.len()
456            )));
457        }
458        Self::check_slice(&dim_range, query_coords.len() / 16, "query vector")?;
459        let results = self.inner.nearest_semantic(query_coords, k, &dim_range)?;
460        Ok(results)
461    }
462
463    /// Find the k nearest nodes to an existing node by semantic dimensional distance.
464    ///
465    /// Reads the node's semantic coordinates and finds the closest other nodes
466    /// in the specified dimensional slice. The queried node is excluded.
467    ///
468    /// **Complexity**: same routing as [`Store::nearest_semantic`] (indexed
469    /// above the node floor, brute-force below).
470    ///
471    /// Returns keys sorted by ascending semantic distance.
472    pub fn neighbors_semantic(
473        &self,
474        path: &str,
475        k: usize,
476        dim_range: Range<usize>,
477    ) -> Result<Vec<(String, FixedPoint)>, StoreError> {
478        let anchor_dims = self.get_semantic(path).map(|c| c.len() / 16).unwrap_or(0);
479        Self::check_slice(&dim_range, anchor_dims, "anchor node")?;
480        let results = self.inner.neighbors_semantic(path, k, &dim_range)?;
481        Ok(results)
482    }
483
484    /// Find the k stored nodes most similar to an existing node across a
485    /// dimensional slice — "what's like this one?".
486    ///
487    /// This is [`Store::neighbors_semantic`] under a task-shaped name: it
488    /// reads the node's semantic coordinates and returns the k nearest other
489    /// nodes by Euclidean distance over `dim_range`, sorted ascending by
490    /// `(distance, key)`. Same routing and cost as `nearest_semantic`.
491    pub fn find_similar(
492        &self,
493        key: &str,
494        k: usize,
495        dim_range: Range<usize>,
496    ) -> Result<Vec<(String, FixedPoint)>, StoreError> {
497        self.neighbors_semantic(key, k, dim_range)
498    }
499
500    /// Find semantic outliers among the nodes under a key prefix:
501    /// nodes whose average distance to their nearest peers is anomalously
502    /// large relative to the population.
503    ///
504    /// For each node under `prefix` that has semantic coordinates, computes
505    /// the average distance to its `OUTLIER_KNN` (10, capped at
506    /// population−1) nearest peers **within the same population** over
507    /// `dim_range`, then flags nodes whose average exceeds the population
508    /// mean by more than `z_threshold` standard deviations. Returns outliers
509    /// sorted by descending z-score (ties by key). This is the
510    /// "room 403 rates unlike its floor-mates" / "course far from every
511    /// peer" query as one call.
512    ///
513    /// Statistics are computed strictly within the prefix population — nodes
514    /// outside `prefix` (or without coordinates) neither appear nor skew the
515    /// baseline. Populations below `OUTLIER_MIN_POPULATION` (5) return no
516    /// outliers: z-scores over a handful of nodes are noise, not findings.
517    ///
518    /// **Complexity**: builds a dedicated VP-tree over the population
519    /// (O(m log m) distance evaluations) plus one k-NN query per node —
520    /// ~seconds at 10k nodes, versus the O(m²) pairwise scan this replaces.
521    /// Deterministic: identical stores produce identical results.
522    ///
523    /// Returns `InvalidOperation` if `z_threshold` is not a finite positive
524    /// number.
525    pub fn find_outliers(
526        &self,
527        prefix: &str,
528        z_threshold: FixedPoint,
529        dim_range: Range<usize>,
530    ) -> Result<Vec<SemanticOutlier>, StoreError> {
531        if z_threshold <= FixedPoint::from_int(0) {
532            return Err(StoreError::InvalidOperation(format!(
533                "z_threshold must be a positive number; got {}",
534                z_threshold.to_f64()
535            )));
536        }
537        // No single query vector here, so only the empty-range case is
538        // checkable without scanning the population.
539        if dim_range.start >= dim_range.end {
540            return Err(StoreError::InvalidOperation(format!(
541                "dimension range {}..{} is empty, so every node would tie at distance \
542                 zero and no node could be an outlier",
543                dim_range.start, dim_range.end
544            )));
545        }
546
547        // Population: prefix members with semantic coordinates, key-sorted
548        // (deterministic accumulation order for the statistics below).
549        let mut keys = self.list(prefix)?;
550        keys.sort();
551        let entries: Vec<(String, Vec<FixedPoint>)> = keys
552            .into_iter()
553            .filter_map(|key| {
554                let coords = self.inner.get_semantic(&key).ok()?;
555                if coords.is_empty() {
556                    return None;
557                }
558                Some((
559                    key,
560                    HyperbolicTensorNetwork::decode_semantic_slice(&coords, &dim_range),
561                ))
562            })
563            .collect();
564
565        if entries.len() < OUTLIER_MIN_POPULATION {
566            return Ok(Vec::new());
567        }
568
569        // Population-local index: outlier statistics must not be skewed by
570        // nodes outside the prefix, so the store-wide slice cache is not
571        // reusable here.
572        let tree = MetricVpTree::build(entries.clone(), &EuclideanMetric);
573        let k = OUTLIER_KNN.min(entries.len() - 1);
574
575        // Per-node average k-NN distance (querying k+1 to skip self).
576        let zero = FixedPoint::from_int(0);
577        let mut scored: Vec<(String, FixedPoint, String, FixedPoint)> = entries
578            .iter()
579            .map(|(key, point)| {
580                let peers: Vec<(String, FixedPoint)> = tree
581                    .knn(point, k + 1, &EuclideanMetric)
582                    .into_iter()
583                    .filter(|(id, _)| id != key)
584                    .take(k)
585                    .collect();
586                let sum = peers.iter().fold(zero, |acc, (_, d)| acc + *d);
587                let avg = sum / FixedPoint::from_int(k as i32);
588                let (nearest_peer, nearest_distance) = peers[0].clone();
589                (key.clone(), avg, nearest_peer, nearest_distance)
590            })
591            .collect();
592
593        // Population statistics in fixed point: these feed the z-score that
594        // decides which nodes are reported, so float arithmetic here would
595        // put a nondeterministic step inside a query result.
596        let n = FixedPoint::from_int(scored.len() as i32);
597        let mean = scored.iter().fold(zero, |acc, (_, avg, _, _)| acc + *avg) / n;
598        let variance = scored
599            .iter()
600            .fold(zero, |acc, (_, avg, _, _)| {
601                let d = *avg - mean;
602                acc + d * d
603            })
604            / n;
605        let stdev = variance.sqrt();
606        if stdev <= FixedPoint::from_raw(1) {
607            return Ok(Vec::new()); // uniform population — no outliers
608        }
609
610        scored.sort_by(|a, b| {
611            b.1.partial_cmp(&a.1)
612                .unwrap_or(std::cmp::Ordering::Equal)
613                .then_with(|| a.0.cmp(&b.0))
614        });
615
616        Ok(scored
617            .into_iter()
618            .filter_map(|(key, avg, nearest_peer, nearest_distance)| {
619                let z_score = (avg - mean) / stdev;
620                (z_score > z_threshold).then_some(SemanticOutlier {
621                    key,
622                    avg_knn_distance: avg,
623                    z_score,
624                    nearest_peer,
625                    nearest_distance,
626                })
627            })
628            .collect())
629    }
630
631    /// Upgrade a data-only key (inserted via [`Store::put_data_only`]) to a
632    /// full geometric embedding, in place.
633    ///
634    /// Missing ancestors are embedded first; the node's key, value,
635    /// metadata, and semantic coordinates are preserved. After this call the
636    /// key participates in spatial queries (`nearest`, `neighbors`,
637    /// `find_within`) and has a [`Store::position`].
638    ///
639    /// Returns whether this call performed the upgrade (`false` = the key
640    /// was already embedded; idempotent). Positions are derived state, not
641    /// persisted: deterministic for a fixed operation sequence, but a
642    /// lazily-loaded store must re-embed after reopening.
643    pub fn embed_existing(&self, key: &str) -> Result<bool, StoreError> {
644        Ok(self.inner.embed_existing(key)?)
645    }
646
647    /// Embed the prefix node (when it exists) and every data-only key under
648    /// it (convenience). Parents embed before children (sorted order +
649    /// ancestor recursion). Returns how many keys this call upgraded.
650    pub fn embed_all(&self, prefix: &str) -> Result<usize, StoreError> {
651        let mut upgraded = 0;
652        if self.exists(prefix) && self.embed_existing(prefix)? {
653            upgraded += 1;
654        }
655        let mut keys = self.list(prefix)?;
656        keys.sort();
657        for key in keys {
658            if self.embed_existing(&key)? {
659                upgraded += 1;
660            }
661        }
662        Ok(upgraded)
663    }
664
665    /// The hyperbolic (Poincaré) position of a stored key.
666    ///
667    /// Errors for unknown keys and for data-only nodes (no embedding).
668    pub fn position(&self, key: &str) -> Result<Vec<FixedPoint>, StoreError> {
669        let point = self.inner.position(key)?;
670        Ok(point.coords().iter().copied().collect())
671    }
672
673    /// The exact fixed-point position — crate-internal (semantic disk
674    /// derives barycenters from it without an f64 round-trip).
675    pub(crate) fn position_fixed(
676        &self,
677        key: &str,
678    ) -> Result<crate::hyperbolic_geometry::HyperbolicPoint, StoreError> {
679        Ok(self.inner.position(key)?)
680    }
681
682    /// Monotone counter of semantic-relevant mutations (coordinate writes,
683    /// inserts, deletes). External caches over semantic state — e.g. the semantic disk
684    /// [`crate::semantic_disk::SemanticDisk`] — tag their builds with it and
685    /// rebuild when it has advanced, exactly like the internal index cache.
686    pub fn semantic_epoch(&self) -> u64 {
687        self.inner.semantic_epoch()
688    }
689
690    /// Compute the Euclidean distance between two raw semantic coordinate vectors
691    /// across a dimensional slice.
692    ///
693    /// Utility method for computing distances without querying the store.
694    pub fn semantic_distance(
695        coords_a: &[u8],
696        coords_b: &[u8],
697        dim_range: Range<usize>,
698    ) -> FixedPoint {
699        HyperbolicTensorNetwork::semantic_distance(coords_a, coords_b, &dim_range)
700    }
701
702    /// Find all nodes within a hyperbolic distance of an existing node.
703    ///
704    /// **Cost**: ring expansion bounded by `radius` rather than by a running
705    /// k-th distance, so it grows with the radius and the result size. A
706    /// radius too large to express as a `cosh` prunes nothing and sweeps the
707    /// whole index — slow, but still exact.
708    pub fn find_within(&self, path: &str, radius: FixedPoint) -> Result<Vec<String>, StoreError> {
709        Ok(self.inner.find_in_radius(path, radius)?)
710    }
711
712    /// Execute a query using a pluggable adapter.
713    ///
714    /// The adapter receives a reference to this store and the query string,
715    /// and returns results by calling `get`, `list`, `neighbors`, etc.
716    pub fn query(&self, adapter: &dyn QueryAdapter, query: &str) -> Result<Vec<QueryResult>, StoreError> {
717        adapter.execute(self, query)
718    }
719
720    /// Number of stored entries (excludes the root node).
721    pub fn len(&self) -> usize {
722        self.inner.node_count().saturating_sub(1) // exclude root
723    }
724
725    /// Returns `true` if the store contains no user data.
726    pub fn is_empty(&self) -> bool {
727        self.len() == 0
728    }
729
730    /// Access the underlying `HTTStorage` for advanced operations.
731    pub fn inner(&self) -> &HTTStorage {
732        &self.inner
733    }
734
735    /// Mutably access the underlying `HTTStorage` for advanced operations.
736    #[deprecated(note = "All HTTStorage methods now take &self; use inner() instead")]
737    pub fn inner_mut(&mut self) -> &mut HTTStorage {
738        &mut self.inner
739    }
740}
741
742impl Default for Store {
743    fn default() -> Self {
744        Self::new()
745    }
746}
747
748// ---------------------------------------------------------------------------
749// Tests
750// ---------------------------------------------------------------------------
751
752#[cfg(test)]
753mod tests {
754
755/// Exact fixed-point coordinates from decimal literals.
756fn fp(vals: &[f64]) -> Vec<g_math::fixed_point::FixedPoint> {
757    vals.iter().map(|&v| g_math::fixed_point::FixedPoint::from_f64(v)).collect()
758}
759
760    use super::*;
761
762    #[test]
763    fn test_new_store_is_empty() {
764        let store = Store::new();
765        assert!(store.is_empty());
766        assert_eq!(store.len(), 0);
767    }
768
769    #[test]
770    fn test_put_get_roundtrip() {
771        let store = Store::new();
772        store.put("/hello", b"world").unwrap();
773        assert_eq!(store.get("/hello").unwrap(), b"world");
774    }
775
776    #[test]
777    fn test_upsert() {
778        let store = Store::new();
779        store.put("/key", b"v1").unwrap();
780        store.put("/key", b"v2").unwrap();
781        assert_eq!(store.get("/key").unwrap(), b"v2");
782    }
783
784    #[test]
785    fn test_remove() {
786        let store = Store::new();
787        store.put("/tmp", b"data").unwrap();
788        assert!(store.exists("/tmp"));
789        store.remove("/tmp").unwrap();
790        assert!(!store.exists("/tmp"));
791    }
792
793    #[test]
794    fn test_exists() {
795        let store = Store::new();
796        assert!(!store.exists("/nope"));
797        store.put("/yes", b"").unwrap();
798        assert!(store.exists("/yes"));
799    }
800
801    #[test]
802    fn test_children() {
803        let store = Store::new();
804        store.put("/a/b", b"1").unwrap();
805        store.put("/a/c", b"2").unwrap();
806        store.put("/a/c/d", b"3").unwrap();
807
808        let kids = store.children("/a").unwrap();
809        assert!(kids.contains(&"/a/b".to_string()));
810        assert!(kids.contains(&"/a/c".to_string()));
811        // /a/c/d is a grandchild, not a direct child
812        assert!(!kids.contains(&"/a/c/d".to_string()));
813    }
814
815    #[test]
816    fn test_list() {
817        let store = Store::new();
818        store.put("/x/y", b"1").unwrap();
819        store.put("/x/z", b"2").unwrap();
820
821        let all = store.list("/x").unwrap();
822        assert!(all.contains(&"/x/y".to_string()));
823        assert!(all.contains(&"/x/z".to_string()));
824    }
825
826    #[test]
827    fn test_metadata() {
828        let store = Store::new();
829        store.put("/doc", b"content").unwrap();
830        store.set_meta("/doc", "author", "alice").unwrap();
831
832        let meta = store.get_meta("/doc").unwrap();
833        assert_eq!(meta.get("author"), Some(&"alice".to_string()));
834    }
835
836    #[test]
837    fn test_nearest() {
838        let store = Store::new();
839        store.put("/a", b"a").unwrap();
840        store.put("/b", b"b").unwrap();
841
842        let (path, dist) = store.nearest(&fp(&[0.0, 0.0, 0.0, 0.0])).unwrap();
843        // Origin query should find root "/"
844        assert_eq!(path, "/");
845        assert!(dist.to_f64() < 0.1);
846    }
847
848    #[test]
849    fn test_neighbors() {
850        let store = Store::new();
851        store.put("/a", b"a").unwrap();
852        store.put("/b", b"b").unwrap();
853        store.put("/c", b"c").unwrap();
854
855        let nbrs = store.neighbors("/a", 2).unwrap();
856        assert!(!nbrs.is_empty());
857        assert!(nbrs.len() <= 2);
858        assert!(!nbrs.contains(&"/a".to_string()));
859    }
860
861    #[test]
862    fn test_find_within() {
863        let store = Store::new();
864        store.put("/x", b"x").unwrap();
865        store.put("/y", b"y").unwrap();
866
867        let results = store.find_within("/x", g_math::fixed_point::FixedPoint::from_f64(10.0)).unwrap();
868        assert!(!results.is_empty());
869    }
870
871    #[test]
872    fn test_error_not_found() {
873        let store = Store::new();
874        let err = store.get("/missing").unwrap_err();
875        assert!(matches!(err, StoreError::NotFound(_)));
876    }
877
878    #[test]
879    fn test_len_tracking() {
880        let store = Store::new();
881        assert_eq!(store.len(), 0);
882
883        store.put("/one", b"1").unwrap();
884        assert_eq!(store.len(), 1);
885
886        store.put("/two", b"2").unwrap();
887        assert_eq!(store.len(), 2);
888
889        store.remove("/one").unwrap();
890        assert_eq!(store.len(), 1);
891    }
892
893    #[test]
894    fn test_inner_escape_hatch() {
895        let store = Store::new();
896        store.put("/test", b"data").unwrap();
897
898        // Read access via inner()
899        assert!(store.inner().exists("/test"));
900
901        // Write access via inner() — all methods are now &self
902        store.inner().store("/via_inner", b"inner", None).unwrap();
903        assert!(store.exists("/via_inner"));
904    }
905
906    #[test]
907    fn test_with_config() {
908        let config = StoreConfig::new().capacity(500);
909        let store = Store::with_config(config);
910        assert!(store.is_empty());
911    }
912
913    #[test]
914    fn test_tau_config() {
915        let store = Store::with_config(StoreConfig::new().capacity(1000).tau(FixedPoint::from_f64(0.8)));
916        store.put("/a", b"a").unwrap();
917        store.put("/b", b"b").unwrap();
918        store.put("/a/child", b"c").unwrap();
919        assert_eq!(store.len(), 3);
920
921        // NN should still work
922        let (path, dist) = store.nearest(&fp(&[0.0, 0.0, 0.0, 0.0])).unwrap();
923        assert_eq!(path, "/");
924        assert!(dist.to_f64() < 0.1);
925    }
926
927    #[test]
928    fn test_tau_deep_tree() {
929        // A smaller tau buys depth, because a node sits at radius depth × tau
930        // and the usable radius is fixed by the arithmetic, not by tau.
931        //
932        // This test previously built 40 levels at tau=0.8 — radius 32, well
933        // past the point where the distance kernel saturates — and asserted
934        // only that the key existed. It passed while every distance among
935        // those nodes was meaningless. Placement past the limit is now
936        // refused, so the honest assertions are: the limit scales with tau,
937        // depth up to it works, and beyond it fails loudly.
938        let store = Store::with_config(StoreConfig::new().tau(FixedPoint::from_f64(0.8)));
939        let limit = store.max_depth();
940        assert_eq!(limit, 26, "21 / 0.8 = 26 levels");
941        assert!(
942            limit > Store::new().max_depth(),
943            "a smaller tau must allow more depth than the default"
944        );
945
946        let mut path = String::new();
947        for i in 0..limit {
948            path = format!("{}/n{}", path, i);
949            store.put(&path, b"x").unwrap_or_else(|e| {
950                panic!("level {i} is inside the limit of {limit} but was refused: {e:?}")
951            });
952        }
953        assert!(store.exists(&path));
954
955        // Past the limit: refused, not silently placed in the saturated band.
956        let mut over = path.clone();
957        let mut refused = false;
958        for i in limit..(limit + 6) {
959            over = format!("{}/n{}", over, i);
960            if store.put(&over, b"x").is_err() {
961                refused = true;
962                break;
963            }
964        }
965        assert!(refused, "placement past the depth limit must fail, not saturate");
966
967        let (nn, _) = store.nearest(&fp(&[0.0, 0.0, 0.0, 0.0])).unwrap();
968        assert!(store.exists(&nn));
969    }
970
971    #[test]
972    fn test_query_adapter() {
973        // Simple adapter that lists children of a path
974        struct ChildrenAdapter;
975        impl QueryAdapter for ChildrenAdapter {
976            fn execute(&self, store: &Store, query: &str) -> Result<Vec<QueryResult>, StoreError> {
977                let children = store.children(query)?;
978                Ok(vec![QueryResult::Keys(children)])
979            }
980        }
981
982        let store = Store::new();
983        store.put("/a/b", b"1").unwrap();
984        store.put("/a/c", b"2").unwrap();
985
986        let results = store.query(&ChildrenAdapter, "/a").unwrap();
987        assert_eq!(results.len(), 1);
988        match &results[0] {
989            QueryResult::Keys(keys) => {
990                assert!(keys.contains(&"/a/b".to_string()));
991                assert!(keys.contains(&"/a/c".to_string()));
992            }
993            _ => panic!("Expected Keys result"),
994        }
995    }
996
997    #[test]
998    fn test_query_adapter_object_safe() {
999        // Verify QueryAdapter is object-safe (dyn QueryAdapter works)
1000        struct CountAdapter;
1001        impl QueryAdapter for CountAdapter {
1002            fn execute(&self, store: &Store, query: &str) -> Result<Vec<QueryResult>, StoreError> {
1003                let keys = store.list(query)?;
1004                Ok(vec![QueryResult::Count(keys.len())])
1005            }
1006        }
1007
1008        let adapter: Box<dyn QueryAdapter> = Box::new(CountAdapter);
1009        let store = Store::new();
1010        store.put("/x", b"x").unwrap();
1011        store.put("/y", b"y").unwrap();
1012
1013        let results = store.query(&*adapter, "/").unwrap();
1014        match &results[0] {
1015            QueryResult::Count(n) => assert_eq!(*n, 2),
1016            _ => panic!("Expected Count result"),
1017        }
1018    }
1019
1020    #[test]
1021    fn test_nearest_semantic() {
1022        use g_math::fixed_point::FixedPoint;
1023
1024        let store = Store::new();
1025        store.put("/courses/trauma/emdr", b"EMDR").unwrap();
1026        store.put("/courses/trauma/ptss", b"PTSS").unwrap();
1027        store.put("/courses/cgt/basis", b"CGT").unwrap();
1028
1029        // Encode helper: 2 dims (dim 0 = trauma, dim 1 = cgt)
1030        let coords = |d0: f64, d1: f64| -> Vec<u8> {
1031            let mut v = vec![0u8; 2 * 16];
1032            v[0..16].copy_from_slice(&FixedPoint::from_f64(d0).raw().to_le_bytes());
1033            v[16..32].copy_from_slice(&FixedPoint::from_f64(d1).raw().to_le_bytes());
1034            v
1035        };
1036
1037        store.set_semantic("/courses/trauma/emdr", coords(0.9, 0.1)).unwrap();
1038        store.set_semantic("/courses/trauma/ptss", coords(0.8, 0.2)).unwrap();
1039        store.set_semantic("/courses/cgt/basis", coords(0.1, 0.9)).unwrap();
1040
1041        // Query: student with strong trauma preference
1042        let query = coords(0.85, 0.15);
1043        let results = store.nearest_semantic(&query, 3, 0..2).unwrap();
1044
1045        assert_eq!(results.len(), 3);
1046        // EMDR and PTSS should be closer than CGT
1047        let paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect();
1048        assert!(paths[0].contains("trauma"), "Nearest should be a trauma course, got {}", paths[0]);
1049        assert!(paths[2].contains("cgt"), "Farthest should be CGT, got {}", paths[2]);
1050    }
1051
1052    #[test]
1053    fn test_neighbors_semantic() {
1054        use g_math::fixed_point::FixedPoint;
1055
1056        let store = Store::new();
1057        store.put("/a", b"a").unwrap();
1058        store.put("/b", b"b").unwrap();
1059        store.put("/c", b"c").unwrap();
1060
1061        let coords = |v: f64| -> Vec<u8> {
1062            let mut buf = vec![0u8; 16];
1063            buf[0..16].copy_from_slice(&FixedPoint::from_f64(v).raw().to_le_bytes());
1064            buf
1065        };
1066
1067        store.set_semantic("/a", coords(0.1)).unwrap();
1068        store.set_semantic("/b", coords(0.2)).unwrap();
1069        store.set_semantic("/c", coords(0.9)).unwrap();
1070
1071        // Neighbors of /a: /b should be closest, /c farthest
1072        let results = store.neighbors_semantic("/a", 2, 0..1).unwrap();
1073        assert_eq!(results.len(), 2);
1074        assert_eq!(results[0].0, "/b", "Nearest semantic neighbor of /a should be /b");
1075        assert_eq!(results[1].0, "/c", "Second neighbor of /a should be /c");
1076
1077        // Self (/a) should not appear in results
1078        let paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect();
1079        assert!(!paths.contains(&"/a"), "Self should be excluded from neighbors_semantic");
1080    }
1081
1082    #[test]
1083    fn test_semantic_distance_utility() {
1084        use g_math::fixed_point::FixedPoint;
1085
1086        let coords = |d0: f64, d1: f64| -> Vec<u8> {
1087            let mut v = vec![0u8; 2 * 16];
1088            v[0..16].copy_from_slice(&FixedPoint::from_f64(d0).raw().to_le_bytes());
1089            v[16..32].copy_from_slice(&FixedPoint::from_f64(d1).raw().to_le_bytes());
1090            v
1091        };
1092
1093        let a = coords(0.0, 0.0);
1094        let b = coords(0.3, 0.4);
1095
1096        // Euclidean distance should be 0.5 (3-4-5 triangle)
1097        let dist = Store::semantic_distance(&a, &b, 0..2);
1098        assert!((dist.to_f64() - 0.5).abs() < 0.01,
1099            "Distance (0,0)→(0.3,0.4) should be 0.5, got {}", dist.to_f64());
1100    }
1101}