1use lmdb::{
7 Cursor, Database, DatabaseFlags, Environment, EnvironmentFlags, RwTransaction, Transaction,
8 WriteFlags,
9};
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, RwLock};
14use wm_core::{CoreError, Galaxy, Result};
15
16#[cfg(unix)]
17use std::os::unix::fs::PermissionsExt;
18
19use crate::episodic::EpisodicStore;
20use crate::indexes::IndexDbs;
21use crate::memory::{Memory, MemoryId, decode_embedding, encode_embedding};
22use crate::semantic::SemanticEncoder;
23
24#[derive(Debug, Clone, Default)]
26pub struct MemoryQuery {
27 pub tags: Vec<String>,
29 pub min_importance: Option<f32>,
31 pub max_importance: Option<f32>,
33 pub created_after: Option<chrono::DateTime<chrono::Utc>>,
35 pub created_before: Option<chrono::DateTime<chrono::Utc>>,
37 pub content_substring: Option<String>,
40 pub limit: usize,
42}
43
44impl MemoryQuery {
45 #[must_use]
47 pub fn new() -> Self {
48 Self {
49 limit: 100,
50 ..Default::default()
51 }
52 }
53
54 #[must_use]
56 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
57 self.tags = tags;
58 self
59 }
60
61 #[must_use]
63 pub const fn with_importance_range(mut self, min: f32, max: f32) -> Self {
64 self.min_importance = Some(min);
65 self.max_importance = Some(max);
66 self
67 }
68
69 #[must_use]
71 pub const fn with_time_range(
72 mut self,
73 after: chrono::DateTime<chrono::Utc>,
74 before: chrono::DateTime<chrono::Utc>,
75 ) -> Self {
76 self.created_after = Some(after);
77 self.created_before = Some(before);
78 self
79 }
80
81 #[must_use]
84 pub const fn with_created_after(mut self, after: chrono::DateTime<chrono::Utc>) -> Self {
85 self.created_after = Some(after);
86 self
87 }
88
89 #[must_use]
92 pub const fn with_created_before(mut self, before: chrono::DateTime<chrono::Utc>) -> Self {
93 self.created_before = Some(before);
94 self
95 }
96
97 #[must_use]
99 pub const fn with_limit(mut self, limit: usize) -> Self {
100 self.limit = limit;
101 self
102 }
103
104 #[must_use]
106 pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
107 self.content_substring = Some(substring.into().to_lowercase());
108 self
109 }
110
111 #[must_use]
113 pub fn matches(&self, mem: &Memory) -> bool {
114 if !self.tags.is_empty() {
116 for tag in &self.tags {
117 if !mem.metadata.tags.iter().any(|t| t == tag) {
118 return false;
119 }
120 }
121 }
122
123 if let Some(min) = self.min_importance {
125 if mem.metadata.importance < min {
126 return false;
127 }
128 }
129 if let Some(max) = self.max_importance {
130 if mem.metadata.importance > max {
131 return false;
132 }
133 }
134
135 if let Some(after) = self.created_after {
137 if mem.metadata.created_at < after {
138 return false;
139 }
140 }
141 if let Some(before) = self.created_before {
142 if mem.metadata.created_at > before {
143 return false;
144 }
145 }
146
147 if let Some(sub) = &self.content_substring {
149 if !mem.content.to_lowercase().contains(sub) {
150 return false;
151 }
152 }
153
154 true
155 }
156}
157
158pub struct MemoryStore {
160 path: std::path::PathBuf,
162 env: Environment,
164 index_dbs: IndexDbs,
166 semantic_encoder: SemanticEncoder,
168 max_entries_per_galaxy: Option<usize>,
170 mutation_count: AtomicU64,
174 episodic_db: Database,
176 episodic_terms_v2_db: Database,
178 embedding_cache_db: Database,
183 revisions_db: Database,
187 attestations_db: Database,
191 pub(crate) cold_storage_db: Database,
193 session_sequences_db: Option<Database>,
199 index_pending_db: Option<Database>,
206 keyring_db: Option<Database>,
209 at_rest: Option<crate::at_rest::AtRestState>,
212 episodic_term_cache: std::sync::Arc<RwLock<HashMap<String, Vec<uuid::Uuid>>>>,
214 episodic_embedder:
216 std::sync::OnceLock<Option<Arc<dyn crate::embedder::Embedder + Send + Sync>>>,
217 episodic_sidecar_ensured: std::sync::OnceLock<()>,
219 episodic_aliases: std::sync::OnceLock<Option<crate::episodic_keys::AdaptiveAliases>>,
221 episodic_enrichment: std::sync::OnceLock<Option<crate::enrichment::VocabularyEnrichment>>,
223}
224
225impl MemoryStore {
226 #[cfg(unix)]
242 pub fn probe_write_lock(store_root: &Path) -> std::io::Result<()> {
243 use rustix::fs::{FlockOperation, fcntl_lock};
244 let lock_path = store_root.join("lmdb").join("lock.mdb");
245 let file = std::fs::OpenOptions::new()
246 .read(true)
247 .write(true)
248 .open(&lock_path)?;
249 match fcntl_lock(&file, FlockOperation::NonBlockingLockExclusive) {
250 Ok(()) => Ok(()),
251 Err(rustix::io::Errno::AGAIN | rustix::io::Errno::ACCESS) => Err(std::io::Error::new(
252 std::io::ErrorKind::WouldBlock,
253 "LMDB writer lock held by another process",
254 )),
255 Err(e) => Err(e.into()),
256 }
257 }
258
259 #[cfg(not(unix))]
262 pub fn probe_write_lock(_store_root: &Path) -> std::io::Result<()> {
263 Ok(())
264 }
265
266 pub fn open(path: impl AsRef<Path>, map_size: usize) -> Result<Self> {
278 Self::open_with_at_rest(path, map_size, &crate::at_rest::AtRestConfig::from_env()?)
279 }
280
281 pub fn open_with_at_rest(
288 path: impl AsRef<Path>,
289 map_size: usize,
290 at_rest_config: &crate::at_rest::AtRestConfig,
291 ) -> Result<Self> {
292 let path = path.as_ref().to_path_buf();
293
294 std::fs::create_dir_all(&path)
296 .map_err(|e| CoreError::Memory(format!("Cannot create store dir: {e}")))?;
297 #[cfg(unix)]
298 {
299 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
300 .map_err(|e| CoreError::Memory(format!("Cannot set store dir permissions: {e}")))?;
301 }
302
303 let env = Environment::new()
304 .set_map_size(map_size)
305 .set_max_dbs(64)
306 .open(&path)
307 .map_err(|e| CoreError::Memory(format!("LMDB open failed: {e}")))?;
308
309 for galaxy in Galaxy::all() {
311 let db = env
312 .create_db(Some(galaxy.db_name()), DatabaseFlags::default())
313 .map_err(|e| {
314 CoreError::Memory(format!(
315 "LMDB create_db failed for {}: {e}",
316 galaxy.db_name()
317 ))
318 })?;
319 let _ = db;
320 }
321
322 for (name, flags) in crate::indexes::INDEX_DBS {
324 let db = env
325 .create_db(Some(name), *flags)
326 .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for {name}: {e}")))?;
327 let _ = db;
328 }
329
330 let index_dbs = IndexDbs::open(&env)?;
331 let episodic_db = env
332 .create_db(Some("episodic_records"), DatabaseFlags::default())
333 .map_err(|e| {
334 CoreError::Memory(format!("LMDB create_db failed for episodic_records: {e}"))
335 })?;
336 let episodic_terms_v2_db = env
343 .create_db(Some("episodic_terms_v2"), DatabaseFlags::DUP_SORT)
344 .map_err(|e| {
345 CoreError::Memory(format!("LMDB create_db failed for episodic_terms_v2: {e}"))
346 })?;
347 let embedding_cache_db = env
348 .create_db(Some("embedding_cache"), DatabaseFlags::default())
349 .map_err(|e| {
350 CoreError::Memory(format!("LMDB create_db failed for embedding_cache: {e}"))
351 })?;
352 let revisions_db = env
353 .create_db(Some("revisions"), DatabaseFlags::default())
354 .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for revisions: {e}")))?;
355 let attestations_db = env
356 .create_db(
357 Some(crate::attestation::ATTESTATIONS_DB),
358 DatabaseFlags::default(),
359 )
360 .map_err(|e| {
361 CoreError::Memory(format!("LMDB create_db failed for attestations: {e}"))
362 })?;
363 let cold_storage_db = env
364 .create_db(Some("cold_storage"), DatabaseFlags::default())
365 .map_err(|e| {
366 CoreError::Memory(format!("LMDB create_db failed for cold_storage: {e}"))
367 })?;
368 let session_sequences_db = env
372 .create_db(Some("session_sequences"), DatabaseFlags::default())
373 .map_err(|e| {
374 CoreError::Memory(format!("LMDB create_db failed for session_sequences: {e}"))
375 })?;
376 let index_pending_db = env
379 .create_db(Some("index_pending"), DatabaseFlags::default())
380 .map_err(|e| {
381 CoreError::Memory(format!("LMDB create_db failed for index_pending: {e}"))
382 })?;
383 let (keyring_db, at_rest) = crate::at_rest::open_at_rest(&env, &path, at_rest_config)?;
384 Ok(Self {
385 path,
386 env,
387 index_dbs,
388 semantic_encoder: SemanticEncoder::new(),
389 max_entries_per_galaxy: None,
390 mutation_count: AtomicU64::new(0),
391 episodic_db,
392 episodic_terms_v2_db,
393 embedding_cache_db,
394 revisions_db,
395 attestations_db,
396 cold_storage_db,
397 session_sequences_db: Some(session_sequences_db),
398 index_pending_db: Some(index_pending_db),
399 keyring_db,
400 at_rest,
401 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
402 episodic_embedder: std::sync::OnceLock::new(),
403 episodic_sidecar_ensured: std::sync::OnceLock::new(),
404 episodic_aliases: std::sync::OnceLock::new(),
405 episodic_enrichment: std::sync::OnceLock::new(),
406 })
407 }
408
409 #[must_use]
413 pub fn at_rest_status(&self) -> crate::at_rest::AtRestStatus {
414 if let Some(state) = &self.at_rest {
415 return crate::at_rest::AtRestStatus::Present(state.status());
416 }
417 match &self.keyring_db {
418 None => crate::at_rest::AtRestStatus::Absent,
419 Some(db) => crate::at_rest::read_status(&self.env, *db, &self.path),
420 }
421 }
422
423 #[must_use]
426 pub const fn at_rest_state(&self) -> Option<&crate::at_rest::AtRestState> {
427 self.at_rest.as_ref()
428 }
429
430 #[must_use]
434 pub const fn keyring_db(&self) -> Option<Database> {
435 self.keyring_db
436 }
437
438 pub(crate) fn record_cipher(&self, galaxy: Galaxy) -> Option<&[u8; 32]> {
441 self.at_rest
442 .as_ref()
443 .and_then(|state| state.galaxy_dek(galaxy.db_name()))
444 }
445
446 pub(crate) fn encode_record_value(&self, galaxy: Galaxy, memory: &Memory) -> Result<Vec<u8>> {
450 let plaintext = rmp_serde::to_vec_named(memory)
451 .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
452 let Some(key) = self.record_cipher(galaxy) else {
453 return Ok(plaintext);
454 };
455 crate::codec::seal_record(
456 &plaintext,
457 key,
458 galaxy.db_name(),
459 memory.metadata.id.as_bytes(),
460 memory.metadata.version,
461 )
462 .map_err(|e| CoreError::Memory(format!("at-rest seal failed: {e}")))
463 }
464
465 pub(crate) fn decode_record_value(
469 &self,
470 galaxy: Galaxy,
471 key_bytes: &[u8],
472 value: &[u8],
473 ) -> Result<Memory> {
474 if crate::codec::is_sealed_record(value) {
475 let Some(dek) = self.record_cipher(galaxy) else {
476 return Err(CoreError::Memory(format!(
477 "sealed record in {} but no at-rest key is loaded (WM_AT_REST_MODE off?)",
478 galaxy.db_name()
479 )));
480 };
481 let record_id: [u8; 16] = key_bytes
482 .try_into()
483 .map_err(|_| CoreError::Memory("sealed record key is not a 16-byte id".into()))?;
484 let opened = crate::codec::open_record(value, dek, galaxy.db_name(), &record_id)
485 .map_err(|e| CoreError::Memory(format!("at-rest open failed: {e}")))?;
486 return crate::codec::decode(&opened)
487 .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")));
488 }
489 crate::codec::decode(value)
490 .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")))
491 }
492
493 pub fn open_readonly_bounded(
503 path: impl AsRef<Path>,
504 timeout: std::time::Duration,
505 ) -> Result<Option<Self>> {
506 let path = path.as_ref().to_path_buf();
507 let (tx, rx) = std::sync::mpsc::channel();
508 std::thread::spawn(move || {
509 let result = Self::open_readonly(&path);
510 let _ = tx.send(result);
511 });
512 match rx.recv_timeout(timeout) {
513 Ok(result) => result.map(Some),
514 Err(_) => Ok(None),
515 }
516 }
517
518 pub fn open_default_bounded(
522 path: impl AsRef<Path>,
523 timeout: std::time::Duration,
524 ) -> Result<Option<Self>> {
525 let path = path.as_ref().to_path_buf();
526 let (tx, rx) = std::sync::mpsc::channel();
527 std::thread::spawn(move || {
528 let result = Self::open_default(&path);
529 let _ = tx.send(result);
530 });
531 match rx.recv_timeout(timeout) {
532 Ok(result) => result.map(Some),
533 Err(_) => Ok(None),
534 }
535 }
536
537 #[must_use]
540 pub fn default_map_size() -> usize {
541 let platform_default = if cfg!(windows) {
542 256 * 1024 * 1024
543 } else {
544 4 * 1024 * 1024 * 1024
545 };
546 std::env::var("WM_DEFAULT_MAP_SIZE")
547 .ok()
548 .and_then(|v| v.parse::<usize>().ok())
549 .filter(|&v| v > 0)
550 .unwrap_or(platform_default)
551 }
552
553 pub fn open_default(path: impl AsRef<Path>) -> Result<Self> {
562 let size = Self::default_map_size();
567 Self::open(path, size)
568 }
569
570 pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
576 let path = path.as_ref().to_path_buf();
577 if !path.is_dir() {
578 return Err(CoreError::Memory(format!(
579 "Read-only LMDB store directory does not exist: {}",
580 path.display()
581 )));
582 }
583 if !path.join("data.mdb").is_file() {
584 return Err(CoreError::Memory(format!(
585 "Read-only LMDB store is missing data.mdb: {}",
586 path.display()
587 )));
588 }
589
590 let env = Environment::new()
591 .set_max_dbs(32)
592 .set_flags(EnvironmentFlags::READ_ONLY)
593 .open(&path)
594 .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
595
596 let index_dbs = IndexDbs::open(&env)?;
597 let open_named = |name: &str| {
598 env.open_db(Some(name)).map_err(|e| {
599 CoreError::Memory(format!("Read-only LMDB missing database {name}: {e}"))
600 })
601 };
602 let episodic_db = open_named("episodic_records")?;
603 let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
604 let embedding_cache_db = open_named("embedding_cache")?;
605 let revisions_db = open_named("revisions")?;
606 let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
607 let cold_storage_db = open_named("cold_storage")?;
608 let session_sequences_db = env.open_db(Some("session_sequences")).ok();
611 let index_pending_db = env.open_db(Some("index_pending")).ok();
613 let keyring_db = crate::at_rest::open_keyring_optional(&env)?;
616
617 Ok(Self {
618 path,
619 env,
620 index_dbs,
621 semantic_encoder: SemanticEncoder::new(),
622 max_entries_per_galaxy: None,
623 mutation_count: AtomicU64::new(0),
624 episodic_db,
625 episodic_terms_v2_db,
626 embedding_cache_db,
627 revisions_db,
628 attestations_db,
629 cold_storage_db,
630 session_sequences_db,
631 index_pending_db,
632 keyring_db,
633 at_rest: None,
634 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
635 episodic_embedder: std::sync::OnceLock::new(),
636 episodic_sidecar_ensured: std::sync::OnceLock::new(),
637 episodic_aliases: std::sync::OnceLock::new(),
638 episodic_enrichment: std::sync::OnceLock::new(),
639 })
640 }
641
642 const NAMED_DBIS: [&'static str; 6] = [
645 "episodic_records",
646 "episodic_terms_v2",
647 "embedding_cache",
648 "revisions",
649 crate::attestation::ATTESTATIONS_DB,
650 "cold_storage",
651 ];
652
653 pub fn open_inspection(path: impl AsRef<Path>) -> Result<Self> {
666 let path = path.as_ref().to_path_buf();
667 if !path.is_dir() {
668 return Err(CoreError::Memory(format!(
669 "Read-only LMDB store directory does not exist: {}",
670 path.display()
671 )));
672 }
673 if !path.join("data.mdb").is_file() {
674 return Err(CoreError::Memory(format!(
675 "Read-only LMDB store is missing data.mdb: {}",
676 path.display()
677 )));
678 }
679
680 let env = Environment::new()
681 .set_max_dbs(32)
682 .set_flags(EnvironmentFlags::READ_ONLY | EnvironmentFlags::NO_LOCK)
683 .open(&path)
684 .map_err(|e| CoreError::Memory(format!("Inspection LMDB open failed: {e}")))?;
685
686 let index_dbs = IndexDbs::open(&env)?;
687 let open_named = |name: &str| {
688 env.open_db(Some(name)).map_err(|e| {
689 CoreError::Memory(format!("Inspection LMDB missing database {name}: {e}"))
690 })
691 };
692 let episodic_db = open_named("episodic_records")?;
693 let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
694 let embedding_cache_db = open_named("embedding_cache")?;
695 let revisions_db = open_named("revisions")?;
696 let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
697 let cold_storage_db = open_named("cold_storage")?;
698 let session_sequences_db = env.open_db(Some("session_sequences")).ok();
701 let index_pending_db = env.open_db(Some("index_pending")).ok();
703 let keyring_db = crate::at_rest::open_keyring_optional(&env)?;
706
707 Ok(Self {
708 path,
709 env,
710 index_dbs,
711 semantic_encoder: SemanticEncoder::new(),
712 max_entries_per_galaxy: None,
713 mutation_count: AtomicU64::new(0),
714 episodic_db,
715 episodic_terms_v2_db,
716 embedding_cache_db,
717 revisions_db,
718 attestations_db,
719 cold_storage_db,
720 session_sequences_db,
721 index_pending_db,
722 keyring_db,
723 at_rest: None,
724 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
725 episodic_embedder: std::sync::OnceLock::new(),
726 episodic_sidecar_ensured: std::sync::OnceLock::new(),
727 episodic_aliases: std::sync::OnceLock::new(),
728 episodic_enrichment: std::sync::OnceLock::new(),
729 })
730 }
731
732 pub fn ensure_schema(path: impl AsRef<Path>) -> Result<Vec<String>> {
745 let path = path.as_ref().to_path_buf();
746 if !path.is_dir() {
747 return Err(CoreError::Memory(format!(
748 "Store directory does not exist: {}",
749 path.display()
750 )));
751 }
752 let expected = || {
753 Galaxy::all()
754 .into_iter()
755 .map(|galaxy| galaxy.db_name().to_string())
756 .chain(
757 crate::indexes::INDEX_DBS
758 .iter()
759 .map(|(name, _)| (*name).to_string()),
760 )
761 .chain(Self::NAMED_DBIS.iter().map(|name| (*name).to_string()))
762 };
763 let missing: Vec<String> = {
764 let env = Environment::new()
765 .set_max_dbs(64)
766 .set_flags(EnvironmentFlags::READ_ONLY)
767 .open(&path)
768 .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
769 expected()
770 .filter(|name| env.open_db(Some(name.as_str())).is_err())
771 .collect()
772 };
773 if missing.is_empty() {
774 return Ok(missing);
775 }
776 let store = Self::open_default(&path)?;
779 drop(store);
780 Ok(missing)
781 }
782
783 #[must_use]
788 pub const fn with_entry_limit(mut self, limit: usize) -> Self {
789 self.max_entries_per_galaxy = Some(limit);
790 self
791 }
792
793 pub fn path(&self) -> &Path {
795 &self.path
796 }
797
798 pub const fn env(&self) -> &Environment {
800 &self.env
801 }
802
803 pub fn mutation_count(&self) -> u64 {
807 self.mutation_count.load(Ordering::Relaxed)
808 }
809
810 pub const fn index_dbs(&self) -> &IndexDbs {
812 &self.index_dbs
813 }
814
815 pub const fn semantic_encoder(&self) -> &SemanticEncoder {
817 &self.semantic_encoder
818 }
819
820 pub fn episodic_sidecar_health(&self) -> Result<(u64, bool)> {
832 let view = EpisodicStore::new(
833 &self.env,
834 self.episodic_db,
835 self.episodic_terms_v2_db,
836 self.episodic_term_cache.clone(),
837 &self.mutation_count,
838 );
839 let empty = view.sidecar_is_empty()?;
840 if !empty {
841 return Ok((view.record_count()?, false));
842 }
843 let indexable = view
844 .scan(None, usize::MAX)?
845 .iter()
846 .filter(|record| !record.is_private && !record.model_exclude)
847 .count() as u64;
848 Ok((indexable, true))
849 }
850
851 pub fn episodic_record_count(&self) -> Result<u64> {
855 let view = EpisodicStore::new(
856 &self.env,
857 self.episodic_db,
858 self.episodic_terms_v2_db,
859 self.episodic_term_cache.clone(),
860 &self.mutation_count,
861 );
862 view.record_count()
863 }
864
865 fn ensure_episodic_sidecar(&self) {
870 if self.episodic_sidecar_ensured.get().is_some() {
871 return;
872 }
873 let _ = self.episodic_sidecar_ensured.set(());
874 let view = EpisodicStore::new(
875 &self.env,
876 self.episodic_db,
877 self.episodic_terms_v2_db,
878 self.episodic_term_cache.clone(),
879 &self.mutation_count,
880 );
881 let needs_rebuild = matches!(
882 (view.sidecar_is_empty(), view.record_count()),
883 (Ok(true), Ok(n)) if n > 0
884 );
885 if needs_rebuild {
886 match view.rebuild_sidecar() {
887 Ok(n) => tracing::info!("episodic sidecar rebuilt from {n} records"),
888 Err(e) => {
889 tracing::warn!("episodic sidecar rebuild failed: {e}");
890 }
891 }
892 }
893 }
894
895 #[must_use]
897 pub fn episodic(&self) -> EpisodicStore<'_> {
898 self.ensure_episodic_sidecar();
899 let mut store = EpisodicStore::new(
900 &self.env,
901 self.episodic_db,
902 self.episodic_terms_v2_db,
903 self.episodic_term_cache.clone(),
904 &self.mutation_count,
905 );
906 if let Some(Some(embedder)) = self.episodic_embedder.get() {
907 store = store.with_embedder(embedder.clone());
908 }
909 if let Some(Some(aliases)) = self.episodic_aliases.get() {
910 store = store.with_adaptive_aliases(aliases.clone());
911 }
912 if let Some(Some(enrichment)) = self.episodic_enrichment.get() {
913 store = store.with_enrichment(enrichment.clone());
914 }
915 store
916 }
917
918 pub fn set_episodic_embedder(
920 &self,
921 embedder: Arc<dyn crate::embedder::Embedder + Send + Sync>,
922 ) {
923 let _ = self.episodic_embedder.set(Some(embedder));
924 }
925
926 pub fn set_episodic_aliases(&self, aliases: crate::episodic_keys::AdaptiveAliases) {
928 let _ = self.episodic_aliases.set(Some(aliases));
929 }
930
931 pub fn set_episodic_enrichment(&self, enrichment: crate::enrichment::VocabularyEnrichment) {
933 let _ = self.episodic_enrichment.set(Some(enrichment));
934 }
935
936 pub fn galaxy_db(&self, galaxy: Galaxy) -> Result<Database> {
938 self.env.open_db(Some(galaxy.db_name())).map_err(|e| {
939 CoreError::Memory(format!("LMDB open_db failed for {}: {e}", galaxy.db_name()))
940 })
941 }
942
943 pub fn put(&self, galaxy: Galaxy, memory: &Memory) -> Result<()> {
951 if let Some(limit) = self.max_entries_per_galaxy {
953 let current = self.count(galaxy)?;
954 if current >= limit {
955 return Err(CoreError::Memory(format!(
956 "galaxy {} entry limit reached ({current}/{limit}), write rejected",
957 galaxy.db_name()
958 )));
959 }
960 }
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 self.put_in_txn(&mut tx, galaxy, memory)?;
967 tx.commit()
968 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
969 self.mutation_count.fetch_add(1, Ordering::Relaxed);
970 Ok(())
971 }
972
973 fn put_in_txn(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
979 let db = self.galaxy_db(galaxy)?;
980 let key = memory.metadata.id.as_bytes();
981 let val = self.encode_record_value(galaxy, memory)?;
982
983 let existing = tx
988 .get(db, key)
989 .ok()
990 .and_then(|bytes| self.decode_record_value(galaxy, key, bytes).ok());
991
992 match tx.put(db, key, &val, lmdb::WriteFlags::default()) {
993 Ok(()) => {}
994 Err(lmdb::Error::MapFull) => {
995 return Err(CoreError::Memory(format!(
996 "LMDB map full: galaxy {}, consider growing map size or pruning old memories",
997 galaxy.db_name()
998 )));
999 }
1000 Err(e) => {
1001 return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
1002 }
1003 }
1004 if let Some(existing) = existing {
1005 self.index_dbs.remove(tx, galaxy, &existing)?;
1006 }
1007 self.index_dbs.add(tx, galaxy, memory)?;
1008 Ok(())
1009 }
1010
1011 pub fn put_session_turn<F>(&self, session_id: &str, build: F) -> Result<(u64, Memory)>
1029 where
1030 F: FnOnce(u64) -> Memory,
1031 {
1032 let seq_db = self.session_sequences_db.ok_or_else(|| {
1033 CoreError::Memory(
1034 "session_sequences DBI missing (legacy store opened read-only); \
1035 a writable open repairs it via ensure_schema"
1036 .into(),
1037 )
1038 })?;
1039 if let Some(limit) = self.max_entries_per_galaxy {
1040 let current = self.count(Galaxy::Sessions)?;
1041 if current >= limit {
1042 return Err(CoreError::Memory(format!(
1043 "galaxy {} entry limit reached ({current}/{limit}), write rejected",
1044 Galaxy::Sessions.db_name()
1045 )));
1046 }
1047 }
1048
1049 let mut tx = self
1050 .env
1051 .begin_rw_txn()
1052 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1053 let key: &[u8] = session_id.as_bytes();
1054 let current = match tx.get(seq_db, &key) {
1057 Ok(bytes) => {
1058 let arr: [u8; 8] = <[u8; 8]>::try_from(bytes).map_err(|_| {
1059 CoreError::Memory(format!(
1060 "session_sequences value for {session_id} is malformed \
1061 ({} bytes, want 8)",
1062 bytes.len()
1063 ))
1064 })?;
1065 u64::from_be_bytes(arr)
1066 }
1067 Err(lmdb::Error::NotFound) => 0,
1068 Err(e) => {
1069 return Err(CoreError::Memory(format!(
1070 "LMDB get failed (session_sequences): {e}"
1071 )));
1072 }
1073 };
1074 let next = current
1075 .checked_add(1)
1076 .ok_or_else(|| CoreError::Memory("session sequence overflow (u64)".into()))?;
1077 tx.put(
1078 seq_db,
1079 &key,
1080 &next.to_be_bytes(),
1081 lmdb::WriteFlags::default(),
1082 )
1083 .map_err(|e| CoreError::Memory(format!("LMDB put failed (session_sequences): {e}")))?;
1084
1085 let memory = build(next);
1086 self.put_in_txn(&mut tx, Galaxy::Sessions, &memory)?;
1087 tx.commit()
1088 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1089 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1090 Ok((next, memory))
1091 }
1092
1093 pub fn last_session_sequence(&self, session_id: &str) -> Result<Option<u64>> {
1097 let Some(seq_db) = self.session_sequences_db else {
1098 return Ok(None);
1099 };
1100 let tx = self
1101 .env
1102 .begin_ro_txn()
1103 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1104 let key: &[u8] = session_id.as_bytes();
1105 match tx.get(seq_db, &key) {
1106 Ok(bytes) => {
1107 let arr: [u8; 8] = <[u8; 8]>::try_from(bytes).map_err(|_| {
1108 CoreError::Memory(format!(
1109 "session_sequences value for {session_id} is malformed \
1110 ({} bytes, want 8)",
1111 bytes.len()
1112 ))
1113 })?;
1114 Ok(Some(u64::from_be_bytes(arr)))
1115 }
1116 Err(lmdb::Error::NotFound) => Ok(None),
1117 Err(e) => Err(CoreError::Memory(format!(
1118 "LMDB get failed (session_sequences): {e}"
1119 ))),
1120 }
1121 }
1122
1123 pub fn mark_index_pending(&self, galaxy: &str, memory_id: &str, at_ms: i64) -> Result<()> {
1129 let db = self.index_pending_db.ok_or_else(|| {
1130 CoreError::Memory(
1131 "index_pending DBI missing (store opened without the writable schema repair)"
1132 .into(),
1133 )
1134 })?;
1135 let value = serde_json::to_vec(&serde_json::json!({"galaxy": galaxy, "at_ms": at_ms}))
1136 .map_err(|e| CoreError::Memory(format!("index_pending encode: {e}")))?;
1137 let mut tx = self
1138 .env
1139 .begin_rw_txn()
1140 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1141 let key = memory_id.as_bytes();
1142 tx.put(db, &key, &value, lmdb::WriteFlags::default())
1143 .map_err(|e| CoreError::Memory(format!("LMDB put failed (index_pending): {e}")))?;
1144 tx.commit()
1145 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1146 Ok(())
1147 }
1148
1149 pub fn index_pending_entries(&self) -> Result<Vec<(String, String, i64)>> {
1152 let Some(db) = self.index_pending_db else {
1153 return Ok(Vec::new());
1154 };
1155 let tx = self
1156 .env
1157 .begin_ro_txn()
1158 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1159 let mut cursor = tx
1160 .open_ro_cursor(db)
1161 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed (index_pending): {e}")))?;
1162 let mut out: Vec<(String, String, i64)> = Vec::new();
1163 for (key, value) in cursor.iter() {
1164 let id = String::from_utf8_lossy(key).into_owned();
1165 let parsed: serde_json::Value =
1166 serde_json::from_slice(value).unwrap_or(serde_json::Value::Null);
1167 let galaxy = parsed
1168 .get("galaxy")
1169 .and_then(serde_json::Value::as_str)
1170 .unwrap_or_default()
1171 .to_string();
1172 let at_ms = parsed
1173 .get("at_ms")
1174 .and_then(serde_json::Value::as_i64)
1175 .unwrap_or_default();
1176 out.push((id, galaxy, at_ms));
1177 }
1178 drop(cursor);
1179 tx.commit()
1180 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1181 out.sort_by_key(|(_, _, at)| *at);
1182 Ok(out)
1183 }
1184
1185 pub fn count_index_pending(&self) -> Result<usize> {
1187 Ok(self.index_pending_entries()?.len())
1188 }
1189
1190 pub fn clear_index_pending(&self, ids: &[String]) -> Result<usize> {
1193 let Some(db) = self.index_pending_db else {
1194 return Ok(0);
1195 };
1196 if ids.is_empty() {
1197 return Ok(0);
1198 }
1199 let mut tx = self
1200 .env
1201 .begin_rw_txn()
1202 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1203 let mut cleared = 0usize;
1204 for id in ids {
1205 let key = id.as_bytes();
1206 match tx.del(db, &key, None) {
1207 Ok(()) => cleared += 1,
1208 Err(lmdb::Error::NotFound) => {}
1209 Err(e) => {
1210 return Err(CoreError::Memory(format!(
1211 "LMDB del failed (index_pending): {e}"
1212 )));
1213 }
1214 }
1215 }
1216 tx.commit()
1217 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1218 Ok(cleared)
1219 }
1220
1221 pub fn clear_all_index_pending(&self) -> Result<usize> {
1224 let Some(db) = self.index_pending_db else {
1225 return Ok(0);
1226 };
1227 let mut tx = self
1228 .env
1229 .begin_rw_txn()
1230 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1231 let count = tx
1232 .open_ro_cursor(db)
1233 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed (index_pending): {e}")))?
1234 .iter()
1235 .count();
1236 tx.clear_db(db)
1237 .map_err(|e| CoreError::Memory(format!("LMDB clear failed (index_pending): {e}")))?;
1238 tx.commit()
1239 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1240 Ok(count)
1241 }
1242
1243 pub fn get(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<Option<Memory>> {
1245 let db = self.galaxy_db(galaxy)?;
1246 let key = id.as_bytes();
1247
1248 let tx = self
1249 .env
1250 .begin_ro_txn()
1251 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1252 let result = tx.get(db, key);
1253 match result {
1254 Ok(bytes) => {
1255 let memory = self.decode_record_value(galaxy, key, bytes)?;
1256 tx.commit()
1257 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1258 Ok(Some(memory))
1259 }
1260 Err(lmdb::Error::NotFound) => {
1261 tx.commit()
1263 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1264 Ok(None)
1265 }
1266 Err(e) => Err(CoreError::Memory(format!("LMDB get failed: {e}"))),
1267 }
1268 }
1269
1270 pub fn find_across_galaxies(&self, id: uuid::Uuid) -> Result<Option<(Galaxy, Memory)>> {
1276 for galaxy in Galaxy::memory_galaxies() {
1277 if let Some(mem) = self.get(galaxy, id)? {
1278 return Ok(Some((galaxy, mem)));
1279 }
1280 }
1281 Ok(None)
1282 }
1283
1284 pub fn delete(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<bool> {
1287 let db = self.galaxy_db(galaxy)?;
1288 let key = id.as_bytes();
1289
1290 let mut tx = self
1291 .env
1292 .begin_rw_txn()
1293 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1294
1295 let exists = tx.get(db, key).is_ok();
1297 if exists {
1298 if let Ok(bytes) = tx.get(db, key) {
1300 if let Ok(memory) = self.decode_record_value(galaxy, key, bytes) {
1301 let _ = self.index_dbs.remove(&mut tx, galaxy, &memory);
1302 }
1303 }
1304 tx.del(db, key, None)
1305 .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
1306 }
1307 tx.commit()
1308 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1309 if exists {
1310 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1311 }
1312 Ok(exists)
1313 }
1314
1315 pub fn scan(&self, galaxy: Galaxy, limit: usize) -> Result<Vec<Memory>> {
1317 let db = self.galaxy_db(galaxy)?;
1318 let tx = self
1319 .env
1320 .begin_ro_txn()
1321 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1322
1323 let mut cursor = tx
1324 .open_ro_cursor(db)
1325 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1326
1327 let mut memories = Vec::with_capacity(limit.min(256));
1328 for (i, (key, val)) in cursor.iter().enumerate() {
1329 if memories.len() >= limit {
1330 break;
1331 }
1332 match self.decode_record_value(galaxy, key, val) {
1333 Ok(memory) => memories.push(memory),
1334 Err(e) => {
1335 tracing::warn!(
1336 "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
1337 galaxy
1338 );
1339 }
1340 }
1341 }
1342
1343 drop(cursor);
1344 tx.commit()
1345 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1346 Ok(memories)
1347 }
1348
1349 pub fn scan_all(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
1354 self.scan_all_impl(galaxy, false)
1355 }
1356
1357 pub fn scan_all_strict(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
1361 self.scan_all_impl(galaxy, true)
1362 }
1363
1364 fn scan_all_impl(&self, galaxy: Galaxy, strict: bool) -> Result<Vec<Memory>> {
1365 let db = self.galaxy_db(galaxy)?;
1366 let tx = self
1367 .env
1368 .begin_ro_txn()
1369 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1370
1371 let mut cursor = tx
1372 .open_ro_cursor(db)
1373 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1374
1375 let mut memories = Vec::new();
1376 for (i, (key, val)) in cursor.iter().enumerate() {
1377 match self.decode_record_value(galaxy, key, val) {
1378 Ok(memory) => memories.push(memory),
1379 Err(e) => {
1380 if strict {
1381 return Err(CoreError::Memory(format!(
1382 "refusing incomplete scan of {}: record {i} cannot be decoded: {e}",
1383 galaxy.db_name()
1384 )));
1385 }
1386 tracing::warn!(
1387 "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
1388 galaxy
1389 );
1390 }
1391 }
1392 }
1393
1394 drop(cursor);
1395 tx.commit()
1396 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1397 Ok(memories)
1398 }
1399
1400 pub fn count(&self, galaxy: Galaxy) -> Result<usize> {
1402 let db = self.galaxy_db(galaxy)?;
1403 let tx = self
1404 .env
1405 .begin_ro_txn()
1406 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1407 let mut cursor = tx
1408 .open_ro_cursor(db)
1409 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1410 let count = cursor.iter().count();
1411 drop(cursor);
1412 tx.commit()
1413 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1414 Ok(count)
1415 }
1416
1417 pub fn count_by_tag(&self, galaxy: Galaxy, tag: &str) -> Result<usize> {
1422 let tx = self
1423 .env
1424 .begin_ro_txn()
1425 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1426 let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1427 tx.commit()
1428 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1429 Ok(ids.len())
1430 }
1431
1432 pub fn clear_galaxy(&self, galaxy: Galaxy) -> Result<usize> {
1436 let db = self.galaxy_db(galaxy)?;
1437
1438 let mut tx = self
1439 .env
1440 .begin_rw_txn()
1441 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1442
1443 let mut cursor = tx
1444 .open_ro_cursor(db)
1445 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1446
1447 let mut count = 0usize;
1448 let keys_to_delete: Vec<(Vec<u8>, Memory)> = cursor
1449 .iter()
1450 .filter_map(|(key, val)| {
1451 if let Ok(memory) = self.decode_record_value(galaxy, key, val) {
1452 Some((key.to_vec(), memory))
1453 } else {
1454 None
1455 }
1456 })
1457 .collect();
1458
1459 drop(cursor);
1460
1461 for (key, memory) in &keys_to_delete {
1462 let _ = self.index_dbs.remove(&mut tx, galaxy, memory);
1463 tx.del(db, &key, None)
1464 .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
1465 count += 1;
1466 }
1467
1468 tx.commit()
1469 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1470 self.mutation_count
1471 .fetch_add(count as u64, Ordering::Relaxed);
1472 Ok(count)
1473 }
1474
1475 pub fn batch_put(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<usize> {
1478 if memories.is_empty() {
1479 return Ok(0);
1480 }
1481
1482 let db = self.galaxy_db(galaxy)?;
1483
1484 let mut tx = self
1485 .env
1486 .begin_rw_txn()
1487 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1488
1489 let mut count = 0usize;
1490 for memory in memories {
1491 let key = memory.metadata.id.as_bytes();
1492 let val = self.encode_record_value(galaxy, memory)?;
1493 match tx.put(db, key, &val, WriteFlags::default()) {
1494 Ok(()) => {}
1495 Err(lmdb::Error::MapFull) => {
1496 tx.abort();
1497 return Err(CoreError::Memory(format!(
1498 "LMDB map full: galaxy {}, consider growing map size",
1499 galaxy.db_name()
1500 )));
1501 }
1502 Err(e) => {
1503 tx.abort();
1504 return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
1505 }
1506 }
1507 self.index_dbs.add(&mut tx, galaxy, memory)?;
1508 count += 1;
1509 }
1510
1511 tx.commit()
1512 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1513 self.mutation_count
1514 .fetch_add(count as u64, Ordering::Relaxed);
1515 Ok(count)
1516 }
1517
1518 pub fn get_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<Option<Vec<u8>>> {
1520 let db = self.galaxy_db(galaxy)?;
1521 let tx = self
1522 .env
1523 .begin_ro_txn()
1524 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1525 match tx.get(db, &key) {
1526 Ok(bytes) => {
1527 let data = bytes.to_vec();
1528 tx.commit()
1529 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1530 Ok(Some(data))
1531 }
1532 Err(lmdb::Error::NotFound) => {
1533 tx.commit()
1534 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1535 Ok(None)
1536 }
1537 Err(e) => Err(CoreError::Memory(format!("LMDB get_raw failed: {e}"))),
1538 }
1539 }
1540
1541 pub fn put_raw(&self, galaxy: Galaxy, key: &[u8], val: &[u8]) -> Result<()> {
1543 let db = self.galaxy_db(galaxy)?;
1544 let mut tx = self
1545 .env
1546 .begin_rw_txn()
1547 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1548 tx.put(db, &key, &val, lmdb::WriteFlags::default())
1549 .map_err(|e| CoreError::Memory(format!("LMDB put_raw failed: {e}")))?;
1550 tx.commit()
1551 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1552 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1553 Ok(())
1554 }
1555
1556 pub fn delete_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<bool> {
1559 let db = self.galaxy_db(galaxy)?;
1560 let mut tx = self
1561 .env
1562 .begin_rw_txn()
1563 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1564 let deleted = tx.del(db, &key, None).is_ok();
1565 tx.commit()
1566 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1567 if deleted {
1568 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1569 }
1570 Ok(deleted)
1571 }
1572
1573 pub fn put_raw_batch(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1576 self.put_raw_batch_impl(galaxy, entries)?;
1577 self.mutation_count
1578 .fetch_add(entries.len() as u64, Ordering::Relaxed);
1579 Ok(())
1580 }
1581
1582 pub fn put_raw_batch_untracked(
1592 &self,
1593 galaxy: Galaxy,
1594 entries: &[(&[u8], &[u8])],
1595 ) -> Result<()> {
1596 self.put_raw_batch_impl(galaxy, entries)
1597 }
1598
1599 fn put_raw_batch_impl(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1600 let db = self.galaxy_db(galaxy)?;
1601 let mut tx = self
1602 .env
1603 .begin_rw_txn()
1604 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1605 for (key, val) in entries {
1606 tx.put(db, key, val, WriteFlags::default())
1607 .map_err(|e| CoreError::Memory(format!("LMDB put_raw_batch failed: {e}")))?;
1608 }
1609 tx.commit()
1610 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1611 Ok(())
1612 }
1613
1614 pub fn find_by_content_hash(&self, galaxy: Galaxy, hash: &str) -> Result<Option<uuid::Uuid>> {
1620 let tx = self
1621 .env
1622 .begin_ro_txn()
1623 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1624 let result = self.index_dbs.find_by_content_hash(&tx, galaxy, hash)?;
1625 tx.commit()
1626 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1627 Ok(result)
1628 }
1629
1630 pub fn find_by_content_hash_scan(
1632 &self,
1633 galaxy: Galaxy,
1634 hash: &str,
1635 ) -> Result<Option<uuid::Uuid>> {
1636 let memories = self.scan(galaxy, 10_000)?;
1637 for mem in memories {
1638 if mem.metadata.content_hash == hash {
1639 return Ok(Some(mem.metadata.id));
1640 }
1641 }
1642 Ok(None)
1643 }
1644
1645 pub fn put_dedup(&self, galaxy: Galaxy, memory: &Memory) -> Result<uuid::Uuid> {
1649 if let Some(existing_id) =
1650 self.find_by_content_hash(galaxy, &memory.metadata.content_hash)?
1651 {
1652 return Ok(existing_id);
1653 }
1654 let id = memory.metadata.id;
1655 self.put(galaxy, memory)?;
1656 Ok(id)
1657 }
1658
1659 pub fn put_batch(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<()> {
1664 let db = self.galaxy_db(galaxy)?;
1665 let mut tx = self
1666 .env
1667 .begin_rw_txn()
1668 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1669
1670 for memory in memories {
1671 let key = memory.metadata.id.as_bytes();
1672 let val = self.encode_record_value(galaxy, memory)?;
1673 tx.put(db, key, &val, WriteFlags::default())
1674 .map_err(|e| CoreError::Memory(format!("LMDB put_batch failed: {e}")))?;
1675 self.index_dbs.add(&mut tx, galaxy, memory)?;
1676 }
1677
1678 tx.commit()
1679 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1680 self.mutation_count
1681 .fetch_add(memories.len() as u64, Ordering::Relaxed);
1682 Ok(())
1683 }
1684
1685 pub fn query(&self, galaxy: Galaxy, query: &MemoryQuery) -> Result<Vec<Memory>> {
1692 if query.content_substring.is_none()
1695 && query.tags.len() == 1
1696 && query.min_importance.is_none()
1697 && query.max_importance.is_none()
1698 && query.created_after.is_none()
1699 && query.created_before.is_none()
1700 {
1701 return self.query_by_tag_indexed(galaxy, &query.tags[0], query.limit);
1702 }
1703
1704 if query.content_substring.is_none()
1705 && query.tags.is_empty()
1706 && let Some(min) = query.min_importance
1707 && let Some(max) = query.max_importance
1708 && query.created_after.is_none()
1709 && query.created_before.is_none()
1710 {
1711 return self.query_by_importance_indexed(galaxy, min, max, query.limit);
1712 }
1713
1714 if query.content_substring.is_none()
1715 && query.tags.is_empty()
1716 && query.min_importance.is_none()
1717 && query.max_importance.is_none()
1718 && let Some(after) = query.created_after
1719 && let Some(before) = query.created_before
1720 {
1721 return self.query_by_time_indexed(galaxy, after, before, query.limit);
1722 }
1723
1724 let memories = self.scan(galaxy, 10_000)?;
1726 let mut results = Vec::new();
1727 for mem in memories {
1728 if query.matches(&mem) {
1729 results.push(mem);
1730 if results.len() >= query.limit {
1731 break;
1732 }
1733 }
1734 }
1735 Ok(results)
1736 }
1737
1738 fn query_by_tag_indexed(&self, galaxy: Galaxy, tag: &str, limit: usize) -> Result<Vec<Memory>> {
1740 let db = self.galaxy_db(galaxy)?;
1741 let tx = self
1742 .env
1743 .begin_ro_txn()
1744 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1745 let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1746 let mut results = Vec::new();
1747 for id in &ids {
1748 if results.len() >= limit {
1749 break;
1750 }
1751 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1752 if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1753 results.push(mem);
1754 }
1755 }
1756 }
1757 tx.commit()
1758 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1759 Ok(results)
1760 }
1761
1762 fn query_by_importance_indexed(
1764 &self,
1765 galaxy: Galaxy,
1766 min: f32,
1767 max: f32,
1768 limit: usize,
1769 ) -> Result<Vec<Memory>> {
1770 let db = self.galaxy_db(galaxy)?;
1771 let tx = self
1772 .env
1773 .begin_ro_txn()
1774 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1775 let ids = self
1776 .index_dbs
1777 .find_by_importance_range(&tx, galaxy, min, max)?;
1778 let mut results = Vec::new();
1779 for id in &ids {
1780 if results.len() >= limit {
1781 break;
1782 }
1783 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1784 if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1785 results.push(mem);
1786 }
1787 }
1788 }
1789 tx.commit()
1790 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1791 Ok(results)
1792 }
1793
1794 fn query_by_time_indexed(
1796 &self,
1797 galaxy: Galaxy,
1798 after: chrono::DateTime<chrono::Utc>,
1799 before: chrono::DateTime<chrono::Utc>,
1800 limit: usize,
1801 ) -> Result<Vec<Memory>> {
1802 let db = self.galaxy_db(galaxy)?;
1803 let tx = self
1804 .env
1805 .begin_ro_txn()
1806 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1807 let ids = self
1808 .index_dbs
1809 .find_by_time_range(&tx, galaxy, after, before)?;
1810 let mut results = Vec::new();
1811 for id in &ids {
1812 if results.len() >= limit {
1813 break;
1814 }
1815 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1816 if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1817 results.push(mem);
1818 }
1819 }
1820 }
1821 tx.commit()
1822 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1823 Ok(results)
1824 }
1825
1826 pub fn put_semantic(&self, galaxy: Galaxy, memory: &mut Memory) -> Result<()> {
1834 let temporal_weight = memory.metadata.coord5d.w;
1835 let importance = memory.metadata.importance;
1836 memory.metadata.coord5d =
1837 self.semantic_encoder
1838 .encode_coordinate(&memory.content, temporal_weight, importance);
1839 self.put(galaxy, memory)
1840 }
1841
1842 pub fn find_similar(
1847 &self,
1848 galaxy: Galaxy,
1849 query_text: &str,
1850 limit: usize,
1851 ) -> Result<Vec<(Memory, f32)>> {
1852 let query_coord = self
1853 .semantic_encoder
1854 .encode_coordinate(query_text, 0.5, 0.5);
1855 let memories = self.scan(galaxy, 10_000)?;
1856 let mut results: Vec<(Memory, f32)> = memories
1857 .into_iter()
1858 .map(|m| {
1859 let dist = query_coord.semantic_distance_to(&m.metadata.coord5d);
1860 (m, dist)
1861 })
1862 .collect();
1863 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1864 results.truncate(limit);
1865 Ok(results)
1866 }
1867
1868 pub fn put_embedding(&self, memory_id: uuid::Uuid, embedding: &[f32]) -> Result<()> {
1873 let db = self.galaxy_db(Galaxy::Embeddings)?;
1874 let key = memory_id.as_bytes();
1875 let val = encode_embedding(embedding);
1876
1877 let mut tx = self
1878 .env
1879 .begin_rw_txn()
1880 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1881 tx.put(db, key, &val, WriteFlags::default())
1882 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding failed: {e}")))?;
1883 tx.commit()
1884 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1885 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1886 Ok(())
1887 }
1888
1889 pub fn get_embedding(&self, memory_id: uuid::Uuid) -> Result<Option<Vec<f32>>> {
1891 let db = self.galaxy_db(Galaxy::Embeddings)?;
1892 let key = memory_id.as_bytes();
1893
1894 let tx = self
1895 .env
1896 .begin_ro_txn()
1897 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1898 match tx.get(db, key) {
1899 Ok(bytes) => {
1900 let embedding = decode_embedding(bytes);
1901 tx.commit()
1902 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1903 Ok(Some(embedding))
1904 }
1905 Err(lmdb::Error::NotFound) => {
1906 tx.commit()
1907 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1908 Ok(None)
1909 }
1910 Err(e) => Err(CoreError::Memory(format!("LMDB get_embedding failed: {e}"))),
1911 }
1912 }
1913
1914 pub fn delete_embedding(&self, memory_id: uuid::Uuid) -> Result<bool> {
1916 let db = self.galaxy_db(Galaxy::Embeddings)?;
1917 let key = memory_id.as_bytes();
1918
1919 let mut tx = self
1920 .env
1921 .begin_rw_txn()
1922 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1923 let exists = tx.get(db, key).is_ok();
1924 if exists {
1925 tx.del(db, key, None)
1926 .map_err(|e| CoreError::Memory(format!("LMDB del_embedding failed: {e}")))?;
1927 }
1928 tx.commit()
1929 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1930 if exists {
1931 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1932 }
1933 Ok(exists)
1934 }
1935
1936 pub fn put_embedding_cache(&self, cache_key: &str, embedding: &[f32]) -> Result<()> {
1942 let mut tx = self
1943 .env
1944 .begin_rw_txn()
1945 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1946 tx.put(
1947 self.embedding_cache_db,
1948 &cache_key.as_bytes().to_vec(),
1949 &encode_embedding(embedding),
1950 WriteFlags::default(),
1951 )
1952 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1953 tx.commit()
1954 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1955 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1956 Ok(())
1957 }
1958
1959 pub fn put_embedding_cache_batch(&self, entries: &[(String, Vec<f32>)]) -> Result<()> {
1961 if entries.is_empty() {
1962 return Ok(());
1963 }
1964 let mut tx = self
1965 .env
1966 .begin_rw_txn()
1967 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1968 for (key, embedding) in entries {
1969 tx.put(
1970 self.embedding_cache_db,
1971 &key.as_bytes().to_vec(),
1972 &encode_embedding(embedding),
1973 WriteFlags::default(),
1974 )
1975 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1976 }
1977 tx.commit()
1978 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1979 self.mutation_count
1980 .fetch_add(entries.len() as u64, Ordering::Relaxed);
1981 Ok(())
1982 }
1983
1984 pub fn get_embedding_cache(&self, cache_key: &str) -> Result<Option<Vec<f32>>> {
1986 let tx = self
1987 .env
1988 .begin_ro_txn()
1989 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1990 match tx.get(self.embedding_cache_db, &cache_key.as_bytes().to_vec()) {
1991 Ok(bytes) => {
1992 let embedding = decode_embedding(bytes);
1993 tx.commit()
1994 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1995 Ok(Some(embedding))
1996 }
1997 Err(lmdb::Error::NotFound) => {
1998 tx.commit()
1999 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2000 Ok(None)
2001 }
2002 Err(e) => Err(CoreError::Memory(format!(
2003 "LMDB get_embedding_cache failed: {e}"
2004 ))),
2005 }
2006 }
2007
2008 pub fn get_embedding_cache_batch(&self, keys: &[String]) -> Result<Vec<Option<Vec<f32>>>> {
2011 let tx = self
2012 .env
2013 .begin_ro_txn()
2014 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2015 let mut out = Vec::with_capacity(keys.len());
2016 for key in keys {
2017 out.push(
2018 tx.get(self.embedding_cache_db, &key.as_bytes().to_vec())
2019 .ok()
2020 .map(decode_embedding),
2021 );
2022 }
2023 tx.commit()
2024 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2025 Ok(out)
2026 }
2027
2028 pub fn embedding_cache_count(&self) -> Result<u64> {
2030 let tx = self
2031 .env
2032 .begin_ro_txn()
2033 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2034 let mut cursor = tx
2035 .open_ro_cursor(self.embedding_cache_db)
2036 .map_err(|e| CoreError::Memory(format!("LMDB cursor embedding_cache failed: {e}")))?;
2037 let mut count = 0u64;
2038 for _ in cursor.iter() {
2039 count += 1;
2040 }
2041 Ok(count)
2042 }
2043
2044 pub fn record_revision(
2050 &self,
2051 galaxy: Galaxy,
2052 id: MemoryId,
2053 old_hash: &str,
2054 new_hash: &str,
2055 actor: crate::revision::RevisionActor,
2056 ) -> Result<crate::revision::MemoryRevision> {
2057 let seq = self.revisions(galaxy, id)?.len() as u32;
2058 let entry = crate::revision::MemoryRevision {
2059 seq,
2060 timestamp: wm_core::time::now_unix_secs(),
2061 old_hash: old_hash.to_string(),
2062 new_hash: new_hash.to_string(),
2063 actor_session: actor.session,
2064 actor_user: actor.user,
2065 actor_compartment: actor.compartment,
2066 };
2067 let key = crate::revision::revision_key(galaxy, id, seq);
2068 let val = serde_json::to_vec(&entry)
2069 .map_err(|e| CoreError::Memory(format!("revision serialize failed: {e}")))?;
2070 let mut tx = self
2071 .env
2072 .begin_rw_txn()
2073 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2074 tx.put(self.revisions_db, &key, &val, WriteFlags::default())
2075 .map_err(|e| CoreError::Memory(format!("LMDB put revision failed: {e}")))?;
2076 tx.commit()
2077 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2078 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2079 Ok(entry)
2080 }
2081
2082 pub fn revisions(
2085 &self,
2086 galaxy: Galaxy,
2087 id: MemoryId,
2088 ) -> Result<Vec<crate::revision::MemoryRevision>> {
2089 const MDB_GET_CURRENT: u32 = 4;
2094 const MDB_NEXT: u32 = 8;
2095 const MDB_SET_RANGE: u32 = 17;
2096 let prefix = crate::revision::revision_prefix(galaxy, id);
2097 let tx = self
2098 .env
2099 .begin_ro_txn()
2100 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2101 let cursor = tx
2102 .open_ro_cursor(self.revisions_db)
2103 .map_err(|e| CoreError::Memory(format!("LMDB cursor revisions failed: {e}")))?;
2104 let mut out = Vec::new();
2105 if cursor.get(Some(&prefix), None, MDB_SET_RANGE).is_ok() {
2106 while let Ok((key, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
2107 if !key.is_some_and(|k| k.starts_with(&prefix)) {
2110 break;
2111 }
2112 let entry: crate::revision::MemoryRevision = serde_json::from_slice(val)
2113 .map_err(|e| CoreError::Memory(format!("revision deserialize failed: {e}")))?;
2114 out.push(entry);
2115 if out.len() >= 10_000 || cursor.get(None, None, MDB_NEXT).is_err() {
2117 break;
2118 }
2119 }
2120 }
2121 drop(cursor);
2122 tx.commit()
2123 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2124 Ok(out)
2125 }
2126
2127 pub fn verify_revision_chain(
2130 &self,
2131 galaxy: Galaxy,
2132 id: MemoryId,
2133 current_hash: &str,
2134 ) -> Result<crate::revision::RevisionChainReport> {
2135 let entries = self.revisions(galaxy, id)?;
2136 Ok(crate::revision::verify_chain(&entries, current_hash))
2137 }
2138
2139 pub fn record_attestation(
2147 &self,
2148 galaxy: Galaxy,
2149 id: MemoryId,
2150 entry: &crate::attestation::RecordAttestation,
2151 ) -> Result<()> {
2152 let key = crate::attestation::attestation_key(galaxy, id);
2153 let val = serde_json::to_vec(entry)
2154 .map_err(|e| CoreError::Memory(format!("attestation serialize failed: {e}")))?;
2155 let mut tx = self
2156 .env
2157 .begin_rw_txn()
2158 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2159 tx.put(self.attestations_db, &key, &val, WriteFlags::default())
2160 .map_err(|e| CoreError::Memory(format!("LMDB put attestation failed: {e}")))?;
2161 tx.commit()
2162 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2163 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2164 Ok(())
2165 }
2166
2167 pub fn attestation(
2170 &self,
2171 galaxy: Galaxy,
2172 id: MemoryId,
2173 ) -> Result<Option<crate::attestation::RecordAttestation>> {
2174 let key = crate::attestation::attestation_key(galaxy, id);
2175 let tx = self
2176 .env
2177 .begin_ro_txn()
2178 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2179 let out =
2180 match tx.get(self.attestations_db, &key) {
2181 Ok(val) => Some(serde_json::from_slice(val).map_err(|e| {
2182 CoreError::Memory(format!("attestation deserialize failed: {e}"))
2183 })?),
2184 Err(lmdb::Error::NotFound) => None,
2185 Err(e) => {
2186 return Err(CoreError::Memory(format!(
2187 "LMDB get attestation failed: {e}"
2188 )));
2189 }
2190 };
2191 drop(tx);
2192 Ok(out)
2193 }
2194
2195 pub fn scan_attestations(&self) -> Result<Vec<crate::attestation::RecordAttestation>> {
2198 const MDB_GET_CURRENT: u32 = 4;
2199 const MDB_NEXT: u32 = 8;
2200 const MDB_FIRST: u32 = 9;
2201 let tx = self
2202 .env
2203 .begin_ro_txn()
2204 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2205 let cursor = tx
2206 .open_ro_cursor(self.attestations_db)
2207 .map_err(|e| CoreError::Memory(format!("LMDB cursor attestations failed: {e}")))?;
2208 let mut out = Vec::new();
2209 if cursor.get(None, None, MDB_FIRST).is_ok() {
2210 while let Ok((_, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
2211 let entry: crate::attestation::RecordAttestation = serde_json::from_slice(val)
2212 .map_err(|e| {
2213 CoreError::Memory(format!("attestation deserialize failed: {e}"))
2214 })?;
2215 out.push(entry);
2216 if out.len() >= 1_000_000 || cursor.get(None, None, MDB_NEXT).is_err() {
2218 break;
2219 }
2220 }
2221 }
2222 drop(cursor);
2223 tx.commit()
2224 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2225 Ok(out)
2226 }
2227
2228 pub fn verify_attestation(
2234 &self,
2235 galaxy: Galaxy,
2236 id: MemoryId,
2237 ) -> Result<crate::attestation::AttestationReport> {
2238 use crate::attestation::AttestationReport;
2239 let Some(att) = self.attestation(galaxy, id)? else {
2240 return Ok(AttestationReport {
2241 attested: false,
2242 signature_valid: false,
2243 matches_head: false,
2244 memory_present: self.get(galaxy, id)?.is_some(),
2245 breaks: vec!["no attestation recorded for this memory".to_string()],
2246 });
2247 };
2248 let mut breaks = Vec::new();
2249 let signature_valid = crate::attestation::verify_attestation(&att);
2250 if !signature_valid {
2251 breaks.push("signature does not verify against recorded pubkey".to_string());
2252 }
2253 let (matches_head, memory_present) = if let Some(memory) = self.get(galaxy, id)? {
2254 let matches = memory.metadata.content_hash == att.record_hash;
2255 if !matches {
2256 breaks.push(
2257 "attested record_hash != live content_hash (memory updated after attestation)"
2258 .to_string(),
2259 );
2260 }
2261 (matches, true)
2262 } else {
2263 breaks.push("attested memory id not present in galaxy".to_string());
2264 (false, false)
2265 };
2266 Ok(AttestationReport {
2267 attested: true,
2268 signature_valid,
2269 matches_head,
2270 memory_present,
2271 breaks,
2272 })
2273 }
2274
2275 pub fn attestation_sweep(
2279 &self,
2280 ) -> Result<
2281 Vec<(
2282 crate::attestation::RecordAttestation,
2283 crate::attestation::AttestationReport,
2284 )>,
2285 > {
2286 use crate::attestation::AttestationReport;
2287 let mut out = Vec::new();
2288 for att in self.scan_attestations()? {
2289 let parsed = match (
2290 Galaxy::from_db_name(&att.galaxy),
2291 uuid::Uuid::parse_str(&att.memory_id),
2292 ) {
2293 (Some(galaxy), Ok(id)) => Some((galaxy, id)),
2294 _ => None,
2295 };
2296 match parsed {
2297 Some((galaxy, id)) => out.push((att, self.verify_attestation(galaxy, id)?)),
2298 None => out.push((
2299 att,
2300 AttestationReport {
2301 attested: true,
2302 signature_valid: false,
2303 matches_head: false,
2304 memory_present: false,
2305 breaks: vec![
2306 "attestation row has unparseable galaxy or memory id".to_string(),
2307 ],
2308 },
2309 )),
2310 }
2311 }
2312 Ok(out)
2313 }
2314
2315 pub fn put_cold_record(&self, record: &crate::cold_storage::ColdRecord) -> Result<()> {
2319 let key = record.id.as_bytes();
2320 let val = rmp_serde::to_vec_named(record)
2321 .map_err(|e| CoreError::Memory(format!("Cold record serialization failed: {e}")))?;
2322 let mut tx = self
2323 .env
2324 .begin_rw_txn()
2325 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2326 tx.put(self.cold_storage_db, key, &val, WriteFlags::default())
2327 .map_err(|e| CoreError::Memory(format!("LMDB put cold_storage failed: {e}")))?;
2328 tx.commit()
2329 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2330 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2331 Ok(())
2332 }
2333
2334 pub fn get_cold_record(&self, id: MemoryId) -> Result<Option<crate::cold_storage::ColdRecord>> {
2336 let key = id.as_bytes();
2337 let tx = self
2338 .env
2339 .begin_ro_txn()
2340 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2341 match tx.get(self.cold_storage_db, key) {
2342 Ok(bytes) => {
2343 let record: crate::cold_storage::ColdRecord = rmp_serde::from_slice(bytes)
2344 .map_err(|e| {
2345 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2346 })?;
2347 Ok(Some(record))
2348 }
2349 Err(lmdb::Error::NotFound) => Ok(None),
2350 Err(e) => Err(CoreError::Memory(format!(
2351 "LMDB get cold_storage failed: {e}"
2352 ))),
2353 }
2354 }
2355
2356 pub fn delete_cold_record(&self, id: MemoryId) -> Result<bool> {
2358 let key = id.as_bytes();
2359 let mut tx = self
2360 .env
2361 .begin_rw_txn()
2362 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2363 let deleted = match tx.del(self.cold_storage_db, key, None) {
2364 Ok(()) => true,
2365 Err(lmdb::Error::NotFound) => false,
2366 Err(e) => {
2367 return Err(CoreError::Memory(format!(
2368 "LMDB del cold_storage failed: {e}"
2369 )));
2370 }
2371 };
2372 tx.commit()
2373 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2374 if deleted {
2375 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2376 }
2377 Ok(deleted)
2378 }
2379
2380 pub fn count_cold(&self, galaxy: Option<Galaxy>) -> Result<usize> {
2382 let tx = self
2383 .env
2384 .begin_ro_txn()
2385 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2386 let mut cursor = tx
2387 .open_ro_cursor(self.cold_storage_db)
2388 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2389 let mut count = 0;
2390 for (_key, val) in cursor.iter() {
2391 if let Some(target_g) = galaxy {
2392 let record: crate::cold_storage::ColdRecord =
2393 rmp_serde::from_slice(val).map_err(|e| {
2394 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2395 })?;
2396 if record.galaxy == target_g {
2397 count += 1;
2398 }
2399 } else {
2400 count += 1;
2401 }
2402 }
2403 Ok(count)
2404 }
2405
2406 pub fn list_cold_records(
2408 &self,
2409 galaxy: Option<Galaxy>,
2410 limit: usize,
2411 ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
2412 let query = crate::cold_storage::ColdQuery {
2413 galaxy,
2414 limit: if limit == 0 { 100 } else { limit },
2415 ..Default::default()
2416 };
2417 self.query_cold_records(&query)
2418 }
2419
2420 pub fn query_cold_records(
2422 &self,
2423 query: &crate::cold_storage::ColdQuery,
2424 ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
2425 let tx = self
2426 .env
2427 .begin_ro_txn()
2428 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2429 let mut cursor = tx
2430 .open_ro_cursor(self.cold_storage_db)
2431 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2432 let mut results = Vec::new();
2433 let limit = if query.limit == 0 {
2434 usize::MAX
2435 } else {
2436 query.limit
2437 };
2438
2439 for (_key, val) in cursor.iter() {
2440 let record: crate::cold_storage::ColdRecord =
2441 rmp_serde::from_slice(val).map_err(|e| {
2442 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2443 })?;
2444 let summary = record.summary();
2445 if query.matches(&summary) {
2446 results.push(summary);
2447 if results.len() >= limit {
2448 break;
2449 }
2450 }
2451 }
2452 Ok(results)
2453 }
2454
2455 pub fn find_cold_matching(
2464 &self,
2465 terms: &[String],
2466 galaxy: Option<Galaxy>,
2467 limit: usize,
2468 max_scan: usize,
2469 ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2470 self.find_cold_matching_eligible(terms, galaxy, limit, max_scan, |_| true)
2471 }
2472
2473 pub fn find_cold_matching_eligible(
2476 &self,
2477 terms: &[String],
2478 galaxy: Option<Galaxy>,
2479 limit: usize,
2480 max_scan: usize,
2481 eligible: impl Fn(&Memory) -> bool,
2482 ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2483 use crate::cold_storage::ColdDiscoveryStop;
2484 let mut out = crate::cold_storage::ColdDiscoveryOutcome::default();
2485 if terms.is_empty() || limit == 0 || max_scan == 0 {
2486 return Ok(out);
2487 }
2488 let tx = self
2489 .env
2490 .begin_ro_txn()
2491 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2492 let mut cursor = tx
2493 .open_ro_cursor(self.cold_storage_db)
2494 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2495 let mut iter = cursor.iter();
2496 loop {
2497 if out.scanned >= max_scan {
2498 out.stop_reason = ColdDiscoveryStop::ScanLimit;
2499 break;
2500 }
2501 if out.records.len() >= limit {
2502 out.stop_reason = ColdDiscoveryStop::ResultLimit;
2503 break;
2504 }
2505 let Some((key, val)) = iter.next() else {
2506 out.stop_reason = ColdDiscoveryStop::Exhausted;
2507 break;
2508 };
2509 out.scanned += 1;
2510 let record: crate::cold_storage::ColdRecord = if let Ok(r) = rmp_serde::from_slice(val)
2511 {
2512 r
2513 } else {
2514 out.integrity_rejected += 1;
2515 continue;
2516 };
2517 if let Some(g) = galaxy {
2518 if record.galaxy != g {
2519 continue;
2520 }
2521 }
2522 out.candidates += 1;
2523 let mem = if let Ok(m) = record.decompress() {
2524 m
2525 } else {
2526 out.integrity_rejected += 1;
2527 continue;
2528 };
2529 if mem.metadata.is_private {
2530 out.private_skipped += 1;
2531 continue;
2532 }
2533 if !mem.metadata.validity.is_current() {
2534 out.non_current_skipped += 1;
2535 continue;
2536 }
2537 let integrity_ok = key == record.id.as_bytes()
2538 && mem.metadata.id == record.id
2539 && mem.metadata.galaxy == record.galaxy
2540 && mem.metadata.content_hash == record.content_hash
2541 && crate::content_hash(&mem.content) == record.content_hash;
2542 if !integrity_ok {
2543 out.integrity_rejected += 1;
2544 continue;
2545 }
2546 let haystack = format!(
2547 "{} {}",
2548 mem.content.to_lowercase(),
2549 mem.metadata.tags.join(" ").to_lowercase()
2550 );
2551 if !terms.iter().all(|t| haystack.contains(t.as_str())) {
2552 continue;
2553 }
2554 out.matched += 1;
2555 if !eligible(&mem) {
2556 out.eligibility_skipped += 1;
2557 continue;
2558 }
2559 out.records.push(record);
2560 }
2561 Ok(out)
2562 }
2563
2564 pub fn freeze_to_cold(
2570 &self,
2571 search: Option<&crate::SearchEngine>,
2572 memory_id: MemoryId,
2573 distance: f32,
2574 factors: crate::cold_storage::OuterRimFactors,
2575 digest_id: Option<MemoryId>,
2576 notes: Option<String>,
2577 codec: crate::cold_storage::CompressionCodec,
2578 ) -> Result<crate::cold_storage::ColdRecord> {
2579 let (galaxy, mut mem) = self.find_across_galaxies(memory_id)?.ok_or_else(|| {
2580 CoreError::NotFound(format!("Memory {memory_id} not found in hot store"))
2581 })?;
2582
2583 if mem.metadata.tier != crate::memory::Tier::Archival {
2585 let _ = mem.transition_tier(crate::memory::Tier::Archival);
2586 }
2587
2588 let record =
2589 crate::cold_storage::ColdRecord::new(&mem, distance, factors, digest_id, notes, codec)?;
2590
2591 self.put_cold_record(&record)?;
2593
2594 self.delete(galaxy, memory_id)?;
2596
2597 if let Some(engine) = search {
2599 if let Ok(mut writer_guard) = engine.writer() {
2600 let _ = engine.delete_document(&mut writer_guard, &memory_id.to_string());
2601 let _ = engine.commit(&mut writer_guard);
2602 }
2603 }
2604
2605 Ok(record)
2606 }
2607
2608 pub fn thaw_from_cold(
2614 &self,
2615 search: Option<&crate::SearchEngine>,
2616 memory_id: MemoryId,
2617 ) -> Result<Memory> {
2618 let record = self.get_cold_record(memory_id)?.ok_or_else(|| {
2619 CoreError::NotFound(format!("Memory {memory_id} not found in cold storage"))
2620 })?;
2621
2622 let mut mem = record.decompress()?;
2623
2624 let _ = mem.transition_tier(crate::memory::Tier::Episodic);
2626 mem.metadata.accessed_at = chrono::Utc::now();
2627 mem.metadata.access_count += 1;
2628 mem.metadata.recall_count += 1;
2629 if !mem.metadata.tags.iter().any(|t| t == "thawed:phagic") {
2630 mem.metadata.tags.push("thawed:phagic".to_string());
2631 }
2632
2633 self.put(record.galaxy, &mem)?;
2635
2636 if let Some(engine) = search {
2638 if let Ok(mut writer_guard) = engine.writer() {
2639 let _ = engine.index_memory(&mut writer_guard, &mem);
2640 let _ = engine.commit(&mut writer_guard);
2641 }
2642 }
2643
2644 self.delete_cold_record(memory_id)?;
2646
2647 Ok(mem)
2648 }
2649
2650 pub fn find_anywhere(&self, id: MemoryId) -> Result<Option<(Galaxy, Memory, bool)>> {
2654 if let Some((galaxy, mem)) = self.find_across_galaxies(id)? {
2655 return Ok(Some((galaxy, mem, false)));
2656 }
2657 if let Some(cold_record) = self.get_cold_record(id)? {
2658 let galaxy = cold_record.galaxy;
2659 let mem = cold_record.decompress()?;
2660 return Ok(Some((galaxy, mem, true)));
2661 }
2662 Ok(None)
2663 }
2664}
2665
2666#[cfg(test)]
2667mod tests {
2668 use super::*;
2669 use crate::content_hash;
2670
2671 #[test]
2672 fn open_and_create_galaxies() {
2673 let tmp = tempfile::tempdir().unwrap();
2674 let store = MemoryStore::open_default(tmp.path()).unwrap();
2675 for galaxy in Galaxy::all() {
2676 let _db = store.galaxy_db(galaxy).unwrap();
2677 }
2678 }
2679
2680 #[test]
2681 fn ensure_schema_completes_a_pre_cold_store() {
2682 use lmdb::{DatabaseFlags as LmdbFlags, Environment as LmdbEnv};
2683 use uuid::Uuid;
2684
2685 let tmp = tempfile::tempdir().unwrap();
2688 let path = tmp.path().join("old-store");
2689 std::fs::create_dir_all(&path).unwrap();
2690 {
2691 let env = LmdbEnv::new().set_max_dbs(64).open(&path).unwrap();
2692 for galaxy in Galaxy::all() {
2693 env.create_db(Some(galaxy.db_name()), LmdbFlags::default())
2694 .unwrap();
2695 }
2696 for (name, flags) in crate::indexes::INDEX_DBS {
2697 env.create_db(Some(name), *flags).unwrap();
2698 }
2699 for (name, flags) in [
2700 ("episodic_records", LmdbFlags::default()),
2701 ("episodic_terms_v2", LmdbFlags::DUP_SORT),
2702 ("embedding_cache", LmdbFlags::default()),
2703 ("revisions", LmdbFlags::default()),
2704 (crate::attestation::ATTESTATIONS_DB, LmdbFlags::default()),
2705 ] {
2706 env.create_db(Some(name), flags).unwrap();
2707 }
2708 }
2709
2710 let files_before: Vec<String> = {
2711 let mut names: Vec<String> = std::fs::read_dir(&path)
2712 .unwrap()
2713 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2714 .collect();
2715 names.sort();
2716 names
2717 };
2718 let data_before = std::fs::read(path.join("data.mdb")).unwrap();
2719
2720 let error = match MemoryStore::open_readonly(&path) {
2721 Ok(_) => panic!("strict read-only open must refuse an incomplete store"),
2722 Err(e) => e.to_string(),
2723 };
2724 assert!(error.contains("cold_storage"), "{error}");
2725 assert_eq!(
2726 std::fs::read(path.join("data.mdb")).unwrap(),
2727 data_before,
2728 "readonly refusal must not mutate the pre-cold store"
2729 );
2730 let files_after: Vec<String> = {
2731 let mut names: Vec<String> = std::fs::read_dir(&path)
2732 .unwrap()
2733 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2734 .collect();
2735 names.sort();
2736 names
2737 };
2738 assert_eq!(
2739 files_after, files_before,
2740 "readonly refusal changed the store directory"
2741 );
2742
2743 let created = MemoryStore::ensure_schema(&path).unwrap();
2744 assert_eq!(created, vec!["cold_storage".to_string()], "{created:?}");
2745
2746 let store = MemoryStore::open_readonly(&path).unwrap();
2747 assert!(store.get_cold_record(Uuid::nil()).unwrap().is_none());
2748
2749 assert!(MemoryStore::ensure_schema(&path).unwrap().is_empty());
2751 }
2752
2753 #[test]
2754 fn cold_discovery_hydrates_verifies_and_respects_visibility() {
2755 use crate::cold_storage::{ColdRecord, CompressionCodec, OuterRimFactors};
2756 let tmp = tempfile::tempdir().unwrap();
2757 let store = MemoryStore::open_default(tmp.path()).unwrap();
2758 let factors = OuterRimFactors {
2759 age_factor: 0.5,
2760 access_factor: 0.5,
2761 resonance_factor: 0.5,
2762 emotional_factor: 0.5,
2763 importance_factor: 0.5,
2764 distance: 0.5,
2765 };
2766
2767 let pub_mem = Memory::new(
2768 Galaxy::Codex,
2769 "public needle zxquniquecoldfact741 buried".into(),
2770 );
2771 let rec = ColdRecord::new(
2772 &pub_mem,
2773 0.5,
2774 factors.clone(),
2775 None,
2776 None,
2777 CompressionCodec::Gzip,
2778 )
2779 .unwrap();
2780 store.put_cold_record(&rec).unwrap();
2781 let out = store
2782 .find_cold_matching(&["zxquniquecoldfact741".to_string()], None, 10, 100)
2783 .unwrap();
2784 assert_eq!(out.matched, 1);
2785 assert_eq!(out.integrity_rejected, 0);
2786 assert_eq!(out.records.len(), 1);
2787
2788 let mut priv_mem = Memory::new(Galaxy::Codex, "private needle zxquniquecoldfact742".into());
2790 priv_mem.metadata.is_private = true;
2791 store
2792 .put_cold_record(
2793 &ColdRecord::new(&priv_mem, 0.5, factors, None, None, CompressionCodec::Gzip)
2794 .unwrap(),
2795 )
2796 .unwrap();
2797 let out_priv = store
2798 .find_cold_matching(&["zxquniquecoldfact742".to_string()], None, 10, 100)
2799 .unwrap();
2800 assert_eq!(out_priv.matched, 0);
2801 assert_eq!(out_priv.private_skipped, 1);
2802
2803 let mut tampered = rec.clone();
2806 let mut bad = Memory::new(Galaxy::Codex, "tampered needle zxquniquecoldfact743".into());
2807 bad.metadata.id = rec.id;
2808 let (payload, size) =
2809 crate::cold_storage::compress_memory(&bad, CompressionCodec::Gzip).unwrap();
2810 tampered.compressed_payload = payload;
2811 tampered.uncompressed_size = size;
2812 store.put_cold_record(&tampered).unwrap();
2813 let out_tamper = store
2814 .find_cold_matching(&["zxquniquecoldfact743".to_string()], None, 10, 100)
2815 .unwrap();
2816 assert_eq!(out_tamper.matched, 0);
2817 assert_eq!(out_tamper.integrity_rejected, 1);
2818
2819 store.delete_cold_record(rec.id).unwrap();
2822 let wrong_key = uuid::Uuid::from_u128(741);
2823 assert_ne!(wrong_key, rec.id);
2824 let value = rmp_serde::to_vec_named(&rec).unwrap();
2825 let mut tx = store.env.begin_rw_txn().unwrap();
2826 tx.put(
2827 store.cold_storage_db,
2828 wrong_key.as_bytes(),
2829 &value,
2830 WriteFlags::default(),
2831 )
2832 .unwrap();
2833 tx.commit().unwrap();
2834 let wrong_key_out = store
2835 .find_cold_matching(&["zxquniquecoldfact741".into()], None, 10, 100)
2836 .unwrap();
2837 assert!(wrong_key_out.records.is_empty());
2838 assert_eq!(wrong_key_out.integrity_rejected, 1);
2839 }
2840
2841 #[test]
2845 fn query_substring_filters_galaxy_wide() {
2846 let tmp = tempfile::tempdir().unwrap();
2847 let store = MemoryStore::open_default(tmp.path()).unwrap();
2848
2849 for (i, content) in [
2850 "the mesh joins at dawn",
2851 "unrelated content entirely",
2852 "MESH joins at dusk",
2853 ]
2854 .iter()
2855 .enumerate()
2856 {
2857 let mut m = Memory::new(Galaxy::Codex, content.to_string());
2858 m.metadata.importance = 0.5 + i as f32 / 10.0;
2859 store.put(Galaxy::Codex, &m).unwrap();
2860 }
2861
2862 let hits = store
2863 .query(
2864 Galaxy::Codex,
2865 &MemoryQuery::new().with_content_substring("mesh joins"),
2866 )
2867 .unwrap();
2868 assert_eq!(hits.len(), 2, "CI substring must match both: {hits:?}");
2869 assert!(
2870 hits.iter()
2871 .all(|m| m.content.to_lowercase().contains("mesh joins"))
2872 );
2873
2874 let none = store
2875 .query(
2876 Galaxy::Codex,
2877 &MemoryQuery::new().with_content_substring("quantum calendar"),
2878 )
2879 .unwrap();
2880 assert!(none.is_empty(), "no match must be an honest empty set");
2881
2882 let mut tagged = Memory::new(Galaxy::Codex, "mesh joins again".to_string());
2884 tagged.metadata.tags = vec!["mesh".into()];
2885 store.put(Galaxy::Codex, &tagged).unwrap();
2886 let combined = store
2887 .query(
2888 Galaxy::Codex,
2889 &MemoryQuery::new()
2890 .with_tags(vec!["mesh".into()])
2891 .with_content_substring("again"),
2892 )
2893 .unwrap();
2894 assert_eq!(combined.len(), 1);
2895 assert_eq!(combined[0].content, "mesh joins again");
2896 }
2897
2898 #[cfg(unix)]
2899 #[test]
2900 fn store_dir_has_restrictive_permissions() {
2901 let tmp = tempfile::tempdir().unwrap();
2902 let store_path = tmp.path().join("lmdb");
2903 let _store = MemoryStore::open_default(&store_path).unwrap();
2904 let perms = std::fs::metadata(&store_path).unwrap().permissions().mode();
2905 assert_eq!(
2906 perms & 0o777,
2907 0o700,
2908 "store directory should have 0700 permissions, got {:o}",
2909 perms & 0o777
2910 );
2911 }
2912
2913 #[test]
2914 fn put_get_delete_memory() {
2915 let tmp = tempfile::tempdir().unwrap();
2916 let store = MemoryStore::open_default(tmp.path()).unwrap();
2917
2918 let mem = Memory::new(Galaxy::Codex, "Hello world".to_string());
2919 let id = mem.metadata.id;
2920
2921 store.put(Galaxy::Codex, &mem).unwrap();
2922 let retrieved = store.get(Galaxy::Codex, id).unwrap();
2923 assert!(retrieved.is_some());
2924 assert_eq!(retrieved.unwrap().content, "Hello world");
2925
2926 let deleted = store.delete(Galaxy::Codex, id).unwrap();
2927 assert!(deleted);
2928
2929 let gone = store.get(Galaxy::Codex, id).unwrap();
2930 assert!(gone.is_none());
2931 }
2932
2933 #[test]
2934 fn scan_memories() {
2935 let tmp = tempfile::tempdir().unwrap();
2936 let store = MemoryStore::open_default(tmp.path()).unwrap();
2937
2938 for i in 0..5 {
2939 let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
2940 store.put(Galaxy::Codex, &mem).unwrap();
2941 }
2942
2943 let all = store.scan(Galaxy::Codex, 100).unwrap();
2944 assert_eq!(all.len(), 5);
2945
2946 let limited = store.scan(Galaxy::Codex, 3).unwrap();
2947 assert_eq!(limited.len(), 3);
2948 }
2949
2950 #[test]
2951 fn overwrite_removes_stale_index_entries() {
2952 let tmp = tempfile::tempdir().unwrap();
2953 let store = MemoryStore::open_default(tmp.path()).unwrap();
2954
2955 let mut mem = Memory::new(Galaxy::Codex, "overwrite target".to_string());
2957 mem.metadata.tags = vec!["alpha".to_string()];
2958 mem.metadata.importance = 0.9;
2959 let id = mem.metadata.id;
2960 store.put(Galaxy::Codex, &mem).unwrap();
2961
2962 let mut updated = Memory::new(Galaxy::Codex, "overwritten content".to_string());
2964 updated.metadata.id = id;
2965 updated.metadata.tags = vec!["beta".to_string()];
2966 updated.metadata.importance = 0.1;
2967 store.put(Galaxy::Codex, &updated).unwrap();
2968
2969 let tx = store.env().begin_ro_txn().unwrap();
2971 let by_alpha = store
2972 .index_dbs()
2973 .find_by_tag(&tx, Galaxy::Codex, "alpha")
2974 .unwrap();
2975 let by_beta = store
2976 .index_dbs()
2977 .find_by_tag(&tx, Galaxy::Codex, "beta")
2978 .unwrap();
2979 assert!(
2980 by_alpha.is_empty(),
2981 "stale tag index entries must be removed on overwrite"
2982 );
2983 assert_eq!(by_beta, vec![id]);
2984
2985 let by_importance = store
2986 .index_dbs()
2987 .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 0.2)
2988 .unwrap();
2989 assert!(
2990 by_importance.contains(&id),
2991 "new importance must be indexed"
2992 );
2993 let by_high = store
2994 .index_dbs()
2995 .find_by_importance_range(&tx, Galaxy::Codex, 0.8, 1.0)
2996 .unwrap();
2997 assert!(
2998 !by_high.contains(&id),
2999 "stale importance index entries must be removed on overwrite"
3000 );
3001
3002 let old_hash = content_hash("overwrite target");
3003 let new_hash = content_hash("overwritten content");
3004 assert_eq!(
3005 store
3006 .index_dbs()
3007 .find_by_content_hash(&tx, Galaxy::Codex, &old_hash)
3008 .unwrap(),
3009 None,
3010 "stale content-hash index entry must be removed"
3011 );
3012 assert_eq!(
3013 store
3014 .index_dbs()
3015 .find_by_content_hash(&tx, Galaxy::Codex, &new_hash)
3016 .unwrap(),
3017 Some(id)
3018 );
3019 }
3020
3021 #[test]
3022 fn count_memories() {
3023 let tmp = tempfile::tempdir().unwrap();
3024 let store = MemoryStore::open_default(tmp.path()).unwrap();
3025
3026 assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
3027
3028 for i in 0..3 {
3029 let mem = Memory::new(Galaxy::Codex, format!("count-{i}"));
3030 store.put(Galaxy::Codex, &mem).unwrap();
3031 }
3032
3033 assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
3034 }
3035
3036 #[test]
3040 fn count_by_tag_counts_indexed_records() {
3041 let tmp = tempfile::tempdir().unwrap();
3042 let store = MemoryStore::open_default(tmp.path()).unwrap();
3043
3044 assert_eq!(store.count_by_tag(Galaxy::Sessions, "start").unwrap(), 0);
3045
3046 let mut start = Memory::new(Galaxy::Sessions, "{\"type\":\"session_start\"}".into());
3047 start.metadata.tags = vec!["session".into(), "start".into()];
3048 store.put(Galaxy::Sessions, &start).unwrap();
3049 for i in 0..2 {
3050 let mut turn =
3051 Memory::new(Galaxy::Sessions, format!("{{\"type\":\"turn\",\"i\":{i}}}"));
3052 turn.metadata.tags = vec!["session".into(), "turn".into()];
3053 store.put(Galaxy::Sessions, &turn).unwrap();
3054 }
3055
3056 assert_eq!(store.count(Galaxy::Sessions).unwrap(), 3);
3057 assert_eq!(store.count_by_tag(Galaxy::Sessions, "start").unwrap(), 1);
3058 assert_eq!(store.count_by_tag(Galaxy::Sessions, "turn").unwrap(), 2);
3059 assert_eq!(store.count_by_tag(Galaxy::Sessions, "absent").unwrap(), 0);
3060 }
3061
3062 #[test]
3063 fn get_nonexistent_returns_none() {
3064 let tmp = tempfile::tempdir().unwrap();
3065 let store = MemoryStore::open_default(tmp.path()).unwrap();
3066 let result = store.get(Galaxy::Codex, uuid::Uuid::new_v4()).unwrap();
3067 assert!(result.is_none());
3068 }
3069
3070 #[test]
3071 fn raw_put_get() {
3072 let tmp = tempfile::tempdir().unwrap();
3073 let store = MemoryStore::open_default(tmp.path()).unwrap();
3074
3075 store
3076 .put_raw(Galaxy::Substrate, b"config:key", b"value123")
3077 .unwrap();
3078 let val = store.get_raw(Galaxy::Substrate, b"config:key").unwrap();
3079 assert_eq!(val, Some(b"value123".to_vec()));
3080 }
3081
3082 #[test]
3083 fn put_dedup_prevents_duplicates() {
3084 let tmp = tempfile::tempdir().unwrap();
3085 let store = MemoryStore::open_default(tmp.path()).unwrap();
3086
3087 let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3088 let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
3089
3090 let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3091 let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
3092
3093 assert_eq!(id1, id2, "dedup should return same ID for same content");
3094 assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
3095 }
3096
3097 #[test]
3098 fn put_dedup_allows_different_content() {
3099 let tmp = tempfile::tempdir().unwrap();
3100 let store = MemoryStore::open_default(tmp.path()).unwrap();
3101
3102 let mem1 = Memory::new(Galaxy::Codex, "content A".into());
3103 store.put_dedup(Galaxy::Codex, &mem1).unwrap();
3104
3105 let mem2 = Memory::new(Galaxy::Codex, "content B".into());
3106 store.put_dedup(Galaxy::Codex, &mem2).unwrap();
3107
3108 assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
3109 }
3110
3111 #[test]
3112 fn put_batch_atomic_write() {
3113 let tmp = tempfile::tempdir().unwrap();
3114 let store = MemoryStore::open_default(tmp.path()).unwrap();
3115
3116 let memories: Vec<Memory> = (0..10)
3117 .map(|i| Memory::new(Galaxy::Codex, format!("batch-{i}")))
3118 .collect();
3119
3120 store.put_batch(Galaxy::Codex, &memories).unwrap();
3121 assert_eq!(store.count(Galaxy::Codex).unwrap(), 10);
3122 }
3123
3124 #[test]
3125 fn query_by_tags() {
3126 let tmp = tempfile::tempdir().unwrap();
3127 let store = MemoryStore::open_default(tmp.path()).unwrap();
3128
3129 let mem1 = Memory::new(Galaxy::Codex, "tagged memory".into())
3130 .with_tags(vec!["rust".into(), "memory".into()]);
3131 let mem2 =
3132 Memory::new(Galaxy::Codex, "other memory".into()).with_tags(vec!["python".into()]);
3133 store.put(Galaxy::Codex, &mem1).unwrap();
3134 store.put(Galaxy::Codex, &mem2).unwrap();
3135
3136 let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
3137 let results = store.query(Galaxy::Codex, &query).unwrap();
3138 assert_eq!(results.len(), 1);
3139 assert_eq!(results[0].content, "tagged memory");
3140 }
3141
3142 #[test]
3143 fn query_by_importance_range() {
3144 let tmp = tempfile::tempdir().unwrap();
3145 let store = MemoryStore::open_default(tmp.path()).unwrap();
3146
3147 store
3148 .put(
3149 Galaxy::Codex,
3150 &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
3151 )
3152 .unwrap();
3153 store
3154 .put(
3155 Galaxy::Codex,
3156 &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
3157 )
3158 .unwrap();
3159 store
3160 .put(
3161 Galaxy::Codex,
3162 &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
3163 )
3164 .unwrap();
3165
3166 let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
3167 let results = store.query(Galaxy::Codex, &query).unwrap();
3168 assert_eq!(results.len(), 1);
3169 assert_eq!(results[0].content, "mid");
3170 }
3171
3172 #[test]
3173 fn memory_query_one_sided_time_bounds() {
3174 let old = Memory::new(Galaxy::Codex, "old".into());
3177 let mut recent = Memory::new(Galaxy::Codex, "recent".into());
3178 recent.metadata.created_at = old.metadata.created_at + chrono::Duration::days(30);
3179
3180 let cutoff = old.metadata.created_at + chrono::Duration::days(10);
3181 let after = MemoryQuery::new().with_created_after(cutoff);
3182 assert!(!after.matches(&old), "pre-cutoff memory must not match");
3183 assert!(after.matches(&recent), "post-cutoff memory must match");
3184
3185 let before = MemoryQuery::new().with_created_before(cutoff);
3186 assert!(before.matches(&old), "pre-cutoff memory must match");
3187 assert!(
3188 !before.matches(&recent),
3189 "post-cutoff memory must not match"
3190 );
3191
3192 let edge = MemoryQuery::new().with_created_after(cutoff);
3194 let mut at = Memory::new(Galaxy::Codex, "at cutoff".into());
3195 at.metadata.created_at = cutoff;
3196 assert!(edge.matches(&at), "created_at == after bound is inclusive");
3197 }
3198
3199 #[test]
3200 fn embedding_put_get_delete() {
3201 let tmp = tempfile::tempdir().unwrap();
3202 let store = MemoryStore::open_default(tmp.path()).unwrap();
3203
3204 let id = uuid::Uuid::new_v4();
3205 let embedding = vec![0.1, 0.2, 0.3, 0.4, 0.5];
3206
3207 store.put_embedding(id, &embedding).unwrap();
3208 let retrieved = store.get_embedding(id).unwrap();
3209 assert!(retrieved.is_some());
3210 let retrieved = retrieved.unwrap();
3211 assert_eq!(retrieved.len(), 5);
3212 assert!((retrieved[0] - 0.1).abs() < f32::EPSILON);
3213
3214 assert!(store.delete_embedding(id).unwrap());
3215 assert!(store.get_embedding(id).unwrap().is_none());
3216 }
3217
3218 #[test]
3219 fn embedding_cache_roundtrip_batch_and_count() {
3220 let tmp = tempfile::tempdir().unwrap();
3221 let store = MemoryStore::open_default(tmp.path()).unwrap();
3222
3223 let entries: Vec<(String, Vec<f32>)> = (0..5)
3224 .map(|i| (format!("ns:model:{i:016x}"), vec![i as f32; 8]))
3225 .collect();
3226 store.put_embedding_cache_batch(&entries).unwrap();
3227 assert_eq!(store.embedding_cache_count().unwrap(), 5);
3228
3229 let hit = store
3231 .get_embedding_cache("ns:model:0000000000000003")
3232 .unwrap();
3233 assert_eq!(hit.unwrap(), vec![3.0f32; 8]);
3234 assert!(
3235 store
3236 .get_embedding_cache("ns:model:absent")
3237 .unwrap()
3238 .is_none()
3239 );
3240
3241 let keys: Vec<String> = (0..6).map(|i| format!("ns:model:{i:016x}")).collect();
3243 let batch = store.get_embedding_cache_batch(&keys).unwrap();
3244 assert_eq!(batch.len(), 6);
3245 assert!(batch[0..5].iter().all(Option::is_some));
3246 assert!(batch[5].is_none());
3247
3248 store
3250 .put_embedding_cache("ns:model:0000000000000001", &[9.0; 8])
3251 .unwrap();
3252 assert_eq!(store.embedding_cache_count().unwrap(), 5);
3253 assert_eq!(
3254 store
3255 .get_embedding_cache("ns:model:0000000000000001")
3256 .unwrap()
3257 .unwrap(),
3258 vec![9.0f32; 8]
3259 );
3260 }
3261
3262 #[test]
3263 fn embedding_cache_survives_store_reopen() {
3264 let tmp = tempfile::tempdir().unwrap();
3266 {
3267 let store = MemoryStore::open_default(tmp.path()).unwrap();
3268 store
3269 .put_embedding_cache("onnx:bge-small:abc", &[0.5; 384])
3270 .unwrap();
3271 }
3272 let reopened = MemoryStore::open_default(tmp.path()).unwrap();
3273 let cached = reopened.get_embedding_cache("onnx:bge-small:abc").unwrap();
3274 assert_eq!(cached.unwrap(), vec![0.5f32; 384]);
3275 }
3276
3277 #[test]
3278 fn content_hash_is_sha256() {
3279 let hash1 = content_hash("test content");
3280 let hash2 = content_hash("test content");
3281 let hash3 = content_hash("different content");
3282
3283 assert_eq!(hash1, hash2, "same content should produce same hash");
3284 assert_ne!(
3285 hash1, hash3,
3286 "different content should produce different hash"
3287 );
3288 assert_eq!(hash1.len(), 64, "SHA-256 hex should be 64 chars");
3289 }
3290
3291 #[test]
3292 fn query_by_tag_uses_index() {
3293 let tmp = tempfile::tempdir().unwrap();
3294 let store = MemoryStore::open_default(tmp.path()).unwrap();
3295
3296 let mem1 = Memory::new(Galaxy::Codex, "tagged".into())
3297 .with_tags(vec!["rust".into(), "memory".into()]);
3298 let mem2 = Memory::new(Galaxy::Codex, "other".into()).with_tags(vec!["python".into()]);
3299 store.put(Galaxy::Codex, &mem1).unwrap();
3300 store.put(Galaxy::Codex, &mem2).unwrap();
3301
3302 let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
3303 let results = store.query(Galaxy::Codex, &query).unwrap();
3304 assert_eq!(results.len(), 1);
3305 assert_eq!(results[0].content, "tagged");
3306 }
3307
3308 #[test]
3309 fn query_by_importance_uses_index() {
3310 let tmp = tempfile::tempdir().unwrap();
3311 let store = MemoryStore::open_default(tmp.path()).unwrap();
3312
3313 store
3314 .put(
3315 Galaxy::Codex,
3316 &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
3317 )
3318 .unwrap();
3319 store
3320 .put(
3321 Galaxy::Codex,
3322 &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
3323 )
3324 .unwrap();
3325 store
3326 .put(
3327 Galaxy::Codex,
3328 &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
3329 )
3330 .unwrap();
3331
3332 let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
3333 let results = store.query(Galaxy::Codex, &query).unwrap();
3334 assert_eq!(results.len(), 1);
3335 assert_eq!(results[0].content, "mid");
3336 }
3337
3338 #[test]
3339 fn query_by_time_uses_index() {
3340 let tmp = tempfile::tempdir().unwrap();
3341 let store = MemoryStore::open_default(tmp.path()).unwrap();
3342
3343 let t0 = chrono::Utc::now();
3344 std::thread::sleep(std::time::Duration::from_millis(10));
3345 let mem = Memory::new(Galaxy::Codex, "timed".into());
3346 store.put(Galaxy::Codex, &mem).unwrap();
3347 std::thread::sleep(std::time::Duration::from_millis(10));
3348 let t2 = chrono::Utc::now();
3349
3350 let query = MemoryQuery::new().with_time_range(t0, t2);
3351 let results = store.query(Galaxy::Codex, &query).unwrap();
3352 assert_eq!(results.len(), 1);
3353 assert_eq!(results[0].content, "timed");
3354 }
3355
3356 #[test]
3357 fn delete_removes_index_entries() {
3358 let tmp = tempfile::tempdir().unwrap();
3359 let store = MemoryStore::open_default(tmp.path()).unwrap();
3360
3361 let mem = Memory::new(Galaxy::Codex, "test".into())
3362 .with_tags(vec!["tag1".into()])
3363 .with_importance(0.7);
3364 let id = mem.metadata.id;
3365 let hash = mem.metadata.content_hash.clone();
3366 store.put(Galaxy::Codex, &mem).unwrap();
3367
3368 assert!(
3370 store
3371 .find_by_content_hash(Galaxy::Codex, &hash)
3372 .unwrap()
3373 .is_some()
3374 );
3375
3376 store.delete(Galaxy::Codex, id).unwrap();
3378
3379 assert!(
3381 store
3382 .find_by_content_hash(Galaxy::Codex, &hash)
3383 .unwrap()
3384 .is_none()
3385 );
3386
3387 let query = MemoryQuery::new().with_tags(vec!["tag1".into()]);
3389 let results = store.query(Galaxy::Codex, &query).unwrap();
3390 assert!(results.is_empty());
3391 }
3392
3393 #[test]
3394 fn put_batch_updates_indexes() {
3395 let tmp = tempfile::tempdir().unwrap();
3396 let store = MemoryStore::open_default(tmp.path()).unwrap();
3397
3398 let memories: Vec<Memory> = (0..5)
3399 .map(|i| {
3400 Memory::new(Galaxy::Codex, format!("batch-{i}"))
3401 .with_tags(vec![format!("tag{i}")])
3402 .with_importance(i as f32 * 0.2)
3403 })
3404 .collect();
3405 store.put_batch(Galaxy::Codex, &memories).unwrap();
3406
3407 for i in 0..5 {
3408 let query = MemoryQuery::new().with_tags(vec![format!("tag{i}")]);
3409 let results = store.query(Galaxy::Codex, &query).unwrap();
3410 assert_eq!(results.len(), 1, "tag{i} should have 1 result");
3411 }
3412 }
3413
3414 #[test]
3415 fn find_by_content_hash_indexed_matches_scan() {
3416 let tmp = tempfile::tempdir().unwrap();
3417 let store = MemoryStore::open_default(tmp.path()).unwrap();
3418
3419 let mem = Memory::new(Galaxy::Codex, "dedup test".into());
3420 let id = mem.metadata.id;
3421 let hash = mem.metadata.content_hash.clone();
3422 store.put(Galaxy::Codex, &mem).unwrap();
3423
3424 let indexed = store.find_by_content_hash(Galaxy::Codex, &hash).unwrap();
3425 let scanned = store
3426 .find_by_content_hash_scan(Galaxy::Codex, &hash)
3427 .unwrap();
3428
3429 assert_eq!(indexed, scanned);
3430 assert_eq!(indexed, Some(id));
3431 }
3432
3433 #[test]
3434 fn put_dedup_uses_index() {
3435 let tmp = tempfile::tempdir().unwrap();
3436 let store = MemoryStore::open_default(tmp.path()).unwrap();
3437
3438 let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3439 let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
3440
3441 let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3442 let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
3443
3444 assert_eq!(id1, id2, "dedup should return same ID for same content");
3445 assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
3446 }
3447
3448 #[test]
3449 fn put_semantic_updates_coord5d() {
3450 let tmp = tempfile::tempdir().unwrap();
3451 let store = MemoryStore::open_default(tmp.path()).unwrap();
3452
3453 let mut mem = Memory::new(
3454 Galaxy::Codex,
3455 "The algorithm computes data using a systematic method".to_string(),
3456 );
3457 let original_coord = mem.metadata.coord5d.clone();
3458 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3459
3460 assert_ne!(
3462 mem.metadata.coord5d.x, original_coord.x,
3463 "semantic encoding should change x"
3464 );
3465 assert_ne!(
3466 mem.metadata.coord5d.y, original_coord.y,
3467 "semantic encoding should change y"
3468 );
3469
3470 let retrieved = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
3472 assert_eq!(retrieved.metadata.coord5d.x, mem.metadata.coord5d.x);
3473 }
3474
3475 #[test]
3476 fn put_semantic_preserves_temporal_and_importance() {
3477 let tmp = tempfile::tempdir().unwrap();
3478 let store = MemoryStore::open_default(tmp.path()).unwrap();
3479
3480 let mut mem = Memory::new(Galaxy::Codex, "test content".into()).with_importance(0.8);
3481 mem.metadata.coord5d.w = 0.6;
3482 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3483
3484 assert!((mem.metadata.coord5d.w - 0.6).abs() < f32::EPSILON);
3485 assert!((mem.metadata.coord5d.v - 0.8).abs() < f32::EPSILON);
3486 }
3487
3488 #[test]
3489 fn find_similar_returns_nearest_first() {
3490 let tmp = tempfile::tempdir().unwrap();
3491 let store = MemoryStore::open_default(tmp.path()).unwrap();
3492
3493 let mut logic_mem = Memory::new(
3494 Galaxy::Codex,
3495 "The algorithm computes data using systematic logic and analysis".to_string(),
3496 );
3497 store.put_semantic(Galaxy::Codex, &mut logic_mem).unwrap();
3498
3499 let mut emotion_mem = Memory::new(
3500 Galaxy::Codex,
3501 "I feel love and joy with deep passion and empathy in my heart".to_string(),
3502 );
3503 store.put_semantic(Galaxy::Codex, &mut emotion_mem).unwrap();
3504
3505 let results = store
3507 .find_similar(Galaxy::Codex, "algorithm data systematic method", 10)
3508 .unwrap();
3509 assert!(!results.is_empty());
3510 assert_eq!(results[0].0.metadata.id, logic_mem.metadata.id);
3511
3512 let results = store
3514 .find_similar(Galaxy::Codex, "love joy passion heart feeling", 10)
3515 .unwrap();
3516 assert!(!results.is_empty());
3517 assert_eq!(results[0].0.metadata.id, emotion_mem.metadata.id);
3518 }
3519
3520 #[test]
3521 fn find_similar_empty_galaxy() {
3522 let tmp = tempfile::tempdir().unwrap();
3523 let store = MemoryStore::open_default(tmp.path()).unwrap();
3524
3525 let results = store.find_similar(Galaxy::Codex, "anything", 10).unwrap();
3526 assert!(results.is_empty());
3527 }
3528
3529 #[test]
3530 fn find_similar_respects_limit() {
3531 let tmp = tempfile::tempdir().unwrap();
3532 let store = MemoryStore::open_default(tmp.path()).unwrap();
3533
3534 for i in 0..5 {
3535 let mut mem = Memory::new(Galaxy::Codex, format!("algorithm data method {i}"));
3536 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3537 }
3538
3539 let results = store
3540 .find_similar(Galaxy::Codex, "algorithm data", 3)
3541 .unwrap();
3542 assert_eq!(results.len(), 3);
3543 }
3544
3545 #[test]
3546 fn semantic_encoder_accessible() {
3547 let tmp = tempfile::tempdir().unwrap();
3548 let store = MemoryStore::open_default(tmp.path()).unwrap();
3549
3550 let scores = store.semantic_encoder().encode("algorithm data logic");
3551 assert!(scores.x < 0.5);
3553 }
3554
3555 #[test]
3556 fn put_raw_batch_writes_atomically() {
3557 let tmp = tempfile::tempdir().unwrap();
3558 let store = MemoryStore::open_default(tmp.path()).unwrap();
3559
3560 let entries: &[(&[u8], &[u8])] =
3561 &[(b"key1", b"val1"), (b"key2", b"val2"), (b"key3", b"val3")];
3562 store.put_raw_batch(Galaxy::Karma, entries).unwrap();
3563
3564 assert_eq!(
3565 store.get_raw(Galaxy::Karma, b"key1").unwrap().unwrap(),
3566 b"val1"
3567 );
3568 assert_eq!(
3569 store.get_raw(Galaxy::Karma, b"key2").unwrap().unwrap(),
3570 b"val2"
3571 );
3572 assert_eq!(
3573 store.get_raw(Galaxy::Karma, b"key3").unwrap().unwrap(),
3574 b"val3"
3575 );
3576 }
3577
3578 #[test]
3579 fn put_raw_batch_empty_is_noop() {
3580 let tmp = tempfile::tempdir().unwrap();
3581 let store = MemoryStore::open_default(tmp.path()).unwrap();
3582
3583 store.put_raw_batch(Galaxy::Karma, &[]).unwrap();
3584 assert_eq!(store.count(Galaxy::Karma).unwrap(), 0);
3585 }
3586
3587 #[test]
3588 fn entry_limit_rejects_excess_writes() {
3589 let tmp = tempfile::tempdir().unwrap();
3590 let store = MemoryStore::open_default(tmp.path())
3591 .unwrap()
3592 .with_entry_limit(3);
3593
3594 for i in 0..3 {
3595 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3596 store.put(Galaxy::Codex, &mem).unwrap();
3597 }
3598
3599 let mem = Memory::new(Galaxy::Codex, "overflow memory".to_string());
3601 let result = store.put(Galaxy::Codex, &mem);
3602 assert!(result.is_err(), "write beyond limit should be rejected");
3603 let err_msg = result.unwrap_err().to_string();
3604 assert!(
3605 err_msg.contains("entry limit reached"),
3606 "error should mention entry limit: {err_msg}"
3607 );
3608 assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
3609 }
3610
3611 #[test]
3612 fn entry_limit_per_galaxy_independent() {
3613 let tmp = tempfile::tempdir().unwrap();
3614 let store = MemoryStore::open_default(tmp.path())
3615 .unwrap()
3616 .with_entry_limit(2);
3617
3618 for i in 0..2 {
3620 let mem = Memory::new(Galaxy::Codex, format!("codex {i}"));
3621 store.put(Galaxy::Codex, &mem).unwrap();
3622 }
3623
3624 let mem = Memory::new(Galaxy::Research, "science memory".to_string());
3626 let result = store.put(Galaxy::Research, &mem);
3627 assert!(
3628 result.is_ok(),
3629 "different galaxy should not be affected by limit"
3630 );
3631 }
3632
3633 #[test]
3634 fn entry_limit_none_allows_unlimited() {
3635 let tmp = tempfile::tempdir().unwrap();
3636 let store = MemoryStore::open_default(tmp.path()).unwrap();
3637
3638 for i in 0..50 {
3640 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3641 store.put(Galaxy::Codex, &mem).unwrap();
3642 }
3643 assert_eq!(store.count(Galaxy::Codex).unwrap(), 50);
3644 }
3645
3646 #[test]
3647 fn map_full_error_is_graceful() {
3648 let tmp = tempfile::tempdir().unwrap();
3652 let store = MemoryStore::open(tmp.path(), 512 * 1024).unwrap();
3653
3654 let mut written = 0;
3656 let mut got_map_full = false;
3657 for i in 0..1000 {
3658 let mem = Memory::new(
3659 Galaxy::Codex,
3660 format!("memory content {i} {}", "with padding ".repeat(50)),
3661 );
3662 match store.put(Galaxy::Codex, &mem) {
3663 Ok(()) => written += 1,
3664 Err(e) => {
3665 let msg = e.to_string();
3666 if msg.contains("map full") {
3667 got_map_full = true;
3668 break;
3669 }
3670 break;
3672 }
3673 }
3674 }
3675
3676 assert!(
3677 got_map_full || written < 1000,
3678 "should eventually hit map full or error"
3679 );
3680 assert!(written > 0, "should have written at least some memories");
3681 }
3682
3683 #[test]
3684 fn test_find_across_galaxies() {
3685 let tmp = tempfile::tempdir().unwrap();
3686 let store = MemoryStore::open_default(tmp.path()).unwrap();
3687
3688 let mem = Memory::new(Galaxy::Research, "Cross-galaxy research memo".into());
3689 let id = mem.metadata.id;
3690 store.put(Galaxy::Research, &mem).unwrap();
3691
3692 let found = store.find_across_galaxies(id).unwrap();
3693 assert!(found.is_some());
3694 let (galaxy, retrieved) = found.unwrap();
3695 assert_eq!(galaxy, Galaxy::Research);
3696 assert_eq!(retrieved.metadata.id, id);
3697 assert_eq!(retrieved.content, "Cross-galaxy research memo");
3698
3699 assert!(
3701 store
3702 .find_across_galaxies(uuid::Uuid::new_v4())
3703 .unwrap()
3704 .is_none()
3705 );
3706 }
3707}