Skip to main content

horon_engine/
semantic_disk.rs

1//! Semantic disk — taxonomy-embedded meaning space (E+D design).
2//!
3//! Design: `docs/SEMANTIC_DISK.md` (ratified 2026-07-11). The domain's
4//! concept taxonomy — derived from structure within the data (a category
5//! tree, a directory tree) — is Sarkar-embedded into its own Poincaré disk
6//! by inserting the concept paths into a private [`Store`]. Every data
7//! node's position in that disk is a **pure function** of its affinity
8//! dimensions: the weighted Klein barycenter (Einstein midpoint) of the
9//! concept anchors. Nothing is stored; the position can never disagree
10//! with the dims; and because the mapping is deterministic, an epoch
11//! record of the dims replays the node's path through meaning-space
12//! bit-identically.
13//!
14//! Three query families fall out:
15//! - [`SemanticDisk::concept_of`] — which concept does this node belong to
16//!   *right now* (hyperbolic Voronoi cell location among the anchors;
17//!   constant in data-node count). Compared with the node's storage path,
18//!   this is miscategorization detection as a primitive.
19//! - [`SemanticDisk::nearest`] — k nearest data nodes in meaning-space
20//!   (hyperbolic distance between derived positions; the semantic index's metric tree with
21//!   [`crate::metric_tree::HyperbolicMetric`], epoch-cached).
22//! - [`SemanticDisk::classify_trajectory`] — a temporal trajectory readout
23//!   pushed through the anchor cells: a moving point becomes a sequence of
24//!   discrete meaning-states across epochs.
25//!
26//! The disk is a standalone object the application owns (anchors are few —
27//! dozens, not thousands — so building one is cheap). `Store` gains no new
28//! state. The (concept path ↔ affinity dim) mapping is **calibration**:
29//! fixed for a dataset's life, like the dimensional schema itself.
30
31use std::sync::{Arc, Mutex};
32
33use g_math::fixed_point::FixedPoint;
34
35use crate::hyperbolic_geometry::HyperbolicPoint;
36use crate::klein::{self, KleinPoint};
37use crate::metric_tree::{CachedNormPoint, HyperbolicMetric, MetricVpTree};
38use crate::store::{Store, StoreError};
39use crate::tensor_network::HyperbolicTensorNetwork;
40
41/// A concept anchor: its taxonomy path, the affinity dimension that weights
42/// it, and its embedded site in the concept disk.
43struct Anchor {
44    path: String,
45    dim: usize,
46    site: KleinPoint,
47    /// Cached Einstein-midpoint factor γ = 1/√(1−‖site‖²): the barycenter
48    /// needs it per (node × anchor), and it never changes — caching it
49    /// removes every sqrt from position derivation (measured: ~75 µs/node
50    /// → ~5 µs/node at 5 anchors).
51    gamma: FixedPoint,
52}
53
54/// The taxonomy-embedded meaning space (see module docs).
55pub struct SemanticDisk {
56    /// Mapped anchors, sorted by concept path (deterministic order for the
57    /// barycenter accumulation and all downstream results). The embedding
58    /// store used to place them is dropped after build — the anchor sites
59    /// are the complete geometry.
60    anchors: Vec<Anchor>,
61    /// Derived-position NN index, tagged with the data store's semantic
62    /// epoch it was built at (the semantic index's invalidation model). Entries cache
63    /// their squared norms so the proxy search never pays a sqrt.
64    cache: Mutex<Option<(u64, Arc<MetricVpTree<CachedNormPoint>>)>>,
65}
66
67impl SemanticDisk {
68    /// Build a semantic disk from a concept specification: `(concept path,
69    /// affinity dim)` pairs — e.g. `[("/trauma", 16), ("/cgt", 17), …]`.
70    /// Nested paths are allowed and embed with their real tree shape;
71    /// missing ancestors are created automatically (they become unmapped,
72    /// purely structural anchors).
73    ///
74    /// Errors on an empty spec, duplicate concept paths, or duplicate dims.
75    pub fn build(spec: &[(&str, usize)]) -> Result<Self, StoreError> {
76        if spec.is_empty() {
77            return Err(StoreError::InvalidOperation(
78                "semantic disk spec must name at least one concept".to_string(),
79            ));
80        }
81        let mut pairs: Vec<(String, usize)> = spec
82            .iter()
83            .map(|(p, d)| (normalize_concept_path(p), *d))
84            .collect();
85        pairs.sort();
86        for w in pairs.windows(2) {
87            if w[0].0 == w[1].0 {
88                return Err(StoreError::InvalidOperation(format!(
89                    "duplicate concept path in spec: {}",
90                    w[0].0
91                )));
92            }
93        }
94        {
95            let mut dims: Vec<usize> = pairs.iter().map(|(_, d)| *d).collect();
96            dims.sort_unstable();
97            if dims.windows(2).any(|w| w[0] == w[1]) {
98                return Err(StoreError::InvalidOperation(
99                    "duplicate affinity dim in spec: each concept needs its own dimension"
100                        .to_string(),
101                ));
102            }
103        }
104
105        // Embed the taxonomy: insert concept paths (ancestors first) into a
106        // private store. Sorted order is the deterministic insertion order.
107        let taxonomy = Store::new();
108        for (path, _) in &pairs {
109            for ancestor in ancestors_of(path) {
110                if !taxonomy.exists(&ancestor) {
111                    taxonomy.put(&ancestor, ancestor.as_bytes())?;
112                }
113            }
114            if !taxonomy.exists(path) {
115                taxonomy.put(path, path.as_bytes())?;
116            }
117        }
118
119        // Resolve anchor sites (Klein coordinates of the embedded concepts)
120        // and cache their Einstein factors.
121        let one = FixedPoint::from_int(1);
122        let mut anchors = Vec::with_capacity(pairs.len());
123        for (path, dim) in pairs {
124            let point = taxonomy.position_fixed(&path)?;
125            let site = klein::poincare_to_klein(&point);
126            let radicand = if site.weight > crate::constants::small_epsilon() {
127                site.weight
128            } else {
129                crate::constants::small_epsilon()
130            };
131            let gamma = one / radicand.sqrt();
132            anchors.push(Anchor { path, dim, site, gamma });
133        }
134
135        Ok(Self { anchors, cache: Mutex::new(None) })
136    }
137
138    /// The mapped concept paths, in canonical (sorted) order.
139    pub fn concepts(&self) -> Vec<&str> {
140        self.anchors.iter().map(|a| a.path.as_str()).collect()
141    }
142
143    /// A node's derived position in the concept disk, as f64 Poincaré
144    /// coordinates. `Ok(None)` when the node has no positive affinity on
145    /// any mapped dim (no concept position — same convention as empty
146    /// semantic coords).
147    pub fn position_of(&self, store: &Store, key: &str) -> Result<Option<Vec<f64>>, StoreError> {
148        Ok(self
149            .derive_from_coords(&store.get_semantic(key)?)
150            .map(|p| p.coords().iter().map(|c| c.to_f64()).collect()))
151    }
152
153    /// Which concept a node belongs to **right now**: the anchor whose
154    /// hyperbolic Voronoi cell contains the node's derived position.
155    /// Constant in data-node count (linear only in the anchor count —
156    /// dozens).
157    /// `Ok(None)` when the node has no concept position.
158    ///
159    /// Compared against the node's storage path, this is the
160    /// miscategorization primitive: filed under `/overig`, classifies to
161    /// `/trauma`.
162    pub fn concept_of(&self, store: &Store, key: &str) -> Result<Option<String>, StoreError> {
163        Ok(self
164            .derive_from_coords(&store.get_semantic(key)?)
165            .and_then(|p| self.classify_point(&p)))
166    }
167
168    /// The k data nodes nearest to `key` in the concept disk (hyperbolic
169    /// distance between derived positions), excluding `key` itself.
170    /// Sorted ascending by `(distance, key)`.
171    pub fn nearest(
172        &self,
173        store: &Store,
174        key: &str,
175        k: usize,
176    ) -> Result<Vec<(String, f64)>, StoreError> {
177        let Some(query) = self.derive_from_coords(&store.get_semantic(key)?) else {
178            return Err(StoreError::InvalidOperation(format!(
179                "{} has no concept position (no positive affinity on any mapped dim)",
180                key
181            )));
182        };
183        let index = self.index(store)?;
184        Ok(index
185            .knn(&CachedNormPoint::new(query), k + 1, &HyperbolicMetric)
186            .into_iter()
187            .filter(|(id, _)| id != key)
188            .take(k)
189            .map(|(id, d)| (id, d.to_f64()))
190            .collect())
191    }
192
193    /// The k data nodes nearest to an explicit affinity-weight vector
194    /// (one weight per mapped concept, in [`Self::concepts`] order).
195    pub fn nearest_to_weights(
196        &self,
197        store: &Store,
198        weights: &[f64],
199        k: usize,
200    ) -> Result<Vec<(String, f64)>, StoreError> {
201        if weights.len() != self.anchors.len() {
202            return Err(StoreError::InvalidOperation(format!(
203                "expected {} weights (one per mapped concept), got {}",
204                self.anchors.len(),
205                weights.len()
206            )));
207        }
208        let fixed: Vec<FixedPoint> = weights.iter().map(|w| FixedPoint::from_f64(*w)).collect();
209        let Some(query) = self.derive_from_weights(&fixed) else {
210            return Err(StoreError::InvalidOperation(
211                "no positive weight supplied — the query has no concept position".to_string(),
212            ));
213        };
214        let index = self.index(store)?;
215        Ok(index
216            .knn(&CachedNormPoint::new(query), k, &HyperbolicMetric)
217            .into_iter()
218            .map(|(id, d)| (id, d.to_f64()))
219            .collect())
220    }
221
222    /// Push a temporal trajectory readout through the anchor cells: for each
223    /// `(epoch, coords)` sample — the shape `HttHistory::trajectory`
224    /// returns — classify the derived position, yielding the node's
225    /// **symbolic trajectory** (`(epoch, concept)` pairs). Samples whose
226    /// weights are all non-positive are omitted (no position at that
227    /// epoch).
228    ///
229    /// `sample_dim_start` is the first dimension index the samples cover
230    /// (the `dim_range.start` the trajectory was read with); mapped dims
231    /// outside the sampled range weigh zero.
232    pub fn classify_trajectory(
233        &self,
234        sample_dim_start: usize,
235        samples: &[(u64, Vec<f64>)],
236    ) -> Vec<(u64, String)> {
237        samples
238            .iter()
239            .filter_map(|(epoch, values)| {
240                let weights: Vec<FixedPoint> = self
241                    .anchors
242                    .iter()
243                    .map(|a| {
244                        a.dim
245                            .checked_sub(sample_dim_start)
246                            .and_then(|i| values.get(i))
247                            .map_or(FixedPoint::from_int(0), |v| FixedPoint::from_f64(*v))
248                    })
249                    .collect();
250                let point = self.derive_from_weights(&weights)?;
251                self.classify_point(&point).map(|c| (*epoch, c))
252            })
253            .collect()
254    }
255
256    // -----------------------------------------------------------------------
257    // Internals
258    // -----------------------------------------------------------------------
259
260    /// Decode a node's mapped affinity dims from raw semantic bytes and
261    /// derive its concept-disk position.
262    fn derive_from_coords(&self, coords: &[u8]) -> Option<HyperbolicPoint> {
263        if coords.is_empty() {
264            return None;
265        }
266        let weights: Vec<FixedPoint> = self
267            .anchors
268            .iter()
269            .map(|a| {
270                HyperbolicTensorNetwork::decode_semantic_slice(coords, &(a.dim..a.dim + 1))[0]
271            })
272            .collect();
273        self.derive_from_weights(&weights)
274    }
275
276    /// Weighted Klein barycenter of the anchors (negatives ignored),
277    /// using the cached per-anchor γ factors — no sqrt per node. Same
278    /// formula as [`klein::weighted_barycenter`] (property-tested there);
279    /// `None` when no weight is positive.
280    fn derive_from_weights(&self, weights: &[FixedPoint]) -> Option<HyperbolicPoint> {
281        let zero = FixedPoint::from_int(0);
282        let one = FixedPoint::from_int(1);
283        let mut denom = zero;
284        let mut numer: Option<g_math::fixed_point::FixedVector> = None;
285        for (a, w) in self.anchors.iter().zip(weights) {
286            if *w <= zero {
287                continue;
288            }
289            let coeff = *w * a.gamma;
290            let dim = a.site.dimension();
291            let acc = numer.get_or_insert_with(|| g_math::fixed_point::FixedVector::new(dim));
292            for i in 0..dim {
293                acc[i] += a.site.coords[i] * coeff;
294            }
295            denom += coeff;
296        }
297        let numer = numer?;
298        if denom <= zero {
299            return None;
300        }
301        let inv = one / denom;
302        let dim = numer.len();
303        let mut coords = g_math::fixed_point::FixedVector::new(dim);
304        for i in 0..dim {
305            coords[i] = numer[i] * inv;
306        }
307        Some(klein::klein_to_poincare(&KleinPoint::new(coords)))
308    }
309
310    /// Hyperbolic Voronoi cell location among the anchor sites.
311    ///
312    /// The cell of anchor *i* is `{x : d_H(x, k_i) ≤ d_H(x, k_j) ∀ j}`. In
313    /// Klein coordinates
314    ///
315    /// ```text
316    /// cosh d_H(x, k_i) = (1 − ⟨x, k_i⟩) · γ_i / √(1 − ‖x‖²),   γ_i = 1/√(1 − ‖k_i‖²)
317    /// ```
318    ///
319    /// and the `√(1 − ‖x‖²)` factor is common to every anchor, so the cell is
320    /// decided by `argmin_i (1 − ⟨x, k_i⟩)·γ_i` — the Nielsen affine
321    /// reduction of the hyperbolic Voronoi diagram, reusing the same cached
322    /// γ the barycenter already needs. One dot product per anchor: no sqrt,
323    /// no division, the same cost class as the Euclidean power distance it
324    /// replaced, and exact for **any** anchor placement.
325    ///
326    /// Anchors sitting at unequal Klein norms — which is every nested
327    /// taxonomy, since Sarkar places deeper concepts further out — are why
328    /// this has to be the true reduction. Scoring them by
329    /// `‖x − k_i‖² − (1 − ‖k_i‖²)` instead agrees with `d_H` only when all
330    /// γ_i are equal (a flat, single-depth spec); otherwise a shallow anchor
331    /// can swallow a deeper anchor's own site, breaking single-anchor
332    /// identity.
333    ///
334    /// Ties keep the first anchor in canonical path order (deterministic).
335    fn classify_point(&self, point: &HyperbolicPoint) -> Option<String> {
336        let query = klein::poincare_to_klein(point);
337        let one = FixedPoint::from_int(1);
338        let mut best: Option<(usize, FixedPoint)> = None;
339        for (i, a) in self.anchors.iter().enumerate() {
340            let score = (one - query.coords.dot(&a.site.coords)) * a.gamma;
341            let better = match &best {
342                None => true,
343                Some((_, incumbent)) => score < *incumbent,
344            };
345            if better {
346                best = Some((i, score));
347            }
348        }
349        best.map(|(i, _)| self.anchors[i].path.clone())
350    }
351
352    /// The derived-position NN index, rebuilt lazily when the data store's
353    /// semantic epoch has advanced (the semantic index's invalidation model, one layer up).
354    fn index(&self, store: &Store) -> Result<Arc<MetricVpTree<CachedNormPoint>>, StoreError> {
355        let epoch = store.semantic_epoch();
356        {
357            let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
358            if let Some((tagged, tree)) = cache.as_ref() {
359                if *tagged == epoch {
360                    return Ok(Arc::clone(tree));
361                }
362            }
363        }
364
365        // Build outside the lock (racing builders produce identical trees).
366        let build_epoch = store.semantic_epoch();
367        let mut keys = store.list("/")?;
368        keys.sort();
369        let entries: Vec<(String, CachedNormPoint)> = keys
370            .into_iter()
371            .filter_map(|key| {
372                let coords = store.get_semantic(&key).ok()?;
373                let point = self.derive_from_coords(&coords)?;
374                Some((key, CachedNormPoint::new(point)))
375            })
376            .collect();
377        let tree = Arc::new(MetricVpTree::build(entries, &HyperbolicMetric));
378
379        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
380        *cache = Some((build_epoch, Arc::clone(&tree)));
381        Ok(tree)
382    }
383}
384
385/// Normalize a concept path: ensure a leading `/`, strip a trailing one.
386fn normalize_concept_path(path: &str) -> String {
387    let mut p = if path.starts_with('/') {
388        path.to_string()
389    } else {
390        format!("/{}", path)
391    };
392    while p.len() > 1 && p.ends_with('/') {
393        p.pop();
394    }
395    p
396}
397
398/// Proper ancestors of a normalized path, shallowest first
399/// (`/a/b/c` → `/a`, `/a/b`).
400fn ancestors_of(path: &str) -> Vec<String> {
401    let mut out = Vec::new();
402    let mut idx = 1;
403    while let Some(next) = path[idx..].find('/') {
404        out.push(path[..idx + next].to_string());
405        idx += next + 1;
406    }
407    out
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn path_helpers() {
416        assert_eq!(normalize_concept_path("trauma"), "/trauma");
417        assert_eq!(normalize_concept_path("/a/b/"), "/a/b");
418        assert_eq!(ancestors_of("/a"), Vec::<String>::new());
419        assert_eq!(ancestors_of("/a/b/c"), vec!["/a".to_string(), "/a/b".to_string()]);
420    }
421
422    #[test]
423    fn build_rejects_bad_specs() {
424        assert!(SemanticDisk::build(&[]).is_err());
425        assert!(SemanticDisk::build(&[("/a", 16), ("/a", 17)]).is_err());
426        assert!(SemanticDisk::build(&[("/a", 16), ("/b", 16)]).is_err());
427    }
428
429    #[test]
430    fn anchors_are_sorted_and_embedded() {
431        let disk = SemanticDisk::build(&[("/zeta", 18), ("/alpha", 16), ("/mid", 17)]).unwrap();
432        assert_eq!(disk.concepts(), vec!["/alpha", "/mid", "/zeta"]);
433        // Anchors are distinct embedded sites.
434        for w in disk.anchors.windows(2) {
435            assert!(w[0].site.coords[0] != w[1].site.coords[0]
436                || w[0].site.coords[1] != w[1].site.coords[1]);
437        }
438    }
439}