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* (power-distance cell location among the anchors; constant
17//!   in data-node count). Compared with the node's storage path, this is
18//!   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    /// power cell contains the node's derived position. Constant in
155    /// data-node count (linear only in the anchor count — dozens).
156    /// `Ok(None)` when the node has no concept position.
157    ///
158    /// Compared against the node's storage path, this is the
159    /// miscategorization primitive: filed under `/overig`, classifies to
160    /// `/trauma`.
161    pub fn concept_of(&self, store: &Store, key: &str) -> Result<Option<String>, StoreError> {
162        Ok(self
163            .derive_from_coords(&store.get_semantic(key)?)
164            .and_then(|p| self.classify_point(&p)))
165    }
166
167    /// The k data nodes nearest to `key` in the concept disk (hyperbolic
168    /// distance between derived positions), excluding `key` itself.
169    /// Sorted ascending by `(distance, key)`.
170    pub fn nearest(
171        &self,
172        store: &Store,
173        key: &str,
174        k: usize,
175    ) -> Result<Vec<(String, f64)>, StoreError> {
176        let Some(query) = self.derive_from_coords(&store.get_semantic(key)?) else {
177            return Err(StoreError::InvalidOperation(format!(
178                "{} has no concept position (no positive affinity on any mapped dim)",
179                key
180            )));
181        };
182        let index = self.index(store)?;
183        Ok(index
184            .knn(&CachedNormPoint::new(query), k + 1, &HyperbolicMetric)
185            .into_iter()
186            .filter(|(id, _)| id != key)
187            .take(k)
188            .map(|(id, d)| (id, d.to_f64()))
189            .collect())
190    }
191
192    /// The k data nodes nearest to an explicit affinity-weight vector
193    /// (one weight per mapped concept, in [`Self::concepts`] order).
194    pub fn nearest_to_weights(
195        &self,
196        store: &Store,
197        weights: &[f64],
198        k: usize,
199    ) -> Result<Vec<(String, f64)>, StoreError> {
200        if weights.len() != self.anchors.len() {
201            return Err(StoreError::InvalidOperation(format!(
202                "expected {} weights (one per mapped concept), got {}",
203                self.anchors.len(),
204                weights.len()
205            )));
206        }
207        let fixed: Vec<FixedPoint> = weights.iter().map(|w| FixedPoint::from_f64(*w)).collect();
208        let Some(query) = self.derive_from_weights(&fixed) else {
209            return Err(StoreError::InvalidOperation(
210                "no positive weight supplied — the query has no concept position".to_string(),
211            ));
212        };
213        let index = self.index(store)?;
214        Ok(index
215            .knn(&CachedNormPoint::new(query), k, &HyperbolicMetric)
216            .into_iter()
217            .map(|(id, d)| (id, d.to_f64()))
218            .collect())
219    }
220
221    /// Push a temporal trajectory readout through the anchor cells: for each
222    /// `(epoch, coords)` sample — the shape `HttHistory::trajectory`
223    /// returns — classify the derived position, yielding the node's
224    /// **symbolic trajectory** (`(epoch, concept)` pairs). Samples whose
225    /// weights are all non-positive are omitted (no position at that
226    /// epoch).
227    ///
228    /// `sample_dim_start` is the first dimension index the samples cover
229    /// (the `dim_range.start` the trajectory was read with); mapped dims
230    /// outside the sampled range weigh zero.
231    pub fn classify_trajectory(
232        &self,
233        sample_dim_start: usize,
234        samples: &[(u64, Vec<f64>)],
235    ) -> Vec<(u64, String)> {
236        samples
237            .iter()
238            .filter_map(|(epoch, values)| {
239                let weights: Vec<FixedPoint> = self
240                    .anchors
241                    .iter()
242                    .map(|a| {
243                        a.dim
244                            .checked_sub(sample_dim_start)
245                            .and_then(|i| values.get(i))
246                            .map_or(FixedPoint::from_int(0), |v| FixedPoint::from_f64(*v))
247                    })
248                    .collect();
249                let point = self.derive_from_weights(&weights)?;
250                self.classify_point(&point).map(|c| (*epoch, c))
251            })
252            .collect()
253    }
254
255    // -----------------------------------------------------------------------
256    // Internals
257    // -----------------------------------------------------------------------
258
259    /// Decode a node's mapped affinity dims from raw semantic bytes and
260    /// derive its concept-disk position.
261    fn derive_from_coords(&self, coords: &[u8]) -> Option<HyperbolicPoint> {
262        if coords.is_empty() {
263            return None;
264        }
265        let weights: Vec<FixedPoint> = self
266            .anchors
267            .iter()
268            .map(|a| {
269                HyperbolicTensorNetwork::decode_semantic_slice(coords, &(a.dim..a.dim + 1))[0]
270            })
271            .collect();
272        self.derive_from_weights(&weights)
273    }
274
275    /// Weighted Klein barycenter of the anchors (negatives ignored),
276    /// using the cached per-anchor γ factors — no sqrt per node. Same
277    /// formula as [`klein::weighted_barycenter`] (property-tested there);
278    /// `None` when no weight is positive.
279    fn derive_from_weights(&self, weights: &[FixedPoint]) -> Option<HyperbolicPoint> {
280        let zero = FixedPoint::from_int(0);
281        let one = FixedPoint::from_int(1);
282        let mut denom = zero;
283        let mut numer: Option<g_math::fixed_point::FixedVector> = None;
284        for (a, w) in self.anchors.iter().zip(weights) {
285            if *w <= zero {
286                continue;
287            }
288            let coeff = *w * a.gamma;
289            let dim = a.site.dimension();
290            let acc = numer.get_or_insert_with(|| g_math::fixed_point::FixedVector::new(dim));
291            for i in 0..dim {
292                acc[i] += a.site.coords[i] * coeff;
293            }
294            denom += coeff;
295        }
296        let numer = numer?;
297        if denom <= zero {
298            return None;
299        }
300        let inv = one / denom;
301        let dim = numer.len();
302        let mut coords = g_math::fixed_point::FixedVector::new(dim);
303        for i in 0..dim {
304            coords[i] = numer[i] * inv;
305        }
306        Some(klein::klein_to_poincare(&KleinPoint::new(coords)))
307    }
308
309    /// Power-distance cell location among the anchor sites.
310    fn classify_point(&self, point: &HyperbolicPoint) -> Option<String> {
311        let query = klein::poincare_to_klein(point);
312        let sites: Vec<KleinPoint> = self.anchors.iter().map(|a| a.site.clone()).collect();
313        klein::nearest_by_power_distance(&query.coords, &sites)
314            .map(|(i, _)| self.anchors[i].path.clone())
315    }
316
317    /// The derived-position NN index, rebuilt lazily when the data store's
318    /// semantic epoch has advanced (the semantic index's invalidation model, one layer up).
319    fn index(&self, store: &Store) -> Result<Arc<MetricVpTree<CachedNormPoint>>, StoreError> {
320        let epoch = store.semantic_epoch();
321        {
322            let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
323            if let Some((tagged, tree)) = cache.as_ref() {
324                if *tagged == epoch {
325                    return Ok(Arc::clone(tree));
326                }
327            }
328        }
329
330        // Build outside the lock (racing builders produce identical trees).
331        let build_epoch = store.semantic_epoch();
332        let mut keys = store.list("/")?;
333        keys.sort();
334        let entries: Vec<(String, CachedNormPoint)> = keys
335            .into_iter()
336            .filter_map(|key| {
337                let coords = store.get_semantic(&key).ok()?;
338                let point = self.derive_from_coords(&coords)?;
339                Some((key, CachedNormPoint::new(point)))
340            })
341            .collect();
342        let tree = Arc::new(MetricVpTree::build(entries, &HyperbolicMetric));
343
344        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
345        *cache = Some((build_epoch, Arc::clone(&tree)));
346        Ok(tree)
347    }
348}
349
350/// Normalize a concept path: ensure a leading `/`, strip a trailing one.
351fn normalize_concept_path(path: &str) -> String {
352    let mut p = if path.starts_with('/') {
353        path.to_string()
354    } else {
355        format!("/{}", path)
356    };
357    while p.len() > 1 && p.ends_with('/') {
358        p.pop();
359    }
360    p
361}
362
363/// Proper ancestors of a normalized path, shallowest first
364/// (`/a/b/c` → `/a`, `/a/b`).
365fn ancestors_of(path: &str) -> Vec<String> {
366    let mut out = Vec::new();
367    let mut idx = 1;
368    while let Some(next) = path[idx..].find('/') {
369        out.push(path[..idx + next].to_string());
370        idx += next + 1;
371    }
372    out
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn path_helpers() {
381        assert_eq!(normalize_concept_path("trauma"), "/trauma");
382        assert_eq!(normalize_concept_path("/a/b/"), "/a/b");
383        assert_eq!(ancestors_of("/a"), Vec::<String>::new());
384        assert_eq!(ancestors_of("/a/b/c"), vec!["/a".to_string(), "/a/b".to_string()]);
385    }
386
387    #[test]
388    fn build_rejects_bad_specs() {
389        assert!(SemanticDisk::build(&[]).is_err());
390        assert!(SemanticDisk::build(&[("/a", 16), ("/a", 17)]).is_err());
391        assert!(SemanticDisk::build(&[("/a", 16), ("/b", 16)]).is_err());
392    }
393
394    #[test]
395    fn anchors_are_sorted_and_embedded() {
396        let disk = SemanticDisk::build(&[("/zeta", 18), ("/alpha", 16), ("/mid", 17)]).unwrap();
397        assert_eq!(disk.concepts(), vec!["/alpha", "/mid", "/zeta"]);
398        // Anchors are distinct embedded sites.
399        for w in disk.anchors.windows(2) {
400            assert!(w[0].site.coords[0] != w[1].site.coords[0]
401                || w[0].site.coords[1] != w[1].site.coords[1]);
402        }
403    }
404}