1use 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
41struct Anchor {
44 path: String,
45 dim: usize,
46 site: KleinPoint,
47 gamma: FixedPoint,
52}
53
54pub struct SemanticDisk {
56 anchors: Vec<Anchor>,
61 cache: Mutex<Option<(u64, Arc<MetricVpTree<CachedNormPoint>>)>>,
65}
66
67impl SemanticDisk {
68 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 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 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 pub fn concepts(&self) -> Vec<&str> {
140 self.anchors.iter().map(|a| a.path.as_str()).collect()
141 }
142
143 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 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 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 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 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 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 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 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 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 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
350fn 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
363fn 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 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}