1use 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
22pub 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
28pub 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#[derive(Clone, Copy)]
38pub struct IndexDbs {
39 content_hash: Database,
40 tags: Database,
41 importance: Database,
42 temporal: Database,
43}
44
45impl IndexDbs {
46 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 pub fn add(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
58 let id_bytes = memory.metadata.id.as_bytes();
59
60 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 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 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 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 pub fn remove(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
89 let key = index_key(galaxy, memory.metadata.content_hash.as_bytes());
91 let _ = tx.del(self.content_hash, &key, None);
92
93 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 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 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 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 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 match cursor.get(Some(&start_key), None, MDB_SET_RANGE) {
140 Ok((key_opt, val)) => {
141 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 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 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 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
235fn 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
256const fn encode_f32(value: f32) -> [u8; 4] {
258 value.to_bits().to_be_bytes()
259}
260
261const 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 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 store.delete(Galaxy::Codex, id).unwrap();
463
464 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 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 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 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}