Skip to main content

engram_core/
indexes.rs

1use std::collections::HashMap;
2use std::io::{Read, Write};
3
4use engram_embeddings::ThreeFieldEmbedding;
5use engram_hnsw::{HnswError, HnswGraph, HnswParams};
6
7use crate::error::CoreError;
8
9const DEDUP_SEARCH_K: usize = 16;
10
11pub struct IndexSet {
12    context_index: HnswGraph,
13    action_index: HnswGraph,
14    result_index: HnswGraph,
15    id_map: HashMap<u64, String>,
16}
17
18impl IndexSet {
19    pub fn new(
20        build_params: impl Fn() -> Result<HnswParams, CoreError>,
21    ) -> Result<Self, CoreError> {
22        let context_index = HnswGraph::new(build_params()?);
23        let action_index = HnswGraph::new(build_params()?);
24        let result_index = HnswGraph::new(build_params()?);
25        Ok(Self {
26            context_index,
27            action_index,
28            result_index,
29            id_map: HashMap::new(),
30        })
31    }
32
33    pub fn insert_atomic(
34        &mut self,
35        id: u64,
36        memory_id: &str,
37        embedding: &ThreeFieldEmbedding,
38        rng_value: f64,
39    ) -> Result<(), CoreError> {
40        if let Some(existing_id) = self.id_map.get(&id) {
41            if existing_id == memory_id {
42                return Ok(());
43            }
44            return Err(CoreError::IndexHashCollision {
45                hash: id,
46                existing_id: existing_id.clone(),
47                conflicting_id: memory_id.to_string(),
48            });
49        }
50
51        self.context_index
52            .insert(id, embedding.context.clone(), rng_value)?;
53        if let Err(error) = self
54            .action_index
55            .insert(id, embedding.action.clone(), rng_value)
56        {
57            let _ = self.context_index.delete(id);
58            return Err(CoreError::from(error));
59        }
60        if let Err(error) = self
61            .result_index
62            .insert(id, embedding.result.clone(), rng_value)
63        {
64            let _ = self.context_index.delete(id);
65            let _ = self.action_index.delete(id);
66            return Err(CoreError::from(error));
67        }
68        self.id_map.insert(id, memory_id.to_string());
69        Ok(())
70    }
71
72    pub fn resolve_node_id(&self, node_id: u64) -> Option<&str> {
73        self.id_map.get(&node_id).map(String::as_str)
74    }
75
76    pub fn rebuild_id_map(&mut self, entries: impl Iterator<Item = (u64, String)>) {
77        self.id_map.clear();
78        for (hash, memory_id) in entries {
79            self.id_map.insert(hash, memory_id);
80        }
81    }
82
83    pub fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<(u64, f32)>, HnswError> {
84        let mut scores: HashMap<u64, f32> = HashMap::new();
85        merge_scores(&mut scores, &self.context_index.search(query, top_k)?);
86        merge_scores(&mut scores, &self.action_index.search(query, top_k)?);
87        merge_scores(&mut scores, &self.result_index.search(query, top_k)?);
88        let mut ranked: Vec<(u64, f32)> = scores.into_iter().collect();
89        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
90        ranked.truncate(top_k);
91        Ok(ranked)
92    }
93
94    /// Find the best existing near-duplicate of `embedding` under the ALL-THREE policy:
95    /// a candidate qualifies only when its context, action AND result similarities each
96    /// exceed `threshold`. Returns the qualifying node with the highest minimum-field
97    /// similarity, or None. Uses only per-graph HNSW search (top_k=K) intersected across
98    /// the three fields — with top_k=1 the three graphs return different top nodes and the
99    /// all-three condition would almost never hold.
100    pub fn find_duplicate(
101        &self,
102        embedding: &ThreeFieldEmbedding,
103        threshold: f32,
104    ) -> Result<Option<(u64, f32)>, HnswError> {
105        let context_hits = self
106            .context_index
107            .search(&embedding.context, DEDUP_SEARCH_K)?;
108        let action_scores: HashMap<u64, f32> = self
109            .action_index
110            .search(&embedding.action, DEDUP_SEARCH_K)?
111            .into_iter()
112            .collect();
113        let result_scores: HashMap<u64, f32> = self
114            .result_index
115            .search(&embedding.result, DEDUP_SEARCH_K)?
116            .into_iter()
117            .collect();
118        let mut best: Option<(u64, f32)> = None;
119        for (node, context_score) in context_hits {
120            let (Some(&action_score), Some(&result_score)) =
121                (action_scores.get(&node), result_scores.get(&node))
122            else {
123                continue;
124            };
125            if context_score > threshold && action_score > threshold && result_score > threshold {
126                let min_similarity = context_score.min(action_score).min(result_score);
127                if best.is_none_or(|(_, best_min)| min_similarity > best_min) {
128                    best = Some((node, min_similarity));
129                }
130            }
131        }
132        Ok(best)
133    }
134
135    pub fn delete(&mut self, id: u64) -> Result<(), HnswError> {
136        self.context_index.delete(id)?;
137        self.action_index.delete(id)?;
138        self.result_index.delete(id)?;
139        self.id_map.remove(&id);
140        Ok(())
141    }
142
143    pub fn contains(&self, id: u64) -> bool {
144        self.context_index.contains(id)
145    }
146
147    pub fn len(&self) -> usize {
148        self.context_index.len()
149    }
150
151    pub fn is_empty(&self) -> bool {
152        self.context_index.is_empty()
153    }
154
155    pub fn serialize<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
156        self.context_index.serialize(writer)?;
157        self.action_index.serialize(writer)?;
158        self.result_index.serialize(writer)?;
159        Ok(())
160    }
161
162    pub fn deserialize<R: Read>(reader: &mut R) -> Result<Self, HnswError> {
163        let context_index = HnswGraph::deserialize(reader)?;
164        let action_index = HnswGraph::deserialize(reader)?;
165        let result_index = HnswGraph::deserialize(reader)?;
166        Ok(Self {
167            context_index,
168            action_index,
169            result_index,
170            id_map: HashMap::new(),
171        })
172    }
173}
174
175fn merge_scores(scores: &mut HashMap<u64, f32>, results: &[(u64, f32)]) {
176    for &(id, similarity) in results {
177        scores
178            .entry(id)
179            .and_modify(|existing| *existing = existing.max(similarity))
180            .or_insert(similarity);
181    }
182}
183
184pub mod instrumentation {
185    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
186
187    static READER_TRACKING_ENABLED: AtomicBool = AtomicBool::new(false);
188    static CURRENT_READERS: AtomicUsize = AtomicUsize::new(0);
189    static MAX_READERS: AtomicUsize = AtomicUsize::new(0);
190
191    pub fn enable_reader_tracking() {
192        READER_TRACKING_ENABLED.store(true, Ordering::Relaxed);
193    }
194
195    pub fn disable_reader_tracking() {
196        READER_TRACKING_ENABLED.store(false, Ordering::Relaxed);
197    }
198
199    pub fn reset_reader_counters() {
200        CURRENT_READERS.store(0, Ordering::Relaxed);
201        MAX_READERS.store(0, Ordering::Relaxed);
202    }
203
204    pub fn concurrent_readers_max() -> usize {
205        MAX_READERS.load(Ordering::Relaxed)
206    }
207
208    pub struct ReaderTracker;
209
210    impl ReaderTracker {
211        pub fn new() -> Self {
212            if READER_TRACKING_ENABLED.load(Ordering::Relaxed) {
213                let current = CURRENT_READERS.fetch_add(1, Ordering::AcqRel) + 1;
214                MAX_READERS.fetch_max(current, Ordering::AcqRel);
215            }
216            Self
217        }
218    }
219
220    impl Drop for ReaderTracker {
221        fn drop(&mut self) {
222            if READER_TRACKING_ENABLED.load(Ordering::Relaxed) {
223                CURRENT_READERS.fetch_sub(1, Ordering::AcqRel);
224            }
225        }
226    }
227
228    impl Default for ReaderTracker {
229        fn default() -> Self {
230            Self::new()
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    const DIMENSION: usize = 8;
240    const THRESHOLD: f32 = 0.95;
241
242    fn new_index_set() -> IndexSet {
243        IndexSet::new(|| HnswParams::new(DIMENSION).map_err(CoreError::from))
244            .expect("index set construction succeeds")
245    }
246
247    /// Builds a one-hot unit vector with its nonzero coordinate at `axis`.
248    /// Two such vectors are identical (cosine 1.0) when the axes match and
249    /// orthogonal (cosine 0.0) when the axes differ.
250    fn axis_vector(axis: usize) -> Vec<f32> {
251        let mut vector = vec![0.0_f32; DIMENSION];
252        vector[axis % DIMENSION] = 1.0;
253        vector
254    }
255
256    fn embedding(
257        context_axis: usize,
258        action_axis: usize,
259        result_axis: usize,
260    ) -> ThreeFieldEmbedding {
261        ThreeFieldEmbedding {
262            context: axis_vector(context_axis),
263            action: axis_vector(action_axis),
264            result: axis_vector(result_axis),
265        }
266    }
267
268    #[test]
269    fn identical_embedding_is_a_duplicate() {
270        let mut index_set = new_index_set();
271        let stored = embedding(0, 1, 2);
272        index_set
273            .insert_atomic(7, "memory-7", &stored, 0.5)
274            .expect("insert succeeds");
275
276        let found = index_set
277            .find_duplicate(&stored, THRESHOLD)
278            .expect("search succeeds");
279
280        let (node, min_similarity) = found.expect("identical embedding qualifies as duplicate");
281        assert_eq!(node, 7);
282        assert_eq!(index_set.resolve_node_id(node), Some("memory-7"));
283        // Identical vectors yield cosine 1.0 on every field, so the all-three
284        // minimum is 1.0.
285        assert!((min_similarity - 1.0).abs() < 1e-4);
286    }
287
288    #[test]
289    fn only_context_matching_is_not_a_duplicate() {
290        let mut index_set = new_index_set();
291        let stored = embedding(0, 1, 2);
292        index_set
293            .insert_atomic(7, "memory-7", &stored, 0.5)
294            .expect("insert succeeds");
295
296        // Context shares axis 0 (cosine 1.0) while action/result use disjoint
297        // axes (cosine 0.0). Under all-three this fails; a max-across-one policy
298        // would wrongly accept the perfect context match.
299        let query = embedding(0, 4, 5);
300        let found = index_set
301            .find_duplicate(&query, THRESHOLD)
302            .expect("search succeeds");
303
304        assert_eq!(found, None);
305    }
306
307    #[test]
308    fn empty_index_has_no_duplicate() {
309        let index_set = new_index_set();
310        let query = embedding(0, 1, 2);
311
312        let found = index_set
313            .find_duplicate(&query, THRESHOLD)
314            .expect("search succeeds");
315
316        assert_eq!(found, None);
317    }
318
319    #[test]
320    fn embedding_below_threshold_is_not_a_duplicate() {
321        let mut index_set = new_index_set();
322        let stored = embedding(0, 1, 2);
323        index_set
324            .insert_atomic(7, "memory-7", &stored, 0.5)
325            .expect("insert succeeds");
326
327        // Every field uses a disjoint axis, so all three cosine similarities are
328        // 0.0 — far below the 0.95 threshold.
329        let query = embedding(3, 4, 5);
330        let found = index_set
331            .find_duplicate(&query, THRESHOLD)
332            .expect("search succeeds");
333
334        assert_eq!(found, None);
335    }
336}