Skip to main content

wm_memory/
indexes.rs

1//! Secondary LMDB indexes for O(log n) lookups.
2//!
3//! Four index sub-databases:
4//! - `idx_content_hash`: `{galaxy}:{hash}` → UUID (O(1) dedup)
5//! - `idx_tags`: `{galaxy}:{tag}` → UUID (DUP_SORT, tag-based queries)
6//! - `idx_importance`: `{galaxy}:{f32_be}` → UUID (DUP_SORT, range queries)
7//! - `idx_temporal`: `{galaxy}:{i64_be}` → UUID (DUP_SORT, time-range queries)
8//!
9//! Keys are raw bytes: `galaxy_db_name + 0x00 + value_bytes`.
10//! The null separator ensures galaxy-scoped sorting.
11
12use lmdb::{
13    Cursor, Database, DatabaseFlags, Environment, RoTransaction, RwTransaction, Transaction,
14    WriteFlags,
15};
16use lmdb_sys::{MDB_NEXT, MDB_NEXT_DUP, MDB_SET_RANGE};
17use uuid::Uuid;
18use wm_core::{CoreError, Galaxy, Result};
19
20use crate::Memory;
21
22/// Index sub-database names.
23pub const IDX_CONTENT_HASH: &str = "idx_content_hash";
24pub const IDX_TAGS: &str = "idx_tags";
25pub const IDX_IMPORTANCE: &str = "idx_importance";
26pub const IDX_TEMPORAL: &str = "idx_temporal";
27
28/// All index DBs with their flags — used at environment creation.
29pub const INDEX_DBS: &[(&str, DatabaseFlags)] = &[
30    (IDX_CONTENT_HASH, DatabaseFlags::empty()),
31    (IDX_TAGS, DatabaseFlags::DUP_SORT),
32    (IDX_IMPORTANCE, DatabaseFlags::DUP_SORT),
33    (IDX_TEMPORAL, DatabaseFlags::DUP_SORT),
34];
35
36/// Cached handles to the four index sub-databases.
37#[derive(Clone, Copy)]
38pub struct IndexDbs {
39    content_hash: Database,
40    tags: Database,
41    importance: Database,
42    temporal: Database,
43}
44
45impl IndexDbs {
46    /// Open handles to all four index DBs. Call after `create_db` at startup.
47    pub fn open(env: &Environment) -> Result<Self> {
48        Ok(Self {
49            content_hash: open_db(env, IDX_CONTENT_HASH)?,
50            tags: open_db(env, IDX_TAGS)?,
51            importance: open_db(env, IDX_IMPORTANCE)?,
52            temporal: open_db(env, IDX_TEMPORAL)?,
53        })
54    }
55
56    /// Add a memory's index entries within an existing write transaction.
57    pub fn add(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
58        let id_bytes = memory.metadata.id.as_bytes();
59
60        // content_hash → UUID
61        let key = index_key(galaxy, memory.metadata.content_hash.as_bytes());
62        tx.put(self.content_hash, &key, id_bytes, WriteFlags::default())
63            .map_err(|e| CoreError::Memory(format!("idx_content_hash put: {e}")))?;
64
65        // tag → UUID (DUP_SORT)
66        for tag in &memory.metadata.tags {
67            let key = index_key(galaxy, tag.as_bytes());
68            tx.put(self.tags, &key, id_bytes, WriteFlags::default())
69                .map_err(|e| CoreError::Memory(format!("idx_tags put: {e}")))?;
70        }
71
72        // importance → UUID (DUP_SORT, sorted by big-endian f32 bits)
73        let imp_bytes = encode_f32(memory.metadata.importance);
74        let key = index_key(galaxy, &imp_bytes);
75        tx.put(self.importance, &key, id_bytes, WriteFlags::default())
76            .map_err(|e| CoreError::Memory(format!("idx_importance put: {e}")))?;
77
78        // timestamp → UUID (DUP_SORT, sorted by big-endian i64)
79        let ts_bytes = encode_timestamp(memory.metadata.created_at);
80        let key = index_key(galaxy, &ts_bytes);
81        tx.put(self.temporal, &key, id_bytes, WriteFlags::default())
82            .map_err(|e| CoreError::Memory(format!("idx_temporal put: {e}")))?;
83
84        Ok(())
85    }
86
87    /// Remove a memory's index entries within an existing write transaction.
88    pub fn remove(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
89        // content_hash
90        let key = index_key(galaxy, memory.metadata.content_hash.as_bytes());
91        let _ = tx.del(self.content_hash, &key, None);
92
93        // tags
94        for tag in &memory.metadata.tags {
95            let key = index_key(galaxy, tag.as_bytes());
96            let _ = tx.del(self.tags, &key, None);
97        }
98
99        // importance
100        let imp_bytes = encode_f32(memory.metadata.importance);
101        let key = index_key(galaxy, &imp_bytes);
102        let _ = tx.del(self.importance, &key, None);
103
104        // temporal
105        let ts_bytes = encode_timestamp(memory.metadata.created_at);
106        let key = index_key(galaxy, &ts_bytes);
107        let _ = tx.del(self.temporal, &key, None);
108
109        Ok(())
110    }
111
112    /// O(1) content-hash lookup → UUID.
113    pub fn find_by_content_hash(
114        &self,
115        tx: &RoTransaction,
116        galaxy: Galaxy,
117        hash: &str,
118    ) -> Result<Option<Uuid>> {
119        let key = index_key(galaxy, hash.as_bytes());
120        match tx.get(self.content_hash, &key) {
121            Ok(bytes) => {
122                let id = decode_uuid(bytes)?;
123                Ok(Some(id))
124            }
125            Err(lmdb::Error::NotFound) => Ok(None),
126            Err(e) => Err(CoreError::Memory(format!("idx_content_hash get: {e}"))),
127        }
128    }
129
130    /// Tag lookup → all UUIDs with that tag in the galaxy.
131    pub fn find_by_tag(&self, tx: &RoTransaction, galaxy: Galaxy, tag: &str) -> Result<Vec<Uuid>> {
132        let start_key = index_key(galaxy, tag.as_bytes());
133        let cursor = tx
134            .open_ro_cursor(self.tags)
135            .map_err(|e| CoreError::Memory(format!("idx_tags cursor: {e}")))?;
136
137        let mut ids = Vec::new();
138        // Position at first key >= start_key
139        match cursor.get(Some(&start_key), None, MDB_SET_RANGE) {
140            Ok((key_opt, val)) => {
141                // For DUP_SORT, key_opt is Some when key changes, None for dups of same key
142                let key_matches = key_opt.is_none_or(|k| k == start_key.as_slice());
143                if key_matches {
144                    if let Ok(id) = decode_uuid(val) {
145                        ids.push(id);
146                    }
147                    // Advance through duplicates (MDB_NEXT_DUP stays within same key)
148                    while let Ok((_, val)) = cursor.get(None, None, MDB_NEXT_DUP) {
149                        if let Ok(id) = decode_uuid(val) {
150                            ids.push(id);
151                        }
152                    }
153                }
154            }
155            Err(lmdb::Error::NotFound) => {}
156            Err(e) => return Err(CoreError::Memory(format!("idx_tags cursor get: {e}"))),
157        }
158        drop(cursor);
159        Ok(ids)
160    }
161
162    /// Importance range query → all UUIDs with importance in [min, max].
163    pub fn find_by_importance_range(
164        &self,
165        tx: &RoTransaction,
166        galaxy: Galaxy,
167        min: f32,
168        max: f32,
169    ) -> Result<Vec<Uuid>> {
170        let prefix = galaxy_prefix(galaxy);
171        let start_key = index_key(galaxy, &encode_f32(min));
172        let max_bytes = encode_f32(max);
173
174        let cursor = tx
175            .open_ro_cursor(self.importance)
176            .map_err(|e| CoreError::Memory(format!("idx_importance cursor: {e}")))?;
177
178        let mut ids = Vec::new();
179        let mut current = cursor.get(Some(&start_key), None, MDB_SET_RANGE).ok();
180        while let Some((key_opt, val)) = current {
181            let key = key_opt.unwrap_or(&start_key);
182            if !key.starts_with(&prefix) {
183                break;
184            }
185            let value_bytes = &key[prefix.len()..];
186            if value_bytes > max_bytes.as_slice() {
187                break;
188            }
189            if let Ok(id) = decode_uuid(val) {
190                ids.push(id);
191            }
192            current = cursor.get(None, None, MDB_NEXT).ok();
193        }
194        drop(cursor);
195        Ok(ids)
196    }
197
198    /// Temporal range query → all UUIDs created in [after, before].
199    pub fn find_by_time_range(
200        &self,
201        tx: &RoTransaction,
202        galaxy: Galaxy,
203        after: chrono::DateTime<chrono::Utc>,
204        before: chrono::DateTime<chrono::Utc>,
205    ) -> Result<Vec<Uuid>> {
206        let prefix = galaxy_prefix(galaxy);
207        let start_key = index_key(galaxy, &encode_timestamp(after));
208        let max_bytes = encode_timestamp(before);
209
210        let cursor = tx
211            .open_ro_cursor(self.temporal)
212            .map_err(|e| CoreError::Memory(format!("idx_temporal cursor: {e}")))?;
213
214        let mut ids = Vec::new();
215        let mut current = cursor.get(Some(&start_key), None, MDB_SET_RANGE).ok();
216        while let Some((key_opt, val)) = current {
217            let key = key_opt.unwrap_or(&start_key);
218            if !key.starts_with(&prefix) {
219                break;
220            }
221            let value_bytes = &key[prefix.len()..];
222            if value_bytes > max_bytes.as_slice() {
223                break;
224            }
225            if let Ok(id) = decode_uuid(val) {
226                ids.push(id);
227            }
228            current = cursor.get(None, None, MDB_NEXT).ok();
229        }
230        drop(cursor);
231        Ok(ids)
232    }
233}
234
235// ── Key encoding helpers ──────────────────────────────────────────────
236
237fn open_db(env: &Environment, name: &str) -> Result<Database> {
238    env.open_db(Some(name))
239        .map_err(|e| CoreError::Memory(format!("LMDB open_db {name}: {e}")))
240}
241
242fn galaxy_prefix(galaxy: Galaxy) -> Vec<u8> {
243    let name = galaxy.db_name();
244    let mut key = Vec::with_capacity(name.len() + 1);
245    key.extend_from_slice(name.as_bytes());
246    key.push(0);
247    key
248}
249
250fn index_key(galaxy: Galaxy, value_bytes: &[u8]) -> Vec<u8> {
251    let mut key = galaxy_prefix(galaxy);
252    key.extend_from_slice(value_bytes);
253    key
254}
255
256/// Encode f32 as big-endian bits. Sorts correctly for positive values (0.0-1.0).
257const fn encode_f32(value: f32) -> [u8; 4] {
258    value.to_bits().to_be_bytes()
259}
260
261/// Encode timestamp as big-endian i64. Sorts correctly for positive values.
262const fn encode_timestamp(ts: chrono::DateTime<chrono::Utc>) -> [u8; 8] {
263    ts.timestamp().to_be_bytes()
264}
265
266fn decode_uuid(bytes: &[u8]) -> Result<Uuid> {
267    Uuid::from_slice(bytes).map_err(|e| CoreError::Memory(format!("UUID decode: {e}")))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::{Memory, MemoryStore};
274    use tempfile::tempdir;
275    use wm_core::Galaxy;
276
277    fn setup() -> (tempfile::TempDir, MemoryStore) {
278        let tmp = tempdir().unwrap();
279        let store = MemoryStore::open_default(tmp.path()).unwrap();
280        (tmp, store)
281    }
282
283    #[test]
284    fn content_hash_index_o1_lookup() {
285        let (_tmp, store) = setup();
286        let mem = Memory::new(Galaxy::Codex, "hello world".into());
287        let id = mem.metadata.id;
288        let hash = mem.metadata.content_hash.clone();
289        store.put(Galaxy::Codex, &mem).unwrap();
290
291        let tx = store.env().begin_ro_txn().unwrap();
292        let found = store
293            .index_dbs()
294            .find_by_content_hash(&tx, Galaxy::Codex, &hash)
295            .unwrap();
296        tx.commit().unwrap();
297        assert_eq!(found, Some(id));
298    }
299
300    #[test]
301    fn content_hash_index_miss() {
302        let (_tmp, store) = setup();
303        let tx = store.env().begin_ro_txn().unwrap();
304        let found = store
305            .index_dbs()
306            .find_by_content_hash(&tx, Galaxy::Codex, "nonexistent")
307            .unwrap();
308        tx.commit().unwrap();
309        assert!(found.is_none());
310    }
311
312    #[test]
313    fn tag_index_returns_all_tagged() {
314        let (_tmp, store) = setup();
315        let mem1 = Memory::new(Galaxy::Codex, "a".into()).with_tags(vec!["rust".into()]);
316        let mem2 = Memory::new(Galaxy::Codex, "b".into()).with_tags(vec!["rust".into()]);
317        let mem3 = Memory::new(Galaxy::Codex, "c".into()).with_tags(vec!["python".into()]);
318        let id1 = mem1.metadata.id;
319        let id2 = mem2.metadata.id;
320        store.put(Galaxy::Codex, &mem1).unwrap();
321        store.put(Galaxy::Codex, &mem2).unwrap();
322        store.put(Galaxy::Codex, &mem3).unwrap();
323
324        let tx = store.env().begin_ro_txn().unwrap();
325        let rust_ids = store
326            .index_dbs()
327            .find_by_tag(&tx, Galaxy::Codex, "rust")
328            .unwrap();
329        tx.commit().unwrap();
330
331        assert_eq!(rust_ids.len(), 2);
332        assert!(rust_ids.contains(&id1));
333        assert!(rust_ids.contains(&id2));
334    }
335
336    #[test]
337    fn tag_index_galaxy_scoped() {
338        let (_tmp, store) = setup();
339        let mem1 = Memory::new(Galaxy::Codex, "a".into()).with_tags(vec!["shared".into()]);
340        let mem2 = Memory::new(Galaxy::Research, "b".into()).with_tags(vec!["shared".into()]);
341        store.put(Galaxy::Codex, &mem1).unwrap();
342        store.put(Galaxy::Research, &mem2).unwrap();
343
344        let tx = store.env().begin_ro_txn().unwrap();
345        let codex_ids = store
346            .index_dbs()
347            .find_by_tag(&tx, Galaxy::Codex, "shared")
348            .unwrap();
349        let research_ids = store
350            .index_dbs()
351            .find_by_tag(&tx, Galaxy::Research, "shared")
352            .unwrap();
353        tx.commit().unwrap();
354
355        assert_eq!(codex_ids.len(), 1);
356        assert_eq!(research_ids.len(), 1);
357    }
358
359    #[test]
360    fn importance_range_query() {
361        let (_tmp, store) = setup();
362        store
363            .put(
364                Galaxy::Codex,
365                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
366            )
367            .unwrap();
368        let mid = Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5);
369        let mid_id = mid.metadata.id;
370        store.put(Galaxy::Codex, &mid).unwrap();
371        store
372            .put(
373                Galaxy::Codex,
374                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
375            )
376            .unwrap();
377
378        let tx = store.env().begin_ro_txn().unwrap();
379        let ids = store
380            .index_dbs()
381            .find_by_importance_range(&tx, Galaxy::Codex, 0.4, 0.6)
382            .unwrap();
383        tx.commit().unwrap();
384
385        assert_eq!(ids.len(), 1);
386        assert_eq!(ids[0], mid_id);
387    }
388
389    #[test]
390    fn importance_range_query_full_range() {
391        let (_tmp, store) = setup();
392        for i in 0..10 {
393            let imp = i as f32 * 0.1;
394            store
395                .put(
396                    Galaxy::Codex,
397                    &Memory::new(Galaxy::Codex, format!("m{i}")).with_importance(imp),
398                )
399                .unwrap();
400        }
401        let tx = store.env().begin_ro_txn().unwrap();
402        let ids = store
403            .index_dbs()
404            .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 1.0)
405            .unwrap();
406        tx.commit().unwrap();
407        assert_eq!(ids.len(), 10);
408    }
409
410    #[test]
411    fn temporal_range_query() {
412        let (_tmp, store) = setup();
413        let t0 = chrono::Utc::now();
414        std::thread::sleep(std::time::Duration::from_millis(10));
415        let mid = Memory::new(Galaxy::Codex, "mid".into());
416        let mid_id = mid.metadata.id;
417        store.put(Galaxy::Codex, &mid).unwrap();
418        std::thread::sleep(std::time::Duration::from_millis(10));
419        let t2 = chrono::Utc::now();
420
421        let tx = store.env().begin_ro_txn().unwrap();
422        let ids = store
423            .index_dbs()
424            .find_by_time_range(&tx, Galaxy::Codex, t0, t2)
425            .unwrap();
426        tx.commit().unwrap();
427
428        assert_eq!(ids.len(), 1);
429        assert_eq!(ids[0], mid_id);
430    }
431
432    #[test]
433    fn delete_removes_index_entries() {
434        let (_tmp, store) = setup();
435        let mem = Memory::new(Galaxy::Codex, "test".into())
436            .with_tags(vec!["tag1".into()])
437            .with_importance(0.7);
438        let id = mem.metadata.id;
439        let hash = mem.metadata.content_hash.clone();
440        store.put(Galaxy::Codex, &mem).unwrap();
441
442        // Verify index entries exist
443        let tx = store.env().begin_ro_txn().unwrap();
444        assert!(
445            store
446                .index_dbs()
447                .find_by_content_hash(&tx, Galaxy::Codex, &hash)
448                .unwrap()
449                .is_some()
450        );
451        assert_eq!(
452            store
453                .index_dbs()
454                .find_by_tag(&tx, Galaxy::Codex, "tag1")
455                .unwrap()
456                .len(),
457            1
458        );
459        tx.commit().unwrap();
460
461        // Delete
462        store.delete(Galaxy::Codex, id).unwrap();
463
464        // Verify index entries are gone
465        let tx = store.env().begin_ro_txn().unwrap();
466        assert!(
467            store
468                .index_dbs()
469                .find_by_content_hash(&tx, Galaxy::Codex, &hash)
470                .unwrap()
471                .is_none()
472        );
473        assert_eq!(
474            store
475                .index_dbs()
476                .find_by_tag(&tx, Galaxy::Codex, "tag1")
477                .unwrap()
478                .len(),
479            0
480        );
481        tx.commit().unwrap();
482    }
483
484    #[test]
485    fn put_batch_updates_indexes() {
486        let (_tmp, store) = setup();
487        let memories: Vec<Memory> = (0..5)
488            .map(|i| {
489                Memory::new(Galaxy::Codex, format!("batch-{i}"))
490                    .with_tags(vec![format!("tag{i}")])
491                    .with_importance(i as f32 * 0.2)
492            })
493            .collect();
494        store.put_batch(Galaxy::Codex, &memories).unwrap();
495
496        let tx = store.env().begin_ro_txn().unwrap();
497        for i in 0..5 {
498            let ids = store
499                .index_dbs()
500                .find_by_tag(&tx, Galaxy::Codex, &format!("tag{i}"))
501                .unwrap();
502            assert_eq!(ids.len(), 1, "tag{i} should have 1 entry");
503        }
504        tx.commit().unwrap();
505    }
506
507    #[test]
508    fn find_by_content_hash_indexed_matches_scan() {
509        let (_tmp, store) = setup();
510        let mem = Memory::new(Galaxy::Codex, "dedup test".into());
511        let id = mem.metadata.id;
512        let hash = mem.metadata.content_hash.clone();
513        store.put(Galaxy::Codex, &mem).unwrap();
514
515        // Indexed lookup
516        let tx = store.env().begin_ro_txn().unwrap();
517        let indexed = store
518            .index_dbs()
519            .find_by_content_hash(&tx, Galaxy::Codex, &hash)
520            .unwrap();
521        tx.commit().unwrap();
522
523        // Scan-based lookup (old method)
524        let scanned = store
525            .find_by_content_hash_scan(Galaxy::Codex, &hash)
526            .unwrap();
527
528        assert_eq!(indexed, scanned);
529        assert_eq!(indexed, Some(id));
530    }
531
532    #[test]
533    fn key_encoding_sorts_correctly() {
534        // Verify that encoded f32 values sort in the same order as the floats
535        let values = [0.0_f32, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0];
536        let encoded: Vec<[u8; 4]> = values.map(encode_f32).to_vec();
537        for i in 0..encoded.len() - 1 {
538            assert!(
539                encoded[i] < encoded[i + 1],
540                "f32 sort order broken: {:?} >= {:?}",
541                values[i],
542                values[i + 1]
543            );
544        }
545    }
546}