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}