Skip to main content

horon_engine/
semantic_index.rs

1//! Lazy per-slice semantic index cache with epoch invalidation.
2//!
3//! Design: `docs/SEMANTIC_INDEX.md`. A VP-tree only prunes correctly for the
4//! exact `dim_range` (metric) it was built over, so each queried slice gets
5//! its own tree, built lazily on first query and cached. A single semantic
6//! epoch counter — bumped by every mutation that could change semantic query
7//! results — invalidates all cached slices at once: staleness is one integer
8//! comparison, and a stale tree is discarded and rebuilt, never mutated.
9//!
10//! Race safety: writers mutate first, then bump; builders read the epoch
11//! *before* snapshotting. A write landing mid-build bumps the counter after
12//! the builder's pre-read, so the cached tree is tagged with the old epoch
13//! and discarded on the next query. Concurrent racing builders both build
14//! the same deterministic tree; last insert wins — harmless.
15
16use std::collections::BTreeMap;
17use std::ops::Range;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::{Arc, RwLock};
20
21use g_math::fixed_point::FixedPoint;
22
23use crate::constants::SEMANTIC_INDEX_MAX_SLICES;
24use crate::metric_tree::{EuclideanMetric, MetricVpTree};
25
26/// A VP-tree over one dimension slice, tagged with the epoch it was built at.
27pub struct SliceIndex {
28    /// Semantic epoch at build time; stale when != the cache's current epoch.
29    epoch: u64,
30    /// The tree over `(unique_id, decoded slice coords)`.
31    pub tree: MetricVpTree<Vec<FixedPoint>>,
32}
33
34/// Per-slice index cache keyed by `(dim_range.start, dim_range.end)`.
35pub struct SemanticIndexCache {
36    /// Bumped (after the mutation) by every semantic-relevant write.
37    epoch: AtomicU64,
38    /// Cached slice trees. BTreeMap for deterministic eviction order.
39    slices: RwLock<BTreeMap<(usize, usize), Arc<SliceIndex>>>,
40}
41
42impl SemanticIndexCache {
43    /// Create an empty cache at epoch 0.
44    pub fn new() -> Self {
45        Self {
46            epoch: AtomicU64::new(0),
47            slices: RwLock::new(BTreeMap::new()),
48        }
49    }
50
51    /// Record a mutation that may change semantic query results.
52    /// Callers must apply the mutation *before* bumping.
53    pub fn bump(&self) {
54        self.epoch.fetch_add(1, Ordering::SeqCst);
55    }
56
57    /// Current epoch (test/diagnostic visibility).
58    pub fn epoch(&self) -> u64 {
59        self.epoch.load(Ordering::SeqCst)
60    }
61
62    /// Get the cached tree for `dim_range`, or build one via `snapshot`
63    /// (a coherent read of all nodes' decoded slice coordinates) and cache it.
64    ///
65    /// `snapshot` runs without any cache lock held, so concurrent queries
66    /// on other slices are never blocked by a build.
67    pub fn get_or_build<F>(&self, dim_range: &Range<usize>, snapshot: F) -> Arc<SliceIndex>
68    where
69        F: FnOnce() -> Vec<(String, Vec<FixedPoint>)>,
70    {
71        let key = (dim_range.start, dim_range.end);
72
73        // Fast path: fresh cached tree.
74        let current = self.epoch.load(Ordering::SeqCst);
75        {
76            let slices = self.slices.read().unwrap_or_else(|e| e.into_inner());
77            if let Some(idx) = slices.get(&key) {
78                if idx.epoch == current {
79                    return Arc::clone(idx);
80                }
81            }
82        }
83
84        // Build outside the lock. The epoch is read BEFORE the snapshot:
85        // any write completing after this read tags the result stale.
86        let build_epoch = self.epoch.load(Ordering::SeqCst);
87        let entries = snapshot();
88        let index = Arc::new(SliceIndex {
89            epoch: build_epoch,
90            tree: MetricVpTree::build(entries, &EuclideanMetric),
91        });
92
93        let mut slices = self.slices.write().unwrap_or_else(|e| e.into_inner());
94        // Drop every stale slice while we hold the write lock anyway.
95        let now = self.epoch.load(Ordering::SeqCst);
96        slices.retain(|_, idx| idx.epoch == now);
97        // If a racing builder already cached a fresh tree for this slice,
98        // keep it (identical content — the build is deterministic).
99        let entry = slices.entry(key).or_insert_with(|| Arc::clone(&index));
100        let result = Arc::clone(entry);
101
102        // Deterministic eviction: drop the lowest key that isn't the one
103        // just used (no wall-clock LRU — determinism over recency).
104        while slices.len() > SEMANTIC_INDEX_MAX_SLICES {
105            let evict = slices
106                .keys()
107                .find(|&&k| k != key)
108                .copied()
109                .expect("cache over capacity implies a second key exists");
110            slices.remove(&evict);
111        }
112
113        result
114    }
115
116    /// Number of currently cached slices (test visibility).
117    pub fn cached_slice_count(&self) -> usize {
118        self.slices.read().unwrap_or_else(|e| e.into_inner()).len()
119    }
120}
121
122impl Default for SemanticIndexCache {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn fp(v: i32) -> FixedPoint {
133        FixedPoint::from_int(v)
134    }
135
136    fn snapshot_of(n: usize) -> Vec<(String, Vec<FixedPoint>)> {
137        (0..n).map(|i| (format!("n{}", i), vec![fp(i as i32)])).collect()
138    }
139
140    #[test]
141    fn cache_hit_and_epoch_invalidation() {
142        let cache = SemanticIndexCache::new();
143        let range = 16..17;
144
145        let a = cache.get_or_build(&range, || snapshot_of(3));
146        let b = cache.get_or_build(&range, || panic!("must be served from cache"));
147        assert!(Arc::ptr_eq(&a, &b));
148
149        cache.bump();
150        let c = cache.get_or_build(&range, || snapshot_of(4));
151        assert!(!Arc::ptr_eq(&a, &c));
152        assert_eq!(c.tree.len(), 4);
153    }
154
155    #[test]
156    fn distinct_slices_get_distinct_trees() {
157        let cache = SemanticIndexCache::new();
158        let a = cache.get_or_build(&(16..18), || snapshot_of(2));
159        let b = cache.get_or_build(&(16..20), || snapshot_of(5));
160        assert!(!Arc::ptr_eq(&a, &b));
161        assert_eq!(cache.cached_slice_count(), 2);
162    }
163
164    #[test]
165    fn eviction_is_bounded_and_deterministic() {
166        let cache = SemanticIndexCache::new();
167        for i in 0..(SEMANTIC_INDEX_MAX_SLICES + 4) {
168            cache.get_or_build(&(i..(i + 1)), || snapshot_of(1));
169        }
170        assert!(cache.cached_slice_count() <= SEMANTIC_INDEX_MAX_SLICES);
171        // The most recently used slice must survive eviction.
172        let last = SEMANTIC_INDEX_MAX_SLICES + 3;
173        let again = cache.get_or_build(&(last..(last + 1)), || panic!("evicted the hot slice"));
174        assert_eq!(again.tree.len(), 1);
175    }
176}