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 keyring_db: Option<Database>,
202 at_rest: Option<crate::at_rest::AtRestState>,
205 episodic_term_cache: std::sync::Arc<RwLock<HashMap<String, Vec<uuid::Uuid>>>>,
207 episodic_embedder:
209 std::sync::OnceLock<Option<Arc<dyn crate::embedder::Embedder + Send + Sync>>>,
210 episodic_sidecar_ensured: std::sync::OnceLock<()>,
212 episodic_aliases: std::sync::OnceLock<Option<crate::episodic_keys::AdaptiveAliases>>,
214 episodic_enrichment: std::sync::OnceLock<Option<crate::enrichment::VocabularyEnrichment>>,
216}
217
218impl MemoryStore {
219 #[cfg(unix)]
235 pub fn probe_write_lock(store_root: &Path) -> std::io::Result<()> {
236 use rustix::fs::{FlockOperation, fcntl_lock};
237 let lock_path = store_root.join("lmdb").join("lock.mdb");
238 let file = std::fs::OpenOptions::new()
239 .read(true)
240 .write(true)
241 .open(&lock_path)?;
242 match fcntl_lock(&file, FlockOperation::NonBlockingLockExclusive) {
243 Ok(()) => Ok(()),
244 Err(rustix::io::Errno::AGAIN | rustix::io::Errno::ACCESS) => Err(std::io::Error::new(
245 std::io::ErrorKind::WouldBlock,
246 "LMDB writer lock held by another process",
247 )),
248 Err(e) => Err(e.into()),
249 }
250 }
251
252 #[cfg(not(unix))]
255 pub fn probe_write_lock(_store_root: &Path) -> std::io::Result<()> {
256 Ok(())
257 }
258
259 pub fn open(path: impl AsRef<Path>, map_size: usize) -> Result<Self> {
271 Self::open_with_at_rest(path, map_size, &crate::at_rest::AtRestConfig::from_env()?)
272 }
273
274 pub fn open_with_at_rest(
281 path: impl AsRef<Path>,
282 map_size: usize,
283 at_rest_config: &crate::at_rest::AtRestConfig,
284 ) -> Result<Self> {
285 let path = path.as_ref().to_path_buf();
286
287 std::fs::create_dir_all(&path)
289 .map_err(|e| CoreError::Memory(format!("Cannot create store dir: {e}")))?;
290 #[cfg(unix)]
291 {
292 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
293 .map_err(|e| CoreError::Memory(format!("Cannot set store dir permissions: {e}")))?;
294 }
295
296 let env = Environment::new()
297 .set_map_size(map_size)
298 .set_max_dbs(64)
299 .open(&path)
300 .map_err(|e| CoreError::Memory(format!("LMDB open failed: {e}")))?;
301
302 for galaxy in Galaxy::all() {
304 let db = env
305 .create_db(Some(galaxy.db_name()), DatabaseFlags::default())
306 .map_err(|e| {
307 CoreError::Memory(format!(
308 "LMDB create_db failed for {}: {e}",
309 galaxy.db_name()
310 ))
311 })?;
312 let _ = db;
313 }
314
315 for (name, flags) in crate::indexes::INDEX_DBS {
317 let db = env
318 .create_db(Some(name), *flags)
319 .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for {name}: {e}")))?;
320 let _ = db;
321 }
322
323 let index_dbs = IndexDbs::open(&env)?;
324 let episodic_db = env
325 .create_db(Some("episodic_records"), DatabaseFlags::default())
326 .map_err(|e| {
327 CoreError::Memory(format!("LMDB create_db failed for episodic_records: {e}"))
328 })?;
329 let episodic_terms_v2_db = env
336 .create_db(Some("episodic_terms_v2"), DatabaseFlags::DUP_SORT)
337 .map_err(|e| {
338 CoreError::Memory(format!("LMDB create_db failed for episodic_terms_v2: {e}"))
339 })?;
340 let embedding_cache_db = env
341 .create_db(Some("embedding_cache"), DatabaseFlags::default())
342 .map_err(|e| {
343 CoreError::Memory(format!("LMDB create_db failed for embedding_cache: {e}"))
344 })?;
345 let revisions_db = env
346 .create_db(Some("revisions"), DatabaseFlags::default())
347 .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for revisions: {e}")))?;
348 let attestations_db = env
349 .create_db(
350 Some(crate::attestation::ATTESTATIONS_DB),
351 DatabaseFlags::default(),
352 )
353 .map_err(|e| {
354 CoreError::Memory(format!("LMDB create_db failed for attestations: {e}"))
355 })?;
356 let cold_storage_db = env
357 .create_db(Some("cold_storage"), DatabaseFlags::default())
358 .map_err(|e| {
359 CoreError::Memory(format!("LMDB create_db failed for cold_storage: {e}"))
360 })?;
361 let session_sequences_db = env
365 .create_db(Some("session_sequences"), DatabaseFlags::default())
366 .map_err(|e| {
367 CoreError::Memory(format!("LMDB create_db failed for session_sequences: {e}"))
368 })?;
369 let (keyring_db, at_rest) = crate::at_rest::open_at_rest(&env, &path, at_rest_config)?;
370 Ok(Self {
371 path,
372 env,
373 index_dbs,
374 semantic_encoder: SemanticEncoder::new(),
375 max_entries_per_galaxy: None,
376 mutation_count: AtomicU64::new(0),
377 episodic_db,
378 episodic_terms_v2_db,
379 embedding_cache_db,
380 revisions_db,
381 attestations_db,
382 cold_storage_db,
383 session_sequences_db: Some(session_sequences_db),
384 keyring_db,
385 at_rest,
386 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
387 episodic_embedder: std::sync::OnceLock::new(),
388 episodic_sidecar_ensured: std::sync::OnceLock::new(),
389 episodic_aliases: std::sync::OnceLock::new(),
390 episodic_enrichment: std::sync::OnceLock::new(),
391 })
392 }
393
394 #[must_use]
398 pub fn at_rest_status(&self) -> crate::at_rest::AtRestStatus {
399 if let Some(state) = &self.at_rest {
400 return crate::at_rest::AtRestStatus::Present(state.status());
401 }
402 match &self.keyring_db {
403 None => crate::at_rest::AtRestStatus::Absent,
404 Some(db) => crate::at_rest::read_status(&self.env, *db, &self.path),
405 }
406 }
407
408 #[must_use]
411 pub const fn at_rest_state(&self) -> Option<&crate::at_rest::AtRestState> {
412 self.at_rest.as_ref()
413 }
414
415 #[must_use]
419 pub const fn keyring_db(&self) -> Option<Database> {
420 self.keyring_db
421 }
422
423 pub(crate) fn record_cipher(&self, galaxy: Galaxy) -> Option<&[u8; 32]> {
426 self.at_rest
427 .as_ref()
428 .and_then(|state| state.galaxy_dek(galaxy.db_name()))
429 }
430
431 pub(crate) fn encode_record_value(&self, galaxy: Galaxy, memory: &Memory) -> Result<Vec<u8>> {
435 let plaintext = rmp_serde::to_vec_named(memory)
436 .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
437 let Some(key) = self.record_cipher(galaxy) else {
438 return Ok(plaintext);
439 };
440 crate::codec::seal_record(
441 &plaintext,
442 key,
443 galaxy.db_name(),
444 memory.metadata.id.as_bytes(),
445 memory.metadata.version,
446 )
447 .map_err(|e| CoreError::Memory(format!("at-rest seal failed: {e}")))
448 }
449
450 pub(crate) fn decode_record_value(
454 &self,
455 galaxy: Galaxy,
456 key_bytes: &[u8],
457 value: &[u8],
458 ) -> Result<Memory> {
459 if crate::codec::is_sealed_record(value) {
460 let Some(dek) = self.record_cipher(galaxy) else {
461 return Err(CoreError::Memory(format!(
462 "sealed record in {} but no at-rest key is loaded (WM_AT_REST_MODE off?)",
463 galaxy.db_name()
464 )));
465 };
466 let record_id: [u8; 16] = key_bytes
467 .try_into()
468 .map_err(|_| CoreError::Memory("sealed record key is not a 16-byte id".into()))?;
469 let opened = crate::codec::open_record(value, dek, galaxy.db_name(), &record_id)
470 .map_err(|e| CoreError::Memory(format!("at-rest open failed: {e}")))?;
471 return crate::codec::decode(&opened)
472 .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")));
473 }
474 crate::codec::decode(value)
475 .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")))
476 }
477
478 pub fn open_readonly_bounded(
488 path: impl AsRef<Path>,
489 timeout: std::time::Duration,
490 ) -> Result<Option<Self>> {
491 let path = path.as_ref().to_path_buf();
492 let (tx, rx) = std::sync::mpsc::channel();
493 std::thread::spawn(move || {
494 let result = Self::open_readonly(&path);
495 let _ = tx.send(result);
496 });
497 match rx.recv_timeout(timeout) {
498 Ok(result) => result.map(Some),
499 Err(_) => Ok(None),
500 }
501 }
502
503 pub fn open_default_bounded(
507 path: impl AsRef<Path>,
508 timeout: std::time::Duration,
509 ) -> Result<Option<Self>> {
510 let path = path.as_ref().to_path_buf();
511 let (tx, rx) = std::sync::mpsc::channel();
512 std::thread::spawn(move || {
513 let result = Self::open_default(&path);
514 let _ = tx.send(result);
515 });
516 match rx.recv_timeout(timeout) {
517 Ok(result) => result.map(Some),
518 Err(_) => Ok(None),
519 }
520 }
521
522 #[must_use]
525 pub fn default_map_size() -> usize {
526 let platform_default = if cfg!(windows) {
527 256 * 1024 * 1024
528 } else {
529 4 * 1024 * 1024 * 1024
530 };
531 std::env::var("WM_DEFAULT_MAP_SIZE")
532 .ok()
533 .and_then(|v| v.parse::<usize>().ok())
534 .filter(|&v| v > 0)
535 .unwrap_or(platform_default)
536 }
537
538 pub fn open_default(path: impl AsRef<Path>) -> Result<Self> {
547 let size = Self::default_map_size();
552 Self::open(path, size)
553 }
554
555 pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
561 let path = path.as_ref().to_path_buf();
562 if !path.is_dir() {
563 return Err(CoreError::Memory(format!(
564 "Read-only LMDB store directory does not exist: {}",
565 path.display()
566 )));
567 }
568 if !path.join("data.mdb").is_file() {
569 return Err(CoreError::Memory(format!(
570 "Read-only LMDB store is missing data.mdb: {}",
571 path.display()
572 )));
573 }
574
575 let env = Environment::new()
576 .set_max_dbs(32)
577 .set_flags(EnvironmentFlags::READ_ONLY)
578 .open(&path)
579 .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
580
581 let index_dbs = IndexDbs::open(&env)?;
582 let open_named = |name: &str| {
583 env.open_db(Some(name)).map_err(|e| {
584 CoreError::Memory(format!("Read-only LMDB missing database {name}: {e}"))
585 })
586 };
587 let episodic_db = open_named("episodic_records")?;
588 let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
589 let embedding_cache_db = open_named("embedding_cache")?;
590 let revisions_db = open_named("revisions")?;
591 let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
592 let cold_storage_db = open_named("cold_storage")?;
593 let session_sequences_db = env.open_db(Some("session_sequences")).ok();
596 let keyring_db = crate::at_rest::open_keyring_optional(&env)?;
599
600 Ok(Self {
601 path,
602 env,
603 index_dbs,
604 semantic_encoder: SemanticEncoder::new(),
605 max_entries_per_galaxy: None,
606 mutation_count: AtomicU64::new(0),
607 episodic_db,
608 episodic_terms_v2_db,
609 embedding_cache_db,
610 revisions_db,
611 attestations_db,
612 cold_storage_db,
613 session_sequences_db,
614 keyring_db,
615 at_rest: None,
616 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
617 episodic_embedder: std::sync::OnceLock::new(),
618 episodic_sidecar_ensured: std::sync::OnceLock::new(),
619 episodic_aliases: std::sync::OnceLock::new(),
620 episodic_enrichment: std::sync::OnceLock::new(),
621 })
622 }
623
624 const NAMED_DBIS: [&'static str; 6] = [
627 "episodic_records",
628 "episodic_terms_v2",
629 "embedding_cache",
630 "revisions",
631 crate::attestation::ATTESTATIONS_DB,
632 "cold_storage",
633 ];
634
635 pub fn open_inspection(path: impl AsRef<Path>) -> Result<Self> {
648 let path = path.as_ref().to_path_buf();
649 if !path.is_dir() {
650 return Err(CoreError::Memory(format!(
651 "Read-only LMDB store directory does not exist: {}",
652 path.display()
653 )));
654 }
655 if !path.join("data.mdb").is_file() {
656 return Err(CoreError::Memory(format!(
657 "Read-only LMDB store is missing data.mdb: {}",
658 path.display()
659 )));
660 }
661
662 let env = Environment::new()
663 .set_max_dbs(32)
664 .set_flags(EnvironmentFlags::READ_ONLY | EnvironmentFlags::NO_LOCK)
665 .open(&path)
666 .map_err(|e| CoreError::Memory(format!("Inspection LMDB open failed: {e}")))?;
667
668 let index_dbs = IndexDbs::open(&env)?;
669 let open_named = |name: &str| {
670 env.open_db(Some(name)).map_err(|e| {
671 CoreError::Memory(format!("Inspection LMDB missing database {name}: {e}"))
672 })
673 };
674 let episodic_db = open_named("episodic_records")?;
675 let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
676 let embedding_cache_db = open_named("embedding_cache")?;
677 let revisions_db = open_named("revisions")?;
678 let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
679 let cold_storage_db = open_named("cold_storage")?;
680 let session_sequences_db = env.open_db(Some("session_sequences")).ok();
683 let keyring_db = crate::at_rest::open_keyring_optional(&env)?;
686
687 Ok(Self {
688 path,
689 env,
690 index_dbs,
691 semantic_encoder: SemanticEncoder::new(),
692 max_entries_per_galaxy: None,
693 mutation_count: AtomicU64::new(0),
694 episodic_db,
695 episodic_terms_v2_db,
696 embedding_cache_db,
697 revisions_db,
698 attestations_db,
699 cold_storage_db,
700 session_sequences_db,
701 keyring_db,
702 at_rest: None,
703 episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
704 episodic_embedder: std::sync::OnceLock::new(),
705 episodic_sidecar_ensured: std::sync::OnceLock::new(),
706 episodic_aliases: std::sync::OnceLock::new(),
707 episodic_enrichment: std::sync::OnceLock::new(),
708 })
709 }
710
711 pub fn ensure_schema(path: impl AsRef<Path>) -> Result<Vec<String>> {
724 let path = path.as_ref().to_path_buf();
725 if !path.is_dir() {
726 return Err(CoreError::Memory(format!(
727 "Store directory does not exist: {}",
728 path.display()
729 )));
730 }
731 let expected = || {
732 Galaxy::all()
733 .into_iter()
734 .map(|galaxy| galaxy.db_name().to_string())
735 .chain(
736 crate::indexes::INDEX_DBS
737 .iter()
738 .map(|(name, _)| (*name).to_string()),
739 )
740 .chain(Self::NAMED_DBIS.iter().map(|name| (*name).to_string()))
741 };
742 let missing: Vec<String> = {
743 let env = Environment::new()
744 .set_max_dbs(64)
745 .set_flags(EnvironmentFlags::READ_ONLY)
746 .open(&path)
747 .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
748 expected()
749 .filter(|name| env.open_db(Some(name.as_str())).is_err())
750 .collect()
751 };
752 if missing.is_empty() {
753 return Ok(missing);
754 }
755 let store = Self::open_default(&path)?;
758 drop(store);
759 Ok(missing)
760 }
761
762 #[must_use]
767 pub const fn with_entry_limit(mut self, limit: usize) -> Self {
768 self.max_entries_per_galaxy = Some(limit);
769 self
770 }
771
772 pub fn path(&self) -> &Path {
774 &self.path
775 }
776
777 pub const fn env(&self) -> &Environment {
779 &self.env
780 }
781
782 pub fn mutation_count(&self) -> u64 {
786 self.mutation_count.load(Ordering::Relaxed)
787 }
788
789 pub const fn index_dbs(&self) -> &IndexDbs {
791 &self.index_dbs
792 }
793
794 pub const fn semantic_encoder(&self) -> &SemanticEncoder {
796 &self.semantic_encoder
797 }
798
799 pub fn episodic_sidecar_health(&self) -> Result<(u64, bool)> {
811 let view = EpisodicStore::new(
812 &self.env,
813 self.episodic_db,
814 self.episodic_terms_v2_db,
815 self.episodic_term_cache.clone(),
816 &self.mutation_count,
817 );
818 let empty = view.sidecar_is_empty()?;
819 if !empty {
820 return Ok((view.record_count()?, false));
821 }
822 let indexable = view
823 .scan(None, usize::MAX)?
824 .iter()
825 .filter(|record| !record.is_private && !record.model_exclude)
826 .count() as u64;
827 Ok((indexable, true))
828 }
829
830 pub fn episodic_record_count(&self) -> Result<u64> {
834 let view = EpisodicStore::new(
835 &self.env,
836 self.episodic_db,
837 self.episodic_terms_v2_db,
838 self.episodic_term_cache.clone(),
839 &self.mutation_count,
840 );
841 view.record_count()
842 }
843
844 fn ensure_episodic_sidecar(&self) {
849 if self.episodic_sidecar_ensured.get().is_some() {
850 return;
851 }
852 let _ = self.episodic_sidecar_ensured.set(());
853 let view = EpisodicStore::new(
854 &self.env,
855 self.episodic_db,
856 self.episodic_terms_v2_db,
857 self.episodic_term_cache.clone(),
858 &self.mutation_count,
859 );
860 let needs_rebuild = matches!(
861 (view.sidecar_is_empty(), view.record_count()),
862 (Ok(true), Ok(n)) if n > 0
863 );
864 if needs_rebuild {
865 match view.rebuild_sidecar() {
866 Ok(n) => tracing::info!("episodic sidecar rebuilt from {n} records"),
867 Err(e) => {
868 tracing::warn!("episodic sidecar rebuild failed: {e}");
869 }
870 }
871 }
872 }
873
874 #[must_use]
876 pub fn episodic(&self) -> EpisodicStore<'_> {
877 self.ensure_episodic_sidecar();
878 let mut store = EpisodicStore::new(
879 &self.env,
880 self.episodic_db,
881 self.episodic_terms_v2_db,
882 self.episodic_term_cache.clone(),
883 &self.mutation_count,
884 );
885 if let Some(Some(embedder)) = self.episodic_embedder.get() {
886 store = store.with_embedder(embedder.clone());
887 }
888 if let Some(Some(aliases)) = self.episodic_aliases.get() {
889 store = store.with_adaptive_aliases(aliases.clone());
890 }
891 if let Some(Some(enrichment)) = self.episodic_enrichment.get() {
892 store = store.with_enrichment(enrichment.clone());
893 }
894 store
895 }
896
897 pub fn set_episodic_embedder(
899 &self,
900 embedder: Arc<dyn crate::embedder::Embedder + Send + Sync>,
901 ) {
902 let _ = self.episodic_embedder.set(Some(embedder));
903 }
904
905 pub fn set_episodic_aliases(&self, aliases: crate::episodic_keys::AdaptiveAliases) {
907 let _ = self.episodic_aliases.set(Some(aliases));
908 }
909
910 pub fn set_episodic_enrichment(&self, enrichment: crate::enrichment::VocabularyEnrichment) {
912 let _ = self.episodic_enrichment.set(Some(enrichment));
913 }
914
915 pub fn galaxy_db(&self, galaxy: Galaxy) -> Result<Database> {
917 self.env.open_db(Some(galaxy.db_name())).map_err(|e| {
918 CoreError::Memory(format!("LMDB open_db failed for {}: {e}", galaxy.db_name()))
919 })
920 }
921
922 pub fn put(&self, galaxy: Galaxy, memory: &Memory) -> Result<()> {
930 if let Some(limit) = self.max_entries_per_galaxy {
932 let current = self.count(galaxy)?;
933 if current >= limit {
934 return Err(CoreError::Memory(format!(
935 "galaxy {} entry limit reached ({current}/{limit}), write rejected",
936 galaxy.db_name()
937 )));
938 }
939 }
940
941 let mut tx = self
942 .env
943 .begin_rw_txn()
944 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
945 self.put_in_txn(&mut tx, galaxy, memory)?;
946 tx.commit()
947 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
948 self.mutation_count.fetch_add(1, Ordering::Relaxed);
949 Ok(())
950 }
951
952 fn put_in_txn(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
958 let db = self.galaxy_db(galaxy)?;
959 let key = memory.metadata.id.as_bytes();
960 let val = self.encode_record_value(galaxy, memory)?;
961
962 let existing = tx
967 .get(db, key)
968 .ok()
969 .and_then(|bytes| self.decode_record_value(galaxy, key, bytes).ok());
970
971 match tx.put(db, key, &val, lmdb::WriteFlags::default()) {
972 Ok(()) => {}
973 Err(lmdb::Error::MapFull) => {
974 return Err(CoreError::Memory(format!(
975 "LMDB map full: galaxy {}, consider growing map size or pruning old memories",
976 galaxy.db_name()
977 )));
978 }
979 Err(e) => {
980 return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
981 }
982 }
983 if let Some(existing) = existing {
984 self.index_dbs.remove(tx, galaxy, &existing)?;
985 }
986 self.index_dbs.add(tx, galaxy, memory)?;
987 Ok(())
988 }
989
990 pub fn put_session_turn<F>(&self, session_id: &str, build: F) -> Result<(u64, Memory)>
1008 where
1009 F: FnOnce(u64) -> Memory,
1010 {
1011 let seq_db = self.session_sequences_db.ok_or_else(|| {
1012 CoreError::Memory(
1013 "session_sequences DBI missing (legacy store opened read-only); \
1014 a writable open repairs it via ensure_schema"
1015 .into(),
1016 )
1017 })?;
1018 if let Some(limit) = self.max_entries_per_galaxy {
1019 let current = self.count(Galaxy::Sessions)?;
1020 if current >= limit {
1021 return Err(CoreError::Memory(format!(
1022 "galaxy {} entry limit reached ({current}/{limit}), write rejected",
1023 Galaxy::Sessions.db_name()
1024 )));
1025 }
1026 }
1027
1028 let mut tx = self
1029 .env
1030 .begin_rw_txn()
1031 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1032 let key: &[u8] = session_id.as_bytes();
1033 let current = match tx.get(seq_db, &key) {
1036 Ok(bytes) => {
1037 let arr: [u8; 8] = <[u8; 8]>::try_from(bytes).map_err(|_| {
1038 CoreError::Memory(format!(
1039 "session_sequences value for {session_id} is malformed \
1040 ({} bytes, want 8)",
1041 bytes.len()
1042 ))
1043 })?;
1044 u64::from_be_bytes(arr)
1045 }
1046 Err(lmdb::Error::NotFound) => 0,
1047 Err(e) => {
1048 return Err(CoreError::Memory(format!(
1049 "LMDB get failed (session_sequences): {e}"
1050 )));
1051 }
1052 };
1053 let next = current
1054 .checked_add(1)
1055 .ok_or_else(|| CoreError::Memory("session sequence overflow (u64)".into()))?;
1056 tx.put(
1057 seq_db,
1058 &key,
1059 &next.to_be_bytes(),
1060 lmdb::WriteFlags::default(),
1061 )
1062 .map_err(|e| CoreError::Memory(format!("LMDB put failed (session_sequences): {e}")))?;
1063
1064 let memory = build(next);
1065 self.put_in_txn(&mut tx, Galaxy::Sessions, &memory)?;
1066 tx.commit()
1067 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1068 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1069 Ok((next, memory))
1070 }
1071
1072 pub fn last_session_sequence(&self, session_id: &str) -> Result<Option<u64>> {
1076 let Some(seq_db) = self.session_sequences_db else {
1077 return Ok(None);
1078 };
1079 let tx = self
1080 .env
1081 .begin_ro_txn()
1082 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1083 let key: &[u8] = session_id.as_bytes();
1084 match tx.get(seq_db, &key) {
1085 Ok(bytes) => {
1086 let arr: [u8; 8] = <[u8; 8]>::try_from(bytes).map_err(|_| {
1087 CoreError::Memory(format!(
1088 "session_sequences value for {session_id} is malformed \
1089 ({} bytes, want 8)",
1090 bytes.len()
1091 ))
1092 })?;
1093 Ok(Some(u64::from_be_bytes(arr)))
1094 }
1095 Err(lmdb::Error::NotFound) => Ok(None),
1096 Err(e) => Err(CoreError::Memory(format!(
1097 "LMDB get failed (session_sequences): {e}"
1098 ))),
1099 }
1100 }
1101
1102 pub fn get(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<Option<Memory>> {
1104 let db = self.galaxy_db(galaxy)?;
1105 let key = id.as_bytes();
1106
1107 let tx = self
1108 .env
1109 .begin_ro_txn()
1110 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1111 let result = tx.get(db, key);
1112 match result {
1113 Ok(bytes) => {
1114 let memory = self.decode_record_value(galaxy, key, bytes)?;
1115 tx.commit()
1116 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1117 Ok(Some(memory))
1118 }
1119 Err(lmdb::Error::NotFound) => {
1120 tx.commit()
1122 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1123 Ok(None)
1124 }
1125 Err(e) => Err(CoreError::Memory(format!("LMDB get failed: {e}"))),
1126 }
1127 }
1128
1129 pub fn find_across_galaxies(&self, id: uuid::Uuid) -> Result<Option<(Galaxy, Memory)>> {
1135 for galaxy in Galaxy::memory_galaxies() {
1136 if let Some(mem) = self.get(galaxy, id)? {
1137 return Ok(Some((galaxy, mem)));
1138 }
1139 }
1140 Ok(None)
1141 }
1142
1143 pub fn delete(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<bool> {
1146 let db = self.galaxy_db(galaxy)?;
1147 let key = id.as_bytes();
1148
1149 let mut tx = self
1150 .env
1151 .begin_rw_txn()
1152 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1153
1154 let exists = tx.get(db, key).is_ok();
1156 if exists {
1157 if let Ok(bytes) = tx.get(db, key) {
1159 if let Ok(memory) = self.decode_record_value(galaxy, key, bytes) {
1160 let _ = self.index_dbs.remove(&mut tx, galaxy, &memory);
1161 }
1162 }
1163 tx.del(db, key, None)
1164 .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
1165 }
1166 tx.commit()
1167 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1168 if exists {
1169 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1170 }
1171 Ok(exists)
1172 }
1173
1174 pub fn scan(&self, galaxy: Galaxy, limit: usize) -> Result<Vec<Memory>> {
1176 let db = self.galaxy_db(galaxy)?;
1177 let tx = self
1178 .env
1179 .begin_ro_txn()
1180 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1181
1182 let mut cursor = tx
1183 .open_ro_cursor(db)
1184 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1185
1186 let mut memories = Vec::with_capacity(limit.min(256));
1187 for (i, (key, val)) in cursor.iter().enumerate() {
1188 if memories.len() >= limit {
1189 break;
1190 }
1191 match self.decode_record_value(galaxy, key, val) {
1192 Ok(memory) => memories.push(memory),
1193 Err(e) => {
1194 tracing::warn!(
1195 "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
1196 galaxy
1197 );
1198 }
1199 }
1200 }
1201
1202 drop(cursor);
1203 tx.commit()
1204 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1205 Ok(memories)
1206 }
1207
1208 pub fn scan_all(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
1213 self.scan_all_impl(galaxy, false)
1214 }
1215
1216 pub fn scan_all_strict(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
1220 self.scan_all_impl(galaxy, true)
1221 }
1222
1223 fn scan_all_impl(&self, galaxy: Galaxy, strict: bool) -> Result<Vec<Memory>> {
1224 let db = self.galaxy_db(galaxy)?;
1225 let tx = self
1226 .env
1227 .begin_ro_txn()
1228 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1229
1230 let mut cursor = tx
1231 .open_ro_cursor(db)
1232 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1233
1234 let mut memories = Vec::new();
1235 for (i, (key, val)) in cursor.iter().enumerate() {
1236 match self.decode_record_value(galaxy, key, val) {
1237 Ok(memory) => memories.push(memory),
1238 Err(e) => {
1239 if strict {
1240 return Err(CoreError::Memory(format!(
1241 "refusing incomplete scan of {}: record {i} cannot be decoded: {e}",
1242 galaxy.db_name()
1243 )));
1244 }
1245 tracing::warn!(
1246 "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
1247 galaxy
1248 );
1249 }
1250 }
1251 }
1252
1253 drop(cursor);
1254 tx.commit()
1255 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1256 Ok(memories)
1257 }
1258
1259 pub fn count(&self, galaxy: Galaxy) -> Result<usize> {
1261 let db = self.galaxy_db(galaxy)?;
1262 let tx = self
1263 .env
1264 .begin_ro_txn()
1265 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1266 let mut cursor = tx
1267 .open_ro_cursor(db)
1268 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1269 let count = cursor.iter().count();
1270 drop(cursor);
1271 tx.commit()
1272 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1273 Ok(count)
1274 }
1275
1276 pub fn count_by_tag(&self, galaxy: Galaxy, tag: &str) -> Result<usize> {
1281 let tx = self
1282 .env
1283 .begin_ro_txn()
1284 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1285 let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1286 tx.commit()
1287 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1288 Ok(ids.len())
1289 }
1290
1291 pub fn clear_galaxy(&self, galaxy: Galaxy) -> Result<usize> {
1295 let db = self.galaxy_db(galaxy)?;
1296
1297 let mut tx = self
1298 .env
1299 .begin_rw_txn()
1300 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1301
1302 let mut cursor = tx
1303 .open_ro_cursor(db)
1304 .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1305
1306 let mut count = 0usize;
1307 let keys_to_delete: Vec<(Vec<u8>, Memory)> = cursor
1308 .iter()
1309 .filter_map(|(key, val)| {
1310 if let Ok(memory) = self.decode_record_value(galaxy, key, val) {
1311 Some((key.to_vec(), memory))
1312 } else {
1313 None
1314 }
1315 })
1316 .collect();
1317
1318 drop(cursor);
1319
1320 for (key, memory) in &keys_to_delete {
1321 let _ = self.index_dbs.remove(&mut tx, galaxy, memory);
1322 tx.del(db, &key, None)
1323 .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
1324 count += 1;
1325 }
1326
1327 tx.commit()
1328 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1329 self.mutation_count
1330 .fetch_add(count as u64, Ordering::Relaxed);
1331 Ok(count)
1332 }
1333
1334 pub fn batch_put(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<usize> {
1337 if memories.is_empty() {
1338 return Ok(0);
1339 }
1340
1341 let db = self.galaxy_db(galaxy)?;
1342
1343 let mut tx = self
1344 .env
1345 .begin_rw_txn()
1346 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1347
1348 let mut count = 0usize;
1349 for memory in memories {
1350 let key = memory.metadata.id.as_bytes();
1351 let val = self.encode_record_value(galaxy, memory)?;
1352 match tx.put(db, key, &val, WriteFlags::default()) {
1353 Ok(()) => {}
1354 Err(lmdb::Error::MapFull) => {
1355 tx.abort();
1356 return Err(CoreError::Memory(format!(
1357 "LMDB map full: galaxy {}, consider growing map size",
1358 galaxy.db_name()
1359 )));
1360 }
1361 Err(e) => {
1362 tx.abort();
1363 return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
1364 }
1365 }
1366 self.index_dbs.add(&mut tx, galaxy, memory)?;
1367 count += 1;
1368 }
1369
1370 tx.commit()
1371 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1372 self.mutation_count
1373 .fetch_add(count as u64, Ordering::Relaxed);
1374 Ok(count)
1375 }
1376
1377 pub fn get_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<Option<Vec<u8>>> {
1379 let db = self.galaxy_db(galaxy)?;
1380 let tx = self
1381 .env
1382 .begin_ro_txn()
1383 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1384 match tx.get(db, &key) {
1385 Ok(bytes) => {
1386 let data = bytes.to_vec();
1387 tx.commit()
1388 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1389 Ok(Some(data))
1390 }
1391 Err(lmdb::Error::NotFound) => {
1392 tx.commit()
1393 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1394 Ok(None)
1395 }
1396 Err(e) => Err(CoreError::Memory(format!("LMDB get_raw failed: {e}"))),
1397 }
1398 }
1399
1400 pub fn put_raw(&self, galaxy: Galaxy, key: &[u8], val: &[u8]) -> Result<()> {
1402 let db = self.galaxy_db(galaxy)?;
1403 let mut tx = self
1404 .env
1405 .begin_rw_txn()
1406 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1407 tx.put(db, &key, &val, lmdb::WriteFlags::default())
1408 .map_err(|e| CoreError::Memory(format!("LMDB put_raw failed: {e}")))?;
1409 tx.commit()
1410 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1411 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1412 Ok(())
1413 }
1414
1415 pub fn delete_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<bool> {
1418 let db = self.galaxy_db(galaxy)?;
1419 let mut tx = self
1420 .env
1421 .begin_rw_txn()
1422 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1423 let deleted = tx.del(db, &key, None).is_ok();
1424 tx.commit()
1425 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1426 if deleted {
1427 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1428 }
1429 Ok(deleted)
1430 }
1431
1432 pub fn put_raw_batch(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1435 self.put_raw_batch_impl(galaxy, entries)?;
1436 self.mutation_count
1437 .fetch_add(entries.len() as u64, Ordering::Relaxed);
1438 Ok(())
1439 }
1440
1441 pub fn put_raw_batch_untracked(
1451 &self,
1452 galaxy: Galaxy,
1453 entries: &[(&[u8], &[u8])],
1454 ) -> Result<()> {
1455 self.put_raw_batch_impl(galaxy, entries)
1456 }
1457
1458 fn put_raw_batch_impl(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1459 let db = self.galaxy_db(galaxy)?;
1460 let mut tx = self
1461 .env
1462 .begin_rw_txn()
1463 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1464 for (key, val) in entries {
1465 tx.put(db, key, val, WriteFlags::default())
1466 .map_err(|e| CoreError::Memory(format!("LMDB put_raw_batch failed: {e}")))?;
1467 }
1468 tx.commit()
1469 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1470 Ok(())
1471 }
1472
1473 pub fn find_by_content_hash(&self, galaxy: Galaxy, hash: &str) -> Result<Option<uuid::Uuid>> {
1479 let tx = self
1480 .env
1481 .begin_ro_txn()
1482 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1483 let result = self.index_dbs.find_by_content_hash(&tx, galaxy, hash)?;
1484 tx.commit()
1485 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1486 Ok(result)
1487 }
1488
1489 pub fn find_by_content_hash_scan(
1491 &self,
1492 galaxy: Galaxy,
1493 hash: &str,
1494 ) -> Result<Option<uuid::Uuid>> {
1495 let memories = self.scan(galaxy, 10_000)?;
1496 for mem in memories {
1497 if mem.metadata.content_hash == hash {
1498 return Ok(Some(mem.metadata.id));
1499 }
1500 }
1501 Ok(None)
1502 }
1503
1504 pub fn put_dedup(&self, galaxy: Galaxy, memory: &Memory) -> Result<uuid::Uuid> {
1508 if let Some(existing_id) =
1509 self.find_by_content_hash(galaxy, &memory.metadata.content_hash)?
1510 {
1511 return Ok(existing_id);
1512 }
1513 let id = memory.metadata.id;
1514 self.put(galaxy, memory)?;
1515 Ok(id)
1516 }
1517
1518 pub fn put_batch(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<()> {
1523 let db = self.galaxy_db(galaxy)?;
1524 let mut tx = self
1525 .env
1526 .begin_rw_txn()
1527 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1528
1529 for memory in memories {
1530 let key = memory.metadata.id.as_bytes();
1531 let val = self.encode_record_value(galaxy, memory)?;
1532 tx.put(db, key, &val, WriteFlags::default())
1533 .map_err(|e| CoreError::Memory(format!("LMDB put_batch failed: {e}")))?;
1534 self.index_dbs.add(&mut tx, galaxy, memory)?;
1535 }
1536
1537 tx.commit()
1538 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1539 self.mutation_count
1540 .fetch_add(memories.len() as u64, Ordering::Relaxed);
1541 Ok(())
1542 }
1543
1544 pub fn query(&self, galaxy: Galaxy, query: &MemoryQuery) -> Result<Vec<Memory>> {
1551 if query.content_substring.is_none()
1554 && query.tags.len() == 1
1555 && query.min_importance.is_none()
1556 && query.max_importance.is_none()
1557 && query.created_after.is_none()
1558 && query.created_before.is_none()
1559 {
1560 return self.query_by_tag_indexed(galaxy, &query.tags[0], query.limit);
1561 }
1562
1563 if query.content_substring.is_none()
1564 && query.tags.is_empty()
1565 && let Some(min) = query.min_importance
1566 && let Some(max) = query.max_importance
1567 && query.created_after.is_none()
1568 && query.created_before.is_none()
1569 {
1570 return self.query_by_importance_indexed(galaxy, min, max, query.limit);
1571 }
1572
1573 if query.content_substring.is_none()
1574 && query.tags.is_empty()
1575 && query.min_importance.is_none()
1576 && query.max_importance.is_none()
1577 && let Some(after) = query.created_after
1578 && let Some(before) = query.created_before
1579 {
1580 return self.query_by_time_indexed(galaxy, after, before, query.limit);
1581 }
1582
1583 let memories = self.scan(galaxy, 10_000)?;
1585 let mut results = Vec::new();
1586 for mem in memories {
1587 if query.matches(&mem) {
1588 results.push(mem);
1589 if results.len() >= query.limit {
1590 break;
1591 }
1592 }
1593 }
1594 Ok(results)
1595 }
1596
1597 fn query_by_tag_indexed(&self, galaxy: Galaxy, tag: &str, limit: usize) -> Result<Vec<Memory>> {
1599 let db = self.galaxy_db(galaxy)?;
1600 let tx = self
1601 .env
1602 .begin_ro_txn()
1603 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1604 let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1605 let mut results = Vec::new();
1606 for id in &ids {
1607 if results.len() >= limit {
1608 break;
1609 }
1610 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1611 if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1612 results.push(mem);
1613 }
1614 }
1615 }
1616 tx.commit()
1617 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1618 Ok(results)
1619 }
1620
1621 fn query_by_importance_indexed(
1623 &self,
1624 galaxy: Galaxy,
1625 min: f32,
1626 max: f32,
1627 limit: usize,
1628 ) -> Result<Vec<Memory>> {
1629 let db = self.galaxy_db(galaxy)?;
1630 let tx = self
1631 .env
1632 .begin_ro_txn()
1633 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1634 let ids = self
1635 .index_dbs
1636 .find_by_importance_range(&tx, galaxy, min, max)?;
1637 let mut results = Vec::new();
1638 for id in &ids {
1639 if results.len() >= limit {
1640 break;
1641 }
1642 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1643 if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1644 results.push(mem);
1645 }
1646 }
1647 }
1648 tx.commit()
1649 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1650 Ok(results)
1651 }
1652
1653 fn query_by_time_indexed(
1655 &self,
1656 galaxy: Galaxy,
1657 after: chrono::DateTime<chrono::Utc>,
1658 before: chrono::DateTime<chrono::Utc>,
1659 limit: usize,
1660 ) -> Result<Vec<Memory>> {
1661 let db = self.galaxy_db(galaxy)?;
1662 let tx = self
1663 .env
1664 .begin_ro_txn()
1665 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1666 let ids = self
1667 .index_dbs
1668 .find_by_time_range(&tx, galaxy, after, before)?;
1669 let mut results = Vec::new();
1670 for id in &ids {
1671 if results.len() >= limit {
1672 break;
1673 }
1674 if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1675 if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1676 results.push(mem);
1677 }
1678 }
1679 }
1680 tx.commit()
1681 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1682 Ok(results)
1683 }
1684
1685 pub fn put_semantic(&self, galaxy: Galaxy, memory: &mut Memory) -> Result<()> {
1693 let temporal_weight = memory.metadata.coord5d.w;
1694 let importance = memory.metadata.importance;
1695 memory.metadata.coord5d =
1696 self.semantic_encoder
1697 .encode_coordinate(&memory.content, temporal_weight, importance);
1698 self.put(galaxy, memory)
1699 }
1700
1701 pub fn find_similar(
1706 &self,
1707 galaxy: Galaxy,
1708 query_text: &str,
1709 limit: usize,
1710 ) -> Result<Vec<(Memory, f32)>> {
1711 let query_coord = self
1712 .semantic_encoder
1713 .encode_coordinate(query_text, 0.5, 0.5);
1714 let memories = self.scan(galaxy, 10_000)?;
1715 let mut results: Vec<(Memory, f32)> = memories
1716 .into_iter()
1717 .map(|m| {
1718 let dist = query_coord.semantic_distance_to(&m.metadata.coord5d);
1719 (m, dist)
1720 })
1721 .collect();
1722 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1723 results.truncate(limit);
1724 Ok(results)
1725 }
1726
1727 pub fn put_embedding(&self, memory_id: uuid::Uuid, embedding: &[f32]) -> Result<()> {
1732 let db = self.galaxy_db(Galaxy::Embeddings)?;
1733 let key = memory_id.as_bytes();
1734 let val = encode_embedding(embedding);
1735
1736 let mut tx = self
1737 .env
1738 .begin_rw_txn()
1739 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1740 tx.put(db, key, &val, WriteFlags::default())
1741 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding failed: {e}")))?;
1742 tx.commit()
1743 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1744 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1745 Ok(())
1746 }
1747
1748 pub fn get_embedding(&self, memory_id: uuid::Uuid) -> Result<Option<Vec<f32>>> {
1750 let db = self.galaxy_db(Galaxy::Embeddings)?;
1751 let key = memory_id.as_bytes();
1752
1753 let tx = self
1754 .env
1755 .begin_ro_txn()
1756 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1757 match tx.get(db, key) {
1758 Ok(bytes) => {
1759 let embedding = decode_embedding(bytes);
1760 tx.commit()
1761 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1762 Ok(Some(embedding))
1763 }
1764 Err(lmdb::Error::NotFound) => {
1765 tx.commit()
1766 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1767 Ok(None)
1768 }
1769 Err(e) => Err(CoreError::Memory(format!("LMDB get_embedding failed: {e}"))),
1770 }
1771 }
1772
1773 pub fn delete_embedding(&self, memory_id: uuid::Uuid) -> Result<bool> {
1775 let db = self.galaxy_db(Galaxy::Embeddings)?;
1776 let key = memory_id.as_bytes();
1777
1778 let mut tx = self
1779 .env
1780 .begin_rw_txn()
1781 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1782 let exists = tx.get(db, key).is_ok();
1783 if exists {
1784 tx.del(db, key, None)
1785 .map_err(|e| CoreError::Memory(format!("LMDB del_embedding failed: {e}")))?;
1786 }
1787 tx.commit()
1788 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1789 if exists {
1790 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1791 }
1792 Ok(exists)
1793 }
1794
1795 pub fn put_embedding_cache(&self, cache_key: &str, embedding: &[f32]) -> Result<()> {
1801 let mut tx = self
1802 .env
1803 .begin_rw_txn()
1804 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1805 tx.put(
1806 self.embedding_cache_db,
1807 &cache_key.as_bytes().to_vec(),
1808 &encode_embedding(embedding),
1809 WriteFlags::default(),
1810 )
1811 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1812 tx.commit()
1813 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1814 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1815 Ok(())
1816 }
1817
1818 pub fn put_embedding_cache_batch(&self, entries: &[(String, Vec<f32>)]) -> Result<()> {
1820 if entries.is_empty() {
1821 return Ok(());
1822 }
1823 let mut tx = self
1824 .env
1825 .begin_rw_txn()
1826 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1827 for (key, embedding) in entries {
1828 tx.put(
1829 self.embedding_cache_db,
1830 &key.as_bytes().to_vec(),
1831 &encode_embedding(embedding),
1832 WriteFlags::default(),
1833 )
1834 .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1835 }
1836 tx.commit()
1837 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1838 self.mutation_count
1839 .fetch_add(entries.len() as u64, Ordering::Relaxed);
1840 Ok(())
1841 }
1842
1843 pub fn get_embedding_cache(&self, cache_key: &str) -> Result<Option<Vec<f32>>> {
1845 let tx = self
1846 .env
1847 .begin_ro_txn()
1848 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1849 match tx.get(self.embedding_cache_db, &cache_key.as_bytes().to_vec()) {
1850 Ok(bytes) => {
1851 let embedding = decode_embedding(bytes);
1852 tx.commit()
1853 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1854 Ok(Some(embedding))
1855 }
1856 Err(lmdb::Error::NotFound) => {
1857 tx.commit()
1858 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1859 Ok(None)
1860 }
1861 Err(e) => Err(CoreError::Memory(format!(
1862 "LMDB get_embedding_cache failed: {e}"
1863 ))),
1864 }
1865 }
1866
1867 pub fn get_embedding_cache_batch(&self, keys: &[String]) -> Result<Vec<Option<Vec<f32>>>> {
1870 let tx = self
1871 .env
1872 .begin_ro_txn()
1873 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1874 let mut out = Vec::with_capacity(keys.len());
1875 for key in keys {
1876 out.push(
1877 tx.get(self.embedding_cache_db, &key.as_bytes().to_vec())
1878 .ok()
1879 .map(decode_embedding),
1880 );
1881 }
1882 tx.commit()
1883 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1884 Ok(out)
1885 }
1886
1887 pub fn embedding_cache_count(&self) -> Result<u64> {
1889 let tx = self
1890 .env
1891 .begin_ro_txn()
1892 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1893 let mut cursor = tx
1894 .open_ro_cursor(self.embedding_cache_db)
1895 .map_err(|e| CoreError::Memory(format!("LMDB cursor embedding_cache failed: {e}")))?;
1896 let mut count = 0u64;
1897 for _ in cursor.iter() {
1898 count += 1;
1899 }
1900 Ok(count)
1901 }
1902
1903 pub fn record_revision(
1909 &self,
1910 galaxy: Galaxy,
1911 id: MemoryId,
1912 old_hash: &str,
1913 new_hash: &str,
1914 actor: crate::revision::RevisionActor,
1915 ) -> Result<crate::revision::MemoryRevision> {
1916 let seq = self.revisions(galaxy, id)?.len() as u32;
1917 let entry = crate::revision::MemoryRevision {
1918 seq,
1919 timestamp: wm_core::time::now_unix_secs(),
1920 old_hash: old_hash.to_string(),
1921 new_hash: new_hash.to_string(),
1922 actor_session: actor.session,
1923 actor_user: actor.user,
1924 actor_compartment: actor.compartment,
1925 };
1926 let key = crate::revision::revision_key(galaxy, id, seq);
1927 let val = serde_json::to_vec(&entry)
1928 .map_err(|e| CoreError::Memory(format!("revision serialize failed: {e}")))?;
1929 let mut tx = self
1930 .env
1931 .begin_rw_txn()
1932 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1933 tx.put(self.revisions_db, &key, &val, WriteFlags::default())
1934 .map_err(|e| CoreError::Memory(format!("LMDB put revision failed: {e}")))?;
1935 tx.commit()
1936 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1937 self.mutation_count.fetch_add(1, Ordering::Relaxed);
1938 Ok(entry)
1939 }
1940
1941 pub fn revisions(
1944 &self,
1945 galaxy: Galaxy,
1946 id: MemoryId,
1947 ) -> Result<Vec<crate::revision::MemoryRevision>> {
1948 const MDB_GET_CURRENT: u32 = 4;
1953 const MDB_NEXT: u32 = 8;
1954 const MDB_SET_RANGE: u32 = 17;
1955 let prefix = crate::revision::revision_prefix(galaxy, id);
1956 let tx = self
1957 .env
1958 .begin_ro_txn()
1959 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1960 let cursor = tx
1961 .open_ro_cursor(self.revisions_db)
1962 .map_err(|e| CoreError::Memory(format!("LMDB cursor revisions failed: {e}")))?;
1963 let mut out = Vec::new();
1964 if cursor.get(Some(&prefix), None, MDB_SET_RANGE).is_ok() {
1965 while let Ok((key, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1966 if !key.is_some_and(|k| k.starts_with(&prefix)) {
1969 break;
1970 }
1971 let entry: crate::revision::MemoryRevision = serde_json::from_slice(val)
1972 .map_err(|e| CoreError::Memory(format!("revision deserialize failed: {e}")))?;
1973 out.push(entry);
1974 if out.len() >= 10_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1976 break;
1977 }
1978 }
1979 }
1980 drop(cursor);
1981 tx.commit()
1982 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1983 Ok(out)
1984 }
1985
1986 pub fn verify_revision_chain(
1989 &self,
1990 galaxy: Galaxy,
1991 id: MemoryId,
1992 current_hash: &str,
1993 ) -> Result<crate::revision::RevisionChainReport> {
1994 let entries = self.revisions(galaxy, id)?;
1995 Ok(crate::revision::verify_chain(&entries, current_hash))
1996 }
1997
1998 pub fn record_attestation(
2006 &self,
2007 galaxy: Galaxy,
2008 id: MemoryId,
2009 entry: &crate::attestation::RecordAttestation,
2010 ) -> Result<()> {
2011 let key = crate::attestation::attestation_key(galaxy, id);
2012 let val = serde_json::to_vec(entry)
2013 .map_err(|e| CoreError::Memory(format!("attestation serialize failed: {e}")))?;
2014 let mut tx = self
2015 .env
2016 .begin_rw_txn()
2017 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2018 tx.put(self.attestations_db, &key, &val, WriteFlags::default())
2019 .map_err(|e| CoreError::Memory(format!("LMDB put attestation failed: {e}")))?;
2020 tx.commit()
2021 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2022 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2023 Ok(())
2024 }
2025
2026 pub fn attestation(
2029 &self,
2030 galaxy: Galaxy,
2031 id: MemoryId,
2032 ) -> Result<Option<crate::attestation::RecordAttestation>> {
2033 let key = crate::attestation::attestation_key(galaxy, id);
2034 let tx = self
2035 .env
2036 .begin_ro_txn()
2037 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2038 let out =
2039 match tx.get(self.attestations_db, &key) {
2040 Ok(val) => Some(serde_json::from_slice(val).map_err(|e| {
2041 CoreError::Memory(format!("attestation deserialize failed: {e}"))
2042 })?),
2043 Err(lmdb::Error::NotFound) => None,
2044 Err(e) => {
2045 return Err(CoreError::Memory(format!(
2046 "LMDB get attestation failed: {e}"
2047 )));
2048 }
2049 };
2050 drop(tx);
2051 Ok(out)
2052 }
2053
2054 pub fn scan_attestations(&self) -> Result<Vec<crate::attestation::RecordAttestation>> {
2057 const MDB_GET_CURRENT: u32 = 4;
2058 const MDB_NEXT: u32 = 8;
2059 const MDB_FIRST: u32 = 9;
2060 let tx = self
2061 .env
2062 .begin_ro_txn()
2063 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2064 let cursor = tx
2065 .open_ro_cursor(self.attestations_db)
2066 .map_err(|e| CoreError::Memory(format!("LMDB cursor attestations failed: {e}")))?;
2067 let mut out = Vec::new();
2068 if cursor.get(None, None, MDB_FIRST).is_ok() {
2069 while let Ok((_, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
2070 let entry: crate::attestation::RecordAttestation = serde_json::from_slice(val)
2071 .map_err(|e| {
2072 CoreError::Memory(format!("attestation deserialize failed: {e}"))
2073 })?;
2074 out.push(entry);
2075 if out.len() >= 1_000_000 || cursor.get(None, None, MDB_NEXT).is_err() {
2077 break;
2078 }
2079 }
2080 }
2081 drop(cursor);
2082 tx.commit()
2083 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2084 Ok(out)
2085 }
2086
2087 pub fn verify_attestation(
2093 &self,
2094 galaxy: Galaxy,
2095 id: MemoryId,
2096 ) -> Result<crate::attestation::AttestationReport> {
2097 use crate::attestation::AttestationReport;
2098 let Some(att) = self.attestation(galaxy, id)? else {
2099 return Ok(AttestationReport {
2100 attested: false,
2101 signature_valid: false,
2102 matches_head: false,
2103 memory_present: self.get(galaxy, id)?.is_some(),
2104 breaks: vec!["no attestation recorded for this memory".to_string()],
2105 });
2106 };
2107 let mut breaks = Vec::new();
2108 let signature_valid = crate::attestation::verify_attestation(&att);
2109 if !signature_valid {
2110 breaks.push("signature does not verify against recorded pubkey".to_string());
2111 }
2112 let (matches_head, memory_present) = if let Some(memory) = self.get(galaxy, id)? {
2113 let matches = memory.metadata.content_hash == att.record_hash;
2114 if !matches {
2115 breaks.push(
2116 "attested record_hash != live content_hash (memory updated after attestation)"
2117 .to_string(),
2118 );
2119 }
2120 (matches, true)
2121 } else {
2122 breaks.push("attested memory id not present in galaxy".to_string());
2123 (false, false)
2124 };
2125 Ok(AttestationReport {
2126 attested: true,
2127 signature_valid,
2128 matches_head,
2129 memory_present,
2130 breaks,
2131 })
2132 }
2133
2134 pub fn attestation_sweep(
2138 &self,
2139 ) -> Result<
2140 Vec<(
2141 crate::attestation::RecordAttestation,
2142 crate::attestation::AttestationReport,
2143 )>,
2144 > {
2145 use crate::attestation::AttestationReport;
2146 let mut out = Vec::new();
2147 for att in self.scan_attestations()? {
2148 let parsed = match (
2149 Galaxy::from_db_name(&att.galaxy),
2150 uuid::Uuid::parse_str(&att.memory_id),
2151 ) {
2152 (Some(galaxy), Ok(id)) => Some((galaxy, id)),
2153 _ => None,
2154 };
2155 match parsed {
2156 Some((galaxy, id)) => out.push((att, self.verify_attestation(galaxy, id)?)),
2157 None => out.push((
2158 att,
2159 AttestationReport {
2160 attested: true,
2161 signature_valid: false,
2162 matches_head: false,
2163 memory_present: false,
2164 breaks: vec![
2165 "attestation row has unparseable galaxy or memory id".to_string(),
2166 ],
2167 },
2168 )),
2169 }
2170 }
2171 Ok(out)
2172 }
2173
2174 pub fn put_cold_record(&self, record: &crate::cold_storage::ColdRecord) -> Result<()> {
2178 let key = record.id.as_bytes();
2179 let val = rmp_serde::to_vec_named(record)
2180 .map_err(|e| CoreError::Memory(format!("Cold record serialization failed: {e}")))?;
2181 let mut tx = self
2182 .env
2183 .begin_rw_txn()
2184 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2185 tx.put(self.cold_storage_db, key, &val, WriteFlags::default())
2186 .map_err(|e| CoreError::Memory(format!("LMDB put cold_storage failed: {e}")))?;
2187 tx.commit()
2188 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2189 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2190 Ok(())
2191 }
2192
2193 pub fn get_cold_record(&self, id: MemoryId) -> Result<Option<crate::cold_storage::ColdRecord>> {
2195 let key = id.as_bytes();
2196 let tx = self
2197 .env
2198 .begin_ro_txn()
2199 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2200 match tx.get(self.cold_storage_db, key) {
2201 Ok(bytes) => {
2202 let record: crate::cold_storage::ColdRecord = rmp_serde::from_slice(bytes)
2203 .map_err(|e| {
2204 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2205 })?;
2206 Ok(Some(record))
2207 }
2208 Err(lmdb::Error::NotFound) => Ok(None),
2209 Err(e) => Err(CoreError::Memory(format!(
2210 "LMDB get cold_storage failed: {e}"
2211 ))),
2212 }
2213 }
2214
2215 pub fn delete_cold_record(&self, id: MemoryId) -> Result<bool> {
2217 let key = id.as_bytes();
2218 let mut tx = self
2219 .env
2220 .begin_rw_txn()
2221 .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2222 let deleted = match tx.del(self.cold_storage_db, key, None) {
2223 Ok(()) => true,
2224 Err(lmdb::Error::NotFound) => false,
2225 Err(e) => {
2226 return Err(CoreError::Memory(format!(
2227 "LMDB del cold_storage failed: {e}"
2228 )));
2229 }
2230 };
2231 tx.commit()
2232 .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2233 if deleted {
2234 self.mutation_count.fetch_add(1, Ordering::Relaxed);
2235 }
2236 Ok(deleted)
2237 }
2238
2239 pub fn count_cold(&self, galaxy: Option<Galaxy>) -> Result<usize> {
2241 let tx = self
2242 .env
2243 .begin_ro_txn()
2244 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2245 let mut cursor = tx
2246 .open_ro_cursor(self.cold_storage_db)
2247 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2248 let mut count = 0;
2249 for (_key, val) in cursor.iter() {
2250 if let Some(target_g) = galaxy {
2251 let record: crate::cold_storage::ColdRecord =
2252 rmp_serde::from_slice(val).map_err(|e| {
2253 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2254 })?;
2255 if record.galaxy == target_g {
2256 count += 1;
2257 }
2258 } else {
2259 count += 1;
2260 }
2261 }
2262 Ok(count)
2263 }
2264
2265 pub fn list_cold_records(
2267 &self,
2268 galaxy: Option<Galaxy>,
2269 limit: usize,
2270 ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
2271 let query = crate::cold_storage::ColdQuery {
2272 galaxy,
2273 limit: if limit == 0 { 100 } else { limit },
2274 ..Default::default()
2275 };
2276 self.query_cold_records(&query)
2277 }
2278
2279 pub fn query_cold_records(
2281 &self,
2282 query: &crate::cold_storage::ColdQuery,
2283 ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
2284 let tx = self
2285 .env
2286 .begin_ro_txn()
2287 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2288 let mut cursor = tx
2289 .open_ro_cursor(self.cold_storage_db)
2290 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2291 let mut results = Vec::new();
2292 let limit = if query.limit == 0 {
2293 usize::MAX
2294 } else {
2295 query.limit
2296 };
2297
2298 for (_key, val) in cursor.iter() {
2299 let record: crate::cold_storage::ColdRecord =
2300 rmp_serde::from_slice(val).map_err(|e| {
2301 CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2302 })?;
2303 let summary = record.summary();
2304 if query.matches(&summary) {
2305 results.push(summary);
2306 if results.len() >= limit {
2307 break;
2308 }
2309 }
2310 }
2311 Ok(results)
2312 }
2313
2314 pub fn find_cold_matching(
2323 &self,
2324 terms: &[String],
2325 galaxy: Option<Galaxy>,
2326 limit: usize,
2327 max_scan: usize,
2328 ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2329 self.find_cold_matching_eligible(terms, galaxy, limit, max_scan, |_| true)
2330 }
2331
2332 pub fn find_cold_matching_eligible(
2335 &self,
2336 terms: &[String],
2337 galaxy: Option<Galaxy>,
2338 limit: usize,
2339 max_scan: usize,
2340 eligible: impl Fn(&Memory) -> bool,
2341 ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2342 use crate::cold_storage::ColdDiscoveryStop;
2343 let mut out = crate::cold_storage::ColdDiscoveryOutcome::default();
2344 if terms.is_empty() || limit == 0 || max_scan == 0 {
2345 return Ok(out);
2346 }
2347 let tx = self
2348 .env
2349 .begin_ro_txn()
2350 .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2351 let mut cursor = tx
2352 .open_ro_cursor(self.cold_storage_db)
2353 .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2354 let mut iter = cursor.iter();
2355 loop {
2356 if out.scanned >= max_scan {
2357 out.stop_reason = ColdDiscoveryStop::ScanLimit;
2358 break;
2359 }
2360 if out.records.len() >= limit {
2361 out.stop_reason = ColdDiscoveryStop::ResultLimit;
2362 break;
2363 }
2364 let Some((key, val)) = iter.next() else {
2365 out.stop_reason = ColdDiscoveryStop::Exhausted;
2366 break;
2367 };
2368 out.scanned += 1;
2369 let record: crate::cold_storage::ColdRecord = if let Ok(r) = rmp_serde::from_slice(val)
2370 {
2371 r
2372 } else {
2373 out.integrity_rejected += 1;
2374 continue;
2375 };
2376 if let Some(g) = galaxy {
2377 if record.galaxy != g {
2378 continue;
2379 }
2380 }
2381 out.candidates += 1;
2382 let mem = if let Ok(m) = record.decompress() {
2383 m
2384 } else {
2385 out.integrity_rejected += 1;
2386 continue;
2387 };
2388 if mem.metadata.is_private {
2389 out.private_skipped += 1;
2390 continue;
2391 }
2392 if !mem.metadata.validity.is_current() {
2393 out.non_current_skipped += 1;
2394 continue;
2395 }
2396 let integrity_ok = key == record.id.as_bytes()
2397 && mem.metadata.id == record.id
2398 && mem.metadata.galaxy == record.galaxy
2399 && mem.metadata.content_hash == record.content_hash
2400 && crate::content_hash(&mem.content) == record.content_hash;
2401 if !integrity_ok {
2402 out.integrity_rejected += 1;
2403 continue;
2404 }
2405 let haystack = format!(
2406 "{} {}",
2407 mem.content.to_lowercase(),
2408 mem.metadata.tags.join(" ").to_lowercase()
2409 );
2410 if !terms.iter().all(|t| haystack.contains(t.as_str())) {
2411 continue;
2412 }
2413 out.matched += 1;
2414 if !eligible(&mem) {
2415 out.eligibility_skipped += 1;
2416 continue;
2417 }
2418 out.records.push(record);
2419 }
2420 Ok(out)
2421 }
2422
2423 pub fn freeze_to_cold(
2429 &self,
2430 search: Option<&crate::SearchEngine>,
2431 memory_id: MemoryId,
2432 distance: f32,
2433 factors: crate::cold_storage::OuterRimFactors,
2434 digest_id: Option<MemoryId>,
2435 notes: Option<String>,
2436 codec: crate::cold_storage::CompressionCodec,
2437 ) -> Result<crate::cold_storage::ColdRecord> {
2438 let (galaxy, mut mem) = self.find_across_galaxies(memory_id)?.ok_or_else(|| {
2439 CoreError::NotFound(format!("Memory {memory_id} not found in hot store"))
2440 })?;
2441
2442 if mem.metadata.tier != crate::memory::Tier::Archival {
2444 let _ = mem.transition_tier(crate::memory::Tier::Archival);
2445 }
2446
2447 let record =
2448 crate::cold_storage::ColdRecord::new(&mem, distance, factors, digest_id, notes, codec)?;
2449
2450 self.put_cold_record(&record)?;
2452
2453 self.delete(galaxy, memory_id)?;
2455
2456 if let Some(engine) = search {
2458 if let Ok(mut writer_guard) = engine.writer() {
2459 let _ = engine.delete_document(&mut writer_guard, &memory_id.to_string());
2460 let _ = engine.commit(&mut writer_guard);
2461 }
2462 }
2463
2464 Ok(record)
2465 }
2466
2467 pub fn thaw_from_cold(
2473 &self,
2474 search: Option<&crate::SearchEngine>,
2475 memory_id: MemoryId,
2476 ) -> Result<Memory> {
2477 let record = self.get_cold_record(memory_id)?.ok_or_else(|| {
2478 CoreError::NotFound(format!("Memory {memory_id} not found in cold storage"))
2479 })?;
2480
2481 let mut mem = record.decompress()?;
2482
2483 let _ = mem.transition_tier(crate::memory::Tier::Episodic);
2485 mem.metadata.accessed_at = chrono::Utc::now();
2486 mem.metadata.access_count += 1;
2487 mem.metadata.recall_count += 1;
2488 if !mem.metadata.tags.iter().any(|t| t == "thawed:phagic") {
2489 mem.metadata.tags.push("thawed:phagic".to_string());
2490 }
2491
2492 self.put(record.galaxy, &mem)?;
2494
2495 if let Some(engine) = search {
2497 if let Ok(mut writer_guard) = engine.writer() {
2498 let _ = engine.index_memory(&mut writer_guard, &mem);
2499 let _ = engine.commit(&mut writer_guard);
2500 }
2501 }
2502
2503 self.delete_cold_record(memory_id)?;
2505
2506 Ok(mem)
2507 }
2508
2509 pub fn find_anywhere(&self, id: MemoryId) -> Result<Option<(Galaxy, Memory, bool)>> {
2513 if let Some((galaxy, mem)) = self.find_across_galaxies(id)? {
2514 return Ok(Some((galaxy, mem, false)));
2515 }
2516 if let Some(cold_record) = self.get_cold_record(id)? {
2517 let galaxy = cold_record.galaxy;
2518 let mem = cold_record.decompress()?;
2519 return Ok(Some((galaxy, mem, true)));
2520 }
2521 Ok(None)
2522 }
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527 use super::*;
2528 use crate::content_hash;
2529
2530 #[test]
2531 fn open_and_create_galaxies() {
2532 let tmp = tempfile::tempdir().unwrap();
2533 let store = MemoryStore::open_default(tmp.path()).unwrap();
2534 for galaxy in Galaxy::all() {
2535 let _db = store.galaxy_db(galaxy).unwrap();
2536 }
2537 }
2538
2539 #[test]
2540 fn ensure_schema_completes_a_pre_cold_store() {
2541 use lmdb::{DatabaseFlags as LmdbFlags, Environment as LmdbEnv};
2542 use uuid::Uuid;
2543
2544 let tmp = tempfile::tempdir().unwrap();
2547 let path = tmp.path().join("old-store");
2548 std::fs::create_dir_all(&path).unwrap();
2549 {
2550 let env = LmdbEnv::new().set_max_dbs(64).open(&path).unwrap();
2551 for galaxy in Galaxy::all() {
2552 env.create_db(Some(galaxy.db_name()), LmdbFlags::default())
2553 .unwrap();
2554 }
2555 for (name, flags) in crate::indexes::INDEX_DBS {
2556 env.create_db(Some(name), *flags).unwrap();
2557 }
2558 for (name, flags) in [
2559 ("episodic_records", LmdbFlags::default()),
2560 ("episodic_terms_v2", LmdbFlags::DUP_SORT),
2561 ("embedding_cache", LmdbFlags::default()),
2562 ("revisions", LmdbFlags::default()),
2563 (crate::attestation::ATTESTATIONS_DB, LmdbFlags::default()),
2564 ] {
2565 env.create_db(Some(name), flags).unwrap();
2566 }
2567 }
2568
2569 let files_before: Vec<String> = {
2570 let mut names: Vec<String> = std::fs::read_dir(&path)
2571 .unwrap()
2572 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2573 .collect();
2574 names.sort();
2575 names
2576 };
2577 let data_before = std::fs::read(path.join("data.mdb")).unwrap();
2578
2579 let error = match MemoryStore::open_readonly(&path) {
2580 Ok(_) => panic!("strict read-only open must refuse an incomplete store"),
2581 Err(e) => e.to_string(),
2582 };
2583 assert!(error.contains("cold_storage"), "{error}");
2584 assert_eq!(
2585 std::fs::read(path.join("data.mdb")).unwrap(),
2586 data_before,
2587 "readonly refusal must not mutate the pre-cold store"
2588 );
2589 let files_after: Vec<String> = {
2590 let mut names: Vec<String> = std::fs::read_dir(&path)
2591 .unwrap()
2592 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2593 .collect();
2594 names.sort();
2595 names
2596 };
2597 assert_eq!(
2598 files_after, files_before,
2599 "readonly refusal changed the store directory"
2600 );
2601
2602 let created = MemoryStore::ensure_schema(&path).unwrap();
2603 assert_eq!(created, vec!["cold_storage".to_string()], "{created:?}");
2604
2605 let store = MemoryStore::open_readonly(&path).unwrap();
2606 assert!(store.get_cold_record(Uuid::nil()).unwrap().is_none());
2607
2608 assert!(MemoryStore::ensure_schema(&path).unwrap().is_empty());
2610 }
2611
2612 #[test]
2613 fn cold_discovery_hydrates_verifies_and_respects_visibility() {
2614 use crate::cold_storage::{ColdRecord, CompressionCodec, OuterRimFactors};
2615 let tmp = tempfile::tempdir().unwrap();
2616 let store = MemoryStore::open_default(tmp.path()).unwrap();
2617 let factors = OuterRimFactors {
2618 age_factor: 0.5,
2619 access_factor: 0.5,
2620 resonance_factor: 0.5,
2621 emotional_factor: 0.5,
2622 importance_factor: 0.5,
2623 distance: 0.5,
2624 };
2625
2626 let pub_mem = Memory::new(
2627 Galaxy::Codex,
2628 "public needle zxquniquecoldfact741 buried".into(),
2629 );
2630 let rec = ColdRecord::new(
2631 &pub_mem,
2632 0.5,
2633 factors.clone(),
2634 None,
2635 None,
2636 CompressionCodec::Gzip,
2637 )
2638 .unwrap();
2639 store.put_cold_record(&rec).unwrap();
2640 let out = store
2641 .find_cold_matching(&["zxquniquecoldfact741".to_string()], None, 10, 100)
2642 .unwrap();
2643 assert_eq!(out.matched, 1);
2644 assert_eq!(out.integrity_rejected, 0);
2645 assert_eq!(out.records.len(), 1);
2646
2647 let mut priv_mem = Memory::new(Galaxy::Codex, "private needle zxquniquecoldfact742".into());
2649 priv_mem.metadata.is_private = true;
2650 store
2651 .put_cold_record(
2652 &ColdRecord::new(&priv_mem, 0.5, factors, None, None, CompressionCodec::Gzip)
2653 .unwrap(),
2654 )
2655 .unwrap();
2656 let out_priv = store
2657 .find_cold_matching(&["zxquniquecoldfact742".to_string()], None, 10, 100)
2658 .unwrap();
2659 assert_eq!(out_priv.matched, 0);
2660 assert_eq!(out_priv.private_skipped, 1);
2661
2662 let mut tampered = rec.clone();
2665 let mut bad = Memory::new(Galaxy::Codex, "tampered needle zxquniquecoldfact743".into());
2666 bad.metadata.id = rec.id;
2667 let (payload, size) =
2668 crate::cold_storage::compress_memory(&bad, CompressionCodec::Gzip).unwrap();
2669 tampered.compressed_payload = payload;
2670 tampered.uncompressed_size = size;
2671 store.put_cold_record(&tampered).unwrap();
2672 let out_tamper = store
2673 .find_cold_matching(&["zxquniquecoldfact743".to_string()], None, 10, 100)
2674 .unwrap();
2675 assert_eq!(out_tamper.matched, 0);
2676 assert_eq!(out_tamper.integrity_rejected, 1);
2677
2678 store.delete_cold_record(rec.id).unwrap();
2681 let wrong_key = uuid::Uuid::from_u128(741);
2682 assert_ne!(wrong_key, rec.id);
2683 let value = rmp_serde::to_vec_named(&rec).unwrap();
2684 let mut tx = store.env.begin_rw_txn().unwrap();
2685 tx.put(
2686 store.cold_storage_db,
2687 wrong_key.as_bytes(),
2688 &value,
2689 WriteFlags::default(),
2690 )
2691 .unwrap();
2692 tx.commit().unwrap();
2693 let wrong_key_out = store
2694 .find_cold_matching(&["zxquniquecoldfact741".into()], None, 10, 100)
2695 .unwrap();
2696 assert!(wrong_key_out.records.is_empty());
2697 assert_eq!(wrong_key_out.integrity_rejected, 1);
2698 }
2699
2700 #[test]
2704 fn query_substring_filters_galaxy_wide() {
2705 let tmp = tempfile::tempdir().unwrap();
2706 let store = MemoryStore::open_default(tmp.path()).unwrap();
2707
2708 for (i, content) in [
2709 "the mesh joins at dawn",
2710 "unrelated content entirely",
2711 "MESH joins at dusk",
2712 ]
2713 .iter()
2714 .enumerate()
2715 {
2716 let mut m = Memory::new(Galaxy::Codex, content.to_string());
2717 m.metadata.importance = 0.5 + i as f32 / 10.0;
2718 store.put(Galaxy::Codex, &m).unwrap();
2719 }
2720
2721 let hits = store
2722 .query(
2723 Galaxy::Codex,
2724 &MemoryQuery::new().with_content_substring("mesh joins"),
2725 )
2726 .unwrap();
2727 assert_eq!(hits.len(), 2, "CI substring must match both: {hits:?}");
2728 assert!(
2729 hits.iter()
2730 .all(|m| m.content.to_lowercase().contains("mesh joins"))
2731 );
2732
2733 let none = store
2734 .query(
2735 Galaxy::Codex,
2736 &MemoryQuery::new().with_content_substring("quantum calendar"),
2737 )
2738 .unwrap();
2739 assert!(none.is_empty(), "no match must be an honest empty set");
2740
2741 let mut tagged = Memory::new(Galaxy::Codex, "mesh joins again".to_string());
2743 tagged.metadata.tags = vec!["mesh".into()];
2744 store.put(Galaxy::Codex, &tagged).unwrap();
2745 let combined = store
2746 .query(
2747 Galaxy::Codex,
2748 &MemoryQuery::new()
2749 .with_tags(vec!["mesh".into()])
2750 .with_content_substring("again"),
2751 )
2752 .unwrap();
2753 assert_eq!(combined.len(), 1);
2754 assert_eq!(combined[0].content, "mesh joins again");
2755 }
2756
2757 #[cfg(unix)]
2758 #[test]
2759 fn store_dir_has_restrictive_permissions() {
2760 let tmp = tempfile::tempdir().unwrap();
2761 let store_path = tmp.path().join("lmdb");
2762 let _store = MemoryStore::open_default(&store_path).unwrap();
2763 let perms = std::fs::metadata(&store_path).unwrap().permissions().mode();
2764 assert_eq!(
2765 perms & 0o777,
2766 0o700,
2767 "store directory should have 0700 permissions, got {:o}",
2768 perms & 0o777
2769 );
2770 }
2771
2772 #[test]
2773 fn put_get_delete_memory() {
2774 let tmp = tempfile::tempdir().unwrap();
2775 let store = MemoryStore::open_default(tmp.path()).unwrap();
2776
2777 let mem = Memory::new(Galaxy::Codex, "Hello world".to_string());
2778 let id = mem.metadata.id;
2779
2780 store.put(Galaxy::Codex, &mem).unwrap();
2781 let retrieved = store.get(Galaxy::Codex, id).unwrap();
2782 assert!(retrieved.is_some());
2783 assert_eq!(retrieved.unwrap().content, "Hello world");
2784
2785 let deleted = store.delete(Galaxy::Codex, id).unwrap();
2786 assert!(deleted);
2787
2788 let gone = store.get(Galaxy::Codex, id).unwrap();
2789 assert!(gone.is_none());
2790 }
2791
2792 #[test]
2793 fn scan_memories() {
2794 let tmp = tempfile::tempdir().unwrap();
2795 let store = MemoryStore::open_default(tmp.path()).unwrap();
2796
2797 for i in 0..5 {
2798 let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
2799 store.put(Galaxy::Codex, &mem).unwrap();
2800 }
2801
2802 let all = store.scan(Galaxy::Codex, 100).unwrap();
2803 assert_eq!(all.len(), 5);
2804
2805 let limited = store.scan(Galaxy::Codex, 3).unwrap();
2806 assert_eq!(limited.len(), 3);
2807 }
2808
2809 #[test]
2810 fn overwrite_removes_stale_index_entries() {
2811 let tmp = tempfile::tempdir().unwrap();
2812 let store = MemoryStore::open_default(tmp.path()).unwrap();
2813
2814 let mut mem = Memory::new(Galaxy::Codex, "overwrite target".to_string());
2816 mem.metadata.tags = vec!["alpha".to_string()];
2817 mem.metadata.importance = 0.9;
2818 let id = mem.metadata.id;
2819 store.put(Galaxy::Codex, &mem).unwrap();
2820
2821 let mut updated = Memory::new(Galaxy::Codex, "overwritten content".to_string());
2823 updated.metadata.id = id;
2824 updated.metadata.tags = vec!["beta".to_string()];
2825 updated.metadata.importance = 0.1;
2826 store.put(Galaxy::Codex, &updated).unwrap();
2827
2828 let tx = store.env().begin_ro_txn().unwrap();
2830 let by_alpha = store
2831 .index_dbs()
2832 .find_by_tag(&tx, Galaxy::Codex, "alpha")
2833 .unwrap();
2834 let by_beta = store
2835 .index_dbs()
2836 .find_by_tag(&tx, Galaxy::Codex, "beta")
2837 .unwrap();
2838 assert!(
2839 by_alpha.is_empty(),
2840 "stale tag index entries must be removed on overwrite"
2841 );
2842 assert_eq!(by_beta, vec![id]);
2843
2844 let by_importance = store
2845 .index_dbs()
2846 .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 0.2)
2847 .unwrap();
2848 assert!(
2849 by_importance.contains(&id),
2850 "new importance must be indexed"
2851 );
2852 let by_high = store
2853 .index_dbs()
2854 .find_by_importance_range(&tx, Galaxy::Codex, 0.8, 1.0)
2855 .unwrap();
2856 assert!(
2857 !by_high.contains(&id),
2858 "stale importance index entries must be removed on overwrite"
2859 );
2860
2861 let old_hash = content_hash("overwrite target");
2862 let new_hash = content_hash("overwritten content");
2863 assert_eq!(
2864 store
2865 .index_dbs()
2866 .find_by_content_hash(&tx, Galaxy::Codex, &old_hash)
2867 .unwrap(),
2868 None,
2869 "stale content-hash index entry must be removed"
2870 );
2871 assert_eq!(
2872 store
2873 .index_dbs()
2874 .find_by_content_hash(&tx, Galaxy::Codex, &new_hash)
2875 .unwrap(),
2876 Some(id)
2877 );
2878 }
2879
2880 #[test]
2881 fn count_memories() {
2882 let tmp = tempfile::tempdir().unwrap();
2883 let store = MemoryStore::open_default(tmp.path()).unwrap();
2884
2885 assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
2886
2887 for i in 0..3 {
2888 let mem = Memory::new(Galaxy::Codex, format!("count-{i}"));
2889 store.put(Galaxy::Codex, &mem).unwrap();
2890 }
2891
2892 assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
2893 }
2894
2895 #[test]
2899 fn count_by_tag_counts_indexed_records() {
2900 let tmp = tempfile::tempdir().unwrap();
2901 let store = MemoryStore::open_default(tmp.path()).unwrap();
2902
2903 assert_eq!(store.count_by_tag(Galaxy::Sessions, "start").unwrap(), 0);
2904
2905 let mut start = Memory::new(Galaxy::Sessions, "{\"type\":\"session_start\"}".into());
2906 start.metadata.tags = vec!["session".into(), "start".into()];
2907 store.put(Galaxy::Sessions, &start).unwrap();
2908 for i in 0..2 {
2909 let mut turn =
2910 Memory::new(Galaxy::Sessions, format!("{{\"type\":\"turn\",\"i\":{i}}}"));
2911 turn.metadata.tags = vec!["session".into(), "turn".into()];
2912 store.put(Galaxy::Sessions, &turn).unwrap();
2913 }
2914
2915 assert_eq!(store.count(Galaxy::Sessions).unwrap(), 3);
2916 assert_eq!(store.count_by_tag(Galaxy::Sessions, "start").unwrap(), 1);
2917 assert_eq!(store.count_by_tag(Galaxy::Sessions, "turn").unwrap(), 2);
2918 assert_eq!(store.count_by_tag(Galaxy::Sessions, "absent").unwrap(), 0);
2919 }
2920
2921 #[test]
2922 fn get_nonexistent_returns_none() {
2923 let tmp = tempfile::tempdir().unwrap();
2924 let store = MemoryStore::open_default(tmp.path()).unwrap();
2925 let result = store.get(Galaxy::Codex, uuid::Uuid::new_v4()).unwrap();
2926 assert!(result.is_none());
2927 }
2928
2929 #[test]
2930 fn raw_put_get() {
2931 let tmp = tempfile::tempdir().unwrap();
2932 let store = MemoryStore::open_default(tmp.path()).unwrap();
2933
2934 store
2935 .put_raw(Galaxy::Substrate, b"config:key", b"value123")
2936 .unwrap();
2937 let val = store.get_raw(Galaxy::Substrate, b"config:key").unwrap();
2938 assert_eq!(val, Some(b"value123".to_vec()));
2939 }
2940
2941 #[test]
2942 fn put_dedup_prevents_duplicates() {
2943 let tmp = tempfile::tempdir().unwrap();
2944 let store = MemoryStore::open_default(tmp.path()).unwrap();
2945
2946 let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2947 let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2948
2949 let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2950 let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2951
2952 assert_eq!(id1, id2, "dedup should return same ID for same content");
2953 assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2954 }
2955
2956 #[test]
2957 fn put_dedup_allows_different_content() {
2958 let tmp = tempfile::tempdir().unwrap();
2959 let store = MemoryStore::open_default(tmp.path()).unwrap();
2960
2961 let mem1 = Memory::new(Galaxy::Codex, "content A".into());
2962 store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2963
2964 let mem2 = Memory::new(Galaxy::Codex, "content B".into());
2965 store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2966
2967 assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
2968 }
2969
2970 #[test]
2971 fn put_batch_atomic_write() {
2972 let tmp = tempfile::tempdir().unwrap();
2973 let store = MemoryStore::open_default(tmp.path()).unwrap();
2974
2975 let memories: Vec<Memory> = (0..10)
2976 .map(|i| Memory::new(Galaxy::Codex, format!("batch-{i}")))
2977 .collect();
2978
2979 store.put_batch(Galaxy::Codex, &memories).unwrap();
2980 assert_eq!(store.count(Galaxy::Codex).unwrap(), 10);
2981 }
2982
2983 #[test]
2984 fn query_by_tags() {
2985 let tmp = tempfile::tempdir().unwrap();
2986 let store = MemoryStore::open_default(tmp.path()).unwrap();
2987
2988 let mem1 = Memory::new(Galaxy::Codex, "tagged memory".into())
2989 .with_tags(vec!["rust".into(), "memory".into()]);
2990 let mem2 =
2991 Memory::new(Galaxy::Codex, "other memory".into()).with_tags(vec!["python".into()]);
2992 store.put(Galaxy::Codex, &mem1).unwrap();
2993 store.put(Galaxy::Codex, &mem2).unwrap();
2994
2995 let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2996 let results = store.query(Galaxy::Codex, &query).unwrap();
2997 assert_eq!(results.len(), 1);
2998 assert_eq!(results[0].content, "tagged memory");
2999 }
3000
3001 #[test]
3002 fn query_by_importance_range() {
3003 let tmp = tempfile::tempdir().unwrap();
3004 let store = MemoryStore::open_default(tmp.path()).unwrap();
3005
3006 store
3007 .put(
3008 Galaxy::Codex,
3009 &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
3010 )
3011 .unwrap();
3012 store
3013 .put(
3014 Galaxy::Codex,
3015 &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
3016 )
3017 .unwrap();
3018 store
3019 .put(
3020 Galaxy::Codex,
3021 &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
3022 )
3023 .unwrap();
3024
3025 let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
3026 let results = store.query(Galaxy::Codex, &query).unwrap();
3027 assert_eq!(results.len(), 1);
3028 assert_eq!(results[0].content, "mid");
3029 }
3030
3031 #[test]
3032 fn memory_query_one_sided_time_bounds() {
3033 let old = Memory::new(Galaxy::Codex, "old".into());
3036 let mut recent = Memory::new(Galaxy::Codex, "recent".into());
3037 recent.metadata.created_at = old.metadata.created_at + chrono::Duration::days(30);
3038
3039 let cutoff = old.metadata.created_at + chrono::Duration::days(10);
3040 let after = MemoryQuery::new().with_created_after(cutoff);
3041 assert!(!after.matches(&old), "pre-cutoff memory must not match");
3042 assert!(after.matches(&recent), "post-cutoff memory must match");
3043
3044 let before = MemoryQuery::new().with_created_before(cutoff);
3045 assert!(before.matches(&old), "pre-cutoff memory must match");
3046 assert!(
3047 !before.matches(&recent),
3048 "post-cutoff memory must not match"
3049 );
3050
3051 let edge = MemoryQuery::new().with_created_after(cutoff);
3053 let mut at = Memory::new(Galaxy::Codex, "at cutoff".into());
3054 at.metadata.created_at = cutoff;
3055 assert!(edge.matches(&at), "created_at == after bound is inclusive");
3056 }
3057
3058 #[test]
3059 fn embedding_put_get_delete() {
3060 let tmp = tempfile::tempdir().unwrap();
3061 let store = MemoryStore::open_default(tmp.path()).unwrap();
3062
3063 let id = uuid::Uuid::new_v4();
3064 let embedding = vec![0.1, 0.2, 0.3, 0.4, 0.5];
3065
3066 store.put_embedding(id, &embedding).unwrap();
3067 let retrieved = store.get_embedding(id).unwrap();
3068 assert!(retrieved.is_some());
3069 let retrieved = retrieved.unwrap();
3070 assert_eq!(retrieved.len(), 5);
3071 assert!((retrieved[0] - 0.1).abs() < f32::EPSILON);
3072
3073 assert!(store.delete_embedding(id).unwrap());
3074 assert!(store.get_embedding(id).unwrap().is_none());
3075 }
3076
3077 #[test]
3078 fn embedding_cache_roundtrip_batch_and_count() {
3079 let tmp = tempfile::tempdir().unwrap();
3080 let store = MemoryStore::open_default(tmp.path()).unwrap();
3081
3082 let entries: Vec<(String, Vec<f32>)> = (0..5)
3083 .map(|i| (format!("ns:model:{i:016x}"), vec![i as f32; 8]))
3084 .collect();
3085 store.put_embedding_cache_batch(&entries).unwrap();
3086 assert_eq!(store.embedding_cache_count().unwrap(), 5);
3087
3088 let hit = store
3090 .get_embedding_cache("ns:model:0000000000000003")
3091 .unwrap();
3092 assert_eq!(hit.unwrap(), vec![3.0f32; 8]);
3093 assert!(
3094 store
3095 .get_embedding_cache("ns:model:absent")
3096 .unwrap()
3097 .is_none()
3098 );
3099
3100 let keys: Vec<String> = (0..6).map(|i| format!("ns:model:{i:016x}")).collect();
3102 let batch = store.get_embedding_cache_batch(&keys).unwrap();
3103 assert_eq!(batch.len(), 6);
3104 assert!(batch[0..5].iter().all(Option::is_some));
3105 assert!(batch[5].is_none());
3106
3107 store
3109 .put_embedding_cache("ns:model:0000000000000001", &[9.0; 8])
3110 .unwrap();
3111 assert_eq!(store.embedding_cache_count().unwrap(), 5);
3112 assert_eq!(
3113 store
3114 .get_embedding_cache("ns:model:0000000000000001")
3115 .unwrap()
3116 .unwrap(),
3117 vec![9.0f32; 8]
3118 );
3119 }
3120
3121 #[test]
3122 fn embedding_cache_survives_store_reopen() {
3123 let tmp = tempfile::tempdir().unwrap();
3125 {
3126 let store = MemoryStore::open_default(tmp.path()).unwrap();
3127 store
3128 .put_embedding_cache("onnx:bge-small:abc", &[0.5; 384])
3129 .unwrap();
3130 }
3131 let reopened = MemoryStore::open_default(tmp.path()).unwrap();
3132 let cached = reopened.get_embedding_cache("onnx:bge-small:abc").unwrap();
3133 assert_eq!(cached.unwrap(), vec![0.5f32; 384]);
3134 }
3135
3136 #[test]
3137 fn content_hash_is_sha256() {
3138 let hash1 = content_hash("test content");
3139 let hash2 = content_hash("test content");
3140 let hash3 = content_hash("different content");
3141
3142 assert_eq!(hash1, hash2, "same content should produce same hash");
3143 assert_ne!(
3144 hash1, hash3,
3145 "different content should produce different hash"
3146 );
3147 assert_eq!(hash1.len(), 64, "SHA-256 hex should be 64 chars");
3148 }
3149
3150 #[test]
3151 fn query_by_tag_uses_index() {
3152 let tmp = tempfile::tempdir().unwrap();
3153 let store = MemoryStore::open_default(tmp.path()).unwrap();
3154
3155 let mem1 = Memory::new(Galaxy::Codex, "tagged".into())
3156 .with_tags(vec!["rust".into(), "memory".into()]);
3157 let mem2 = Memory::new(Galaxy::Codex, "other".into()).with_tags(vec!["python".into()]);
3158 store.put(Galaxy::Codex, &mem1).unwrap();
3159 store.put(Galaxy::Codex, &mem2).unwrap();
3160
3161 let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
3162 let results = store.query(Galaxy::Codex, &query).unwrap();
3163 assert_eq!(results.len(), 1);
3164 assert_eq!(results[0].content, "tagged");
3165 }
3166
3167 #[test]
3168 fn query_by_importance_uses_index() {
3169 let tmp = tempfile::tempdir().unwrap();
3170 let store = MemoryStore::open_default(tmp.path()).unwrap();
3171
3172 store
3173 .put(
3174 Galaxy::Codex,
3175 &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
3176 )
3177 .unwrap();
3178 store
3179 .put(
3180 Galaxy::Codex,
3181 &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
3182 )
3183 .unwrap();
3184 store
3185 .put(
3186 Galaxy::Codex,
3187 &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
3188 )
3189 .unwrap();
3190
3191 let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
3192 let results = store.query(Galaxy::Codex, &query).unwrap();
3193 assert_eq!(results.len(), 1);
3194 assert_eq!(results[0].content, "mid");
3195 }
3196
3197 #[test]
3198 fn query_by_time_uses_index() {
3199 let tmp = tempfile::tempdir().unwrap();
3200 let store = MemoryStore::open_default(tmp.path()).unwrap();
3201
3202 let t0 = chrono::Utc::now();
3203 std::thread::sleep(std::time::Duration::from_millis(10));
3204 let mem = Memory::new(Galaxy::Codex, "timed".into());
3205 store.put(Galaxy::Codex, &mem).unwrap();
3206 std::thread::sleep(std::time::Duration::from_millis(10));
3207 let t2 = chrono::Utc::now();
3208
3209 let query = MemoryQuery::new().with_time_range(t0, t2);
3210 let results = store.query(Galaxy::Codex, &query).unwrap();
3211 assert_eq!(results.len(), 1);
3212 assert_eq!(results[0].content, "timed");
3213 }
3214
3215 #[test]
3216 fn delete_removes_index_entries() {
3217 let tmp = tempfile::tempdir().unwrap();
3218 let store = MemoryStore::open_default(tmp.path()).unwrap();
3219
3220 let mem = Memory::new(Galaxy::Codex, "test".into())
3221 .with_tags(vec!["tag1".into()])
3222 .with_importance(0.7);
3223 let id = mem.metadata.id;
3224 let hash = mem.metadata.content_hash.clone();
3225 store.put(Galaxy::Codex, &mem).unwrap();
3226
3227 assert!(
3229 store
3230 .find_by_content_hash(Galaxy::Codex, &hash)
3231 .unwrap()
3232 .is_some()
3233 );
3234
3235 store.delete(Galaxy::Codex, id).unwrap();
3237
3238 assert!(
3240 store
3241 .find_by_content_hash(Galaxy::Codex, &hash)
3242 .unwrap()
3243 .is_none()
3244 );
3245
3246 let query = MemoryQuery::new().with_tags(vec!["tag1".into()]);
3248 let results = store.query(Galaxy::Codex, &query).unwrap();
3249 assert!(results.is_empty());
3250 }
3251
3252 #[test]
3253 fn put_batch_updates_indexes() {
3254 let tmp = tempfile::tempdir().unwrap();
3255 let store = MemoryStore::open_default(tmp.path()).unwrap();
3256
3257 let memories: Vec<Memory> = (0..5)
3258 .map(|i| {
3259 Memory::new(Galaxy::Codex, format!("batch-{i}"))
3260 .with_tags(vec![format!("tag{i}")])
3261 .with_importance(i as f32 * 0.2)
3262 })
3263 .collect();
3264 store.put_batch(Galaxy::Codex, &memories).unwrap();
3265
3266 for i in 0..5 {
3267 let query = MemoryQuery::new().with_tags(vec![format!("tag{i}")]);
3268 let results = store.query(Galaxy::Codex, &query).unwrap();
3269 assert_eq!(results.len(), 1, "tag{i} should have 1 result");
3270 }
3271 }
3272
3273 #[test]
3274 fn find_by_content_hash_indexed_matches_scan() {
3275 let tmp = tempfile::tempdir().unwrap();
3276 let store = MemoryStore::open_default(tmp.path()).unwrap();
3277
3278 let mem = Memory::new(Galaxy::Codex, "dedup test".into());
3279 let id = mem.metadata.id;
3280 let hash = mem.metadata.content_hash.clone();
3281 store.put(Galaxy::Codex, &mem).unwrap();
3282
3283 let indexed = store.find_by_content_hash(Galaxy::Codex, &hash).unwrap();
3284 let scanned = store
3285 .find_by_content_hash_scan(Galaxy::Codex, &hash)
3286 .unwrap();
3287
3288 assert_eq!(indexed, scanned);
3289 assert_eq!(indexed, Some(id));
3290 }
3291
3292 #[test]
3293 fn put_dedup_uses_index() {
3294 let tmp = tempfile::tempdir().unwrap();
3295 let store = MemoryStore::open_default(tmp.path()).unwrap();
3296
3297 let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3298 let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
3299
3300 let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3301 let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
3302
3303 assert_eq!(id1, id2, "dedup should return same ID for same content");
3304 assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
3305 }
3306
3307 #[test]
3308 fn put_semantic_updates_coord5d() {
3309 let tmp = tempfile::tempdir().unwrap();
3310 let store = MemoryStore::open_default(tmp.path()).unwrap();
3311
3312 let mut mem = Memory::new(
3313 Galaxy::Codex,
3314 "The algorithm computes data using a systematic method".to_string(),
3315 );
3316 let original_coord = mem.metadata.coord5d.clone();
3317 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3318
3319 assert_ne!(
3321 mem.metadata.coord5d.x, original_coord.x,
3322 "semantic encoding should change x"
3323 );
3324 assert_ne!(
3325 mem.metadata.coord5d.y, original_coord.y,
3326 "semantic encoding should change y"
3327 );
3328
3329 let retrieved = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
3331 assert_eq!(retrieved.metadata.coord5d.x, mem.metadata.coord5d.x);
3332 }
3333
3334 #[test]
3335 fn put_semantic_preserves_temporal_and_importance() {
3336 let tmp = tempfile::tempdir().unwrap();
3337 let store = MemoryStore::open_default(tmp.path()).unwrap();
3338
3339 let mut mem = Memory::new(Galaxy::Codex, "test content".into()).with_importance(0.8);
3340 mem.metadata.coord5d.w = 0.6;
3341 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3342
3343 assert!((mem.metadata.coord5d.w - 0.6).abs() < f32::EPSILON);
3344 assert!((mem.metadata.coord5d.v - 0.8).abs() < f32::EPSILON);
3345 }
3346
3347 #[test]
3348 fn find_similar_returns_nearest_first() {
3349 let tmp = tempfile::tempdir().unwrap();
3350 let store = MemoryStore::open_default(tmp.path()).unwrap();
3351
3352 let mut logic_mem = Memory::new(
3353 Galaxy::Codex,
3354 "The algorithm computes data using systematic logic and analysis".to_string(),
3355 );
3356 store.put_semantic(Galaxy::Codex, &mut logic_mem).unwrap();
3357
3358 let mut emotion_mem = Memory::new(
3359 Galaxy::Codex,
3360 "I feel love and joy with deep passion and empathy in my heart".to_string(),
3361 );
3362 store.put_semantic(Galaxy::Codex, &mut emotion_mem).unwrap();
3363
3364 let results = store
3366 .find_similar(Galaxy::Codex, "algorithm data systematic method", 10)
3367 .unwrap();
3368 assert!(!results.is_empty());
3369 assert_eq!(results[0].0.metadata.id, logic_mem.metadata.id);
3370
3371 let results = store
3373 .find_similar(Galaxy::Codex, "love joy passion heart feeling", 10)
3374 .unwrap();
3375 assert!(!results.is_empty());
3376 assert_eq!(results[0].0.metadata.id, emotion_mem.metadata.id);
3377 }
3378
3379 #[test]
3380 fn find_similar_empty_galaxy() {
3381 let tmp = tempfile::tempdir().unwrap();
3382 let store = MemoryStore::open_default(tmp.path()).unwrap();
3383
3384 let results = store.find_similar(Galaxy::Codex, "anything", 10).unwrap();
3385 assert!(results.is_empty());
3386 }
3387
3388 #[test]
3389 fn find_similar_respects_limit() {
3390 let tmp = tempfile::tempdir().unwrap();
3391 let store = MemoryStore::open_default(tmp.path()).unwrap();
3392
3393 for i in 0..5 {
3394 let mut mem = Memory::new(Galaxy::Codex, format!("algorithm data method {i}"));
3395 store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3396 }
3397
3398 let results = store
3399 .find_similar(Galaxy::Codex, "algorithm data", 3)
3400 .unwrap();
3401 assert_eq!(results.len(), 3);
3402 }
3403
3404 #[test]
3405 fn semantic_encoder_accessible() {
3406 let tmp = tempfile::tempdir().unwrap();
3407 let store = MemoryStore::open_default(tmp.path()).unwrap();
3408
3409 let scores = store.semantic_encoder().encode("algorithm data logic");
3410 assert!(scores.x < 0.5);
3412 }
3413
3414 #[test]
3415 fn put_raw_batch_writes_atomically() {
3416 let tmp = tempfile::tempdir().unwrap();
3417 let store = MemoryStore::open_default(tmp.path()).unwrap();
3418
3419 let entries: &[(&[u8], &[u8])] =
3420 &[(b"key1", b"val1"), (b"key2", b"val2"), (b"key3", b"val3")];
3421 store.put_raw_batch(Galaxy::Karma, entries).unwrap();
3422
3423 assert_eq!(
3424 store.get_raw(Galaxy::Karma, b"key1").unwrap().unwrap(),
3425 b"val1"
3426 );
3427 assert_eq!(
3428 store.get_raw(Galaxy::Karma, b"key2").unwrap().unwrap(),
3429 b"val2"
3430 );
3431 assert_eq!(
3432 store.get_raw(Galaxy::Karma, b"key3").unwrap().unwrap(),
3433 b"val3"
3434 );
3435 }
3436
3437 #[test]
3438 fn put_raw_batch_empty_is_noop() {
3439 let tmp = tempfile::tempdir().unwrap();
3440 let store = MemoryStore::open_default(tmp.path()).unwrap();
3441
3442 store.put_raw_batch(Galaxy::Karma, &[]).unwrap();
3443 assert_eq!(store.count(Galaxy::Karma).unwrap(), 0);
3444 }
3445
3446 #[test]
3447 fn entry_limit_rejects_excess_writes() {
3448 let tmp = tempfile::tempdir().unwrap();
3449 let store = MemoryStore::open_default(tmp.path())
3450 .unwrap()
3451 .with_entry_limit(3);
3452
3453 for i in 0..3 {
3454 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3455 store.put(Galaxy::Codex, &mem).unwrap();
3456 }
3457
3458 let mem = Memory::new(Galaxy::Codex, "overflow memory".to_string());
3460 let result = store.put(Galaxy::Codex, &mem);
3461 assert!(result.is_err(), "write beyond limit should be rejected");
3462 let err_msg = result.unwrap_err().to_string();
3463 assert!(
3464 err_msg.contains("entry limit reached"),
3465 "error should mention entry limit: {err_msg}"
3466 );
3467 assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
3468 }
3469
3470 #[test]
3471 fn entry_limit_per_galaxy_independent() {
3472 let tmp = tempfile::tempdir().unwrap();
3473 let store = MemoryStore::open_default(tmp.path())
3474 .unwrap()
3475 .with_entry_limit(2);
3476
3477 for i in 0..2 {
3479 let mem = Memory::new(Galaxy::Codex, format!("codex {i}"));
3480 store.put(Galaxy::Codex, &mem).unwrap();
3481 }
3482
3483 let mem = Memory::new(Galaxy::Research, "science memory".to_string());
3485 let result = store.put(Galaxy::Research, &mem);
3486 assert!(
3487 result.is_ok(),
3488 "different galaxy should not be affected by limit"
3489 );
3490 }
3491
3492 #[test]
3493 fn entry_limit_none_allows_unlimited() {
3494 let tmp = tempfile::tempdir().unwrap();
3495 let store = MemoryStore::open_default(tmp.path()).unwrap();
3496
3497 for i in 0..50 {
3499 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3500 store.put(Galaxy::Codex, &mem).unwrap();
3501 }
3502 assert_eq!(store.count(Galaxy::Codex).unwrap(), 50);
3503 }
3504
3505 #[test]
3506 fn map_full_error_is_graceful() {
3507 let tmp = tempfile::tempdir().unwrap();
3511 let store = MemoryStore::open(tmp.path(), 512 * 1024).unwrap();
3512
3513 let mut written = 0;
3515 let mut got_map_full = false;
3516 for i in 0..1000 {
3517 let mem = Memory::new(
3518 Galaxy::Codex,
3519 format!("memory content {i} {}", "with padding ".repeat(50)),
3520 );
3521 match store.put(Galaxy::Codex, &mem) {
3522 Ok(()) => written += 1,
3523 Err(e) => {
3524 let msg = e.to_string();
3525 if msg.contains("map full") {
3526 got_map_full = true;
3527 break;
3528 }
3529 break;
3531 }
3532 }
3533 }
3534
3535 assert!(
3536 got_map_full || written < 1000,
3537 "should eventually hit map full or error"
3538 );
3539 assert!(written > 0, "should have written at least some memories");
3540 }
3541
3542 #[test]
3543 fn test_find_across_galaxies() {
3544 let tmp = tempfile::tempdir().unwrap();
3545 let store = MemoryStore::open_default(tmp.path()).unwrap();
3546
3547 let mem = Memory::new(Galaxy::Research, "Cross-galaxy research memo".into());
3548 let id = mem.metadata.id;
3549 store.put(Galaxy::Research, &mem).unwrap();
3550
3551 let found = store.find_across_galaxies(id).unwrap();
3552 assert!(found.is_some());
3553 let (galaxy, retrieved) = found.unwrap();
3554 assert_eq!(galaxy, Galaxy::Research);
3555 assert_eq!(retrieved.metadata.id, id);
3556 assert_eq!(retrieved.content, "Cross-galaxy research memo");
3557
3558 assert!(
3560 store
3561 .find_across_galaxies(uuid::Uuid::new_v4())
3562 .unwrap()
3563 .is_none()
3564 );
3565 }
3566}