1use lmdb::{
7 Cursor, Database, DatabaseFlags, Environment, EnvironmentFlags, Transaction, WriteFlags,
8};
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, RwLock};
13use wm_core::{CoreError, Galaxy, Result};
14
15#[cfg(unix)]
16use std::os::unix::fs::PermissionsExt;
17
18use crate::episodic::EpisodicStore;
19use crate::indexes::IndexDbs;
20use crate::memory::{Memory, MemoryId, decode_embedding, encode_embedding};
21use crate::semantic::SemanticEncoder;
22
23#[derive(Debug, Clone, Default)]
25pub struct MemoryQuery {
26 pub tags: Vec<String>,
28 pub min_importance: Option<f32>,
30 pub max_importance: Option<f32>,
32 pub created_after: Option<chrono::DateTime<chrono::Utc>>,
34 pub created_before: Option<chrono::DateTime<chrono::Utc>>,
36 pub content_substring: Option<String>,
39 pub limit: usize,
41}
42
43impl MemoryQuery {
44 #[must_use]
46 pub fn new() -> Self {
47 Self {
48 limit: 100,
49 ..Default::default()
50 }
51 }
52
53 #[must_use]
55 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
56 self.tags = tags;
57 self
58 }
59
60 #[must_use]
62 pub const fn with_importance_range(mut self, min: f32, max: f32) -> Self {
63 self.min_importance = Some(min);
64 self.max_importance = Some(max);
65 self
66 }
67
68 #[must_use]
70 pub const fn with_time_range(
71 mut self,
72 after: chrono::DateTime<chrono::Utc>,
73 before: chrono::DateTime<chrono::Utc>,
74 ) -> Self {
75 self.created_after = Some(after);
76 self.created_before = Some(before);
77 self
78 }
79
80 #[must_use]
83 pub const fn with_created_after(mut self, after: chrono::DateTime<chrono::Utc>) -> Self {
84 self.created_after = Some(after);
85 self
86 }
87
88 #[must_use]
91 pub const fn with_created_before(mut self, before: chrono::DateTime<chrono::Utc>) -> Self {
92 self.created_before = Some(before);
93 self
94 }
95
96 #[must_use]
98 pub const fn with_limit(mut self, limit: usize) -> Self {
99 self.limit = limit;
100 self
101 }
102
103 #[must_use]
105 pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
106 self.content_substring = Some(substring.into().to_lowercase());
107 self
108 }
109
110 #[must_use]
112 pub fn matches(&self, mem: &Memory) -> bool {
113 if !self.tags.is_empty() {
115 for tag in &self.tags {
116 if !mem.metadata.tags.iter().any(|t| t == tag) {
117 return false;
118 }
119 }
120 }
121
122 if let Some(min) = self.min_importance {
124 if mem.metadata.importance < min {
125 return false;
126 }
127 }
128 if let Some(max) = self.max_importance {
129 if mem.metadata.importance > max {
130 return false;
131 }
132 }
133
134 if let Some(after) = self.created_after {
136 if mem.metadata.created_at < after {
137 return false;
138 }
139 }
140 if let Some(before) = self.created_before {
141 if mem.metadata.created_at > before {
142 return false;
143 }
144 }
145
146 if let Some(sub) = &self.content_substring {
148 if !mem.content.to_lowercase().contains(sub) {
149 return false;
150 }
151 }
152
153 true
154 }
155}
156
157pub struct MemoryStore {
159 path: std::path::PathBuf,
161 env: Environment,
163 index_dbs: IndexDbs,
165 semantic_encoder: SemanticEncoder,
167 max_entries_per_galaxy: Option<usize>,
169 mutation_count: AtomicU64,
173 episodic_db: Database,
175 episodic_terms_v2_db: Database,
177 embedding_cache_db: Database,
182 revisions_db: Database,
186 attestations_db: Database,
190 pub(crate) cold_storage_db: Database,
192 episodic_term_cache: std::sync::Arc<RwLock<HashMap<String, Vec<uuid::Uuid>>>>,
194 episodic_embedder:
196 std::sync::OnceLock<Option<Arc<dyn crate::embedder::Embedder + Send + Sync>>>,
197 episodic_sidecar_ensured: std::sync::OnceLock<()>,
199 episodic_aliases: std::sync::OnceLock<Option<crate::episodic_keys::AdaptiveAliases>>,
201 episodic_enrichment: std::sync::OnceLock<Option<crate::enrichment::VocabularyEnrichment>>,
203}
204
205impl MemoryStore {
206 #[cfg(unix)]
222 pub fn probe_write_lock(store_root: &Path) -> std::io::Result<()> {
223 use rustix::fs::{FlockOperation, fcntl_lock};
224 let lock_path = store_root.join("lmdb").join("lock.mdb");
225 let file = std::fs::OpenOptions::new()
226 .read(true)
227 .write(true)
228 .open(&lock_path)?;
229 match fcntl_lock(&file, FlockOperation::NonBlockingLockExclusive) {
230 Ok(()) => Ok(()),
231 Err(rustix::io::Errno::AGAIN | rustix::io::Errno::ACCESS) => Err(std::io::Error::new(
232 std::io::ErrorKind::WouldBlock,
233 "LMDB writer lock held by another process",
234 )),
235 Err(e) => Err(e.into()),
236 }
237 }
238
239 #[cfg(not(unix))]
242 pub fn probe_write_lock(_store_root: &Path) -> std::io::Result<()> {
243 Ok(())
244 }
245
246 pub fn open(path: impl AsRef<Path>, map_size: usize) -> Result<Self> {
252 let path = path.as_ref().to_path_buf();
253
254 std::fs::create_dir_all(&path)
256 .map_err(|e| CoreError::Memory(format!("Cannot create store dir: {e}")))?;
257 #[cfg(unix)]
258 {
259 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
260 .map_err(|e| CoreError::Memory(format!("Cannot set store dir permissions: {e}")))?;
261 }
262
263 let env = Environment::new()
264 .set_map_size(map_size)
265 .set_max_dbs(64)
266 .open(&path)
267 .map_err(|e| CoreError::Memory(format!("LMDB open failed: {e}")))?;
268
269 for galaxy in Galaxy::all() {
271 let db = env
272 .create_db(Some(galaxy.db_name()), DatabaseFlags::default())
273 .map_err(|e| {
274 CoreError::Memory(format!(
275 "LMDB create_db failed for {}: {e}",
276 galaxy.db_name()
277 ))
278 })?;
279 let _ = db;
280 }
281
282 for (name, flags) in crate::indexes::INDEX_DBS {
284 let db = env
285 .create_db(Some(name), *flags)
286 .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for {name}: {e}")))?;
287 let _ = db;
288 }
289
290 let index_dbs = IndexDbs::open(&env)?;
291 let episodic_db = env
292 .create_db(Some("episodic_records"), DatabaseFlags::default())
293 .map_err(|e| {
294 CoreError::Memory(format!("LMDB create_db failed for episodic_records: {e}"))
295 })?;
296 let episodic_terms_v2_db = env
303 .create_db(Some("episodic_terms_v2"), DatabaseFlags::DUP_SORT)
304 .map_err(|e| {
305 CoreError::Memory(format!("LMDB create_db failed for episodic_terms_v2: {e}"))
306 })?;
307 let embedding_cache_db = env
308 .create_db(Some("embedding_cache"), DatabaseFlags::default())
309 .map_err(|e| {
310 CoreError::Memory(format!("LMDB create_db failed for embedding_cache: {e}"))
311 })?;
312 let revisions_db = env
313 .create_db(Some("revisions"), DatabaseFlags::default())
314 .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for revisions: {e}")))?;
315 let attestations_db = env
316 .create_db(
317 Some(crate::attestation::ATTESTATIONS_DB),
318 DatabaseFlags::default(),
319 )
320 .map_err(|e| {
321 CoreError::Memory(format!("LMDB create_db failed for attestations: {e}"))
322 })?;
323 let cold_storage_db = env
324 .create_db(Some("cold_storage"), DatabaseFlags::default())
325 .map_err(|e| {
326 CoreError::Memory(format!("LMDB create_db failed for cold_storage: {e}"))
327 })?;
328 Ok(Self {
329 path,
330 env,
331 index_dbs,
332 semantic_encoder: SemanticEncoder::new(),
333 max_entries_per_galaxy: None,
334 mutation_count: AtomicU64::new(0),
335 episodic_db,
336 episodic_terms_v2_db,
337 embedding_cache_db,
338 revisions_db,
339 attestations_db,
340 cold_storage_db,
341 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
342 episodic_embedder: std::sync::OnceLock::new(),
343 episodic_sidecar_ensured: std::sync::OnceLock::new(),
344 episodic_aliases: std::sync::OnceLock::new(),
345 episodic_enrichment: std::sync::OnceLock::new(),
346 })
347 }
348
349 pub fn open_readonly_bounded(
359 path: impl AsRef<Path>,
360 timeout: std::time::Duration,
361 ) -> Result<Option<Self>> {
362 let path = path.as_ref().to_path_buf();
363 let (tx, rx) = std::sync::mpsc::channel();
364 std::thread::spawn(move || {
365 let result = Self::open_readonly(&path);
366 let _ = tx.send(result);
367 });
368 match rx.recv_timeout(timeout) {
369 Ok(result) => result.map(Some),
370 Err(_) => Ok(None),
371 }
372 }
373
374 pub fn open_default_bounded(
378 path: impl AsRef<Path>,
379 timeout: std::time::Duration,
380 ) -> Result<Option<Self>> {
381 let path = path.as_ref().to_path_buf();
382 let (tx, rx) = std::sync::mpsc::channel();
383 std::thread::spawn(move || {
384 let result = Self::open_default(&path);
385 let _ = tx.send(result);
386 });
387 match rx.recv_timeout(timeout) {
388 Ok(result) => result.map(Some),
389 Err(_) => Ok(None),
390 }
391 }
392
393 pub fn open_default(path: impl AsRef<Path>) -> Result<Self> {
402 let platform_default = if cfg!(windows) {
407 256 * 1024 * 1024
408 } else {
409 4 * 1024 * 1024 * 1024
410 };
411 let size = std::env::var("WM_DEFAULT_MAP_SIZE")
412 .ok()
413 .and_then(|v| v.parse::<usize>().ok())
414 .filter(|&v| v > 0)
415 .unwrap_or(platform_default);
416 Self::open(path, size)
417 }
418
419 pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
425 let path = path.as_ref().to_path_buf();
426 if !path.is_dir() {
427 return Err(CoreError::Memory(format!(
428 "Read-only LMDB store directory does not exist: {}",
429 path.display()
430 )));
431 }
432 if !path.join("data.mdb").is_file() {
433 return Err(CoreError::Memory(format!(
434 "Read-only LMDB store is missing data.mdb: {}",
435 path.display()
436 )));
437 }
438
439 let env = Environment::new()
440 .set_max_dbs(32)
441 .set_flags(EnvironmentFlags::READ_ONLY)
442 .open(&path)
443 .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
444
445 let index_dbs = IndexDbs::open(&env)?;
446 let open_named = |name: &str| {
447 env.open_db(Some(name)).map_err(|e| {
448 CoreError::Memory(format!("Read-only LMDB missing database {name}: {e}"))
449 })
450 };
451 let episodic_db = open_named("episodic_records")?;
452 let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
453 let embedding_cache_db = open_named("embedding_cache")?;
454 let revisions_db = open_named("revisions")?;
455 let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
456 let cold_storage_db = open_named("cold_storage")?;
457
458 Ok(Self {
459 path,
460 env,
461 index_dbs,
462 semantic_encoder: SemanticEncoder::new(),
463 max_entries_per_galaxy: None,
464 mutation_count: AtomicU64::new(0),
465 episodic_db,
466 episodic_terms_v2_db,
467 embedding_cache_db,
468 revisions_db,
469 attestations_db,
470 cold_storage_db,
471 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
472 episodic_embedder: std::sync::OnceLock::new(),
473 episodic_sidecar_ensured: std::sync::OnceLock::new(),
474 episodic_aliases: std::sync::OnceLock::new(),
475 episodic_enrichment: std::sync::OnceLock::new(),
476 })
477 }
478
479 const NAMED_DBIS: [&'static str; 6] = [
482 "episodic_records",
483 "episodic_terms_v2",
484 "embedding_cache",
485 "revisions",
486 crate::attestation::ATTESTATIONS_DB,
487 "cold_storage",
488 ];
489
490 pub fn open_inspection(path: impl AsRef<Path>) -> Result<Self> {
503 let path = path.as_ref().to_path_buf();
504 if !path.is_dir() {
505 return Err(CoreError::Memory(format!(
506 "Read-only LMDB store directory does not exist: {}",
507 path.display()
508 )));
509 }
510 if !path.join("data.mdb").is_file() {
511 return Err(CoreError::Memory(format!(
512 "Read-only LMDB store is missing data.mdb: {}",
513 path.display()
514 )));
515 }
516
517 let env = Environment::new()
518 .set_max_dbs(32)
519 .set_flags(EnvironmentFlags::READ_ONLY | EnvironmentFlags::NO_LOCK)
520 .open(&path)
521 .map_err(|e| CoreError::Memory(format!("Inspection LMDB open failed: {e}")))?;
522
523 let index_dbs = IndexDbs::open(&env)?;
524 let open_named = |name: &str| {
525 env.open_db(Some(name)).map_err(|e| {
526 CoreError::Memory(format!("Inspection LMDB missing database {name}: {e}"))
527 })
528 };
529 let episodic_db = open_named("episodic_records")?;
530 let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
531 let embedding_cache_db = open_named("embedding_cache")?;
532 let revisions_db = open_named("revisions")?;
533 let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
534 let cold_storage_db = open_named("cold_storage")?;
535
536 Ok(Self {
537 path,
538 env,
539 index_dbs,
540 semantic_encoder: SemanticEncoder::new(),
541 max_entries_per_galaxy: None,
542 mutation_count: AtomicU64::new(0),
543 episodic_db,
544 episodic_terms_v2_db,
545 embedding_cache_db,
546 revisions_db,
547 attestations_db,
548 cold_storage_db,
549 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
550 episodic_embedder: std::sync::OnceLock::new(),
551 episodic_sidecar_ensured: std::sync::OnceLock::new(),
552 episodic_aliases: std::sync::OnceLock::new(),
553 episodic_enrichment: std::sync::OnceLock::new(),
554 })
555 }
556
557 pub fn ensure_schema(path: impl AsRef<Path>) -> Result<Vec<String>> {
566 let path = path.as_ref().to_path_buf();
567 if !path.is_dir() {
568 return Err(CoreError::Memory(format!(
569 "Store directory does not exist: {}",
570 path.display()
571 )));
572 }
573 let expected = || {
574 Galaxy::all()
575 .into_iter()
576 .map(|galaxy| galaxy.db_name().to_string())
577 .chain(
578 crate::indexes::INDEX_DBS
579 .iter()
580 .map(|(name, _)| (*name).to_string()),
581 )
582 .chain(Self::NAMED_DBIS.iter().map(|name| (*name).to_string()))
583 };
584 let missing: Vec<String> = {
585 let env = Environment::new()
586 .set_max_dbs(64)
587 .set_flags(EnvironmentFlags::READ_ONLY)
588 .open(&path)
589 .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
590 expected()
591 .filter(|name| env.open_db(Some(name.as_str())).is_err())
592 .collect()
593 };
594 if missing.is_empty() {
595 return Ok(missing);
596 }
597 let store = Self::open_default(&path)?;
600 drop(store);
601 Ok(missing)
602 }
603
604 #[must_use]
609 pub const fn with_entry_limit(mut self, limit: usize) -> Self {
610 self.max_entries_per_galaxy = Some(limit);
611 self
612 }
613
614 pub fn path(&self) -> &Path {
616 &self.path
617 }
618
619 pub const fn env(&self) -> &Environment {
621 &self.env
622 }
623
624 pub fn mutation_count(&self) -> u64 {
628 self.mutation_count.load(Ordering::Relaxed)
629 }
630
631 pub const fn index_dbs(&self) -> &IndexDbs {
633 &self.index_dbs
634 }
635
636 pub const fn semantic_encoder(&self) -> &SemanticEncoder {
638 &self.semantic_encoder
639 }
640
641 fn ensure_episodic_sidecar(&self) {
646 if self.episodic_sidecar_ensured.get().is_some() {
647 return;
648 }
649 let _ = self.episodic_sidecar_ensured.set(());
650 let view = EpisodicStore::new(
651 &self.env,
652 self.episodic_db,
653 self.episodic_terms_v2_db,
654 self.episodic_term_cache.clone(),
655 &self.mutation_count,
656 );
657 let needs_rebuild = matches!(
658 (view.sidecar_is_empty(), view.record_count()),
659 (Ok(true), Ok(n)) if n > 0
660 );
661 if needs_rebuild {
662 match view.rebuild_sidecar() {
663 Ok(n) => tracing::info!("episodic sidecar rebuilt from {n} records"),
664 Err(e) => {
665 tracing::warn!("episodic sidecar rebuild failed: {e}");
666 }
667 }
668 }
669 }
670
671 #[must_use]
673 pub fn episodic(&self) -> EpisodicStore<'_> {
674 self.ensure_episodic_sidecar();
675 let mut store = EpisodicStore::new(
676 &self.env,
677 self.episodic_db,
678 self.episodic_terms_v2_db,
679 self.episodic_term_cache.clone(),
680 &self.mutation_count,
681 );
682 if let Some(Some(embedder)) = self.episodic_embedder.get() {
683 store = store.with_embedder(embedder.clone());
684 }
685 if let Some(Some(aliases)) = self.episodic_aliases.get() {
686 store = store.with_adaptive_aliases(aliases.clone());
687 }
688 if let Some(Some(enrichment)) = self.episodic_enrichment.get() {
689 store = store.with_enrichment(enrichment.clone());
690 }
691 store
692 }
693
694 pub fn set_episodic_embedder(
696 &self,
697 embedder: Arc<dyn crate::embedder::Embedder + Send + Sync>,
698 ) {
699 let _ = self.episodic_embedder.set(Some(embedder));
700 }
701
702 pub fn set_episodic_aliases(&self, aliases: crate::episodic_keys::AdaptiveAliases) {
704 let _ = self.episodic_aliases.set(Some(aliases));
705 }
706
707 pub fn set_episodic_enrichment(&self, enrichment: crate::enrichment::VocabularyEnrichment) {
709 let _ = self.episodic_enrichment.set(Some(enrichment));
710 }
711
712 pub fn galaxy_db(&self, galaxy: Galaxy) -> Result<Database> {
714 self.env.open_db(Some(galaxy.db_name())).map_err(|e| {
715 CoreError::Memory(format!("LMDB open_db failed for {}: {e}", galaxy.db_name()))
716 })
717 }
718
719 pub fn put(&self, galaxy: Galaxy, memory: &Memory) -> Result<()> {
727 if let Some(limit) = self.max_entries_per_galaxy {
729 let current = self.count(galaxy)?;
730 if current >= limit {
731 return Err(CoreError::Memory(format!(
732 "galaxy {} entry limit reached ({current}/{limit}), write rejected",
733 galaxy.db_name()
734 )));
735 }
736 }
737
738 let db = self.galaxy_db(galaxy)?;
739 let key = memory.metadata.id.as_bytes();
740 let val = rmp_serde::to_vec_named(memory)
741 .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
742
743 let mut tx = self
744 .env
745 .begin_rw_txn()
746 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
747
748 let existing = tx
753 .get(db, key)
754 .ok()
755 .and_then(|bytes| crate::codec::decode(bytes).ok());
756
757 match tx.put(db, key, &val, lmdb::WriteFlags::default()) {
758 Ok(()) => {}
759 Err(lmdb::Error::MapFull) => {
760 tx.abort();
761 return Err(CoreError::Memory(format!(
762 "LMDB map full: galaxy {}, consider growing map size or pruning old memories",
763 galaxy.db_name()
764 )));
765 }
766 Err(e) => {
767 tx.abort();
768 return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
769 }
770 }
771 if let Some(existing) = existing {
772 self.index_dbs.remove(&mut tx, galaxy, &existing)?;
773 }
774 self.index_dbs.add(&mut tx, galaxy, memory)?;
775 tx.commit()
776 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
777 self.mutation_count.fetch_add(1, Ordering::Relaxed);
778 Ok(())
779 }
780
781 pub fn get(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<Option<Memory>> {
783 let db = self.galaxy_db(galaxy)?;
784 let key = id.as_bytes();
785
786 let tx = self
787 .env
788 .begin_ro_txn()
789 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
790 let result = tx.get(db, key);
791 match result {
792 Ok(bytes) => {
793 let memory: Memory = crate::codec::decode(bytes)
794 .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")))?;
795 tx.commit()
796 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
797 Ok(Some(memory))
798 }
799 Err(lmdb::Error::NotFound) => {
800 tx.commit()
802 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
803 Ok(None)
804 }
805 Err(e) => Err(CoreError::Memory(format!("LMDB get failed: {e}"))),
806 }
807 }
808
809 pub fn find_across_galaxies(&self, id: uuid::Uuid) -> Result<Option<(Galaxy, Memory)>> {
815 for galaxy in Galaxy::memory_galaxies() {
816 if let Some(mem) = self.get(galaxy, id)? {
817 return Ok(Some((galaxy, mem)));
818 }
819 }
820 Ok(None)
821 }
822
823 pub fn delete(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<bool> {
826 let db = self.galaxy_db(galaxy)?;
827 let key = id.as_bytes();
828
829 let mut tx = self
830 .env
831 .begin_rw_txn()
832 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
833
834 let exists = tx.get(db, key).is_ok();
836 if exists {
837 if let Ok(bytes) = tx.get(db, key) {
839 if let Ok(memory) = crate::codec::decode(bytes) {
840 let _ = self.index_dbs.remove(&mut tx, galaxy, &memory);
841 }
842 }
843 tx.del(db, key, None)
844 .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
845 }
846 tx.commit()
847 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
848 if exists {
849 self.mutation_count.fetch_add(1, Ordering::Relaxed);
850 }
851 Ok(exists)
852 }
853
854 pub fn scan(&self, galaxy: Galaxy, limit: usize) -> Result<Vec<Memory>> {
856 let db = self.galaxy_db(galaxy)?;
857 let tx = self
858 .env
859 .begin_ro_txn()
860 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
861
862 let mut cursor = tx
863 .open_ro_cursor(db)
864 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
865
866 let mut memories = Vec::with_capacity(limit.min(256));
867 for (i, (_key, val)) in cursor.iter().enumerate() {
868 if memories.len() >= limit {
869 break;
870 }
871 match crate::codec::decode(val) {
872 Ok(memory) => memories.push(memory),
873 Err(e) => {
874 tracing::warn!(
875 "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
876 galaxy
877 );
878 }
879 }
880 }
881
882 drop(cursor);
883 tx.commit()
884 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
885 Ok(memories)
886 }
887
888 pub fn scan_all(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
893 self.scan_all_impl(galaxy, false)
894 }
895
896 pub fn scan_all_strict(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
900 self.scan_all_impl(galaxy, true)
901 }
902
903 fn scan_all_impl(&self, galaxy: Galaxy, strict: bool) -> Result<Vec<Memory>> {
904 let db = self.galaxy_db(galaxy)?;
905 let tx = self
906 .env
907 .begin_ro_txn()
908 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
909
910 let mut cursor = tx
911 .open_ro_cursor(db)
912 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
913
914 let mut memories = Vec::new();
915 for (i, (_key, val)) in cursor.iter().enumerate() {
916 match crate::codec::decode(val) {
917 Ok(memory) => memories.push(memory),
918 Err(e) => {
919 if strict {
920 return Err(CoreError::Memory(format!(
921 "refusing incomplete scan of {}: record {i} cannot be decoded: {e}",
922 galaxy.db_name()
923 )));
924 }
925 tracing::warn!(
926 "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
927 galaxy
928 );
929 }
930 }
931 }
932
933 drop(cursor);
934 tx.commit()
935 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
936 Ok(memories)
937 }
938
939 pub fn count(&self, galaxy: Galaxy) -> Result<usize> {
941 let db = self.galaxy_db(galaxy)?;
942 let tx = self
943 .env
944 .begin_ro_txn()
945 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
946 let mut cursor = tx
947 .open_ro_cursor(db)
948 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
949 let count = cursor.iter().count();
950 drop(cursor);
951 tx.commit()
952 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
953 Ok(count)
954 }
955
956 pub fn clear_galaxy(&self, galaxy: Galaxy) -> Result<usize> {
960 let db = self.galaxy_db(galaxy)?;
961
962 let mut tx = self
963 .env
964 .begin_rw_txn()
965 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
966
967 let mut cursor = tx
968 .open_ro_cursor(db)
969 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
970
971 let mut count = 0usize;
972 let keys_to_delete: Vec<(Vec<u8>, Memory)> = cursor
973 .iter()
974 .filter_map(|(key, val)| {
975 if let Ok(memory) = crate::codec::decode(val) {
976 Some((key.to_vec(), memory))
977 } else {
978 None
979 }
980 })
981 .collect();
982
983 drop(cursor);
984
985 for (key, memory) in &keys_to_delete {
986 let _ = self.index_dbs.remove(&mut tx, galaxy, memory);
987 tx.del(db, &key, None)
988 .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
989 count += 1;
990 }
991
992 tx.commit()
993 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
994 self.mutation_count
995 .fetch_add(count as u64, Ordering::Relaxed);
996 Ok(count)
997 }
998
999 pub fn batch_put(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<usize> {
1002 if memories.is_empty() {
1003 return Ok(0);
1004 }
1005
1006 let db = self.galaxy_db(galaxy)?;
1007
1008 let mut tx = self
1009 .env
1010 .begin_rw_txn()
1011 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1012
1013 let mut count = 0usize;
1014 for memory in memories {
1015 let key = memory.metadata.id.as_bytes();
1016 let val = rmp_serde::to_vec_named(memory)
1017 .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
1018 match tx.put(db, key, &val, WriteFlags::default()) {
1019 Ok(()) => {}
1020 Err(lmdb::Error::MapFull) => {
1021 tx.abort();
1022 return Err(CoreError::Memory(format!(
1023 "LMDB map full: galaxy {}, consider growing map size",
1024 galaxy.db_name()
1025 )));
1026 }
1027 Err(e) => {
1028 tx.abort();
1029 return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
1030 }
1031 }
1032 self.index_dbs.add(&mut tx, galaxy, memory)?;
1033 count += 1;
1034 }
1035
1036 tx.commit()
1037 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1038 self.mutation_count
1039 .fetch_add(count as u64, Ordering::Relaxed);
1040 Ok(count)
1041 }
1042
1043 pub fn get_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<Option<Vec<u8>>> {
1045 let db = self.galaxy_db(galaxy)?;
1046 let tx = self
1047 .env
1048 .begin_ro_txn()
1049 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1050 match tx.get(db, &key) {
1051 Ok(bytes) => {
1052 let data = bytes.to_vec();
1053 tx.commit()
1054 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1055 Ok(Some(data))
1056 }
1057 Err(lmdb::Error::NotFound) => {
1058 tx.commit()
1059 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1060 Ok(None)
1061 }
1062 Err(e) => Err(CoreError::Memory(format!("LMDB get_raw failed: {e}"))),
1063 }
1064 }
1065
1066 pub fn put_raw(&self, galaxy: Galaxy, key: &[u8], val: &[u8]) -> Result<()> {
1068 let db = self.galaxy_db(galaxy)?;
1069 let mut tx = self
1070 .env
1071 .begin_rw_txn()
1072 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1073 tx.put(db, &key, &val, lmdb::WriteFlags::default())
1074 .map_err(|e| CoreError::Memory(format!("LMDB put_raw failed: {e}")))?;
1075 tx.commit()
1076 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1077 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1078 Ok(())
1079 }
1080
1081 pub fn delete_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<bool> {
1084 let db = self.galaxy_db(galaxy)?;
1085 let mut tx = self
1086 .env
1087 .begin_rw_txn()
1088 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1089 let deleted = tx.del(db, &key, None).is_ok();
1090 tx.commit()
1091 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1092 if deleted {
1093 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1094 }
1095 Ok(deleted)
1096 }
1097
1098 pub fn put_raw_batch(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1101 self.put_raw_batch_impl(galaxy, entries)?;
1102 self.mutation_count
1103 .fetch_add(entries.len() as u64, Ordering::Relaxed);
1104 Ok(())
1105 }
1106
1107 pub fn put_raw_batch_untracked(
1117 &self,
1118 galaxy: Galaxy,
1119 entries: &[(&[u8], &[u8])],
1120 ) -> Result<()> {
1121 self.put_raw_batch_impl(galaxy, entries)
1122 }
1123
1124 fn put_raw_batch_impl(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1125 let db = self.galaxy_db(galaxy)?;
1126 let mut tx = self
1127 .env
1128 .begin_rw_txn()
1129 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1130 for (key, val) in entries {
1131 tx.put(db, key, val, WriteFlags::default())
1132 .map_err(|e| CoreError::Memory(format!("LMDB put_raw_batch failed: {e}")))?;
1133 }
1134 tx.commit()
1135 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1136 Ok(())
1137 }
1138
1139 pub fn find_by_content_hash(&self, galaxy: Galaxy, hash: &str) -> Result<Option<uuid::Uuid>> {
1145 let tx = self
1146 .env
1147 .begin_ro_txn()
1148 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1149 let result = self.index_dbs.find_by_content_hash(&tx, galaxy, hash)?;
1150 tx.commit()
1151 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1152 Ok(result)
1153 }
1154
1155 pub fn find_by_content_hash_scan(
1157 &self,
1158 galaxy: Galaxy,
1159 hash: &str,
1160 ) -> Result<Option<uuid::Uuid>> {
1161 let memories = self.scan(galaxy, 10_000)?;
1162 for mem in memories {
1163 if mem.metadata.content_hash == hash {
1164 return Ok(Some(mem.metadata.id));
1165 }
1166 }
1167 Ok(None)
1168 }
1169
1170 pub fn put_dedup(&self, galaxy: Galaxy, memory: &Memory) -> Result<uuid::Uuid> {
1174 if let Some(existing_id) =
1175 self.find_by_content_hash(galaxy, &memory.metadata.content_hash)?
1176 {
1177 return Ok(existing_id);
1178 }
1179 let id = memory.metadata.id;
1180 self.put(galaxy, memory)?;
1181 Ok(id)
1182 }
1183
1184 pub fn put_batch(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<()> {
1189 let db = self.galaxy_db(galaxy)?;
1190 let mut tx = self
1191 .env
1192 .begin_rw_txn()
1193 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1194
1195 for memory in memories {
1196 let key = memory.metadata.id.as_bytes();
1197 let val = rmp_serde::to_vec_named(memory)
1198 .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
1199 tx.put(db, key, &val, WriteFlags::default())
1200 .map_err(|e| CoreError::Memory(format!("LMDB put_batch failed: {e}")))?;
1201 self.index_dbs.add(&mut tx, galaxy, memory)?;
1202 }
1203
1204 tx.commit()
1205 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1206 self.mutation_count
1207 .fetch_add(memories.len() as u64, Ordering::Relaxed);
1208 Ok(())
1209 }
1210
1211 pub fn query(&self, galaxy: Galaxy, query: &MemoryQuery) -> Result<Vec<Memory>> {
1218 if query.content_substring.is_none()
1221 && query.tags.len() == 1
1222 && query.min_importance.is_none()
1223 && query.max_importance.is_none()
1224 && query.created_after.is_none()
1225 && query.created_before.is_none()
1226 {
1227 return self.query_by_tag_indexed(galaxy, &query.tags[0], query.limit);
1228 }
1229
1230 if query.content_substring.is_none()
1231 && query.tags.is_empty()
1232 && let Some(min) = query.min_importance
1233 && let Some(max) = query.max_importance
1234 && query.created_after.is_none()
1235 && query.created_before.is_none()
1236 {
1237 return self.query_by_importance_indexed(galaxy, min, max, query.limit);
1238 }
1239
1240 if query.content_substring.is_none()
1241 && query.tags.is_empty()
1242 && query.min_importance.is_none()
1243 && query.max_importance.is_none()
1244 && let Some(after) = query.created_after
1245 && let Some(before) = query.created_before
1246 {
1247 return self.query_by_time_indexed(galaxy, after, before, query.limit);
1248 }
1249
1250 let memories = self.scan(galaxy, 10_000)?;
1252 let mut results = Vec::new();
1253 for mem in memories {
1254 if query.matches(&mem) {
1255 results.push(mem);
1256 if results.len() >= query.limit {
1257 break;
1258 }
1259 }
1260 }
1261 Ok(results)
1262 }
1263
1264 fn query_by_tag_indexed(&self, galaxy: Galaxy, tag: &str, limit: usize) -> Result<Vec<Memory>> {
1266 let db = self.galaxy_db(galaxy)?;
1267 let tx = self
1268 .env
1269 .begin_ro_txn()
1270 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1271 let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1272 let mut results = Vec::new();
1273 for id in &ids {
1274 if results.len() >= limit {
1275 break;
1276 }
1277 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1278 if let Ok(mem) = crate::codec::decode(bytes) {
1279 results.push(mem);
1280 }
1281 }
1282 }
1283 tx.commit()
1284 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1285 Ok(results)
1286 }
1287
1288 fn query_by_importance_indexed(
1290 &self,
1291 galaxy: Galaxy,
1292 min: f32,
1293 max: f32,
1294 limit: usize,
1295 ) -> Result<Vec<Memory>> {
1296 let db = self.galaxy_db(galaxy)?;
1297 let tx = self
1298 .env
1299 .begin_ro_txn()
1300 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1301 let ids = self
1302 .index_dbs
1303 .find_by_importance_range(&tx, galaxy, min, max)?;
1304 let mut results = Vec::new();
1305 for id in &ids {
1306 if results.len() >= limit {
1307 break;
1308 }
1309 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1310 if let Ok(mem) = crate::codec::decode(bytes) {
1311 results.push(mem);
1312 }
1313 }
1314 }
1315 tx.commit()
1316 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1317 Ok(results)
1318 }
1319
1320 fn query_by_time_indexed(
1322 &self,
1323 galaxy: Galaxy,
1324 after: chrono::DateTime<chrono::Utc>,
1325 before: chrono::DateTime<chrono::Utc>,
1326 limit: usize,
1327 ) -> Result<Vec<Memory>> {
1328 let db = self.galaxy_db(galaxy)?;
1329 let tx = self
1330 .env
1331 .begin_ro_txn()
1332 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1333 let ids = self
1334 .index_dbs
1335 .find_by_time_range(&tx, galaxy, after, before)?;
1336 let mut results = Vec::new();
1337 for id in &ids {
1338 if results.len() >= limit {
1339 break;
1340 }
1341 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1342 if let Ok(mem) = crate::codec::decode(bytes) {
1343 results.push(mem);
1344 }
1345 }
1346 }
1347 tx.commit()
1348 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1349 Ok(results)
1350 }
1351
1352 pub fn put_semantic(&self, galaxy: Galaxy, memory: &mut Memory) -> Result<()> {
1360 let temporal_weight = memory.metadata.coord5d.w;
1361 let importance = memory.metadata.importance;
1362 memory.metadata.coord5d =
1363 self.semantic_encoder
1364 .encode_coordinate(&memory.content, temporal_weight, importance);
1365 self.put(galaxy, memory)
1366 }
1367
1368 pub fn find_similar(
1373 &self,
1374 galaxy: Galaxy,
1375 query_text: &str,
1376 limit: usize,
1377 ) -> Result<Vec<(Memory, f32)>> {
1378 let query_coord = self
1379 .semantic_encoder
1380 .encode_coordinate(query_text, 0.5, 0.5);
1381 let memories = self.scan(galaxy, 10_000)?;
1382 let mut results: Vec<(Memory, f32)> = memories
1383 .into_iter()
1384 .map(|m| {
1385 let dist = query_coord.semantic_distance_to(&m.metadata.coord5d);
1386 (m, dist)
1387 })
1388 .collect();
1389 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1390 results.truncate(limit);
1391 Ok(results)
1392 }
1393
1394 pub fn put_embedding(&self, memory_id: uuid::Uuid, embedding: &[f32]) -> Result<()> {
1399 let db = self.galaxy_db(Galaxy::Embeddings)?;
1400 let key = memory_id.as_bytes();
1401 let val = encode_embedding(embedding);
1402
1403 let mut tx = self
1404 .env
1405 .begin_rw_txn()
1406 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1407 tx.put(db, key, &val, WriteFlags::default())
1408 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding failed: {e}")))?;
1409 tx.commit()
1410 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1411 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1412 Ok(())
1413 }
1414
1415 pub fn get_embedding(&self, memory_id: uuid::Uuid) -> Result<Option<Vec<f32>>> {
1417 let db = self.galaxy_db(Galaxy::Embeddings)?;
1418 let key = memory_id.as_bytes();
1419
1420 let tx = self
1421 .env
1422 .begin_ro_txn()
1423 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1424 match tx.get(db, key) {
1425 Ok(bytes) => {
1426 let embedding = decode_embedding(bytes);
1427 tx.commit()
1428 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1429 Ok(Some(embedding))
1430 }
1431 Err(lmdb::Error::NotFound) => {
1432 tx.commit()
1433 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1434 Ok(None)
1435 }
1436 Err(e) => Err(CoreError::Memory(format!("LMDB get_embedding failed: {e}"))),
1437 }
1438 }
1439
1440 pub fn delete_embedding(&self, memory_id: uuid::Uuid) -> Result<bool> {
1442 let db = self.galaxy_db(Galaxy::Embeddings)?;
1443 let key = memory_id.as_bytes();
1444
1445 let mut tx = self
1446 .env
1447 .begin_rw_txn()
1448 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1449 let exists = tx.get(db, key).is_ok();
1450 if exists {
1451 tx.del(db, key, None)
1452 .map_err(|e| CoreError::Memory(format!("LMDB del_embedding failed: {e}")))?;
1453 }
1454 tx.commit()
1455 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1456 if exists {
1457 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1458 }
1459 Ok(exists)
1460 }
1461
1462 pub fn put_embedding_cache(&self, cache_key: &str, embedding: &[f32]) -> Result<()> {
1468 let mut tx = self
1469 .env
1470 .begin_rw_txn()
1471 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1472 tx.put(
1473 self.embedding_cache_db,
1474 &cache_key.as_bytes().to_vec(),
1475 &encode_embedding(embedding),
1476 WriteFlags::default(),
1477 )
1478 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1479 tx.commit()
1480 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1481 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1482 Ok(())
1483 }
1484
1485 pub fn put_embedding_cache_batch(&self, entries: &[(String, Vec<f32>)]) -> Result<()> {
1487 if entries.is_empty() {
1488 return Ok(());
1489 }
1490 let mut tx = self
1491 .env
1492 .begin_rw_txn()
1493 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1494 for (key, embedding) in entries {
1495 tx.put(
1496 self.embedding_cache_db,
1497 &key.as_bytes().to_vec(),
1498 &encode_embedding(embedding),
1499 WriteFlags::default(),
1500 )
1501 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1502 }
1503 tx.commit()
1504 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1505 self.mutation_count
1506 .fetch_add(entries.len() as u64, Ordering::Relaxed);
1507 Ok(())
1508 }
1509
1510 pub fn get_embedding_cache(&self, cache_key: &str) -> Result<Option<Vec<f32>>> {
1512 let tx = self
1513 .env
1514 .begin_ro_txn()
1515 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1516 match tx.get(self.embedding_cache_db, &cache_key.as_bytes().to_vec()) {
1517 Ok(bytes) => {
1518 let embedding = decode_embedding(bytes);
1519 tx.commit()
1520 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1521 Ok(Some(embedding))
1522 }
1523 Err(lmdb::Error::NotFound) => {
1524 tx.commit()
1525 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1526 Ok(None)
1527 }
1528 Err(e) => Err(CoreError::Memory(format!(
1529 "LMDB get_embedding_cache failed: {e}"
1530 ))),
1531 }
1532 }
1533
1534 pub fn get_embedding_cache_batch(&self, keys: &[String]) -> Result<Vec<Option<Vec<f32>>>> {
1537 let tx = self
1538 .env
1539 .begin_ro_txn()
1540 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1541 let mut out = Vec::with_capacity(keys.len());
1542 for key in keys {
1543 out.push(
1544 tx.get(self.embedding_cache_db, &key.as_bytes().to_vec())
1545 .ok()
1546 .map(decode_embedding),
1547 );
1548 }
1549 tx.commit()
1550 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1551 Ok(out)
1552 }
1553
1554 pub fn embedding_cache_count(&self) -> Result<u64> {
1556 let tx = self
1557 .env
1558 .begin_ro_txn()
1559 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1560 let mut cursor = tx
1561 .open_ro_cursor(self.embedding_cache_db)
1562 .map_err(|e| CoreError::Memory(format!("LMDB cursor embedding_cache failed: {e}")))?;
1563 let mut count = 0u64;
1564 for _ in cursor.iter() {
1565 count += 1;
1566 }
1567 Ok(count)
1568 }
1569
1570 pub fn record_revision(
1576 &self,
1577 galaxy: Galaxy,
1578 id: MemoryId,
1579 old_hash: &str,
1580 new_hash: &str,
1581 actor: crate::revision::RevisionActor,
1582 ) -> Result<crate::revision::MemoryRevision> {
1583 let seq = self.revisions(galaxy, id)?.len() as u32;
1584 let entry = crate::revision::MemoryRevision {
1585 seq,
1586 timestamp: wm_core::time::now_unix_secs(),
1587 old_hash: old_hash.to_string(),
1588 new_hash: new_hash.to_string(),
1589 actor_session: actor.session,
1590 actor_user: actor.user,
1591 actor_compartment: actor.compartment,
1592 };
1593 let key = crate::revision::revision_key(galaxy, id, seq);
1594 let val = serde_json::to_vec(&entry)
1595 .map_err(|e| CoreError::Memory(format!("revision serialize failed: {e}")))?;
1596 let mut tx = self
1597 .env
1598 .begin_rw_txn()
1599 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1600 tx.put(self.revisions_db, &key, &val, WriteFlags::default())
1601 .map_err(|e| CoreError::Memory(format!("LMDB put revision failed: {e}")))?;
1602 tx.commit()
1603 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1604 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1605 Ok(entry)
1606 }
1607
1608 pub fn revisions(
1611 &self,
1612 galaxy: Galaxy,
1613 id: MemoryId,
1614 ) -> Result<Vec<crate::revision::MemoryRevision>> {
1615 const MDB_GET_CURRENT: u32 = 4;
1620 const MDB_NEXT: u32 = 8;
1621 const MDB_SET_RANGE: u32 = 17;
1622 let prefix = crate::revision::revision_prefix(galaxy, id);
1623 let tx = self
1624 .env
1625 .begin_ro_txn()
1626 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1627 let cursor = tx
1628 .open_ro_cursor(self.revisions_db)
1629 .map_err(|e| CoreError::Memory(format!("LMDB cursor revisions failed: {e}")))?;
1630 let mut out = Vec::new();
1631 if cursor.get(Some(&prefix), None, MDB_SET_RANGE).is_ok() {
1632 while let Ok((key, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1633 if !key.is_some_and(|k| k.starts_with(&prefix)) {
1636 break;
1637 }
1638 let entry: crate::revision::MemoryRevision = serde_json::from_slice(val)
1639 .map_err(|e| CoreError::Memory(format!("revision deserialize failed: {e}")))?;
1640 out.push(entry);
1641 if out.len() >= 10_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1643 break;
1644 }
1645 }
1646 }
1647 drop(cursor);
1648 tx.commit()
1649 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1650 Ok(out)
1651 }
1652
1653 pub fn verify_revision_chain(
1656 &self,
1657 galaxy: Galaxy,
1658 id: MemoryId,
1659 current_hash: &str,
1660 ) -> Result<crate::revision::RevisionChainReport> {
1661 let entries = self.revisions(galaxy, id)?;
1662 Ok(crate::revision::verify_chain(&entries, current_hash))
1663 }
1664
1665 pub fn record_attestation(
1673 &self,
1674 galaxy: Galaxy,
1675 id: MemoryId,
1676 entry: &crate::attestation::RecordAttestation,
1677 ) -> Result<()> {
1678 let key = crate::attestation::attestation_key(galaxy, id);
1679 let val = serde_json::to_vec(entry)
1680 .map_err(|e| CoreError::Memory(format!("attestation serialize failed: {e}")))?;
1681 let mut tx = self
1682 .env
1683 .begin_rw_txn()
1684 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1685 tx.put(self.attestations_db, &key, &val, WriteFlags::default())
1686 .map_err(|e| CoreError::Memory(format!("LMDB put attestation failed: {e}")))?;
1687 tx.commit()
1688 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1689 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1690 Ok(())
1691 }
1692
1693 pub fn attestation(
1696 &self,
1697 galaxy: Galaxy,
1698 id: MemoryId,
1699 ) -> Result<Option<crate::attestation::RecordAttestation>> {
1700 let key = crate::attestation::attestation_key(galaxy, id);
1701 let tx = self
1702 .env
1703 .begin_ro_txn()
1704 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1705 let out =
1706 match tx.get(self.attestations_db, &key) {
1707 Ok(val) => Some(serde_json::from_slice(val).map_err(|e| {
1708 CoreError::Memory(format!("attestation deserialize failed: {e}"))
1709 })?),
1710 Err(lmdb::Error::NotFound) => None,
1711 Err(e) => {
1712 return Err(CoreError::Memory(format!(
1713 "LMDB get attestation failed: {e}"
1714 )));
1715 }
1716 };
1717 drop(tx);
1718 Ok(out)
1719 }
1720
1721 pub fn scan_attestations(&self) -> Result<Vec<crate::attestation::RecordAttestation>> {
1724 const MDB_GET_CURRENT: u32 = 4;
1725 const MDB_NEXT: u32 = 8;
1726 const MDB_FIRST: u32 = 9;
1727 let tx = self
1728 .env
1729 .begin_ro_txn()
1730 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1731 let cursor = tx
1732 .open_ro_cursor(self.attestations_db)
1733 .map_err(|e| CoreError::Memory(format!("LMDB cursor attestations failed: {e}")))?;
1734 let mut out = Vec::new();
1735 if cursor.get(None, None, MDB_FIRST).is_ok() {
1736 while let Ok((_, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1737 let entry: crate::attestation::RecordAttestation = serde_json::from_slice(val)
1738 .map_err(|e| {
1739 CoreError::Memory(format!("attestation deserialize failed: {e}"))
1740 })?;
1741 out.push(entry);
1742 if out.len() >= 1_000_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1744 break;
1745 }
1746 }
1747 }
1748 drop(cursor);
1749 tx.commit()
1750 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1751 Ok(out)
1752 }
1753
1754 pub fn verify_attestation(
1760 &self,
1761 galaxy: Galaxy,
1762 id: MemoryId,
1763 ) -> Result<crate::attestation::AttestationReport> {
1764 use crate::attestation::AttestationReport;
1765 let Some(att) = self.attestation(galaxy, id)? else {
1766 return Ok(AttestationReport {
1767 attested: false,
1768 signature_valid: false,
1769 matches_head: false,
1770 memory_present: self.get(galaxy, id)?.is_some(),
1771 breaks: vec!["no attestation recorded for this memory".to_string()],
1772 });
1773 };
1774 let mut breaks = Vec::new();
1775 let signature_valid = crate::attestation::verify_attestation(&att);
1776 if !signature_valid {
1777 breaks.push("signature does not verify against recorded pubkey".to_string());
1778 }
1779 let (matches_head, memory_present) = if let Some(memory) = self.get(galaxy, id)? {
1780 let matches = memory.metadata.content_hash == att.record_hash;
1781 if !matches {
1782 breaks.push(
1783 "attested record_hash != live content_hash (memory updated after attestation)"
1784 .to_string(),
1785 );
1786 }
1787 (matches, true)
1788 } else {
1789 breaks.push("attested memory id not present in galaxy".to_string());
1790 (false, false)
1791 };
1792 Ok(AttestationReport {
1793 attested: true,
1794 signature_valid,
1795 matches_head,
1796 memory_present,
1797 breaks,
1798 })
1799 }
1800
1801 pub fn attestation_sweep(
1805 &self,
1806 ) -> Result<
1807 Vec<(
1808 crate::attestation::RecordAttestation,
1809 crate::attestation::AttestationReport,
1810 )>,
1811 > {
1812 use crate::attestation::AttestationReport;
1813 let mut out = Vec::new();
1814 for att in self.scan_attestations()? {
1815 let parsed = match (
1816 Galaxy::from_db_name(&att.galaxy),
1817 uuid::Uuid::parse_str(&att.memory_id),
1818 ) {
1819 (Some(galaxy), Ok(id)) => Some((galaxy, id)),
1820 _ => None,
1821 };
1822 match parsed {
1823 Some((galaxy, id)) => out.push((att, self.verify_attestation(galaxy, id)?)),
1824 None => out.push((
1825 att,
1826 AttestationReport {
1827 attested: true,
1828 signature_valid: false,
1829 matches_head: false,
1830 memory_present: false,
1831 breaks: vec![
1832 "attestation row has unparseable galaxy or memory id".to_string(),
1833 ],
1834 },
1835 )),
1836 }
1837 }
1838 Ok(out)
1839 }
1840
1841 pub fn put_cold_record(&self, record: &crate::cold_storage::ColdRecord) -> Result<()> {
1845 let key = record.id.as_bytes();
1846 let val = rmp_serde::to_vec_named(record)
1847 .map_err(|e| CoreError::Memory(format!("Cold record serialization failed: {e}")))?;
1848 let mut tx = self
1849 .env
1850 .begin_rw_txn()
1851 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1852 tx.put(self.cold_storage_db, key, &val, WriteFlags::default())
1853 .map_err(|e| CoreError::Memory(format!("LMDB put cold_storage failed: {e}")))?;
1854 tx.commit()
1855 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1856 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1857 Ok(())
1858 }
1859
1860 pub fn get_cold_record(&self, id: MemoryId) -> Result<Option<crate::cold_storage::ColdRecord>> {
1862 let key = id.as_bytes();
1863 let tx = self
1864 .env
1865 .begin_ro_txn()
1866 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1867 match tx.get(self.cold_storage_db, key) {
1868 Ok(bytes) => {
1869 let record: crate::cold_storage::ColdRecord = rmp_serde::from_slice(bytes)
1870 .map_err(|e| {
1871 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
1872 })?;
1873 Ok(Some(record))
1874 }
1875 Err(lmdb::Error::NotFound) => Ok(None),
1876 Err(e) => Err(CoreError::Memory(format!(
1877 "LMDB get cold_storage failed: {e}"
1878 ))),
1879 }
1880 }
1881
1882 pub fn delete_cold_record(&self, id: MemoryId) -> Result<bool> {
1884 let key = id.as_bytes();
1885 let mut tx = self
1886 .env
1887 .begin_rw_txn()
1888 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1889 let deleted = match tx.del(self.cold_storage_db, key, None) {
1890 Ok(()) => true,
1891 Err(lmdb::Error::NotFound) => false,
1892 Err(e) => {
1893 return Err(CoreError::Memory(format!(
1894 "LMDB del cold_storage failed: {e}"
1895 )));
1896 }
1897 };
1898 tx.commit()
1899 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1900 if deleted {
1901 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1902 }
1903 Ok(deleted)
1904 }
1905
1906 pub fn count_cold(&self, galaxy: Option<Galaxy>) -> Result<usize> {
1908 let tx = self
1909 .env
1910 .begin_ro_txn()
1911 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1912 let mut cursor = tx
1913 .open_ro_cursor(self.cold_storage_db)
1914 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
1915 let mut count = 0;
1916 for (_key, val) in cursor.iter() {
1917 if let Some(target_g) = galaxy {
1918 let record: crate::cold_storage::ColdRecord =
1919 rmp_serde::from_slice(val).map_err(|e| {
1920 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
1921 })?;
1922 if record.galaxy == target_g {
1923 count += 1;
1924 }
1925 } else {
1926 count += 1;
1927 }
1928 }
1929 Ok(count)
1930 }
1931
1932 pub fn list_cold_records(
1934 &self,
1935 galaxy: Option<Galaxy>,
1936 limit: usize,
1937 ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
1938 let query = crate::cold_storage::ColdQuery {
1939 galaxy,
1940 limit: if limit == 0 { 100 } else { limit },
1941 ..Default::default()
1942 };
1943 self.query_cold_records(&query)
1944 }
1945
1946 pub fn query_cold_records(
1948 &self,
1949 query: &crate::cold_storage::ColdQuery,
1950 ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
1951 let tx = self
1952 .env
1953 .begin_ro_txn()
1954 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1955 let mut cursor = tx
1956 .open_ro_cursor(self.cold_storage_db)
1957 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
1958 let mut results = Vec::new();
1959 let limit = if query.limit == 0 {
1960 usize::MAX
1961 } else {
1962 query.limit
1963 };
1964
1965 for (_key, val) in cursor.iter() {
1966 let record: crate::cold_storage::ColdRecord =
1967 rmp_serde::from_slice(val).map_err(|e| {
1968 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
1969 })?;
1970 let summary = record.summary();
1971 if query.matches(&summary) {
1972 results.push(summary);
1973 if results.len() >= limit {
1974 break;
1975 }
1976 }
1977 }
1978 Ok(results)
1979 }
1980
1981 pub fn find_cold_matching(
1990 &self,
1991 terms: &[String],
1992 galaxy: Option<Galaxy>,
1993 limit: usize,
1994 max_scan: usize,
1995 ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
1996 self.find_cold_matching_eligible(terms, galaxy, limit, max_scan, |_| true)
1997 }
1998
1999 pub fn find_cold_matching_eligible(
2002 &self,
2003 terms: &[String],
2004 galaxy: Option<Galaxy>,
2005 limit: usize,
2006 max_scan: usize,
2007 eligible: impl Fn(&Memory) -> bool,
2008 ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2009 use crate::cold_storage::ColdDiscoveryStop;
2010 let mut out = crate::cold_storage::ColdDiscoveryOutcome::default();
2011 if terms.is_empty() || limit == 0 || max_scan == 0 {
2012 return Ok(out);
2013 }
2014 let tx = self
2015 .env
2016 .begin_ro_txn()
2017 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2018 let mut cursor = tx
2019 .open_ro_cursor(self.cold_storage_db)
2020 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2021 let mut iter = cursor.iter();
2022 loop {
2023 if out.scanned >= max_scan {
2024 out.stop_reason = ColdDiscoveryStop::ScanLimit;
2025 break;
2026 }
2027 if out.records.len() >= limit {
2028 out.stop_reason = ColdDiscoveryStop::ResultLimit;
2029 break;
2030 }
2031 let Some((key, val)) = iter.next() else {
2032 out.stop_reason = ColdDiscoveryStop::Exhausted;
2033 break;
2034 };
2035 out.scanned += 1;
2036 let record: crate::cold_storage::ColdRecord = if let Ok(r) = rmp_serde::from_slice(val)
2037 {
2038 r
2039 } else {
2040 out.integrity_rejected += 1;
2041 continue;
2042 };
2043 if let Some(g) = galaxy {
2044 if record.galaxy != g {
2045 continue;
2046 }
2047 }
2048 out.candidates += 1;
2049 let mem = if let Ok(m) = record.decompress() {
2050 m
2051 } else {
2052 out.integrity_rejected += 1;
2053 continue;
2054 };
2055 if mem.metadata.is_private {
2056 out.private_skipped += 1;
2057 continue;
2058 }
2059 if !mem.metadata.validity.is_current() {
2060 out.non_current_skipped += 1;
2061 continue;
2062 }
2063 let integrity_ok = key == record.id.as_bytes()
2064 && mem.metadata.id == record.id
2065 && mem.metadata.galaxy == record.galaxy
2066 && mem.metadata.content_hash == record.content_hash
2067 && crate::content_hash(&mem.content) == record.content_hash;
2068 if !integrity_ok {
2069 out.integrity_rejected += 1;
2070 continue;
2071 }
2072 let haystack = format!(
2073 "{} {}",
2074 mem.content.to_lowercase(),
2075 mem.metadata.tags.join(" ").to_lowercase()
2076 );
2077 if !terms.iter().all(|t| haystack.contains(t.as_str())) {
2078 continue;
2079 }
2080 out.matched += 1;
2081 if !eligible(&mem) {
2082 out.eligibility_skipped += 1;
2083 continue;
2084 }
2085 out.records.push(record);
2086 }
2087 Ok(out)
2088 }
2089
2090 pub fn freeze_to_cold(
2096 &self,
2097 search: Option<&crate::SearchEngine>,
2098 memory_id: MemoryId,
2099 distance: f32,
2100 factors: crate::cold_storage::OuterRimFactors,
2101 digest_id: Option<MemoryId>,
2102 notes: Option<String>,
2103 codec: crate::cold_storage::CompressionCodec,
2104 ) -> Result<crate::cold_storage::ColdRecord> {
2105 let (galaxy, mut mem) = self.find_across_galaxies(memory_id)?.ok_or_else(|| {
2106 CoreError::NotFound(format!("Memory {memory_id} not found in hot store"))
2107 })?;
2108
2109 if mem.metadata.tier != crate::memory::Tier::Archival {
2111 let _ = mem.transition_tier(crate::memory::Tier::Archival);
2112 }
2113
2114 let record =
2115 crate::cold_storage::ColdRecord::new(&mem, distance, factors, digest_id, notes, codec)?;
2116
2117 self.put_cold_record(&record)?;
2119
2120 self.delete(galaxy, memory_id)?;
2122
2123 if let Some(engine) = search {
2125 if let Ok(mut writer_guard) = engine.writer() {
2126 let _ = engine.delete_document(&mut writer_guard, &memory_id.to_string());
2127 let _ = engine.commit(&mut writer_guard);
2128 }
2129 }
2130
2131 Ok(record)
2132 }
2133
2134 pub fn thaw_from_cold(
2140 &self,
2141 search: Option<&crate::SearchEngine>,
2142 memory_id: MemoryId,
2143 ) -> Result<Memory> {
2144 let record = self.get_cold_record(memory_id)?.ok_or_else(|| {
2145 CoreError::NotFound(format!("Memory {memory_id} not found in cold storage"))
2146 })?;
2147
2148 let mut mem = record.decompress()?;
2149
2150 let _ = mem.transition_tier(crate::memory::Tier::Episodic);
2152 mem.metadata.accessed_at = chrono::Utc::now();
2153 mem.metadata.access_count += 1;
2154 mem.metadata.recall_count += 1;
2155 if !mem.metadata.tags.iter().any(|t| t == "thawed:phagic") {
2156 mem.metadata.tags.push("thawed:phagic".to_string());
2157 }
2158
2159 self.put(record.galaxy, &mem)?;
2161
2162 if let Some(engine) = search {
2164 if let Ok(mut writer_guard) = engine.writer() {
2165 let _ = engine.index_memory(&mut writer_guard, &mem);
2166 let _ = engine.commit(&mut writer_guard);
2167 }
2168 }
2169
2170 self.delete_cold_record(memory_id)?;
2172
2173 Ok(mem)
2174 }
2175
2176 pub fn find_anywhere(&self, id: MemoryId) -> Result<Option<(Galaxy, Memory, bool)>> {
2180 if let Some((galaxy, mem)) = self.find_across_galaxies(id)? {
2181 return Ok(Some((galaxy, mem, false)));
2182 }
2183 if let Some(cold_record) = self.get_cold_record(id)? {
2184 let galaxy = cold_record.galaxy;
2185 let mem = cold_record.decompress()?;
2186 return Ok(Some((galaxy, mem, true)));
2187 }
2188 Ok(None)
2189 }
2190}
2191
2192#[cfg(test)]
2193mod tests {
2194 use super::*;
2195 use crate::content_hash;
2196
2197 #[test]
2198 fn open_and_create_galaxies() {
2199 let tmp = tempfile::tempdir().unwrap();
2200 let store = MemoryStore::open_default(tmp.path()).unwrap();
2201 for galaxy in Galaxy::all() {
2202 let _db = store.galaxy_db(galaxy).unwrap();
2203 }
2204 }
2205
2206 #[test]
2207 fn ensure_schema_completes_a_pre_cold_store() {
2208 use lmdb::{DatabaseFlags as LmdbFlags, Environment as LmdbEnv};
2209 use uuid::Uuid;
2210
2211 let tmp = tempfile::tempdir().unwrap();
2214 let path = tmp.path().join("old-store");
2215 std::fs::create_dir_all(&path).unwrap();
2216 {
2217 let env = LmdbEnv::new().set_max_dbs(64).open(&path).unwrap();
2218 for galaxy in Galaxy::all() {
2219 env.create_db(Some(galaxy.db_name()), LmdbFlags::default())
2220 .unwrap();
2221 }
2222 for (name, flags) in crate::indexes::INDEX_DBS {
2223 env.create_db(Some(name), *flags).unwrap();
2224 }
2225 for (name, flags) in [
2226 ("episodic_records", LmdbFlags::default()),
2227 ("episodic_terms_v2", LmdbFlags::DUP_SORT),
2228 ("embedding_cache", LmdbFlags::default()),
2229 ("revisions", LmdbFlags::default()),
2230 (crate::attestation::ATTESTATIONS_DB, LmdbFlags::default()),
2231 ] {
2232 env.create_db(Some(name), flags).unwrap();
2233 }
2234 }
2235
2236 let files_before: Vec<String> = {
2237 let mut names: Vec<String> = std::fs::read_dir(&path)
2238 .unwrap()
2239 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2240 .collect();
2241 names.sort();
2242 names
2243 };
2244 let data_before = std::fs::read(path.join("data.mdb")).unwrap();
2245
2246 let error = match MemoryStore::open_readonly(&path) {
2247 Ok(_) => panic!("strict read-only open must refuse an incomplete store"),
2248 Err(e) => e.to_string(),
2249 };
2250 assert!(error.contains("cold_storage"), "{error}");
2251 assert_eq!(
2252 std::fs::read(path.join("data.mdb")).unwrap(),
2253 data_before,
2254 "readonly refusal must not mutate the pre-cold store"
2255 );
2256 let files_after: Vec<String> = {
2257 let mut names: Vec<String> = std::fs::read_dir(&path)
2258 .unwrap()
2259 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2260 .collect();
2261 names.sort();
2262 names
2263 };
2264 assert_eq!(
2265 files_after, files_before,
2266 "readonly refusal changed the store directory"
2267 );
2268
2269 let created = MemoryStore::ensure_schema(&path).unwrap();
2270 assert_eq!(created, vec!["cold_storage".to_string()], "{created:?}");
2271
2272 let store = MemoryStore::open_readonly(&path).unwrap();
2273 assert!(store.get_cold_record(Uuid::nil()).unwrap().is_none());
2274
2275 assert!(MemoryStore::ensure_schema(&path).unwrap().is_empty());
2277 }
2278
2279 #[test]
2280 fn cold_discovery_hydrates_verifies_and_respects_visibility() {
2281 use crate::cold_storage::{ColdRecord, CompressionCodec, OuterRimFactors};
2282 let tmp = tempfile::tempdir().unwrap();
2283 let store = MemoryStore::open_default(tmp.path()).unwrap();
2284 let factors = OuterRimFactors {
2285 age_factor: 0.5,
2286 access_factor: 0.5,
2287 resonance_factor: 0.5,
2288 emotional_factor: 0.5,
2289 importance_factor: 0.5,
2290 distance: 0.5,
2291 };
2292
2293 let pub_mem = Memory::new(
2294 Galaxy::Codex,
2295 "public needle zxquniquecoldfact741 buried".into(),
2296 );
2297 let rec = ColdRecord::new(
2298 &pub_mem,
2299 0.5,
2300 factors.clone(),
2301 None,
2302 None,
2303 CompressionCodec::Gzip,
2304 )
2305 .unwrap();
2306 store.put_cold_record(&rec).unwrap();
2307 let out = store
2308 .find_cold_matching(&["zxquniquecoldfact741".to_string()], None, 10, 100)
2309 .unwrap();
2310 assert_eq!(out.matched, 1);
2311 assert_eq!(out.integrity_rejected, 0);
2312 assert_eq!(out.records.len(), 1);
2313
2314 let mut priv_mem = Memory::new(Galaxy::Codex, "private needle zxquniquecoldfact742".into());
2316 priv_mem.metadata.is_private = true;
2317 store
2318 .put_cold_record(
2319 &ColdRecord::new(&priv_mem, 0.5, factors, None, None, CompressionCodec::Gzip)
2320 .unwrap(),
2321 )
2322 .unwrap();
2323 let out_priv = store
2324 .find_cold_matching(&["zxquniquecoldfact742".to_string()], None, 10, 100)
2325 .unwrap();
2326 assert_eq!(out_priv.matched, 0);
2327 assert_eq!(out_priv.private_skipped, 1);
2328
2329 let mut tampered = rec.clone();
2332 let mut bad = Memory::new(Galaxy::Codex, "tampered needle zxquniquecoldfact743".into());
2333 bad.metadata.id = rec.id;
2334 let (payload, size) =
2335 crate::cold_storage::compress_memory(&bad, CompressionCodec::Gzip).unwrap();
2336 tampered.compressed_payload = payload;
2337 tampered.uncompressed_size = size;
2338 store.put_cold_record(&tampered).unwrap();
2339 let out_tamper = store
2340 .find_cold_matching(&["zxquniquecoldfact743".to_string()], None, 10, 100)
2341 .unwrap();
2342 assert_eq!(out_tamper.matched, 0);
2343 assert_eq!(out_tamper.integrity_rejected, 1);
2344
2345 store.delete_cold_record(rec.id).unwrap();
2348 let wrong_key = uuid::Uuid::from_u128(741);
2349 assert_ne!(wrong_key, rec.id);
2350 let value = rmp_serde::to_vec_named(&rec).unwrap();
2351 let mut tx = store.env.begin_rw_txn().unwrap();
2352 tx.put(
2353 store.cold_storage_db,
2354 wrong_key.as_bytes(),
2355 &value,
2356 WriteFlags::default(),
2357 )
2358 .unwrap();
2359 tx.commit().unwrap();
2360 let wrong_key_out = store
2361 .find_cold_matching(&["zxquniquecoldfact741".into()], None, 10, 100)
2362 .unwrap();
2363 assert!(wrong_key_out.records.is_empty());
2364 assert_eq!(wrong_key_out.integrity_rejected, 1);
2365 }
2366
2367 #[test]
2371 fn query_substring_filters_galaxy_wide() {
2372 let tmp = tempfile::tempdir().unwrap();
2373 let store = MemoryStore::open_default(tmp.path()).unwrap();
2374
2375 for (i, content) in [
2376 "the mesh joins at dawn",
2377 "unrelated content entirely",
2378 "MESH joins at dusk",
2379 ]
2380 .iter()
2381 .enumerate()
2382 {
2383 let mut m = Memory::new(Galaxy::Codex, content.to_string());
2384 m.metadata.importance = 0.5 + i as f32 / 10.0;
2385 store.put(Galaxy::Codex, &m).unwrap();
2386 }
2387
2388 let hits = store
2389 .query(
2390 Galaxy::Codex,
2391 &MemoryQuery::new().with_content_substring("mesh joins"),
2392 )
2393 .unwrap();
2394 assert_eq!(hits.len(), 2, "CI substring must match both: {hits:?}");
2395 assert!(
2396 hits.iter()
2397 .all(|m| m.content.to_lowercase().contains("mesh joins"))
2398 );
2399
2400 let none = store
2401 .query(
2402 Galaxy::Codex,
2403 &MemoryQuery::new().with_content_substring("quantum calendar"),
2404 )
2405 .unwrap();
2406 assert!(none.is_empty(), "no match must be an honest empty set");
2407
2408 let mut tagged = Memory::new(Galaxy::Codex, "mesh joins again".to_string());
2410 tagged.metadata.tags = vec!["mesh".into()];
2411 store.put(Galaxy::Codex, &tagged).unwrap();
2412 let combined = store
2413 .query(
2414 Galaxy::Codex,
2415 &MemoryQuery::new()
2416 .with_tags(vec!["mesh".into()])
2417 .with_content_substring("again"),
2418 )
2419 .unwrap();
2420 assert_eq!(combined.len(), 1);
2421 assert_eq!(combined[0].content, "mesh joins again");
2422 }
2423
2424 #[cfg(unix)]
2425 #[test]
2426 fn store_dir_has_restrictive_permissions() {
2427 let tmp = tempfile::tempdir().unwrap();
2428 let store_path = tmp.path().join("lmdb");
2429 let _store = MemoryStore::open_default(&store_path).unwrap();
2430 let perms = std::fs::metadata(&store_path).unwrap().permissions().mode();
2431 assert_eq!(
2432 perms & 0o777,
2433 0o700,
2434 "store directory should have 0700 permissions, got {:o}",
2435 perms & 0o777
2436 );
2437 }
2438
2439 #[test]
2440 fn put_get_delete_memory() {
2441 let tmp = tempfile::tempdir().unwrap();
2442 let store = MemoryStore::open_default(tmp.path()).unwrap();
2443
2444 let mem = Memory::new(Galaxy::Codex, "Hello world".to_string());
2445 let id = mem.metadata.id;
2446
2447 store.put(Galaxy::Codex, &mem).unwrap();
2448 let retrieved = store.get(Galaxy::Codex, id).unwrap();
2449 assert!(retrieved.is_some());
2450 assert_eq!(retrieved.unwrap().content, "Hello world");
2451
2452 let deleted = store.delete(Galaxy::Codex, id).unwrap();
2453 assert!(deleted);
2454
2455 let gone = store.get(Galaxy::Codex, id).unwrap();
2456 assert!(gone.is_none());
2457 }
2458
2459 #[test]
2460 fn scan_memories() {
2461 let tmp = tempfile::tempdir().unwrap();
2462 let store = MemoryStore::open_default(tmp.path()).unwrap();
2463
2464 for i in 0..5 {
2465 let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
2466 store.put(Galaxy::Codex, &mem).unwrap();
2467 }
2468
2469 let all = store.scan(Galaxy::Codex, 100).unwrap();
2470 assert_eq!(all.len(), 5);
2471
2472 let limited = store.scan(Galaxy::Codex, 3).unwrap();
2473 assert_eq!(limited.len(), 3);
2474 }
2475
2476 #[test]
2477 fn overwrite_removes_stale_index_entries() {
2478 let tmp = tempfile::tempdir().unwrap();
2479 let store = MemoryStore::open_default(tmp.path()).unwrap();
2480
2481 let mut mem = Memory::new(Galaxy::Codex, "overwrite target".to_string());
2483 mem.metadata.tags = vec!["alpha".to_string()];
2484 mem.metadata.importance = 0.9;
2485 let id = mem.metadata.id;
2486 store.put(Galaxy::Codex, &mem).unwrap();
2487
2488 let mut updated = Memory::new(Galaxy::Codex, "overwritten content".to_string());
2490 updated.metadata.id = id;
2491 updated.metadata.tags = vec!["beta".to_string()];
2492 updated.metadata.importance = 0.1;
2493 store.put(Galaxy::Codex, &updated).unwrap();
2494
2495 let tx = store.env().begin_ro_txn().unwrap();
2497 let by_alpha = store
2498 .index_dbs()
2499 .find_by_tag(&tx, Galaxy::Codex, "alpha")
2500 .unwrap();
2501 let by_beta = store
2502 .index_dbs()
2503 .find_by_tag(&tx, Galaxy::Codex, "beta")
2504 .unwrap();
2505 assert!(
2506 by_alpha.is_empty(),
2507 "stale tag index entries must be removed on overwrite"
2508 );
2509 assert_eq!(by_beta, vec![id]);
2510
2511 let by_importance = store
2512 .index_dbs()
2513 .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 0.2)
2514 .unwrap();
2515 assert!(
2516 by_importance.contains(&id),
2517 "new importance must be indexed"
2518 );
2519 let by_high = store
2520 .index_dbs()
2521 .find_by_importance_range(&tx, Galaxy::Codex, 0.8, 1.0)
2522 .unwrap();
2523 assert!(
2524 !by_high.contains(&id),
2525 "stale importance index entries must be removed on overwrite"
2526 );
2527
2528 let old_hash = content_hash("overwrite target");
2529 let new_hash = content_hash("overwritten content");
2530 assert_eq!(
2531 store
2532 .index_dbs()
2533 .find_by_content_hash(&tx, Galaxy::Codex, &old_hash)
2534 .unwrap(),
2535 None,
2536 "stale content-hash index entry must be removed"
2537 );
2538 assert_eq!(
2539 store
2540 .index_dbs()
2541 .find_by_content_hash(&tx, Galaxy::Codex, &new_hash)
2542 .unwrap(),
2543 Some(id)
2544 );
2545 }
2546
2547 #[test]
2548 fn count_memories() {
2549 let tmp = tempfile::tempdir().unwrap();
2550 let store = MemoryStore::open_default(tmp.path()).unwrap();
2551
2552 assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
2553
2554 for i in 0..3 {
2555 let mem = Memory::new(Galaxy::Codex, format!("count-{i}"));
2556 store.put(Galaxy::Codex, &mem).unwrap();
2557 }
2558
2559 assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
2560 }
2561
2562 #[test]
2563 fn get_nonexistent_returns_none() {
2564 let tmp = tempfile::tempdir().unwrap();
2565 let store = MemoryStore::open_default(tmp.path()).unwrap();
2566 let result = store.get(Galaxy::Codex, uuid::Uuid::new_v4()).unwrap();
2567 assert!(result.is_none());
2568 }
2569
2570 #[test]
2571 fn raw_put_get() {
2572 let tmp = tempfile::tempdir().unwrap();
2573 let store = MemoryStore::open_default(tmp.path()).unwrap();
2574
2575 store
2576 .put_raw(Galaxy::Substrate, b"config:key", b"value123")
2577 .unwrap();
2578 let val = store.get_raw(Galaxy::Substrate, b"config:key").unwrap();
2579 assert_eq!(val, Some(b"value123".to_vec()));
2580 }
2581
2582 #[test]
2583 fn put_dedup_prevents_duplicates() {
2584 let tmp = tempfile::tempdir().unwrap();
2585 let store = MemoryStore::open_default(tmp.path()).unwrap();
2586
2587 let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2588 let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2589
2590 let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2591 let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2592
2593 assert_eq!(id1, id2, "dedup should return same ID for same content");
2594 assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2595 }
2596
2597 #[test]
2598 fn put_dedup_allows_different_content() {
2599 let tmp = tempfile::tempdir().unwrap();
2600 let store = MemoryStore::open_default(tmp.path()).unwrap();
2601
2602 let mem1 = Memory::new(Galaxy::Codex, "content A".into());
2603 store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2604
2605 let mem2 = Memory::new(Galaxy::Codex, "content B".into());
2606 store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2607
2608 assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
2609 }
2610
2611 #[test]
2612 fn put_batch_atomic_write() {
2613 let tmp = tempfile::tempdir().unwrap();
2614 let store = MemoryStore::open_default(tmp.path()).unwrap();
2615
2616 let memories: Vec<Memory> = (0..10)
2617 .map(|i| Memory::new(Galaxy::Codex, format!("batch-{i}")))
2618 .collect();
2619
2620 store.put_batch(Galaxy::Codex, &memories).unwrap();
2621 assert_eq!(store.count(Galaxy::Codex).unwrap(), 10);
2622 }
2623
2624 #[test]
2625 fn query_by_tags() {
2626 let tmp = tempfile::tempdir().unwrap();
2627 let store = MemoryStore::open_default(tmp.path()).unwrap();
2628
2629 let mem1 = Memory::new(Galaxy::Codex, "tagged memory".into())
2630 .with_tags(vec!["rust".into(), "memory".into()]);
2631 let mem2 =
2632 Memory::new(Galaxy::Codex, "other memory".into()).with_tags(vec!["python".into()]);
2633 store.put(Galaxy::Codex, &mem1).unwrap();
2634 store.put(Galaxy::Codex, &mem2).unwrap();
2635
2636 let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2637 let results = store.query(Galaxy::Codex, &query).unwrap();
2638 assert_eq!(results.len(), 1);
2639 assert_eq!(results[0].content, "tagged memory");
2640 }
2641
2642 #[test]
2643 fn query_by_importance_range() {
2644 let tmp = tempfile::tempdir().unwrap();
2645 let store = MemoryStore::open_default(tmp.path()).unwrap();
2646
2647 store
2648 .put(
2649 Galaxy::Codex,
2650 &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
2651 )
2652 .unwrap();
2653 store
2654 .put(
2655 Galaxy::Codex,
2656 &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
2657 )
2658 .unwrap();
2659 store
2660 .put(
2661 Galaxy::Codex,
2662 &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
2663 )
2664 .unwrap();
2665
2666 let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
2667 let results = store.query(Galaxy::Codex, &query).unwrap();
2668 assert_eq!(results.len(), 1);
2669 assert_eq!(results[0].content, "mid");
2670 }
2671
2672 #[test]
2673 fn memory_query_one_sided_time_bounds() {
2674 let old = Memory::new(Galaxy::Codex, "old".into());
2677 let mut recent = Memory::new(Galaxy::Codex, "recent".into());
2678 recent.metadata.created_at = old.metadata.created_at + chrono::Duration::days(30);
2679
2680 let cutoff = old.metadata.created_at + chrono::Duration::days(10);
2681 let after = MemoryQuery::new().with_created_after(cutoff);
2682 assert!(!after.matches(&old), "pre-cutoff memory must not match");
2683 assert!(after.matches(&recent), "post-cutoff memory must match");
2684
2685 let before = MemoryQuery::new().with_created_before(cutoff);
2686 assert!(before.matches(&old), "pre-cutoff memory must match");
2687 assert!(
2688 !before.matches(&recent),
2689 "post-cutoff memory must not match"
2690 );
2691
2692 let edge = MemoryQuery::new().with_created_after(cutoff);
2694 let mut at = Memory::new(Galaxy::Codex, "at cutoff".into());
2695 at.metadata.created_at = cutoff;
2696 assert!(edge.matches(&at), "created_at == after bound is inclusive");
2697 }
2698
2699 #[test]
2700 fn embedding_put_get_delete() {
2701 let tmp = tempfile::tempdir().unwrap();
2702 let store = MemoryStore::open_default(tmp.path()).unwrap();
2703
2704 let id = uuid::Uuid::new_v4();
2705 let embedding = vec![0.1, 0.2, 0.3, 0.4, 0.5];
2706
2707 store.put_embedding(id, &embedding).unwrap();
2708 let retrieved = store.get_embedding(id).unwrap();
2709 assert!(retrieved.is_some());
2710 let retrieved = retrieved.unwrap();
2711 assert_eq!(retrieved.len(), 5);
2712 assert!((retrieved[0] - 0.1).abs() < f32::EPSILON);
2713
2714 assert!(store.delete_embedding(id).unwrap());
2715 assert!(store.get_embedding(id).unwrap().is_none());
2716 }
2717
2718 #[test]
2719 fn embedding_cache_roundtrip_batch_and_count() {
2720 let tmp = tempfile::tempdir().unwrap();
2721 let store = MemoryStore::open_default(tmp.path()).unwrap();
2722
2723 let entries: Vec<(String, Vec<f32>)> = (0..5)
2724 .map(|i| (format!("ns:model:{i:016x}"), vec![i as f32; 8]))
2725 .collect();
2726 store.put_embedding_cache_batch(&entries).unwrap();
2727 assert_eq!(store.embedding_cache_count().unwrap(), 5);
2728
2729 let hit = store
2731 .get_embedding_cache("ns:model:0000000000000003")
2732 .unwrap();
2733 assert_eq!(hit.unwrap(), vec![3.0f32; 8]);
2734 assert!(
2735 store
2736 .get_embedding_cache("ns:model:absent")
2737 .unwrap()
2738 .is_none()
2739 );
2740
2741 let keys: Vec<String> = (0..6).map(|i| format!("ns:model:{i:016x}")).collect();
2743 let batch = store.get_embedding_cache_batch(&keys).unwrap();
2744 assert_eq!(batch.len(), 6);
2745 assert!(batch[0..5].iter().all(Option::is_some));
2746 assert!(batch[5].is_none());
2747
2748 store
2750 .put_embedding_cache("ns:model:0000000000000001", &[9.0; 8])
2751 .unwrap();
2752 assert_eq!(store.embedding_cache_count().unwrap(), 5);
2753 assert_eq!(
2754 store
2755 .get_embedding_cache("ns:model:0000000000000001")
2756 .unwrap()
2757 .unwrap(),
2758 vec![9.0f32; 8]
2759 );
2760 }
2761
2762 #[test]
2763 fn embedding_cache_survives_store_reopen() {
2764 let tmp = tempfile::tempdir().unwrap();
2766 {
2767 let store = MemoryStore::open_default(tmp.path()).unwrap();
2768 store
2769 .put_embedding_cache("onnx:bge-small:abc", &[0.5; 384])
2770 .unwrap();
2771 }
2772 let reopened = MemoryStore::open_default(tmp.path()).unwrap();
2773 let cached = reopened.get_embedding_cache("onnx:bge-small:abc").unwrap();
2774 assert_eq!(cached.unwrap(), vec![0.5f32; 384]);
2775 }
2776
2777 #[test]
2778 fn content_hash_is_sha256() {
2779 let hash1 = content_hash("test content");
2780 let hash2 = content_hash("test content");
2781 let hash3 = content_hash("different content");
2782
2783 assert_eq!(hash1, hash2, "same content should produce same hash");
2784 assert_ne!(
2785 hash1, hash3,
2786 "different content should produce different hash"
2787 );
2788 assert_eq!(hash1.len(), 64, "SHA-256 hex should be 64 chars");
2789 }
2790
2791 #[test]
2792 fn query_by_tag_uses_index() {
2793 let tmp = tempfile::tempdir().unwrap();
2794 let store = MemoryStore::open_default(tmp.path()).unwrap();
2795
2796 let mem1 = Memory::new(Galaxy::Codex, "tagged".into())
2797 .with_tags(vec!["rust".into(), "memory".into()]);
2798 let mem2 = Memory::new(Galaxy::Codex, "other".into()).with_tags(vec!["python".into()]);
2799 store.put(Galaxy::Codex, &mem1).unwrap();
2800 store.put(Galaxy::Codex, &mem2).unwrap();
2801
2802 let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2803 let results = store.query(Galaxy::Codex, &query).unwrap();
2804 assert_eq!(results.len(), 1);
2805 assert_eq!(results[0].content, "tagged");
2806 }
2807
2808 #[test]
2809 fn query_by_importance_uses_index() {
2810 let tmp = tempfile::tempdir().unwrap();
2811 let store = MemoryStore::open_default(tmp.path()).unwrap();
2812
2813 store
2814 .put(
2815 Galaxy::Codex,
2816 &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
2817 )
2818 .unwrap();
2819 store
2820 .put(
2821 Galaxy::Codex,
2822 &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
2823 )
2824 .unwrap();
2825 store
2826 .put(
2827 Galaxy::Codex,
2828 &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
2829 )
2830 .unwrap();
2831
2832 let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
2833 let results = store.query(Galaxy::Codex, &query).unwrap();
2834 assert_eq!(results.len(), 1);
2835 assert_eq!(results[0].content, "mid");
2836 }
2837
2838 #[test]
2839 fn query_by_time_uses_index() {
2840 let tmp = tempfile::tempdir().unwrap();
2841 let store = MemoryStore::open_default(tmp.path()).unwrap();
2842
2843 let t0 = chrono::Utc::now();
2844 std::thread::sleep(std::time::Duration::from_millis(10));
2845 let mem = Memory::new(Galaxy::Codex, "timed".into());
2846 store.put(Galaxy::Codex, &mem).unwrap();
2847 std::thread::sleep(std::time::Duration::from_millis(10));
2848 let t2 = chrono::Utc::now();
2849
2850 let query = MemoryQuery::new().with_time_range(t0, t2);
2851 let results = store.query(Galaxy::Codex, &query).unwrap();
2852 assert_eq!(results.len(), 1);
2853 assert_eq!(results[0].content, "timed");
2854 }
2855
2856 #[test]
2857 fn delete_removes_index_entries() {
2858 let tmp = tempfile::tempdir().unwrap();
2859 let store = MemoryStore::open_default(tmp.path()).unwrap();
2860
2861 let mem = Memory::new(Galaxy::Codex, "test".into())
2862 .with_tags(vec!["tag1".into()])
2863 .with_importance(0.7);
2864 let id = mem.metadata.id;
2865 let hash = mem.metadata.content_hash.clone();
2866 store.put(Galaxy::Codex, &mem).unwrap();
2867
2868 assert!(
2870 store
2871 .find_by_content_hash(Galaxy::Codex, &hash)
2872 .unwrap()
2873 .is_some()
2874 );
2875
2876 store.delete(Galaxy::Codex, id).unwrap();
2878
2879 assert!(
2881 store
2882 .find_by_content_hash(Galaxy::Codex, &hash)
2883 .unwrap()
2884 .is_none()
2885 );
2886
2887 let query = MemoryQuery::new().with_tags(vec!["tag1".into()]);
2889 let results = store.query(Galaxy::Codex, &query).unwrap();
2890 assert!(results.is_empty());
2891 }
2892
2893 #[test]
2894 fn put_batch_updates_indexes() {
2895 let tmp = tempfile::tempdir().unwrap();
2896 let store = MemoryStore::open_default(tmp.path()).unwrap();
2897
2898 let memories: Vec<Memory> = (0..5)
2899 .map(|i| {
2900 Memory::new(Galaxy::Codex, format!("batch-{i}"))
2901 .with_tags(vec![format!("tag{i}")])
2902 .with_importance(i as f32 * 0.2)
2903 })
2904 .collect();
2905 store.put_batch(Galaxy::Codex, &memories).unwrap();
2906
2907 for i in 0..5 {
2908 let query = MemoryQuery::new().with_tags(vec![format!("tag{i}")]);
2909 let results = store.query(Galaxy::Codex, &query).unwrap();
2910 assert_eq!(results.len(), 1, "tag{i} should have 1 result");
2911 }
2912 }
2913
2914 #[test]
2915 fn find_by_content_hash_indexed_matches_scan() {
2916 let tmp = tempfile::tempdir().unwrap();
2917 let store = MemoryStore::open_default(tmp.path()).unwrap();
2918
2919 let mem = Memory::new(Galaxy::Codex, "dedup test".into());
2920 let id = mem.metadata.id;
2921 let hash = mem.metadata.content_hash.clone();
2922 store.put(Galaxy::Codex, &mem).unwrap();
2923
2924 let indexed = store.find_by_content_hash(Galaxy::Codex, &hash).unwrap();
2925 let scanned = store
2926 .find_by_content_hash_scan(Galaxy::Codex, &hash)
2927 .unwrap();
2928
2929 assert_eq!(indexed, scanned);
2930 assert_eq!(indexed, Some(id));
2931 }
2932
2933 #[test]
2934 fn put_dedup_uses_index() {
2935 let tmp = tempfile::tempdir().unwrap();
2936 let store = MemoryStore::open_default(tmp.path()).unwrap();
2937
2938 let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2939 let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2940
2941 let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2942 let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2943
2944 assert_eq!(id1, id2, "dedup should return same ID for same content");
2945 assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2946 }
2947
2948 #[test]
2949 fn put_semantic_updates_coord5d() {
2950 let tmp = tempfile::tempdir().unwrap();
2951 let store = MemoryStore::open_default(tmp.path()).unwrap();
2952
2953 let mut mem = Memory::new(
2954 Galaxy::Codex,
2955 "The algorithm computes data using a systematic method".to_string(),
2956 );
2957 let original_coord = mem.metadata.coord5d.clone();
2958 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2959
2960 assert_ne!(
2962 mem.metadata.coord5d.x, original_coord.x,
2963 "semantic encoding should change x"
2964 );
2965 assert_ne!(
2966 mem.metadata.coord5d.y, original_coord.y,
2967 "semantic encoding should change y"
2968 );
2969
2970 let retrieved = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
2972 assert_eq!(retrieved.metadata.coord5d.x, mem.metadata.coord5d.x);
2973 }
2974
2975 #[test]
2976 fn put_semantic_preserves_temporal_and_importance() {
2977 let tmp = tempfile::tempdir().unwrap();
2978 let store = MemoryStore::open_default(tmp.path()).unwrap();
2979
2980 let mut mem = Memory::new(Galaxy::Codex, "test content".into()).with_importance(0.8);
2981 mem.metadata.coord5d.w = 0.6;
2982 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2983
2984 assert!((mem.metadata.coord5d.w - 0.6).abs() < f32::EPSILON);
2985 assert!((mem.metadata.coord5d.v - 0.8).abs() < f32::EPSILON);
2986 }
2987
2988 #[test]
2989 fn find_similar_returns_nearest_first() {
2990 let tmp = tempfile::tempdir().unwrap();
2991 let store = MemoryStore::open_default(tmp.path()).unwrap();
2992
2993 let mut logic_mem = Memory::new(
2994 Galaxy::Codex,
2995 "The algorithm computes data using systematic logic and analysis".to_string(),
2996 );
2997 store.put_semantic(Galaxy::Codex, &mut logic_mem).unwrap();
2998
2999 let mut emotion_mem = Memory::new(
3000 Galaxy::Codex,
3001 "I feel love and joy with deep passion and empathy in my heart".to_string(),
3002 );
3003 store.put_semantic(Galaxy::Codex, &mut emotion_mem).unwrap();
3004
3005 let results = store
3007 .find_similar(Galaxy::Codex, "algorithm data systematic method", 10)
3008 .unwrap();
3009 assert!(!results.is_empty());
3010 assert_eq!(results[0].0.metadata.id, logic_mem.metadata.id);
3011
3012 let results = store
3014 .find_similar(Galaxy::Codex, "love joy passion heart feeling", 10)
3015 .unwrap();
3016 assert!(!results.is_empty());
3017 assert_eq!(results[0].0.metadata.id, emotion_mem.metadata.id);
3018 }
3019
3020 #[test]
3021 fn find_similar_empty_galaxy() {
3022 let tmp = tempfile::tempdir().unwrap();
3023 let store = MemoryStore::open_default(tmp.path()).unwrap();
3024
3025 let results = store.find_similar(Galaxy::Codex, "anything", 10).unwrap();
3026 assert!(results.is_empty());
3027 }
3028
3029 #[test]
3030 fn find_similar_respects_limit() {
3031 let tmp = tempfile::tempdir().unwrap();
3032 let store = MemoryStore::open_default(tmp.path()).unwrap();
3033
3034 for i in 0..5 {
3035 let mut mem = Memory::new(Galaxy::Codex, format!("algorithm data method {i}"));
3036 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3037 }
3038
3039 let results = store
3040 .find_similar(Galaxy::Codex, "algorithm data", 3)
3041 .unwrap();
3042 assert_eq!(results.len(), 3);
3043 }
3044
3045 #[test]
3046 fn semantic_encoder_accessible() {
3047 let tmp = tempfile::tempdir().unwrap();
3048 let store = MemoryStore::open_default(tmp.path()).unwrap();
3049
3050 let scores = store.semantic_encoder().encode("algorithm data logic");
3051 assert!(scores.x < 0.5);
3053 }
3054
3055 #[test]
3056 fn put_raw_batch_writes_atomically() {
3057 let tmp = tempfile::tempdir().unwrap();
3058 let store = MemoryStore::open_default(tmp.path()).unwrap();
3059
3060 let entries: &[(&[u8], &[u8])] =
3061 &[(b"key1", b"val1"), (b"key2", b"val2"), (b"key3", b"val3")];
3062 store.put_raw_batch(Galaxy::Karma, entries).unwrap();
3063
3064 assert_eq!(
3065 store.get_raw(Galaxy::Karma, b"key1").unwrap().unwrap(),
3066 b"val1"
3067 );
3068 assert_eq!(
3069 store.get_raw(Galaxy::Karma, b"key2").unwrap().unwrap(),
3070 b"val2"
3071 );
3072 assert_eq!(
3073 store.get_raw(Galaxy::Karma, b"key3").unwrap().unwrap(),
3074 b"val3"
3075 );
3076 }
3077
3078 #[test]
3079 fn put_raw_batch_empty_is_noop() {
3080 let tmp = tempfile::tempdir().unwrap();
3081 let store = MemoryStore::open_default(tmp.path()).unwrap();
3082
3083 store.put_raw_batch(Galaxy::Karma, &[]).unwrap();
3084 assert_eq!(store.count(Galaxy::Karma).unwrap(), 0);
3085 }
3086
3087 #[test]
3088 fn entry_limit_rejects_excess_writes() {
3089 let tmp = tempfile::tempdir().unwrap();
3090 let store = MemoryStore::open_default(tmp.path())
3091 .unwrap()
3092 .with_entry_limit(3);
3093
3094 for i in 0..3 {
3095 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3096 store.put(Galaxy::Codex, &mem).unwrap();
3097 }
3098
3099 let mem = Memory::new(Galaxy::Codex, "overflow memory".to_string());
3101 let result = store.put(Galaxy::Codex, &mem);
3102 assert!(result.is_err(), "write beyond limit should be rejected");
3103 let err_msg = result.unwrap_err().to_string();
3104 assert!(
3105 err_msg.contains("entry limit reached"),
3106 "error should mention entry limit: {err_msg}"
3107 );
3108 assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
3109 }
3110
3111 #[test]
3112 fn entry_limit_per_galaxy_independent() {
3113 let tmp = tempfile::tempdir().unwrap();
3114 let store = MemoryStore::open_default(tmp.path())
3115 .unwrap()
3116 .with_entry_limit(2);
3117
3118 for i in 0..2 {
3120 let mem = Memory::new(Galaxy::Codex, format!("codex {i}"));
3121 store.put(Galaxy::Codex, &mem).unwrap();
3122 }
3123
3124 let mem = Memory::new(Galaxy::Research, "science memory".to_string());
3126 let result = store.put(Galaxy::Research, &mem);
3127 assert!(
3128 result.is_ok(),
3129 "different galaxy should not be affected by limit"
3130 );
3131 }
3132
3133 #[test]
3134 fn entry_limit_none_allows_unlimited() {
3135 let tmp = tempfile::tempdir().unwrap();
3136 let store = MemoryStore::open_default(tmp.path()).unwrap();
3137
3138 for i in 0..50 {
3140 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3141 store.put(Galaxy::Codex, &mem).unwrap();
3142 }
3143 assert_eq!(store.count(Galaxy::Codex).unwrap(), 50);
3144 }
3145
3146 #[test]
3147 fn map_full_error_is_graceful() {
3148 let tmp = tempfile::tempdir().unwrap();
3152 let store = MemoryStore::open(tmp.path(), 512 * 1024).unwrap();
3153
3154 let mut written = 0;
3156 let mut got_map_full = false;
3157 for i in 0..1000 {
3158 let mem = Memory::new(
3159 Galaxy::Codex,
3160 format!("memory content {i} {}", "with padding ".repeat(50)),
3161 );
3162 match store.put(Galaxy::Codex, &mem) {
3163 Ok(()) => written += 1,
3164 Err(e) => {
3165 let msg = e.to_string();
3166 if msg.contains("map full") {
3167 got_map_full = true;
3168 break;
3169 }
3170 break;
3172 }
3173 }
3174 }
3175
3176 assert!(
3177 got_map_full || written < 1000,
3178 "should eventually hit map full or error"
3179 );
3180 assert!(written > 0, "should have written at least some memories");
3181 }
3182
3183 #[test]
3184 fn test_find_across_galaxies() {
3185 let tmp = tempfile::tempdir().unwrap();
3186 let store = MemoryStore::open_default(tmp.path()).unwrap();
3187
3188 let mem = Memory::new(Galaxy::Research, "Cross-galaxy research memo".into());
3189 let id = mem.metadata.id;
3190 store.put(Galaxy::Research, &mem).unwrap();
3191
3192 let found = store.find_across_galaxies(id).unwrap();
3193 assert!(found.is_some());
3194 let (galaxy, retrieved) = found.unwrap();
3195 assert_eq!(galaxy, Galaxy::Research);
3196 assert_eq!(retrieved.metadata.id, id);
3197 assert_eq!(retrieved.content, "Cross-galaxy research memo");
3198
3199 assert!(
3201 store
3202 .find_across_galaxies(uuid::Uuid::new_v4())
3203 .unwrap()
3204 .is_none()
3205 );
3206 }
3207}