Skip to main content

wm_memory/
store.rs

1//! LMDB-backed memory store.
2//!
3//! Each galaxy is an LMDB named database (sub-DB within the same file).
4//! Reads are zero-copy (mmap'd). Writes are batched.
5
6use lmdb::{
7    Cursor, Database, DatabaseFlags, Environment, EnvironmentFlags, Transaction, WriteFlags,
8};
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, RwLock};
13use wm_core::{CoreError, Galaxy, Result};
14
15#[cfg(unix)]
16use std::os::unix::fs::PermissionsExt;
17
18use crate::episodic::EpisodicStore;
19use crate::indexes::IndexDbs;
20use crate::memory::{Memory, MemoryId, decode_embedding, encode_embedding};
21use crate::semantic::SemanticEncoder;
22
23/// Query filter for memories.
24#[derive(Debug, Clone, Default)]
25pub struct MemoryQuery {
26    /// Filter by tags (memory must contain ALL specified tags).
27    pub tags: Vec<String>,
28    /// Minimum importance (inclusive).
29    pub min_importance: Option<f32>,
30    /// Maximum importance (inclusive).
31    pub max_importance: Option<f32>,
32    /// Only memories created after this timestamp.
33    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
34    /// Only memories created before this timestamp.
35    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
36    /// Case-insensitive substring filter over content (literal match —
37    /// not tokenized or ranked; that is what the search engine is for).
38    pub content_substring: Option<String>,
39    /// Maximum number of results.
40    pub limit: usize,
41}
42
43impl MemoryQuery {
44    /// Create an empty query (matches all, limit 100).
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            limit: 100,
49            ..Default::default()
50        }
51    }
52
53    /// Set tag filter.
54    #[must_use]
55    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
56        self.tags = tags;
57        self
58    }
59
60    /// Set importance range.
61    #[must_use]
62    pub const fn with_importance_range(mut self, min: f32, max: f32) -> Self {
63        self.min_importance = Some(min);
64        self.max_importance = Some(max);
65        self
66    }
67
68    /// Set temporal range.
69    #[must_use]
70    pub const fn with_time_range(
71        mut self,
72        after: chrono::DateTime<chrono::Utc>,
73        before: chrono::DateTime<chrono::Utc>,
74    ) -> Self {
75        self.created_after = Some(after);
76        self.created_before = Some(before);
77        self
78    }
79
80    /// One-sided temporal bound: only memories created at or after this
81    /// timestamp (`created_after` API passthrough).
82    #[must_use]
83    pub const fn with_created_after(mut self, after: chrono::DateTime<chrono::Utc>) -> Self {
84        self.created_after = Some(after);
85        self
86    }
87
88    /// One-sided temporal bound: only memories created at or before this
89    /// timestamp (`created_before` API passthrough).
90    #[must_use]
91    pub const fn with_created_before(mut self, before: chrono::DateTime<chrono::Utc>) -> Self {
92        self.created_before = Some(before);
93        self
94    }
95
96    /// Set limit.
97    #[must_use]
98    pub const fn with_limit(mut self, limit: usize) -> Self {
99        self.limit = limit;
100        self
101    }
102
103    /// Set a case-insensitive substring filter over content.
104    #[must_use]
105    pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
106        self.content_substring = Some(substring.into().to_lowercase());
107        self
108    }
109
110    /// Check if a memory matches this query.
111    #[must_use]
112    pub fn matches(&self, mem: &Memory) -> bool {
113        // Tag filter: memory must contain all specified tags
114        if !self.tags.is_empty() {
115            for tag in &self.tags {
116                if !mem.metadata.tags.iter().any(|t| t == tag) {
117                    return false;
118                }
119            }
120        }
121
122        // Importance filter
123        if let Some(min) = self.min_importance {
124            if mem.metadata.importance < min {
125                return false;
126            }
127        }
128        if let Some(max) = self.max_importance {
129            if mem.metadata.importance > max {
130                return false;
131            }
132        }
133
134        // Temporal filter
135        if let Some(after) = self.created_after {
136            if mem.metadata.created_at < after {
137                return false;
138            }
139        }
140        if let Some(before) = self.created_before {
141            if mem.metadata.created_at > before {
142                return false;
143            }
144        }
145
146        // Substring filter (literal, case-insensitive — never ranked).
147        if let Some(sub) = &self.content_substring {
148            if !mem.content.to_lowercase().contains(sub) {
149                return false;
150            }
151        }
152
153        true
154    }
155}
156
157/// The LMDB environment containing all 14 galaxy sub-databases plus 4 index DBs.
158pub struct MemoryStore {
159    /// Path to the LMDB file
160    path: std::path::PathBuf,
161    /// LMDB environment (opened once, shared across threads)
162    env: Environment,
163    /// Cached handles to the 4 secondary index sub-databases
164    index_dbs: IndexDbs,
165    /// Semantic encoder for content-derived coordinates
166    semantic_encoder: SemanticEncoder,
167    /// Optional per-galaxy entry limit (DoS prevention)
168    max_entries_per_galaxy: Option<usize>,
169    /// Monotonic counter of successful mutations since this handle was
170    /// opened. Lets the dispatch pipeline cheaply detect actual store
171    /// writes (write-audit journal) without scanning galaxies.
172    mutation_count: AtomicU64,
173    /// Dedicated database for lossless v6 episodic records.
174    episodic_db: Database,
175    /// DUP_SORT term→id postings for bounded episodic search (v2 sidecar).
176    episodic_terms_v2_db: Database,
177    /// Content-hash → vector cache (v26 "Tier 2" idea, finally wired):
178    /// warm-start for re-ingest and re-runs. Keyed by the embedder
179    /// namespace + content hash, so switching models never serves stale
180    /// vectors.
181    embedding_cache_db: Database,
182    /// Per-memory revision chains (V8 S11c): append-only content-change
183    /// history, self-verifying (seq continuity + hash linkage + head
184    /// match). See [`crate::revision`].
185    revisions_db: Database,
186    /// Per-memory creation attestations (Track F Slice A, D5): one signed
187    /// record per created memory, keyed `att:{galaxy}:{memory_id}`.
188    /// See [`crate::attestation`].
189    attestations_db: Database,
190    /// Dedicated database for compressed cold-stored memories.
191    pub(crate) cold_storage_db: Database,
192    /// Warm term-posting cache shared by episodic search views.
193    episodic_term_cache: std::sync::Arc<RwLock<HashMap<String, Vec<uuid::Uuid>>>>,
194    /// Optional embedder for episodic vector reranking.
195    episodic_embedder:
196        std::sync::OnceLock<Option<Arc<dyn crate::embedder::Embedder + Send + Sync>>>,
197    /// One-shot guard: rebuild the v2 episodic sidecar once per process.
198    episodic_sidecar_ensured: std::sync::OnceLock<()>,
199    /// Optional adaptive aliases for episodic key expansion.
200    episodic_aliases: std::sync::OnceLock<Option<crate::episodic_keys::AdaptiveAliases>>,
201    /// Optional vocabulary enrichment for episodic index-time term expansion.
202    episodic_enrichment: std::sync::OnceLock<Option<crate::enrichment::VocabularyEnrichment>>,
203}
204
205impl MemoryStore {
206    /// Probe whether another process holds the LMDB writer lock on this store.
207    ///
208    /// LMDB's write env-open falls back to a blocking *shared* lock when the
209    /// exclusive writer lock is held, then opens `data.mdb` for writing
210    /// anyway and wedges on its internal mutex — `wm grimoire`/`wm status`
211    /// hung forever against a live store until a SIGKILL (9.1.6). Callers
212    /// that need exclusive access probe first and fail loudly instead of
213    /// deadlocking.
214    ///
215    /// The probe is a non-blocking `fcntl(F_SETLK, F_WRLCK)` over the whole
216    /// `lock.mdb` (record locks overlap LMDB's byte-range writer lock), so
217    /// it never blocks and never mutates the store.
218    ///
219    /// Returns `Ok(())` when the writer lock is free, `Err(WouldBlock)`
220    /// when another process holds it.
221    #[cfg(unix)]
222    pub fn probe_write_lock(store_root: &Path) -> std::io::Result<()> {
223        use rustix::fs::{FlockOperation, fcntl_lock};
224        let lock_path = store_root.join("lmdb").join("lock.mdb");
225        let file = std::fs::OpenOptions::new()
226            .read(true)
227            .write(true)
228            .open(&lock_path)?;
229        match fcntl_lock(&file, FlockOperation::NonBlockingLockExclusive) {
230            Ok(()) => Ok(()),
231            Err(rustix::io::Errno::AGAIN | rustix::io::Errno::ACCESS) => Err(std::io::Error::new(
232                std::io::ErrorKind::WouldBlock,
233                "LMDB writer lock held by another process",
234            )),
235            Err(e) => Err(e.into()),
236        }
237    }
238
239    /// Non-unix: LMDB locking differs (LockFileEx on Windows); the probe is
240    /// best-effort there and reports the lock as free.
241    #[cfg(not(unix))]
242    pub fn probe_write_lock(_store_root: &Path) -> std::io::Result<()> {
243        Ok(())
244    }
245
246    /// Open or create an LMDB store at the given path.
247    ///
248    /// On Unix, the store directory is created with mode 0o700 (owner-only
249    /// access) if it does not already exist. Existing directories are
250    /// left untouched.
251    pub fn open(path: impl AsRef<Path>, map_size: usize) -> Result<Self> {
252        let path = path.as_ref().to_path_buf();
253
254        // Ensure the directory exists with restrictive permissions.
255        std::fs::create_dir_all(&path)
256            .map_err(|e| CoreError::Memory(format!("Cannot create store dir: {e}")))?;
257        #[cfg(unix)]
258        {
259            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
260                .map_err(|e| CoreError::Memory(format!("Cannot set store dir permissions: {e}")))?;
261        }
262
263        let env = Environment::new()
264            .set_map_size(map_size)
265            .set_max_dbs(64)
266            .open(&path)
267            .map_err(|e| CoreError::Memory(format!("LMDB open failed: {e}")))?;
268
269        // Create all 14 galaxy sub-databases
270        for galaxy in Galaxy::all() {
271            let db = env
272                .create_db(Some(galaxy.db_name()), DatabaseFlags::default())
273                .map_err(|e| {
274                    CoreError::Memory(format!(
275                        "LMDB create_db failed for {}: {e}",
276                        galaxy.db_name()
277                    ))
278                })?;
279            let _ = db;
280        }
281
282        // Create 4 secondary index sub-databases
283        for (name, flags) in crate::indexes::INDEX_DBS {
284            let db = env
285                .create_db(Some(name), *flags)
286                .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for {name}: {e}")))?;
287            let _ = db;
288        }
289
290        let index_dbs = IndexDbs::open(&env)?;
291        let episodic_db = env
292            .create_db(Some("episodic_records"), DatabaseFlags::default())
293            .map_err(|e| {
294                CoreError::Memory(format!("LMDB create_db failed for episodic_records: {e}"))
295            })?;
296        // v2 sidecar: DUP_SORT postings (term -> set of record ids). Append of
297        // a record touches only the (term, id) pairs it introduces instead of
298        // rewriting whole posting lists, so ingest cost stays O(new records)
299        // as the store grows. The v1 msgpack-Vec database is retained unused
300        // on legacy stores; v2 is rebuilt from the authoritative records when
301        // found empty.
302        let episodic_terms_v2_db = env
303            .create_db(Some("episodic_terms_v2"), DatabaseFlags::DUP_SORT)
304            .map_err(|e| {
305                CoreError::Memory(format!("LMDB create_db failed for episodic_terms_v2: {e}"))
306            })?;
307        let embedding_cache_db = env
308            .create_db(Some("embedding_cache"), DatabaseFlags::default())
309            .map_err(|e| {
310                CoreError::Memory(format!("LMDB create_db failed for embedding_cache: {e}"))
311            })?;
312        let revisions_db = env
313            .create_db(Some("revisions"), DatabaseFlags::default())
314            .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for revisions: {e}")))?;
315        let attestations_db = env
316            .create_db(
317                Some(crate::attestation::ATTESTATIONS_DB),
318                DatabaseFlags::default(),
319            )
320            .map_err(|e| {
321                CoreError::Memory(format!("LMDB create_db failed for attestations: {e}"))
322            })?;
323        let cold_storage_db = env
324            .create_db(Some("cold_storage"), DatabaseFlags::default())
325            .map_err(|e| {
326                CoreError::Memory(format!("LMDB create_db failed for cold_storage: {e}"))
327            })?;
328        Ok(Self {
329            path,
330            env,
331            index_dbs,
332            semantic_encoder: SemanticEncoder::new(),
333            max_entries_per_galaxy: None,
334            mutation_count: AtomicU64::new(0),
335            episodic_db,
336            episodic_terms_v2_db,
337            embedding_cache_db,
338            revisions_db,
339            attestations_db,
340            cold_storage_db,
341            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
342            episodic_embedder: std::sync::OnceLock::new(),
343            episodic_sidecar_ensured: std::sync::OnceLock::new(),
344            episodic_aliases: std::sync::OnceLock::new(),
345            episodic_enrichment: std::sync::OnceLock::new(),
346        })
347    }
348
349    /// Bounded env open for inspection paths (9.1.6).
350    ///
351    /// LMDB env opens can block forever: against a live writer the
352    /// exclusive-lock fallback wedges on an internal mutex, and a crashed
353    /// server can leave the lock file's in-file mutex locked so even
354    /// read-only opens hang. Inspection callers (status, doctor, grimoire)
355    /// must never hang — run the open on a worker thread and give up after
356    /// `timeout`, returning `Ok(None)` so the caller degrades loudly
357    /// instead of deadlocking.
358    pub fn open_readonly_bounded(
359        path: impl AsRef<Path>,
360        timeout: std::time::Duration,
361    ) -> Result<Option<Self>> {
362        let path = path.as_ref().to_path_buf();
363        let (tx, rx) = std::sync::mpsc::channel();
364        std::thread::spawn(move || {
365            let result = Self::open_readonly(&path);
366            let _ = tx.send(result);
367        });
368        match rx.recv_timeout(timeout) {
369            Ok(result) => result.map(Some),
370            Err(_) => Ok(None),
371        }
372    }
373
374    /// Bounded writable env open for exclusive-access paths (9.1.6).
375    /// See [`Self::open_readonly_bounded`] for the wedge rationale; write
376    /// paths bail with an actionable message on timeout instead of hanging.
377    pub fn open_default_bounded(
378        path: impl AsRef<Path>,
379        timeout: std::time::Duration,
380    ) -> Result<Option<Self>> {
381        let path = path.as_ref().to_path_buf();
382        let (tx, rx) = std::sync::mpsc::channel();
383        std::thread::spawn(move || {
384            let result = Self::open_default(&path);
385            let _ = tx.send(result);
386        });
387        match rx.recv_timeout(timeout) {
388            Ok(result) => result.map(Some),
389            Err(_) => Ok(None),
390        }
391    }
392
393    /// Open with the default map size.
394    ///
395    /// 4 GB on Unix: LMDB truncates the data file sparsely (ftruncate), so
396    /// reservation costs nothing until pages are written. On Windows NTFS
397    /// materializes the file at full map size immediately — a 4 GB default
398    /// would allocate 4 GB on disk per store the moment it opens — so the
399    /// Windows default is smaller; pass an explicit size to `open()` for
400    /// large stores. (Auto-grow on MapFull is a planned follow-up.)
401    pub fn open_default(path: impl AsRef<Path>) -> Result<Self> {
402        // Deployment knob: override the platform default explicitly (bytes).
403        // CI uses this on Windows, where NTFS materializes the map file at
404        // full size and hundreds of parallel test stores would exhaust the
405        // runner disk even at the 256MB Windows default.
406        let platform_default = if cfg!(windows) {
407            256 * 1024 * 1024
408        } else {
409            4 * 1024 * 1024 * 1024
410        };
411        let size = std::env::var("WM_DEFAULT_MAP_SIZE")
412            .ok()
413            .and_then(|v| v.parse::<usize>().ok())
414            .filter(|&v| v > 0)
415            .unwrap_or(platform_default);
416        Self::open(path, size)
417    }
418
419    /// Open an existing LMDB store without creating a directory, database, or
420    /// writable LMDB environment. This is the preservation boundary used by
421    /// read-only evaluator servers: an incomplete or incompatible store must
422    /// fail closed for the caller to investigate, never be initialized or
423    /// repaired in place.
424    pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
425        let path = path.as_ref().to_path_buf();
426        if !path.is_dir() {
427            return Err(CoreError::Memory(format!(
428                "Read-only LMDB store directory does not exist: {}",
429                path.display()
430            )));
431        }
432        if !path.join("data.mdb").is_file() {
433            return Err(CoreError::Memory(format!(
434                "Read-only LMDB store is missing data.mdb: {}",
435                path.display()
436            )));
437        }
438
439        let env = Environment::new()
440            .set_max_dbs(32)
441            .set_flags(EnvironmentFlags::READ_ONLY)
442            .open(&path)
443            .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
444
445        let index_dbs = IndexDbs::open(&env)?;
446        let open_named = |name: &str| {
447            env.open_db(Some(name)).map_err(|e| {
448                CoreError::Memory(format!("Read-only LMDB missing database {name}: {e}"))
449            })
450        };
451        let episodic_db = open_named("episodic_records")?;
452        let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
453        let embedding_cache_db = open_named("embedding_cache")?;
454        let revisions_db = open_named("revisions")?;
455        let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
456        let cold_storage_db = open_named("cold_storage")?;
457
458        Ok(Self {
459            path,
460            env,
461            index_dbs,
462            semantic_encoder: SemanticEncoder::new(),
463            max_entries_per_galaxy: None,
464            mutation_count: AtomicU64::new(0),
465            episodic_db,
466            episodic_terms_v2_db,
467            embedding_cache_db,
468            revisions_db,
469            attestations_db,
470            cold_storage_db,
471            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
472            episodic_embedder: std::sync::OnceLock::new(),
473            episodic_sidecar_ensured: std::sync::OnceLock::new(),
474            episodic_aliases: std::sync::OnceLock::new(),
475            episodic_enrichment: std::sync::OnceLock::new(),
476        })
477    }
478
479    /// Named databases `open()` creates and `open_readonly()` requires,
480    /// beyond the galaxy and secondary-index sets.
481    const NAMED_DBIS: [&'static str; 6] = [
482        "episodic_records",
483        "episodic_terms_v2",
484        "embedding_cache",
485        "revisions",
486        crate::attestation::ATTESTATIONS_DB,
487        "cold_storage",
488    ];
489
490    /// Open an existing store for inspection without taking any lock
491    /// (`MDB_NOLOCK | MDB_RDONLY`), 9.1.6.
492    ///
493    /// Read-only env opens still block forever in two real situations:
494    /// a live writer holds the exclusive lock (lmdb-master falls back to a
495    /// blocking shared-lock wait), and a crashed server can leave the lock
496    /// file's in-file mutex wedged so every open hangs. Inspection paths
497    /// (status, doctor, grimoire) never need the lock file — no locks, no
498    /// reader slots, no mutex — just an mmap read of the store. The store
499    /// must exist and be schema-complete (same strict refusal as
500    /// [`Self::open_readonly`]); torn-meta-page reads are theoretically
501    /// possible mid-write and acceptable for display counts.
502    pub fn open_inspection(path: impl AsRef<Path>) -> Result<Self> {
503        let path = path.as_ref().to_path_buf();
504        if !path.is_dir() {
505            return Err(CoreError::Memory(format!(
506                "Read-only LMDB store directory does not exist: {}",
507                path.display()
508            )));
509        }
510        if !path.join("data.mdb").is_file() {
511            return Err(CoreError::Memory(format!(
512                "Read-only LMDB store is missing data.mdb: {}",
513                path.display()
514            )));
515        }
516
517        let env = Environment::new()
518            .set_max_dbs(32)
519            .set_flags(EnvironmentFlags::READ_ONLY | EnvironmentFlags::NO_LOCK)
520            .open(&path)
521            .map_err(|e| CoreError::Memory(format!("Inspection LMDB open failed: {e}")))?;
522
523        let index_dbs = IndexDbs::open(&env)?;
524        let open_named = |name: &str| {
525            env.open_db(Some(name)).map_err(|e| {
526                CoreError::Memory(format!("Inspection LMDB missing database {name}: {e}"))
527            })
528        };
529        let episodic_db = open_named("episodic_records")?;
530        let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
531        let embedding_cache_db = open_named("embedding_cache")?;
532        let revisions_db = open_named("revisions")?;
533        let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
534        let cold_storage_db = open_named("cold_storage")?;
535
536        Ok(Self {
537            path,
538            env,
539            index_dbs,
540            semantic_encoder: SemanticEncoder::new(),
541            max_entries_per_galaxy: None,
542            mutation_count: AtomicU64::new(0),
543            episodic_db,
544            episodic_terms_v2_db,
545            embedding_cache_db,
546            revisions_db,
547            attestations_db,
548            cold_storage_db,
549            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
550            episodic_embedder: std::sync::OnceLock::new(),
551            episodic_sidecar_ensured: std::sync::OnceLock::new(),
552            episodic_aliases: std::sync::OnceLock::new(),
553            episodic_enrichment: std::sync::OnceLock::new(),
554        })
555    }
556
557    /// Complete a store's schema in place: create any galaxy, index, or named
558    /// database this build expects but an older store lacks, then let the
559    /// caller reopen normally. Returns the database names that were missing.
560    ///
561    /// Restores from older builds can be byte-exact yet not openable (found
562    /// 2026-09-14: a 9.0.0 backup lacked `cold_storage`). This is the only
563    /// in-place repair path; `open_readonly` deliberately stays strict so
564    /// preservation callers see an incomplete store instead of a silent fix.
565    pub fn ensure_schema(path: impl AsRef<Path>) -> Result<Vec<String>> {
566        let path = path.as_ref().to_path_buf();
567        if !path.is_dir() {
568            return Err(CoreError::Memory(format!(
569                "Store directory does not exist: {}",
570                path.display()
571            )));
572        }
573        let expected = || {
574            Galaxy::all()
575                .into_iter()
576                .map(|galaxy| galaxy.db_name().to_string())
577                .chain(
578                    crate::indexes::INDEX_DBS
579                        .iter()
580                        .map(|(name, _)| (*name).to_string()),
581                )
582                .chain(Self::NAMED_DBIS.iter().map(|name| (*name).to_string()))
583        };
584        let missing: Vec<String> = {
585            let env = Environment::new()
586                .set_max_dbs(64)
587                .set_flags(EnvironmentFlags::READ_ONLY)
588                .open(&path)
589                .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
590            expected()
591                .filter(|name| env.open_db(Some(name.as_str())).is_err())
592                .collect()
593        };
594        if missing.is_empty() {
595            return Ok(missing);
596        }
597        // A writable open creates every missing galaxy, index, and named
598        // database. Drop it immediately; the caller reopens as usual.
599        let store = Self::open_default(&path)?;
600        drop(store);
601        Ok(missing)
602    }
603
604    /// Set a per-galaxy entry limit for DoS prevention.
605    ///
606    /// When set, `put` will reject writes that would exceed the limit.
607    /// This prevents a single galaxy from exhausting the LMDB map.
608    #[must_use]
609    pub const fn with_entry_limit(mut self, limit: usize) -> Self {
610        self.max_entries_per_galaxy = Some(limit);
611        self
612    }
613
614    /// Path to the LMDB file.
615    pub fn path(&self) -> &Path {
616        &self.path
617    }
618
619    /// Get the LMDB environment handle.
620    pub const fn env(&self) -> &Environment {
621        &self.env
622    }
623
624    /// Monotonic counter of successful mutations since this handle was
625    /// opened (puts, deletes, clears, raw writes). Used by the dispatch
626    /// pipeline's write-audit journal to detect actual store writes.
627    pub fn mutation_count(&self) -> u64 {
628        self.mutation_count.load(Ordering::Relaxed)
629    }
630
631    /// Get the cached index database handles.
632    pub const fn index_dbs(&self) -> &IndexDbs {
633        &self.index_dbs
634    }
635
636    /// Get the semantic encoder.
637    pub const fn semantic_encoder(&self) -> &SemanticEncoder {
638        &self.semantic_encoder
639    }
640
641    /// Rebuild the episodic DUP_SORT sidecar once per process when it is
642    /// empty while authoritative records exist (legacy v1 stores and
643    /// lost-sidecar recovery). Raw records are never modified; a failed
644    /// rebuild leaves search on its raw-scan fallback.
645    fn ensure_episodic_sidecar(&self) {
646        if self.episodic_sidecar_ensured.get().is_some() {
647            return;
648        }
649        let _ = self.episodic_sidecar_ensured.set(());
650        let view = EpisodicStore::new(
651            &self.env,
652            self.episodic_db,
653            self.episodic_terms_v2_db,
654            self.episodic_term_cache.clone(),
655            &self.mutation_count,
656        );
657        let needs_rebuild = matches!(
658            (view.sidecar_is_empty(), view.record_count()),
659            (Ok(true), Ok(n)) if n > 0
660        );
661        if needs_rebuild {
662            match view.rebuild_sidecar() {
663                Ok(n) => tracing::info!("episodic sidecar rebuilt from {n} records"),
664                Err(e) => {
665                    tracing::warn!("episodic sidecar rebuild failed: {e}");
666                }
667            }
668        }
669    }
670
671    /// Open the v6 lossless episodic record view.
672    #[must_use]
673    pub fn episodic(&self) -> EpisodicStore<'_> {
674        self.ensure_episodic_sidecar();
675        let mut store = EpisodicStore::new(
676            &self.env,
677            self.episodic_db,
678            self.episodic_terms_v2_db,
679            self.episodic_term_cache.clone(),
680            &self.mutation_count,
681        );
682        if let Some(Some(embedder)) = self.episodic_embedder.get() {
683            store = store.with_embedder(embedder.clone());
684        }
685        if let Some(Some(aliases)) = self.episodic_aliases.get() {
686            store = store.with_adaptive_aliases(aliases.clone());
687        }
688        if let Some(Some(enrichment)) = self.episodic_enrichment.get() {
689            store = store.with_enrichment(enrichment.clone());
690        }
691        store
692    }
693
694    /// Attach an embedder for episodic vector reranking.
695    pub fn set_episodic_embedder(
696        &self,
697        embedder: Arc<dyn crate::embedder::Embedder + Send + Sync>,
698    ) {
699        let _ = self.episodic_embedder.set(Some(embedder));
700    }
701
702    /// Attach adaptive aliases for episodic key expansion.
703    pub fn set_episodic_aliases(&self, aliases: crate::episodic_keys::AdaptiveAliases) {
704        let _ = self.episodic_aliases.set(Some(aliases));
705    }
706
707    /// Attach vocabulary enrichment for episodic index-time term expansion.
708    pub fn set_episodic_enrichment(&self, enrichment: crate::enrichment::VocabularyEnrichment) {
709        let _ = self.episodic_enrichment.set(Some(enrichment));
710    }
711
712    /// Get a named database handle for a galaxy.
713    pub fn galaxy_db(&self, galaxy: Galaxy) -> Result<Database> {
714        self.env.open_db(Some(galaxy.db_name())).map_err(|e| {
715            CoreError::Memory(format!("LMDB open_db failed for {}: {e}", galaxy.db_name()))
716        })
717    }
718
719    // ── Memory CRUD ───────────────────────────────────────────────────
720
721    /// Store a memory in the given galaxy. Keyed by memory.metadata.id.
722    /// Also updates all secondary indexes.
723    ///
724    /// Returns a clear error if the per-galaxy entry limit is exceeded
725    /// or if the LMDB map is full.
726    pub fn put(&self, galaxy: Galaxy, memory: &Memory) -> Result<()> {
727        // Check per-galaxy entry limit (DoS prevention)
728        if let Some(limit) = self.max_entries_per_galaxy {
729            let current = self.count(galaxy)?;
730            if current >= limit {
731                return Err(CoreError::Memory(format!(
732                    "galaxy {} entry limit reached ({current}/{limit}), write rejected",
733                    galaxy.db_name()
734                )));
735            }
736        }
737
738        let db = self.galaxy_db(galaxy)?;
739        let key = memory.metadata.id.as_bytes();
740        let val = rmp_serde::to_vec_named(memory)
741            .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
742
743        let mut tx = self
744            .env
745            .begin_rw_txn()
746            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
747
748        // Overwrite semantics: capture the previous record (if any) so its
749        // index entries can be removed before the new ones are added.
750        // Otherwise stale tags, importance values, timestamps, and content
751        // hashes stay queryable after updates.
752        let existing = tx
753            .get(db, key)
754            .ok()
755            .and_then(|bytes| crate::codec::decode(bytes).ok());
756
757        match tx.put(db, key, &val, lmdb::WriteFlags::default()) {
758            Ok(()) => {}
759            Err(lmdb::Error::MapFull) => {
760                tx.abort();
761                return Err(CoreError::Memory(format!(
762                    "LMDB map full: galaxy {}, consider growing map size or pruning old memories",
763                    galaxy.db_name()
764                )));
765            }
766            Err(e) => {
767                tx.abort();
768                return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
769            }
770        }
771        if let Some(existing) = existing {
772            self.index_dbs.remove(&mut tx, galaxy, &existing)?;
773        }
774        self.index_dbs.add(&mut tx, galaxy, memory)?;
775        tx.commit()
776            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
777        self.mutation_count.fetch_add(1, Ordering::Relaxed);
778        Ok(())
779    }
780
781    /// Retrieve a memory by ID from the given galaxy.
782    pub fn get(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<Option<Memory>> {
783        let db = self.galaxy_db(galaxy)?;
784        let key = id.as_bytes();
785
786        let tx = self
787            .env
788            .begin_ro_txn()
789            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
790        let result = tx.get(db, key);
791        match result {
792            Ok(bytes) => {
793                let memory: Memory = crate::codec::decode(bytes)
794                    .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")))?;
795                tx.commit()
796                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
797                Ok(Some(memory))
798            }
799            Err(lmdb::Error::NotFound) => {
800                // ReadOnly transactions don't strictly need commit, but it's good practice
801                tx.commit()
802                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
803                Ok(None)
804            }
805            Err(e) => Err(CoreError::Memory(format!("LMDB get failed: {e}"))),
806        }
807    }
808
809    /// Retrieve a memory by ID searching across all memory galaxies (S9 cross-galaxy traversal).
810    ///
811    /// Cross-galaxy associations and edges reference galaxy-blind UUIDs.
812    /// This searches through all memory galaxies in canonical order and returns
813    /// the first matching (Galaxy, Memory) pair, or None if not found.
814    pub fn find_across_galaxies(&self, id: uuid::Uuid) -> Result<Option<(Galaxy, Memory)>> {
815        for galaxy in Galaxy::memory_galaxies() {
816            if let Some(mem) = self.get(galaxy, id)? {
817                return Ok(Some((galaxy, mem)));
818            }
819        }
820        Ok(None)
821    }
822
823    /// Delete a memory by ID from the given galaxy. Returns true if a key was removed.
824    /// Also removes all secondary index entries for the memory.
825    pub fn delete(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<bool> {
826        let db = self.galaxy_db(galaxy)?;
827        let key = id.as_bytes();
828
829        let mut tx = self
830            .env
831            .begin_rw_txn()
832            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
833
834        // Check if key exists and deserialize for index cleanup
835        let exists = tx.get(db, key).is_ok();
836        if exists {
837            // Read memory to get index values for cleanup
838            if let Ok(bytes) = tx.get(db, key) {
839                if let Ok(memory) = crate::codec::decode(bytes) {
840                    let _ = self.index_dbs.remove(&mut tx, galaxy, &memory);
841                }
842            }
843            tx.del(db, key, None)
844                .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
845        }
846        tx.commit()
847            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
848        if exists {
849            self.mutation_count.fetch_add(1, Ordering::Relaxed);
850        }
851        Ok(exists)
852    }
853
854    /// Scan up to `limit` memories from the given galaxy (unordered by LMDB page layout).
855    pub fn scan(&self, galaxy: Galaxy, limit: usize) -> Result<Vec<Memory>> {
856        let db = self.galaxy_db(galaxy)?;
857        let tx = self
858            .env
859            .begin_ro_txn()
860            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
861
862        let mut cursor = tx
863            .open_ro_cursor(db)
864            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
865
866        let mut memories = Vec::with_capacity(limit.min(256));
867        for (i, (_key, val)) in cursor.iter().enumerate() {
868            if memories.len() >= limit {
869                break;
870            }
871            match crate::codec::decode(val) {
872                Ok(memory) => memories.push(memory),
873                Err(e) => {
874                    tracing::warn!(
875                        "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
876                        galaxy
877                    );
878                }
879            }
880        }
881
882        drop(cursor);
883        tx.commit()
884            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
885        Ok(memories)
886    }
887
888    /// Scan every memory in the galaxy (unordered by LMDB page layout).
889    ///
890    /// Used by maintenance tooling (e.g. index rebuild). The full galaxy is
891    /// materialized in memory — prefer [`Self::scan`] for bounded reads.
892    pub fn scan_all(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
893        self.scan_all_impl(galaxy, false)
894    }
895
896    /// Maintenance scan that refuses to omit an undecodable source record.
897    /// Use before replacing derived indexes; a tolerant scan is not a complete
898    /// authoritative snapshot when any record fails decoding.
899    pub fn scan_all_strict(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
900        self.scan_all_impl(galaxy, true)
901    }
902
903    fn scan_all_impl(&self, galaxy: Galaxy, strict: bool) -> Result<Vec<Memory>> {
904        let db = self.galaxy_db(galaxy)?;
905        let tx = self
906            .env
907            .begin_ro_txn()
908            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
909
910        let mut cursor = tx
911            .open_ro_cursor(db)
912            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
913
914        let mut memories = Vec::new();
915        for (i, (_key, val)) in cursor.iter().enumerate() {
916            match crate::codec::decode(val) {
917                Ok(memory) => memories.push(memory),
918                Err(e) => {
919                    if strict {
920                        return Err(CoreError::Memory(format!(
921                            "refusing incomplete scan of {}: record {i} cannot be decoded: {e}",
922                            galaxy.db_name()
923                        )));
924                    }
925                    tracing::warn!(
926                        "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
927                        galaxy
928                    );
929                }
930            }
931        }
932
933        drop(cursor);
934        tx.commit()
935            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
936        Ok(memories)
937    }
938
939    /// Count entries in a galaxy.
940    pub fn count(&self, galaxy: Galaxy) -> Result<usize> {
941        let db = self.galaxy_db(galaxy)?;
942        let tx = self
943            .env
944            .begin_ro_txn()
945            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
946        let mut cursor = tx
947            .open_ro_cursor(db)
948            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
949        let count = cursor.iter().count();
950        drop(cursor);
951        tx.commit()
952            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
953        Ok(count)
954    }
955
956    /// Clear all memories from a galaxy in a single transaction.
957    /// Returns the number of entries cleared.
958    /// Also removes all secondary index entries.
959    pub fn clear_galaxy(&self, galaxy: Galaxy) -> Result<usize> {
960        let db = self.galaxy_db(galaxy)?;
961
962        let mut tx = self
963            .env
964            .begin_rw_txn()
965            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
966
967        let mut cursor = tx
968            .open_ro_cursor(db)
969            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
970
971        let mut count = 0usize;
972        let keys_to_delete: Vec<(Vec<u8>, Memory)> = cursor
973            .iter()
974            .filter_map(|(key, val)| {
975                if let Ok(memory) = crate::codec::decode(val) {
976                    Some((key.to_vec(), memory))
977                } else {
978                    None
979                }
980            })
981            .collect();
982
983        drop(cursor);
984
985        for (key, memory) in &keys_to_delete {
986            let _ = self.index_dbs.remove(&mut tx, galaxy, memory);
987            tx.del(db, &key, None)
988                .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
989            count += 1;
990        }
991
992        tx.commit()
993            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
994        self.mutation_count
995            .fetch_add(count as u64, Ordering::Relaxed);
996        Ok(count)
997    }
998
999    /// Put multiple memories into a galaxy in a single transaction.
1000    /// Returns the number of memories written.
1001    pub fn batch_put(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<usize> {
1002        if memories.is_empty() {
1003            return Ok(0);
1004        }
1005
1006        let db = self.galaxy_db(galaxy)?;
1007
1008        let mut tx = self
1009            .env
1010            .begin_rw_txn()
1011            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1012
1013        let mut count = 0usize;
1014        for memory in memories {
1015            let key = memory.metadata.id.as_bytes();
1016            let val = rmp_serde::to_vec_named(memory)
1017                .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
1018            match tx.put(db, key, &val, WriteFlags::default()) {
1019                Ok(()) => {}
1020                Err(lmdb::Error::MapFull) => {
1021                    tx.abort();
1022                    return Err(CoreError::Memory(format!(
1023                        "LMDB map full: galaxy {}, consider growing map size",
1024                        galaxy.db_name()
1025                    )));
1026                }
1027                Err(e) => {
1028                    tx.abort();
1029                    return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
1030                }
1031            }
1032            self.index_dbs.add(&mut tx, galaxy, memory)?;
1033            count += 1;
1034        }
1035
1036        tx.commit()
1037            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1038        self.mutation_count
1039            .fetch_add(count as u64, Ordering::Relaxed);
1040        Ok(count)
1041    }
1042
1043    /// Get raw key-value bytes (for advanced use cases).
1044    pub fn get_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<Option<Vec<u8>>> {
1045        let db = self.galaxy_db(galaxy)?;
1046        let tx = self
1047            .env
1048            .begin_ro_txn()
1049            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1050        match tx.get(db, &key) {
1051            Ok(bytes) => {
1052                let data = bytes.to_vec();
1053                tx.commit()
1054                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1055                Ok(Some(data))
1056            }
1057            Err(lmdb::Error::NotFound) => {
1058                tx.commit()
1059                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1060                Ok(None)
1061            }
1062            Err(e) => Err(CoreError::Memory(format!("LMDB get_raw failed: {e}"))),
1063        }
1064    }
1065
1066    /// Put raw key-value bytes (for advanced use cases).
1067    pub fn put_raw(&self, galaxy: Galaxy, key: &[u8], val: &[u8]) -> Result<()> {
1068        let db = self.galaxy_db(galaxy)?;
1069        let mut tx = self
1070            .env
1071            .begin_rw_txn()
1072            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1073        tx.put(db, &key, &val, lmdb::WriteFlags::default())
1074            .map_err(|e| CoreError::Memory(format!("LMDB put_raw failed: {e}")))?;
1075        tx.commit()
1076            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1077        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1078        Ok(())
1079    }
1080
1081    /// Delete raw key-value bytes (for advanced use cases).
1082    /// Returns true if a key was removed.
1083    pub fn delete_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<bool> {
1084        let db = self.galaxy_db(galaxy)?;
1085        let mut tx = self
1086            .env
1087            .begin_rw_txn()
1088            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1089        let deleted = tx.del(db, &key, None).is_ok();
1090        tx.commit()
1091            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1092        if deleted {
1093            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1094        }
1095        Ok(deleted)
1096    }
1097
1098    /// Batch multiple raw key-value writes in a single LMDB transaction.
1099    /// All writes succeed or fail atomically.
1100    pub fn put_raw_batch(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1101        self.put_raw_batch_impl(galaxy, entries)?;
1102        self.mutation_count
1103            .fetch_add(entries.len() as u64, Ordering::Relaxed);
1104        Ok(())
1105    }
1106
1107    /// Batch multiple raw key-value writes without advancing the mutation
1108    /// counter.
1109    ///
1110    /// For governance bookkeeping (karma chain, write-audit journal): these
1111    /// writes are metadata *about* dispatches, not memory mutations. Letting
1112    /// them tick the counter attributes a whole batch flush to whichever
1113    /// dispatch happens to be in flight when the threshold trips — the
1114    /// 2026-08-28 restore-drill false-positive class ("read-only" tools
1115    /// flagged with the previous batch's size as their write delta).
1116    pub fn put_raw_batch_untracked(
1117        &self,
1118        galaxy: Galaxy,
1119        entries: &[(&[u8], &[u8])],
1120    ) -> Result<()> {
1121        self.put_raw_batch_impl(galaxy, entries)
1122    }
1123
1124    fn put_raw_batch_impl(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1125        let db = self.galaxy_db(galaxy)?;
1126        let mut tx = self
1127            .env
1128            .begin_rw_txn()
1129            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1130        for (key, val) in entries {
1131            tx.put(db, key, val, WriteFlags::default())
1132                .map_err(|e| CoreError::Memory(format!("LMDB put_raw_batch failed: {e}")))?;
1133        }
1134        tx.commit()
1135            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1136        Ok(())
1137    }
1138
1139    // ── Content-hash Deduplication ────────────────────────────────────
1140
1141    /// Check if a memory with the same content hash already exists in the galaxy.
1142    /// Uses the content_hash index for O(1) lookup.
1143    /// Returns the existing memory's ID if found.
1144    pub fn find_by_content_hash(&self, galaxy: Galaxy, hash: &str) -> Result<Option<uuid::Uuid>> {
1145        let tx = self
1146            .env
1147            .begin_ro_txn()
1148            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1149        let result = self.index_dbs.find_by_content_hash(&tx, galaxy, hash)?;
1150        tx.commit()
1151            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1152        Ok(result)
1153    }
1154
1155    /// Scan-based content hash lookup (O(n) fallback, used for testing index correctness).
1156    pub fn find_by_content_hash_scan(
1157        &self,
1158        galaxy: Galaxy,
1159        hash: &str,
1160    ) -> Result<Option<uuid::Uuid>> {
1161        let memories = self.scan(galaxy, 10_000)?;
1162        for mem in memories {
1163            if mem.metadata.content_hash == hash {
1164                return Ok(Some(mem.metadata.id));
1165            }
1166        }
1167        Ok(None)
1168    }
1169
1170    /// Store a memory with content-hash deduplication.
1171    /// If a memory with the same content already exists in the galaxy,
1172    /// returns the existing memory's ID without creating a duplicate.
1173    pub fn put_dedup(&self, galaxy: Galaxy, memory: &Memory) -> Result<uuid::Uuid> {
1174        if let Some(existing_id) =
1175            self.find_by_content_hash(galaxy, &memory.metadata.content_hash)?
1176        {
1177            return Ok(existing_id);
1178        }
1179        let id = memory.metadata.id;
1180        self.put(galaxy, memory)?;
1181        Ok(id)
1182    }
1183
1184    // ── Write Batching ─────────────────────────────────────────────────
1185
1186    /// Store multiple memories in a single LMDB transaction (batch write).
1187    /// All writes and index updates succeed or fail atomically.
1188    pub fn put_batch(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<()> {
1189        let db = self.galaxy_db(galaxy)?;
1190        let mut tx = self
1191            .env
1192            .begin_rw_txn()
1193            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1194
1195        for memory in memories {
1196            let key = memory.metadata.id.as_bytes();
1197            let val = rmp_serde::to_vec_named(memory)
1198                .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
1199            tx.put(db, key, &val, WriteFlags::default())
1200                .map_err(|e| CoreError::Memory(format!("LMDB put_batch failed: {e}")))?;
1201            self.index_dbs.add(&mut tx, galaxy, memory)?;
1202        }
1203
1204        tx.commit()
1205            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1206        self.mutation_count
1207            .fetch_add(memories.len() as u64, Ordering::Relaxed);
1208        Ok(())
1209    }
1210
1211    // ── Query API ──────────────────────────────────────────────────────
1212
1213    /// Query memories in a galaxy with filtering.
1214    /// Uses secondary indexes when the query is a pure single-dimension filter
1215    /// (single tag, importance range, or time range with no other filters).
1216    /// Falls back to scan for complex multi-dimensional queries.
1217    pub fn query(&self, galaxy: Galaxy, query: &MemoryQuery) -> Result<Vec<Memory>> {
1218        // Try indexed fast paths for single-dimension queries. A substring
1219        // filter forces the full scan — the indexes cannot evaluate it.
1220        if query.content_substring.is_none()
1221            && query.tags.len() == 1
1222            && query.min_importance.is_none()
1223            && query.max_importance.is_none()
1224            && query.created_after.is_none()
1225            && query.created_before.is_none()
1226        {
1227            return self.query_by_tag_indexed(galaxy, &query.tags[0], query.limit);
1228        }
1229
1230        if query.content_substring.is_none()
1231            && query.tags.is_empty()
1232            && let Some(min) = query.min_importance
1233            && let Some(max) = query.max_importance
1234            && query.created_after.is_none()
1235            && query.created_before.is_none()
1236        {
1237            return self.query_by_importance_indexed(galaxy, min, max, query.limit);
1238        }
1239
1240        if query.content_substring.is_none()
1241            && query.tags.is_empty()
1242            && query.min_importance.is_none()
1243            && query.max_importance.is_none()
1244            && let Some(after) = query.created_after
1245            && let Some(before) = query.created_before
1246        {
1247            return self.query_by_time_indexed(galaxy, after, before, query.limit);
1248        }
1249
1250        // Fallback: full scan with in-memory filter
1251        let memories = self.scan(galaxy, 10_000)?;
1252        let mut results = Vec::new();
1253        for mem in memories {
1254            if query.matches(&mem) {
1255                results.push(mem);
1256                if results.len() >= query.limit {
1257                    break;
1258                }
1259            }
1260        }
1261        Ok(results)
1262    }
1263
1264    /// Tag-based indexed query → memories with the given tag.
1265    fn query_by_tag_indexed(&self, galaxy: Galaxy, tag: &str, limit: usize) -> Result<Vec<Memory>> {
1266        let db = self.galaxy_db(galaxy)?;
1267        let tx = self
1268            .env
1269            .begin_ro_txn()
1270            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1271        let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1272        let mut results = Vec::new();
1273        for id in &ids {
1274            if results.len() >= limit {
1275                break;
1276            }
1277            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1278                if let Ok(mem) = crate::codec::decode(bytes) {
1279                    results.push(mem);
1280                }
1281            }
1282        }
1283        tx.commit()
1284            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1285        Ok(results)
1286    }
1287
1288    /// Importance-range indexed query → memories with importance in [min, max].
1289    fn query_by_importance_indexed(
1290        &self,
1291        galaxy: Galaxy,
1292        min: f32,
1293        max: f32,
1294        limit: usize,
1295    ) -> Result<Vec<Memory>> {
1296        let db = self.galaxy_db(galaxy)?;
1297        let tx = self
1298            .env
1299            .begin_ro_txn()
1300            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1301        let ids = self
1302            .index_dbs
1303            .find_by_importance_range(&tx, galaxy, min, max)?;
1304        let mut results = Vec::new();
1305        for id in &ids {
1306            if results.len() >= limit {
1307                break;
1308            }
1309            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1310                if let Ok(mem) = crate::codec::decode(bytes) {
1311                    results.push(mem);
1312                }
1313            }
1314        }
1315        tx.commit()
1316            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1317        Ok(results)
1318    }
1319
1320    /// Time-range indexed query → memories created in [after, before].
1321    fn query_by_time_indexed(
1322        &self,
1323        galaxy: Galaxy,
1324        after: chrono::DateTime<chrono::Utc>,
1325        before: chrono::DateTime<chrono::Utc>,
1326        limit: usize,
1327    ) -> Result<Vec<Memory>> {
1328        let db = self.galaxy_db(galaxy)?;
1329        let tx = self
1330            .env
1331            .begin_ro_txn()
1332            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1333        let ids = self
1334            .index_dbs
1335            .find_by_time_range(&tx, galaxy, after, before)?;
1336        let mut results = Vec::new();
1337        for id in &ids {
1338            if results.len() >= limit {
1339                break;
1340            }
1341            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1342                if let Ok(mem) = crate::codec::decode(bytes) {
1343                    results.push(mem);
1344                }
1345            }
1346        }
1347        tx.commit()
1348            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1349        Ok(results)
1350    }
1351
1352    // ── Semantic Coordinate Encoding ─────────────────────────────────────
1353
1354    /// Store a memory with semantically-derived 5D coordinates.
1355    ///
1356    /// Replaces the SHA-256 hash-based `Coordinate5D::encode()` with
1357    /// anchor-based TF projection. The memory's `coord5d` field is updated
1358    /// with semantically meaningful x/y/z values before storage.
1359    pub fn put_semantic(&self, galaxy: Galaxy, memory: &mut Memory) -> Result<()> {
1360        let temporal_weight = memory.metadata.coord5d.w;
1361        let importance = memory.metadata.importance;
1362        memory.metadata.coord5d =
1363            self.semantic_encoder
1364                .encode_coordinate(&memory.content, temporal_weight, importance);
1365        self.put(galaxy, memory)
1366    }
1367
1368    /// Find memories in a galaxy with content semantically similar to the query text.
1369    ///
1370    /// Encodes the query text into a 5D coordinate and scans the galaxy,
1371    /// returning memories sorted by semantic distance (nearest first).
1372    pub fn find_similar(
1373        &self,
1374        galaxy: Galaxy,
1375        query_text: &str,
1376        limit: usize,
1377    ) -> Result<Vec<(Memory, f32)>> {
1378        let query_coord = self
1379            .semantic_encoder
1380            .encode_coordinate(query_text, 0.5, 0.5);
1381        let memories = self.scan(galaxy, 10_000)?;
1382        let mut results: Vec<(Memory, f32)> = memories
1383            .into_iter()
1384            .map(|m| {
1385                let dist = query_coord.semantic_distance_to(&m.metadata.coord5d);
1386                (m, dist)
1387            })
1388            .collect();
1389        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1390        results.truncate(limit);
1391        Ok(results)
1392    }
1393
1394    // ── Embedding Storage ──────────────────────────────────────────────
1395
1396    /// Store an embedding vector for a memory in the Embeddings galaxy.
1397    /// Keyed by the memory's UUID.
1398    pub fn put_embedding(&self, memory_id: uuid::Uuid, embedding: &[f32]) -> Result<()> {
1399        let db = self.galaxy_db(Galaxy::Embeddings)?;
1400        let key = memory_id.as_bytes();
1401        let val = encode_embedding(embedding);
1402
1403        let mut tx = self
1404            .env
1405            .begin_rw_txn()
1406            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1407        tx.put(db, key, &val, WriteFlags::default())
1408            .map_err(|e| CoreError::Memory(format!("LMDB put_embedding failed: {e}")))?;
1409        tx.commit()
1410            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1411        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1412        Ok(())
1413    }
1414
1415    /// Retrieve an embedding vector for a memory from the Embeddings galaxy.
1416    pub fn get_embedding(&self, memory_id: uuid::Uuid) -> Result<Option<Vec<f32>>> {
1417        let db = self.galaxy_db(Galaxy::Embeddings)?;
1418        let key = memory_id.as_bytes();
1419
1420        let tx = self
1421            .env
1422            .begin_ro_txn()
1423            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1424        match tx.get(db, key) {
1425            Ok(bytes) => {
1426                let embedding = decode_embedding(bytes);
1427                tx.commit()
1428                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1429                Ok(Some(embedding))
1430            }
1431            Err(lmdb::Error::NotFound) => {
1432                tx.commit()
1433                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1434                Ok(None)
1435            }
1436            Err(e) => Err(CoreError::Memory(format!("LMDB get_embedding failed: {e}"))),
1437        }
1438    }
1439
1440    /// Delete an embedding vector from the Embeddings galaxy.
1441    pub fn delete_embedding(&self, memory_id: uuid::Uuid) -> Result<bool> {
1442        let db = self.galaxy_db(Galaxy::Embeddings)?;
1443        let key = memory_id.as_bytes();
1444
1445        let mut tx = self
1446            .env
1447            .begin_rw_txn()
1448            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1449        let exists = tx.get(db, key).is_ok();
1450        if exists {
1451            tx.del(db, key, None)
1452                .map_err(|e| CoreError::Memory(format!("LMDB del_embedding failed: {e}")))?;
1453        }
1454        tx.commit()
1455            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1456        if exists {
1457            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1458        }
1459        Ok(exists)
1460    }
1461
1462    // ── Embedding cache (content-hash → vector) ────────────────────────
1463
1464    /// Store a cached embedding under a caller-computed cache key
1465    /// (embedder namespace + content hash). Vectors for the same content
1466    /// differ across models, so the key must carry the namespace.
1467    pub fn put_embedding_cache(&self, cache_key: &str, embedding: &[f32]) -> Result<()> {
1468        let mut tx = self
1469            .env
1470            .begin_rw_txn()
1471            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1472        tx.put(
1473            self.embedding_cache_db,
1474            &cache_key.as_bytes().to_vec(),
1475            &encode_embedding(embedding),
1476            WriteFlags::default(),
1477        )
1478        .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1479        tx.commit()
1480            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1481        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1482        Ok(())
1483    }
1484
1485    /// Batched cache write: one transaction for the whole ingest chunk.
1486    pub fn put_embedding_cache_batch(&self, entries: &[(String, Vec<f32>)]) -> Result<()> {
1487        if entries.is_empty() {
1488            return Ok(());
1489        }
1490        let mut tx = self
1491            .env
1492            .begin_rw_txn()
1493            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1494        for (key, embedding) in entries {
1495            tx.put(
1496                self.embedding_cache_db,
1497                &key.as_bytes().to_vec(),
1498                &encode_embedding(embedding),
1499                WriteFlags::default(),
1500            )
1501            .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1502        }
1503        tx.commit()
1504            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1505        self.mutation_count
1506            .fetch_add(entries.len() as u64, Ordering::Relaxed);
1507        Ok(())
1508    }
1509
1510    /// Look up a cached embedding. `Ok(None)` = miss; the caller embeds.
1511    pub fn get_embedding_cache(&self, cache_key: &str) -> Result<Option<Vec<f32>>> {
1512        let tx = self
1513            .env
1514            .begin_ro_txn()
1515            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1516        match tx.get(self.embedding_cache_db, &cache_key.as_bytes().to_vec()) {
1517            Ok(bytes) => {
1518                let embedding = decode_embedding(bytes);
1519                tx.commit()
1520                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1521                Ok(Some(embedding))
1522            }
1523            Err(lmdb::Error::NotFound) => {
1524                tx.commit()
1525                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1526                Ok(None)
1527            }
1528            Err(e) => Err(CoreError::Memory(format!(
1529                "LMDB get_embedding_cache failed: {e}"
1530            ))),
1531        }
1532    }
1533
1534    /// Batched lookup: one read transaction for the whole ingest chunk.
1535    /// Result aligns 1:1 with `keys`.
1536    pub fn get_embedding_cache_batch(&self, keys: &[String]) -> Result<Vec<Option<Vec<f32>>>> {
1537        let tx = self
1538            .env
1539            .begin_ro_txn()
1540            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1541        let mut out = Vec::with_capacity(keys.len());
1542        for key in keys {
1543            out.push(
1544                tx.get(self.embedding_cache_db, &key.as_bytes().to_vec())
1545                    .ok()
1546                    .map(decode_embedding),
1547            );
1548        }
1549        tx.commit()
1550            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1551        Ok(out)
1552    }
1553
1554    /// Number of cached vectors (doctor / honesty surfaces).
1555    pub fn embedding_cache_count(&self) -> Result<u64> {
1556        let tx = self
1557            .env
1558            .begin_ro_txn()
1559            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1560        let mut cursor = tx
1561            .open_ro_cursor(self.embedding_cache_db)
1562            .map_err(|e| CoreError::Memory(format!("LMDB cursor embedding_cache failed: {e}")))?;
1563        let mut count = 0u64;
1564        for _ in cursor.iter() {
1565            count += 1;
1566        }
1567        Ok(count)
1568    }
1569
1570    // ── Revision chain (V8 S11c) ────────────────────────────────────────
1571
1572    /// Append one content revision to a memory's chain. Seq is derived
1573    /// from the current chain tail (append-only by convention); the write
1574    /// bumps the store mutation counter so dispatch windows attribute it.
1575    pub fn record_revision(
1576        &self,
1577        galaxy: Galaxy,
1578        id: MemoryId,
1579        old_hash: &str,
1580        new_hash: &str,
1581        actor: crate::revision::RevisionActor,
1582    ) -> Result<crate::revision::MemoryRevision> {
1583        let seq = self.revisions(galaxy, id)?.len() as u32;
1584        let entry = crate::revision::MemoryRevision {
1585            seq,
1586            timestamp: wm_core::time::now_unix_secs(),
1587            old_hash: old_hash.to_string(),
1588            new_hash: new_hash.to_string(),
1589            actor_session: actor.session,
1590            actor_user: actor.user,
1591            actor_compartment: actor.compartment,
1592        };
1593        let key = crate::revision::revision_key(galaxy, id, seq);
1594        let val = serde_json::to_vec(&entry)
1595            .map_err(|e| CoreError::Memory(format!("revision serialize failed: {e}")))?;
1596        let mut tx = self
1597            .env
1598            .begin_rw_txn()
1599            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1600        tx.put(self.revisions_db, &key, &val, WriteFlags::default())
1601            .map_err(|e| CoreError::Memory(format!("LMDB put revision failed: {e}")))?;
1602        tx.commit()
1603            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1604        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1605        Ok(entry)
1606    }
1607
1608    /// Full revision chain for a memory, ordered by seq. Empty for
1609    /// memories never content-updated (or pre-S11c).
1610    pub fn revisions(
1611        &self,
1612        galaxy: Galaxy,
1613        id: MemoryId,
1614    ) -> Result<Vec<crate::revision::MemoryRevision>> {
1615        // Cursor-op constants from lmdb.h (frozen LMDB ABI): the `lmdb`
1616        // crate's `iter_from` panics on a SetRange miss, and a miss is a
1617        // normal state here (a memory with no revisions yet sorts after
1618        // every existing key), so the cursor is driven manually.
1619        const MDB_GET_CURRENT: u32 = 4;
1620        const MDB_NEXT: u32 = 8;
1621        const MDB_SET_RANGE: u32 = 17;
1622        let prefix = crate::revision::revision_prefix(galaxy, id);
1623        let tx = self
1624            .env
1625            .begin_ro_txn()
1626            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1627        let cursor = tx
1628            .open_ro_cursor(self.revisions_db)
1629            .map_err(|e| CoreError::Memory(format!("LMDB cursor revisions failed: {e}")))?;
1630        let mut out = Vec::new();
1631        if cursor.get(Some(&prefix), None, MDB_SET_RANGE).is_ok() {
1632            while let Ok((key, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1633                // `key` is None only for ops that return no key — GET_CURRENT
1634                // after a positioned read always carries one.
1635                if !key.is_some_and(|k| k.starts_with(&prefix)) {
1636                    break;
1637                }
1638                let entry: crate::revision::MemoryRevision = serde_json::from_slice(val)
1639                    .map_err(|e| CoreError::Memory(format!("revision deserialize failed: {e}")))?;
1640                out.push(entry);
1641                // Bounded walk — corrupt data can never spin this loop.
1642                if out.len() >= 10_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1643                    break;
1644                }
1645            }
1646        }
1647        drop(cursor);
1648        tx.commit()
1649            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1650        Ok(out)
1651    }
1652
1653    /// Walk one memory's revision chain and grade it against the memory's
1654    /// current content hash (seq continuity, hash linkage, head match).
1655    pub fn verify_revision_chain(
1656        &self,
1657        galaxy: Galaxy,
1658        id: MemoryId,
1659        current_hash: &str,
1660    ) -> Result<crate::revision::RevisionChainReport> {
1661        let entries = self.revisions(galaxy, id)?;
1662        Ok(crate::revision::verify_chain(&entries, current_hash))
1663    }
1664
1665    // ── Record attestations (Track F Slice A, D5) ───────────────────────
1666
1667    /// Record one creation attestation for a memory. Upsert by key
1668    /// (`att:{galaxy}:{memory_id}`): re-attestation overwrites, which is
1669    /// safe because attestation covers the *creation* event and memory ids
1670    /// are unique per create. The write bumps the mutation counter like
1671    /// every other store mutation.
1672    pub fn record_attestation(
1673        &self,
1674        galaxy: Galaxy,
1675        id: MemoryId,
1676        entry: &crate::attestation::RecordAttestation,
1677    ) -> Result<()> {
1678        let key = crate::attestation::attestation_key(galaxy, id);
1679        let val = serde_json::to_vec(entry)
1680            .map_err(|e| CoreError::Memory(format!("attestation serialize failed: {e}")))?;
1681        let mut tx = self
1682            .env
1683            .begin_rw_txn()
1684            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1685        tx.put(self.attestations_db, &key, &val, WriteFlags::default())
1686            .map_err(|e| CoreError::Memory(format!("LMDB put attestation failed: {e}")))?;
1687        tx.commit()
1688            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1689        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1690        Ok(())
1691    }
1692
1693    /// This memory's creation attestation, if the creating dispatch signed
1694    /// one (key-available creates only — absence is honest, not an error).
1695    pub fn attestation(
1696        &self,
1697        galaxy: Galaxy,
1698        id: MemoryId,
1699    ) -> Result<Option<crate::attestation::RecordAttestation>> {
1700        let key = crate::attestation::attestation_key(galaxy, id);
1701        let tx = self
1702            .env
1703            .begin_ro_txn()
1704            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1705        let out =
1706            match tx.get(self.attestations_db, &key) {
1707                Ok(val) => Some(serde_json::from_slice(val).map_err(|e| {
1708                    CoreError::Memory(format!("attestation deserialize failed: {e}"))
1709                })?),
1710                Err(lmdb::Error::NotFound) => None,
1711                Err(e) => {
1712                    return Err(CoreError::Memory(format!(
1713                        "LMDB get attestation failed: {e}"
1714                    )));
1715                }
1716            };
1717        drop(tx);
1718        Ok(out)
1719    }
1720
1721    /// Every attestation in the store (for `wm anchor`). Bounded walk —
1722    /// corrupt data can never spin this loop.
1723    pub fn scan_attestations(&self) -> Result<Vec<crate::attestation::RecordAttestation>> {
1724        const MDB_GET_CURRENT: u32 = 4;
1725        const MDB_NEXT: u32 = 8;
1726        const MDB_FIRST: u32 = 9;
1727        let tx = self
1728            .env
1729            .begin_ro_txn()
1730            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1731        let cursor = tx
1732            .open_ro_cursor(self.attestations_db)
1733            .map_err(|e| CoreError::Memory(format!("LMDB cursor attestations failed: {e}")))?;
1734        let mut out = Vec::new();
1735        if cursor.get(None, None, MDB_FIRST).is_ok() {
1736            while let Ok((_, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1737                let entry: crate::attestation::RecordAttestation = serde_json::from_slice(val)
1738                    .map_err(|e| {
1739                        CoreError::Memory(format!("attestation deserialize failed: {e}"))
1740                    })?;
1741                out.push(entry);
1742                // Bounded walk — corrupt data can never spin this loop.
1743                if out.len() >= 1_000_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1744                    break;
1745                }
1746            }
1747        }
1748        drop(cursor);
1749        tx.commit()
1750            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1751        Ok(out)
1752    }
1753
1754    /// Grade one memory's attestation: presence, signature validity, and
1755    /// whether the attested hash still matches the live content hash.
1756    /// A content *update* after creation flips `matches_head` to false by
1757    /// design — updates are covered by the revisions chain, not by
1758    /// re-attestation.
1759    pub fn verify_attestation(
1760        &self,
1761        galaxy: Galaxy,
1762        id: MemoryId,
1763    ) -> Result<crate::attestation::AttestationReport> {
1764        use crate::attestation::AttestationReport;
1765        let Some(att) = self.attestation(galaxy, id)? else {
1766            return Ok(AttestationReport {
1767                attested: false,
1768                signature_valid: false,
1769                matches_head: false,
1770                memory_present: self.get(galaxy, id)?.is_some(),
1771                breaks: vec!["no attestation recorded for this memory".to_string()],
1772            });
1773        };
1774        let mut breaks = Vec::new();
1775        let signature_valid = crate::attestation::verify_attestation(&att);
1776        if !signature_valid {
1777            breaks.push("signature does not verify against recorded pubkey".to_string());
1778        }
1779        let (matches_head, memory_present) = if let Some(memory) = self.get(galaxy, id)? {
1780            let matches = memory.metadata.content_hash == att.record_hash;
1781            if !matches {
1782                breaks.push(
1783                    "attested record_hash != live content_hash (memory updated after attestation)"
1784                        .to_string(),
1785                );
1786            }
1787            (matches, true)
1788        } else {
1789            breaks.push("attested memory id not present in galaxy".to_string());
1790            (false, false)
1791        };
1792        Ok(AttestationReport {
1793            attested: true,
1794            signature_valid,
1795            matches_head,
1796            memory_present,
1797            breaks,
1798        })
1799    }
1800
1801    /// Grade every attestation in the store (for `wm anchor`). Entries
1802    /// whose galaxy/id no longer parse are reported as broken — never
1803    /// skipped silently.
1804    pub fn attestation_sweep(
1805        &self,
1806    ) -> Result<
1807        Vec<(
1808            crate::attestation::RecordAttestation,
1809            crate::attestation::AttestationReport,
1810        )>,
1811    > {
1812        use crate::attestation::AttestationReport;
1813        let mut out = Vec::new();
1814        for att in self.scan_attestations()? {
1815            let parsed = match (
1816                Galaxy::from_db_name(&att.galaxy),
1817                uuid::Uuid::parse_str(&att.memory_id),
1818            ) {
1819                (Some(galaxy), Ok(id)) => Some((galaxy, id)),
1820                _ => None,
1821            };
1822            match parsed {
1823                Some((galaxy, id)) => out.push((att, self.verify_attestation(galaxy, id)?)),
1824                None => out.push((
1825                    att,
1826                    AttestationReport {
1827                        attested: true,
1828                        signature_valid: false,
1829                        matches_head: false,
1830                        memory_present: false,
1831                        breaks: vec![
1832                            "attestation row has unparseable galaxy or memory id".to_string(),
1833                        ],
1834                    },
1835                )),
1836            }
1837        }
1838        Ok(out)
1839    }
1840
1841    // ── Non-Destructive Phagic Cold-Storage (the project's sacred rule) ───────
1842
1843    /// Store a compressed cold record in the cold_storage DBI.
1844    pub fn put_cold_record(&self, record: &crate::cold_storage::ColdRecord) -> Result<()> {
1845        let key = record.id.as_bytes();
1846        let val = rmp_serde::to_vec_named(record)
1847            .map_err(|e| CoreError::Memory(format!("Cold record serialization failed: {e}")))?;
1848        let mut tx = self
1849            .env
1850            .begin_rw_txn()
1851            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1852        tx.put(self.cold_storage_db, key, &val, WriteFlags::default())
1853            .map_err(|e| CoreError::Memory(format!("LMDB put cold_storage failed: {e}")))?;
1854        tx.commit()
1855            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1856        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1857        Ok(())
1858    }
1859
1860    /// Retrieve a compressed cold record by memory id.
1861    pub fn get_cold_record(&self, id: MemoryId) -> Result<Option<crate::cold_storage::ColdRecord>> {
1862        let key = id.as_bytes();
1863        let tx = self
1864            .env
1865            .begin_ro_txn()
1866            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1867        match tx.get(self.cold_storage_db, key) {
1868            Ok(bytes) => {
1869                let record: crate::cold_storage::ColdRecord = rmp_serde::from_slice(bytes)
1870                    .map_err(|e| {
1871                        CoreError::Memory(format!("Cold record deserialization failed: {e}"))
1872                    })?;
1873                Ok(Some(record))
1874            }
1875            Err(lmdb::Error::NotFound) => Ok(None),
1876            Err(e) => Err(CoreError::Memory(format!(
1877                "LMDB get cold_storage failed: {e}"
1878            ))),
1879        }
1880    }
1881
1882    /// Delete a cold record from the cold storage DBI (used when thawing back to hot).
1883    pub fn delete_cold_record(&self, id: MemoryId) -> Result<bool> {
1884        let key = id.as_bytes();
1885        let mut tx = self
1886            .env
1887            .begin_rw_txn()
1888            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1889        let deleted = match tx.del(self.cold_storage_db, key, None) {
1890            Ok(()) => true,
1891            Err(lmdb::Error::NotFound) => false,
1892            Err(e) => {
1893                return Err(CoreError::Memory(format!(
1894                    "LMDB del cold_storage failed: {e}"
1895                )));
1896            }
1897        };
1898        tx.commit()
1899            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1900        if deleted {
1901            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1902        }
1903        Ok(deleted)
1904    }
1905
1906    /// Count cold records in the cold storage database, optionally filtered by galaxy.
1907    pub fn count_cold(&self, galaxy: Option<Galaxy>) -> Result<usize> {
1908        let tx = self
1909            .env
1910            .begin_ro_txn()
1911            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1912        let mut cursor = tx
1913            .open_ro_cursor(self.cold_storage_db)
1914            .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
1915        let mut count = 0;
1916        for (_key, val) in cursor.iter() {
1917            if let Some(target_g) = galaxy {
1918                let record: crate::cold_storage::ColdRecord =
1919                    rmp_serde::from_slice(val).map_err(|e| {
1920                        CoreError::Memory(format!("Cold record deserialization failed: {e}"))
1921                    })?;
1922                if record.galaxy == target_g {
1923                    count += 1;
1924                }
1925            } else {
1926                count += 1;
1927            }
1928        }
1929        Ok(count)
1930    }
1931
1932    /// List cold records (summaries) with optional galaxy filter and limit.
1933    pub fn list_cold_records(
1934        &self,
1935        galaxy: Option<Galaxy>,
1936        limit: usize,
1937    ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
1938        let query = crate::cold_storage::ColdQuery {
1939            galaxy,
1940            limit: if limit == 0 { 100 } else { limit },
1941            ..Default::default()
1942        };
1943        self.query_cold_records(&query)
1944    }
1945
1946    /// Query cold records matching a `ColdQuery` filter.
1947    pub fn query_cold_records(
1948        &self,
1949        query: &crate::cold_storage::ColdQuery,
1950    ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
1951        let tx = self
1952            .env
1953            .begin_ro_txn()
1954            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1955        let mut cursor = tx
1956            .open_ro_cursor(self.cold_storage_db)
1957            .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
1958        let mut results = Vec::new();
1959        let limit = if query.limit == 0 {
1960            usize::MAX
1961        } else {
1962            query.limit
1963        };
1964
1965        for (_key, val) in cursor.iter() {
1966            let record: crate::cold_storage::ColdRecord =
1967                rmp_serde::from_slice(val).map_err(|e| {
1968                    CoreError::Memory(format!("Cold record deserialization failed: {e}"))
1969                })?;
1970            let summary = record.summary();
1971            if query.matches(&summary) {
1972                results.push(summary);
1973                if results.len() >= limit {
1974                    break;
1975                }
1976            }
1977        }
1978        Ok(results)
1979    }
1980
1981    /// Bounded, identity-bound cold discovery.
1982    ///
1983    /// Scans at most `max_scan` cold records (LMDB `cold_storage` DBI),
1984    /// filters by galaxy when given, decompresses each candidate, verifies
1985    /// the id/galaxy/content-hash chain, applies visibility (private never
1986    /// surfaces; superseded/non-current records are skipped), and returns
1987    /// up to `limit` full cold records whose content or tags contain every
1988    /// query term (case-insensitive). Nothing is thawed or mutated.
1989    pub fn find_cold_matching(
1990        &self,
1991        terms: &[String],
1992        galaxy: Option<Galaxy>,
1993        limit: usize,
1994        max_scan: usize,
1995    ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
1996        self.find_cold_matching_eligible(terms, galaxy, limit, max_scan, |_| true)
1997    }
1998
1999    /// Apply caller eligibility to verified payloads before consuming result
2000    /// capacity. Rejected matches still consume the bounded scan budget.
2001    pub fn find_cold_matching_eligible(
2002        &self,
2003        terms: &[String],
2004        galaxy: Option<Galaxy>,
2005        limit: usize,
2006        max_scan: usize,
2007        eligible: impl Fn(&Memory) -> bool,
2008    ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2009        use crate::cold_storage::ColdDiscoveryStop;
2010        let mut out = crate::cold_storage::ColdDiscoveryOutcome::default();
2011        if terms.is_empty() || limit == 0 || max_scan == 0 {
2012            return Ok(out);
2013        }
2014        let tx = self
2015            .env
2016            .begin_ro_txn()
2017            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2018        let mut cursor = tx
2019            .open_ro_cursor(self.cold_storage_db)
2020            .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2021        let mut iter = cursor.iter();
2022        loop {
2023            if out.scanned >= max_scan {
2024                out.stop_reason = ColdDiscoveryStop::ScanLimit;
2025                break;
2026            }
2027            if out.records.len() >= limit {
2028                out.stop_reason = ColdDiscoveryStop::ResultLimit;
2029                break;
2030            }
2031            let Some((key, val)) = iter.next() else {
2032                out.stop_reason = ColdDiscoveryStop::Exhausted;
2033                break;
2034            };
2035            out.scanned += 1;
2036            let record: crate::cold_storage::ColdRecord = if let Ok(r) = rmp_serde::from_slice(val)
2037            {
2038                r
2039            } else {
2040                out.integrity_rejected += 1;
2041                continue;
2042            };
2043            if let Some(g) = galaxy {
2044                if record.galaxy != g {
2045                    continue;
2046                }
2047            }
2048            out.candidates += 1;
2049            let mem = if let Ok(m) = record.decompress() {
2050                m
2051            } else {
2052                out.integrity_rejected += 1;
2053                continue;
2054            };
2055            if mem.metadata.is_private {
2056                out.private_skipped += 1;
2057                continue;
2058            }
2059            if !mem.metadata.validity.is_current() {
2060                out.non_current_skipped += 1;
2061                continue;
2062            }
2063            let integrity_ok = key == record.id.as_bytes()
2064                && mem.metadata.id == record.id
2065                && mem.metadata.galaxy == record.galaxy
2066                && mem.metadata.content_hash == record.content_hash
2067                && crate::content_hash(&mem.content) == record.content_hash;
2068            if !integrity_ok {
2069                out.integrity_rejected += 1;
2070                continue;
2071            }
2072            let haystack = format!(
2073                "{} {}",
2074                mem.content.to_lowercase(),
2075                mem.metadata.tags.join(" ").to_lowercase()
2076            );
2077            if !terms.iter().all(|t| haystack.contains(t.as_str())) {
2078                continue;
2079            }
2080            out.matched += 1;
2081            if !eligible(&mem) {
2082                out.eligibility_skipped += 1;
2083                continue;
2084            }
2085            out.records.push(record);
2086        }
2087        Ok(out)
2088    }
2089
2090    /// Freeze an active hot memory into the compressed cold archive.
2091    ///
2092    /// Non-destructive: preserves complete metadata, vector clocks, content, embeddings,
2093    /// and provenance. The memory transitions to `Tier::Archival`, is stored in `cold_storage_db`,
2094    /// is deindexed from Tantivy (if `search` provided), and is removed from the active hot galaxy.
2095    pub fn freeze_to_cold(
2096        &self,
2097        search: Option<&crate::SearchEngine>,
2098        memory_id: MemoryId,
2099        distance: f32,
2100        factors: crate::cold_storage::OuterRimFactors,
2101        digest_id: Option<MemoryId>,
2102        notes: Option<String>,
2103        codec: crate::cold_storage::CompressionCodec,
2104    ) -> Result<crate::cold_storage::ColdRecord> {
2105        let (galaxy, mut mem) = self.find_across_galaxies(memory_id)?.ok_or_else(|| {
2106            CoreError::NotFound(format!("Memory {memory_id} not found in hot store"))
2107        })?;
2108
2109        // Transition tier to Archival
2110        if mem.metadata.tier != crate::memory::Tier::Archival {
2111            let _ = mem.transition_tier(crate::memory::Tier::Archival);
2112        }
2113
2114        let record =
2115            crate::cold_storage::ColdRecord::new(&mem, distance, factors, digest_id, notes, codec)?;
2116
2117        // Store into cold archive
2118        self.put_cold_record(&record)?;
2119
2120        // Remove from active hot galaxy
2121        self.delete(galaxy, memory_id)?;
2122
2123        // Deindex from Tantivy search engine if provided
2124        if let Some(engine) = search {
2125            if let Ok(mut writer_guard) = engine.writer() {
2126                let _ = engine.delete_document(&mut writer_guard, &memory_id.to_string());
2127                let _ = engine.commit(&mut writer_guard);
2128            }
2129        }
2130
2131        Ok(record)
2132    }
2133
2134    /// Thaw a memory from compressed cold storage back into the hot active tier.
2135    ///
2136    /// Zero data loss: restores the original memory with all fields, transitions tier
2137    /// back to `Tier::Episodic`, bumps access/recall count, updates `accessed_at`,
2138    /// stores into the hot galaxy, and reindexes into Tantivy search (if provided).
2139    pub fn thaw_from_cold(
2140        &self,
2141        search: Option<&crate::SearchEngine>,
2142        memory_id: MemoryId,
2143    ) -> Result<Memory> {
2144        let record = self.get_cold_record(memory_id)?.ok_or_else(|| {
2145            CoreError::NotFound(format!("Memory {memory_id} not found in cold storage"))
2146        })?;
2147
2148        let mut mem = record.decompress()?;
2149
2150        // Transition tier back to Episodic (warm serving)
2151        let _ = mem.transition_tier(crate::memory::Tier::Episodic);
2152        mem.metadata.accessed_at = chrono::Utc::now();
2153        mem.metadata.access_count += 1;
2154        mem.metadata.recall_count += 1;
2155        if !mem.metadata.tags.iter().any(|t| t == "thawed:phagic") {
2156            mem.metadata.tags.push("thawed:phagic".to_string());
2157        }
2158
2159        // Put back into active hot galaxy
2160        self.put(record.galaxy, &mem)?;
2161
2162        // Reindex in Tantivy if provided
2163        if let Some(engine) = search {
2164            if let Ok(mut writer_guard) = engine.writer() {
2165                let _ = engine.index_memory(&mut writer_guard, &mem);
2166                let _ = engine.commit(&mut writer_guard);
2167            }
2168        }
2169
2170        // Remove from cold archive
2171        self.delete_cold_record(memory_id)?;
2172
2173        Ok(mem)
2174    }
2175
2176    /// Find a memory anywhere: in the active hot galaxies, or decompressed from cold storage.
2177    ///
2178    /// Returns `(galaxy, memory, is_cold)`.
2179    pub fn find_anywhere(&self, id: MemoryId) -> Result<Option<(Galaxy, Memory, bool)>> {
2180        if let Some((galaxy, mem)) = self.find_across_galaxies(id)? {
2181            return Ok(Some((galaxy, mem, false)));
2182        }
2183        if let Some(cold_record) = self.get_cold_record(id)? {
2184            let galaxy = cold_record.galaxy;
2185            let mem = cold_record.decompress()?;
2186            return Ok(Some((galaxy, mem, true)));
2187        }
2188        Ok(None)
2189    }
2190}
2191
2192#[cfg(test)]
2193mod tests {
2194    use super::*;
2195    use crate::content_hash;
2196
2197    #[test]
2198    fn open_and_create_galaxies() {
2199        let tmp = tempfile::tempdir().unwrap();
2200        let store = MemoryStore::open_default(tmp.path()).unwrap();
2201        for galaxy in Galaxy::all() {
2202            let _db = store.galaxy_db(galaxy).unwrap();
2203        }
2204    }
2205
2206    #[test]
2207    fn ensure_schema_completes_a_pre_cold_store() {
2208        use lmdb::{DatabaseFlags as LmdbFlags, Environment as LmdbEnv};
2209        use uuid::Uuid;
2210
2211        // Synthetic pre-cold store: every DBI a 9.0.0 store had, but no
2212        // `cold_storage` (the exact 2026-09-14 restore-drill finding).
2213        let tmp = tempfile::tempdir().unwrap();
2214        let path = tmp.path().join("old-store");
2215        std::fs::create_dir_all(&path).unwrap();
2216        {
2217            let env = LmdbEnv::new().set_max_dbs(64).open(&path).unwrap();
2218            for galaxy in Galaxy::all() {
2219                env.create_db(Some(galaxy.db_name()), LmdbFlags::default())
2220                    .unwrap();
2221            }
2222            for (name, flags) in crate::indexes::INDEX_DBS {
2223                env.create_db(Some(name), *flags).unwrap();
2224            }
2225            for (name, flags) in [
2226                ("episodic_records", LmdbFlags::default()),
2227                ("episodic_terms_v2", LmdbFlags::DUP_SORT),
2228                ("embedding_cache", LmdbFlags::default()),
2229                ("revisions", LmdbFlags::default()),
2230                (crate::attestation::ATTESTATIONS_DB, LmdbFlags::default()),
2231            ] {
2232                env.create_db(Some(name), flags).unwrap();
2233            }
2234        }
2235
2236        let files_before: Vec<String> = {
2237            let mut names: Vec<String> = std::fs::read_dir(&path)
2238                .unwrap()
2239                .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2240                .collect();
2241            names.sort();
2242            names
2243        };
2244        let data_before = std::fs::read(path.join("data.mdb")).unwrap();
2245
2246        let error = match MemoryStore::open_readonly(&path) {
2247            Ok(_) => panic!("strict read-only open must refuse an incomplete store"),
2248            Err(e) => e.to_string(),
2249        };
2250        assert!(error.contains("cold_storage"), "{error}");
2251        assert_eq!(
2252            std::fs::read(path.join("data.mdb")).unwrap(),
2253            data_before,
2254            "readonly refusal must not mutate the pre-cold store"
2255        );
2256        let files_after: Vec<String> = {
2257            let mut names: Vec<String> = std::fs::read_dir(&path)
2258                .unwrap()
2259                .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2260                .collect();
2261            names.sort();
2262            names
2263        };
2264        assert_eq!(
2265            files_after, files_before,
2266            "readonly refusal changed the store directory"
2267        );
2268
2269        let created = MemoryStore::ensure_schema(&path).unwrap();
2270        assert_eq!(created, vec!["cold_storage".to_string()], "{created:?}");
2271
2272        let store = MemoryStore::open_readonly(&path).unwrap();
2273        assert!(store.get_cold_record(Uuid::nil()).unwrap().is_none());
2274
2275        // Idempotent: a complete store reports nothing missing.
2276        assert!(MemoryStore::ensure_schema(&path).unwrap().is_empty());
2277    }
2278
2279    #[test]
2280    fn cold_discovery_hydrates_verifies_and_respects_visibility() {
2281        use crate::cold_storage::{ColdRecord, CompressionCodec, OuterRimFactors};
2282        let tmp = tempfile::tempdir().unwrap();
2283        let store = MemoryStore::open_default(tmp.path()).unwrap();
2284        let factors = OuterRimFactors {
2285            age_factor: 0.5,
2286            access_factor: 0.5,
2287            resonance_factor: 0.5,
2288            emotional_factor: 0.5,
2289            importance_factor: 0.5,
2290            distance: 0.5,
2291        };
2292
2293        let pub_mem = Memory::new(
2294            Galaxy::Codex,
2295            "public needle zxquniquecoldfact741 buried".into(),
2296        );
2297        let rec = ColdRecord::new(
2298            &pub_mem,
2299            0.5,
2300            factors.clone(),
2301            None,
2302            None,
2303            CompressionCodec::Gzip,
2304        )
2305        .unwrap();
2306        store.put_cold_record(&rec).unwrap();
2307        let out = store
2308            .find_cold_matching(&["zxquniquecoldfact741".to_string()], None, 10, 100)
2309            .unwrap();
2310        assert_eq!(out.matched, 1);
2311        assert_eq!(out.integrity_rejected, 0);
2312        assert_eq!(out.records.len(), 1);
2313
2314        // Private originals never surface over MCP discovery.
2315        let mut priv_mem = Memory::new(Galaxy::Codex, "private needle zxquniquecoldfact742".into());
2316        priv_mem.metadata.is_private = true;
2317        store
2318            .put_cold_record(
2319                &ColdRecord::new(&priv_mem, 0.5, factors, None, None, CompressionCodec::Gzip)
2320                    .unwrap(),
2321            )
2322            .unwrap();
2323        let out_priv = store
2324            .find_cold_matching(&["zxquniquecoldfact742".to_string()], None, 10, 100)
2325            .unwrap();
2326        assert_eq!(out_priv.matched, 0);
2327        assert_eq!(out_priv.private_skipped, 1);
2328
2329        // Tamper: the payload decompresses to content that no longer matches
2330        // the advertised content hash — refuse, never return.
2331        let mut tampered = rec.clone();
2332        let mut bad = Memory::new(Galaxy::Codex, "tampered needle zxquniquecoldfact743".into());
2333        bad.metadata.id = rec.id;
2334        let (payload, size) =
2335            crate::cold_storage::compress_memory(&bad, CompressionCodec::Gzip).unwrap();
2336        tampered.compressed_payload = payload;
2337        tampered.uncompressed_size = size;
2338        store.put_cold_record(&tampered).unwrap();
2339        let out_tamper = store
2340            .find_cold_matching(&["zxquniquecoldfact743".to_string()], None, 10, 100)
2341            .unwrap();
2342        assert_eq!(out_tamper.matched, 0);
2343        assert_eq!(out_tamper.integrity_rejected, 1);
2344
2345        // Even an internally consistent header/payload must not be accepted
2346        // under another physical UUID key (which would break known-ID read).
2347        store.delete_cold_record(rec.id).unwrap();
2348        let wrong_key = uuid::Uuid::from_u128(741);
2349        assert_ne!(wrong_key, rec.id);
2350        let value = rmp_serde::to_vec_named(&rec).unwrap();
2351        let mut tx = store.env.begin_rw_txn().unwrap();
2352        tx.put(
2353            store.cold_storage_db,
2354            wrong_key.as_bytes(),
2355            &value,
2356            WriteFlags::default(),
2357        )
2358        .unwrap();
2359        tx.commit().unwrap();
2360        let wrong_key_out = store
2361            .find_cold_matching(&["zxquniquecoldfact741".into()], None, 10, 100)
2362            .unwrap();
2363        assert!(wrong_key_out.records.is_empty());
2364        assert_eq!(wrong_key_out.integrity_rejected, 1);
2365    }
2366
2367    /// Substring filter (memory.query trap fix, 2026-08-29): literal
2368    /// case-insensitive content match, galaxy-wide — never an arbitrary
2369    /// page, never routed through the indexed fast paths.
2370    #[test]
2371    fn query_substring_filters_galaxy_wide() {
2372        let tmp = tempfile::tempdir().unwrap();
2373        let store = MemoryStore::open_default(tmp.path()).unwrap();
2374
2375        for (i, content) in [
2376            "the mesh joins at dawn",
2377            "unrelated content entirely",
2378            "MESH joins at dusk",
2379        ]
2380        .iter()
2381        .enumerate()
2382        {
2383            let mut m = Memory::new(Galaxy::Codex, content.to_string());
2384            m.metadata.importance = 0.5 + i as f32 / 10.0;
2385            store.put(Galaxy::Codex, &m).unwrap();
2386        }
2387
2388        let hits = store
2389            .query(
2390                Galaxy::Codex,
2391                &MemoryQuery::new().with_content_substring("mesh joins"),
2392            )
2393            .unwrap();
2394        assert_eq!(hits.len(), 2, "CI substring must match both: {hits:?}");
2395        assert!(
2396            hits.iter()
2397                .all(|m| m.content.to_lowercase().contains("mesh joins"))
2398        );
2399
2400        let none = store
2401            .query(
2402                Galaxy::Codex,
2403                &MemoryQuery::new().with_content_substring("quantum calendar"),
2404            )
2405            .unwrap();
2406        assert!(none.is_empty(), "no match must be an honest empty set");
2407
2408        // Substring + tag combined still applies (no fast-path bypass).
2409        let mut tagged = Memory::new(Galaxy::Codex, "mesh joins again".to_string());
2410        tagged.metadata.tags = vec!["mesh".into()];
2411        store.put(Galaxy::Codex, &tagged).unwrap();
2412        let combined = store
2413            .query(
2414                Galaxy::Codex,
2415                &MemoryQuery::new()
2416                    .with_tags(vec!["mesh".into()])
2417                    .with_content_substring("again"),
2418            )
2419            .unwrap();
2420        assert_eq!(combined.len(), 1);
2421        assert_eq!(combined[0].content, "mesh joins again");
2422    }
2423
2424    #[cfg(unix)]
2425    #[test]
2426    fn store_dir_has_restrictive_permissions() {
2427        let tmp = tempfile::tempdir().unwrap();
2428        let store_path = tmp.path().join("lmdb");
2429        let _store = MemoryStore::open_default(&store_path).unwrap();
2430        let perms = std::fs::metadata(&store_path).unwrap().permissions().mode();
2431        assert_eq!(
2432            perms & 0o777,
2433            0o700,
2434            "store directory should have 0700 permissions, got {:o}",
2435            perms & 0o777
2436        );
2437    }
2438
2439    #[test]
2440    fn put_get_delete_memory() {
2441        let tmp = tempfile::tempdir().unwrap();
2442        let store = MemoryStore::open_default(tmp.path()).unwrap();
2443
2444        let mem = Memory::new(Galaxy::Codex, "Hello world".to_string());
2445        let id = mem.metadata.id;
2446
2447        store.put(Galaxy::Codex, &mem).unwrap();
2448        let retrieved = store.get(Galaxy::Codex, id).unwrap();
2449        assert!(retrieved.is_some());
2450        assert_eq!(retrieved.unwrap().content, "Hello world");
2451
2452        let deleted = store.delete(Galaxy::Codex, id).unwrap();
2453        assert!(deleted);
2454
2455        let gone = store.get(Galaxy::Codex, id).unwrap();
2456        assert!(gone.is_none());
2457    }
2458
2459    #[test]
2460    fn scan_memories() {
2461        let tmp = tempfile::tempdir().unwrap();
2462        let store = MemoryStore::open_default(tmp.path()).unwrap();
2463
2464        for i in 0..5 {
2465            let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
2466            store.put(Galaxy::Codex, &mem).unwrap();
2467        }
2468
2469        let all = store.scan(Galaxy::Codex, 100).unwrap();
2470        assert_eq!(all.len(), 5);
2471
2472        let limited = store.scan(Galaxy::Codex, 3).unwrap();
2473        assert_eq!(limited.len(), 3);
2474    }
2475
2476    #[test]
2477    fn overwrite_removes_stale_index_entries() {
2478        let tmp = tempfile::tempdir().unwrap();
2479        let store = MemoryStore::open_default(tmp.path()).unwrap();
2480
2481        // Original record: tag "alpha", importance 0.9.
2482        let mut mem = Memory::new(Galaxy::Codex, "overwrite target".to_string());
2483        mem.metadata.tags = vec!["alpha".to_string()];
2484        mem.metadata.importance = 0.9;
2485        let id = mem.metadata.id;
2486        store.put(Galaxy::Codex, &mem).unwrap();
2487
2488        // Overwrite with tag "beta", importance 0.1, new content hash.
2489        let mut updated = Memory::new(Galaxy::Codex, "overwritten content".to_string());
2490        updated.metadata.id = id;
2491        updated.metadata.tags = vec!["beta".to_string()];
2492        updated.metadata.importance = 0.1;
2493        store.put(Galaxy::Codex, &updated).unwrap();
2494
2495        // Stale entries must be gone, new entries must be queryable.
2496        let tx = store.env().begin_ro_txn().unwrap();
2497        let by_alpha = store
2498            .index_dbs()
2499            .find_by_tag(&tx, Galaxy::Codex, "alpha")
2500            .unwrap();
2501        let by_beta = store
2502            .index_dbs()
2503            .find_by_tag(&tx, Galaxy::Codex, "beta")
2504            .unwrap();
2505        assert!(
2506            by_alpha.is_empty(),
2507            "stale tag index entries must be removed on overwrite"
2508        );
2509        assert_eq!(by_beta, vec![id]);
2510
2511        let by_importance = store
2512            .index_dbs()
2513            .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 0.2)
2514            .unwrap();
2515        assert!(
2516            by_importance.contains(&id),
2517            "new importance must be indexed"
2518        );
2519        let by_high = store
2520            .index_dbs()
2521            .find_by_importance_range(&tx, Galaxy::Codex, 0.8, 1.0)
2522            .unwrap();
2523        assert!(
2524            !by_high.contains(&id),
2525            "stale importance index entries must be removed on overwrite"
2526        );
2527
2528        let old_hash = content_hash("overwrite target");
2529        let new_hash = content_hash("overwritten content");
2530        assert_eq!(
2531            store
2532                .index_dbs()
2533                .find_by_content_hash(&tx, Galaxy::Codex, &old_hash)
2534                .unwrap(),
2535            None,
2536            "stale content-hash index entry must be removed"
2537        );
2538        assert_eq!(
2539            store
2540                .index_dbs()
2541                .find_by_content_hash(&tx, Galaxy::Codex, &new_hash)
2542                .unwrap(),
2543            Some(id)
2544        );
2545    }
2546
2547    #[test]
2548    fn count_memories() {
2549        let tmp = tempfile::tempdir().unwrap();
2550        let store = MemoryStore::open_default(tmp.path()).unwrap();
2551
2552        assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
2553
2554        for i in 0..3 {
2555            let mem = Memory::new(Galaxy::Codex, format!("count-{i}"));
2556            store.put(Galaxy::Codex, &mem).unwrap();
2557        }
2558
2559        assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
2560    }
2561
2562    #[test]
2563    fn get_nonexistent_returns_none() {
2564        let tmp = tempfile::tempdir().unwrap();
2565        let store = MemoryStore::open_default(tmp.path()).unwrap();
2566        let result = store.get(Galaxy::Codex, uuid::Uuid::new_v4()).unwrap();
2567        assert!(result.is_none());
2568    }
2569
2570    #[test]
2571    fn raw_put_get() {
2572        let tmp = tempfile::tempdir().unwrap();
2573        let store = MemoryStore::open_default(tmp.path()).unwrap();
2574
2575        store
2576            .put_raw(Galaxy::Substrate, b"config:key", b"value123")
2577            .unwrap();
2578        let val = store.get_raw(Galaxy::Substrate, b"config:key").unwrap();
2579        assert_eq!(val, Some(b"value123".to_vec()));
2580    }
2581
2582    #[test]
2583    fn put_dedup_prevents_duplicates() {
2584        let tmp = tempfile::tempdir().unwrap();
2585        let store = MemoryStore::open_default(tmp.path()).unwrap();
2586
2587        let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2588        let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2589
2590        let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2591        let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2592
2593        assert_eq!(id1, id2, "dedup should return same ID for same content");
2594        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2595    }
2596
2597    #[test]
2598    fn put_dedup_allows_different_content() {
2599        let tmp = tempfile::tempdir().unwrap();
2600        let store = MemoryStore::open_default(tmp.path()).unwrap();
2601
2602        let mem1 = Memory::new(Galaxy::Codex, "content A".into());
2603        store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2604
2605        let mem2 = Memory::new(Galaxy::Codex, "content B".into());
2606        store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2607
2608        assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
2609    }
2610
2611    #[test]
2612    fn put_batch_atomic_write() {
2613        let tmp = tempfile::tempdir().unwrap();
2614        let store = MemoryStore::open_default(tmp.path()).unwrap();
2615
2616        let memories: Vec<Memory> = (0..10)
2617            .map(|i| Memory::new(Galaxy::Codex, format!("batch-{i}")))
2618            .collect();
2619
2620        store.put_batch(Galaxy::Codex, &memories).unwrap();
2621        assert_eq!(store.count(Galaxy::Codex).unwrap(), 10);
2622    }
2623
2624    #[test]
2625    fn query_by_tags() {
2626        let tmp = tempfile::tempdir().unwrap();
2627        let store = MemoryStore::open_default(tmp.path()).unwrap();
2628
2629        let mem1 = Memory::new(Galaxy::Codex, "tagged memory".into())
2630            .with_tags(vec!["rust".into(), "memory".into()]);
2631        let mem2 =
2632            Memory::new(Galaxy::Codex, "other memory".into()).with_tags(vec!["python".into()]);
2633        store.put(Galaxy::Codex, &mem1).unwrap();
2634        store.put(Galaxy::Codex, &mem2).unwrap();
2635
2636        let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2637        let results = store.query(Galaxy::Codex, &query).unwrap();
2638        assert_eq!(results.len(), 1);
2639        assert_eq!(results[0].content, "tagged memory");
2640    }
2641
2642    #[test]
2643    fn query_by_importance_range() {
2644        let tmp = tempfile::tempdir().unwrap();
2645        let store = MemoryStore::open_default(tmp.path()).unwrap();
2646
2647        store
2648            .put(
2649                Galaxy::Codex,
2650                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
2651            )
2652            .unwrap();
2653        store
2654            .put(
2655                Galaxy::Codex,
2656                &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
2657            )
2658            .unwrap();
2659        store
2660            .put(
2661                Galaxy::Codex,
2662                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
2663            )
2664            .unwrap();
2665
2666        let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
2667        let results = store.query(Galaxy::Codex, &query).unwrap();
2668        assert_eq!(results.len(), 1);
2669        assert_eq!(results[0].content, "mid");
2670    }
2671
2672    #[test]
2673    fn memory_query_one_sided_time_bounds() {
2674        // `created_after` / `created_before` map onto the temporal filter
2675        // one side at a time (the API passthrough for memory.query).
2676        let old = Memory::new(Galaxy::Codex, "old".into());
2677        let mut recent = Memory::new(Galaxy::Codex, "recent".into());
2678        recent.metadata.created_at = old.metadata.created_at + chrono::Duration::days(30);
2679
2680        let cutoff = old.metadata.created_at + chrono::Duration::days(10);
2681        let after = MemoryQuery::new().with_created_after(cutoff);
2682        assert!(!after.matches(&old), "pre-cutoff memory must not match");
2683        assert!(after.matches(&recent), "post-cutoff memory must match");
2684
2685        let before = MemoryQuery::new().with_created_before(cutoff);
2686        assert!(before.matches(&old), "pre-cutoff memory must match");
2687        assert!(
2688            !before.matches(&recent),
2689            "post-cutoff memory must not match"
2690        );
2691
2692        // Bounds are inclusive.
2693        let edge = MemoryQuery::new().with_created_after(cutoff);
2694        let mut at = Memory::new(Galaxy::Codex, "at cutoff".into());
2695        at.metadata.created_at = cutoff;
2696        assert!(edge.matches(&at), "created_at == after bound is inclusive");
2697    }
2698
2699    #[test]
2700    fn embedding_put_get_delete() {
2701        let tmp = tempfile::tempdir().unwrap();
2702        let store = MemoryStore::open_default(tmp.path()).unwrap();
2703
2704        let id = uuid::Uuid::new_v4();
2705        let embedding = vec![0.1, 0.2, 0.3, 0.4, 0.5];
2706
2707        store.put_embedding(id, &embedding).unwrap();
2708        let retrieved = store.get_embedding(id).unwrap();
2709        assert!(retrieved.is_some());
2710        let retrieved = retrieved.unwrap();
2711        assert_eq!(retrieved.len(), 5);
2712        assert!((retrieved[0] - 0.1).abs() < f32::EPSILON);
2713
2714        assert!(store.delete_embedding(id).unwrap());
2715        assert!(store.get_embedding(id).unwrap().is_none());
2716    }
2717
2718    #[test]
2719    fn embedding_cache_roundtrip_batch_and_count() {
2720        let tmp = tempfile::tempdir().unwrap();
2721        let store = MemoryStore::open_default(tmp.path()).unwrap();
2722
2723        let entries: Vec<(String, Vec<f32>)> = (0..5)
2724            .map(|i| (format!("ns:model:{i:016x}"), vec![i as f32; 8]))
2725            .collect();
2726        store.put_embedding_cache_batch(&entries).unwrap();
2727        assert_eq!(store.embedding_cache_count().unwrap(), 5);
2728
2729        // Single read
2730        let hit = store
2731            .get_embedding_cache("ns:model:0000000000000003")
2732            .unwrap();
2733        assert_eq!(hit.unwrap(), vec![3.0f32; 8]);
2734        assert!(
2735            store
2736                .get_embedding_cache("ns:model:absent")
2737                .unwrap()
2738                .is_none()
2739        );
2740
2741        // Batched read aligns 1:1, misses are None
2742        let keys: Vec<String> = (0..6).map(|i| format!("ns:model:{i:016x}")).collect();
2743        let batch = store.get_embedding_cache_batch(&keys).unwrap();
2744        assert_eq!(batch.len(), 6);
2745        assert!(batch[0..5].iter().all(Option::is_some));
2746        assert!(batch[5].is_none());
2747
2748        // Overwrite is a put, not a duplicate
2749        store
2750            .put_embedding_cache("ns:model:0000000000000001", &[9.0; 8])
2751            .unwrap();
2752        assert_eq!(store.embedding_cache_count().unwrap(), 5);
2753        assert_eq!(
2754            store
2755                .get_embedding_cache("ns:model:0000000000000001")
2756                .unwrap()
2757                .unwrap(),
2758            vec![9.0f32; 8]
2759        );
2760    }
2761
2762    #[test]
2763    fn embedding_cache_survives_store_reopen() {
2764        // V8 ship list #2 acceptance shape: vectors persist across restart.
2765        let tmp = tempfile::tempdir().unwrap();
2766        {
2767            let store = MemoryStore::open_default(tmp.path()).unwrap();
2768            store
2769                .put_embedding_cache("onnx:bge-small:abc", &[0.5; 384])
2770                .unwrap();
2771        }
2772        let reopened = MemoryStore::open_default(tmp.path()).unwrap();
2773        let cached = reopened.get_embedding_cache("onnx:bge-small:abc").unwrap();
2774        assert_eq!(cached.unwrap(), vec![0.5f32; 384]);
2775    }
2776
2777    #[test]
2778    fn content_hash_is_sha256() {
2779        let hash1 = content_hash("test content");
2780        let hash2 = content_hash("test content");
2781        let hash3 = content_hash("different content");
2782
2783        assert_eq!(hash1, hash2, "same content should produce same hash");
2784        assert_ne!(
2785            hash1, hash3,
2786            "different content should produce different hash"
2787        );
2788        assert_eq!(hash1.len(), 64, "SHA-256 hex should be 64 chars");
2789    }
2790
2791    #[test]
2792    fn query_by_tag_uses_index() {
2793        let tmp = tempfile::tempdir().unwrap();
2794        let store = MemoryStore::open_default(tmp.path()).unwrap();
2795
2796        let mem1 = Memory::new(Galaxy::Codex, "tagged".into())
2797            .with_tags(vec!["rust".into(), "memory".into()]);
2798        let mem2 = Memory::new(Galaxy::Codex, "other".into()).with_tags(vec!["python".into()]);
2799        store.put(Galaxy::Codex, &mem1).unwrap();
2800        store.put(Galaxy::Codex, &mem2).unwrap();
2801
2802        let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2803        let results = store.query(Galaxy::Codex, &query).unwrap();
2804        assert_eq!(results.len(), 1);
2805        assert_eq!(results[0].content, "tagged");
2806    }
2807
2808    #[test]
2809    fn query_by_importance_uses_index() {
2810        let tmp = tempfile::tempdir().unwrap();
2811        let store = MemoryStore::open_default(tmp.path()).unwrap();
2812
2813        store
2814            .put(
2815                Galaxy::Codex,
2816                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
2817            )
2818            .unwrap();
2819        store
2820            .put(
2821                Galaxy::Codex,
2822                &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
2823            )
2824            .unwrap();
2825        store
2826            .put(
2827                Galaxy::Codex,
2828                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
2829            )
2830            .unwrap();
2831
2832        let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
2833        let results = store.query(Galaxy::Codex, &query).unwrap();
2834        assert_eq!(results.len(), 1);
2835        assert_eq!(results[0].content, "mid");
2836    }
2837
2838    #[test]
2839    fn query_by_time_uses_index() {
2840        let tmp = tempfile::tempdir().unwrap();
2841        let store = MemoryStore::open_default(tmp.path()).unwrap();
2842
2843        let t0 = chrono::Utc::now();
2844        std::thread::sleep(std::time::Duration::from_millis(10));
2845        let mem = Memory::new(Galaxy::Codex, "timed".into());
2846        store.put(Galaxy::Codex, &mem).unwrap();
2847        std::thread::sleep(std::time::Duration::from_millis(10));
2848        let t2 = chrono::Utc::now();
2849
2850        let query = MemoryQuery::new().with_time_range(t0, t2);
2851        let results = store.query(Galaxy::Codex, &query).unwrap();
2852        assert_eq!(results.len(), 1);
2853        assert_eq!(results[0].content, "timed");
2854    }
2855
2856    #[test]
2857    fn delete_removes_index_entries() {
2858        let tmp = tempfile::tempdir().unwrap();
2859        let store = MemoryStore::open_default(tmp.path()).unwrap();
2860
2861        let mem = Memory::new(Galaxy::Codex, "test".into())
2862            .with_tags(vec!["tag1".into()])
2863            .with_importance(0.7);
2864        let id = mem.metadata.id;
2865        let hash = mem.metadata.content_hash.clone();
2866        store.put(Galaxy::Codex, &mem).unwrap();
2867
2868        // Verify index entries exist
2869        assert!(
2870            store
2871                .find_by_content_hash(Galaxy::Codex, &hash)
2872                .unwrap()
2873                .is_some()
2874        );
2875
2876        // Delete
2877        store.delete(Galaxy::Codex, id).unwrap();
2878
2879        // Verify index entries are gone
2880        assert!(
2881            store
2882                .find_by_content_hash(Galaxy::Codex, &hash)
2883                .unwrap()
2884                .is_none()
2885        );
2886
2887        // Tag query should return 0
2888        let query = MemoryQuery::new().with_tags(vec!["tag1".into()]);
2889        let results = store.query(Galaxy::Codex, &query).unwrap();
2890        assert!(results.is_empty());
2891    }
2892
2893    #[test]
2894    fn put_batch_updates_indexes() {
2895        let tmp = tempfile::tempdir().unwrap();
2896        let store = MemoryStore::open_default(tmp.path()).unwrap();
2897
2898        let memories: Vec<Memory> = (0..5)
2899            .map(|i| {
2900                Memory::new(Galaxy::Codex, format!("batch-{i}"))
2901                    .with_tags(vec![format!("tag{i}")])
2902                    .with_importance(i as f32 * 0.2)
2903            })
2904            .collect();
2905        store.put_batch(Galaxy::Codex, &memories).unwrap();
2906
2907        for i in 0..5 {
2908            let query = MemoryQuery::new().with_tags(vec![format!("tag{i}")]);
2909            let results = store.query(Galaxy::Codex, &query).unwrap();
2910            assert_eq!(results.len(), 1, "tag{i} should have 1 result");
2911        }
2912    }
2913
2914    #[test]
2915    fn find_by_content_hash_indexed_matches_scan() {
2916        let tmp = tempfile::tempdir().unwrap();
2917        let store = MemoryStore::open_default(tmp.path()).unwrap();
2918
2919        let mem = Memory::new(Galaxy::Codex, "dedup test".into());
2920        let id = mem.metadata.id;
2921        let hash = mem.metadata.content_hash.clone();
2922        store.put(Galaxy::Codex, &mem).unwrap();
2923
2924        let indexed = store.find_by_content_hash(Galaxy::Codex, &hash).unwrap();
2925        let scanned = store
2926            .find_by_content_hash_scan(Galaxy::Codex, &hash)
2927            .unwrap();
2928
2929        assert_eq!(indexed, scanned);
2930        assert_eq!(indexed, Some(id));
2931    }
2932
2933    #[test]
2934    fn put_dedup_uses_index() {
2935        let tmp = tempfile::tempdir().unwrap();
2936        let store = MemoryStore::open_default(tmp.path()).unwrap();
2937
2938        let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2939        let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2940
2941        let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2942        let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2943
2944        assert_eq!(id1, id2, "dedup should return same ID for same content");
2945        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2946    }
2947
2948    #[test]
2949    fn put_semantic_updates_coord5d() {
2950        let tmp = tempfile::tempdir().unwrap();
2951        let store = MemoryStore::open_default(tmp.path()).unwrap();
2952
2953        let mut mem = Memory::new(
2954            Galaxy::Codex,
2955            "The algorithm computes data using a systematic method".to_string(),
2956        );
2957        let original_coord = mem.metadata.coord5d.clone();
2958        store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2959
2960        // coord5d should have changed from the SHA-256 hash-based encoding
2961        assert_ne!(
2962            mem.metadata.coord5d.x, original_coord.x,
2963            "semantic encoding should change x"
2964        );
2965        assert_ne!(
2966            mem.metadata.coord5d.y, original_coord.y,
2967            "semantic encoding should change y"
2968        );
2969
2970        // Verify it was stored with the semantic coordinate
2971        let retrieved = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
2972        assert_eq!(retrieved.metadata.coord5d.x, mem.metadata.coord5d.x);
2973    }
2974
2975    #[test]
2976    fn put_semantic_preserves_temporal_and_importance() {
2977        let tmp = tempfile::tempdir().unwrap();
2978        let store = MemoryStore::open_default(tmp.path()).unwrap();
2979
2980        let mut mem = Memory::new(Galaxy::Codex, "test content".into()).with_importance(0.8);
2981        mem.metadata.coord5d.w = 0.6;
2982        store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2983
2984        assert!((mem.metadata.coord5d.w - 0.6).abs() < f32::EPSILON);
2985        assert!((mem.metadata.coord5d.v - 0.8).abs() < f32::EPSILON);
2986    }
2987
2988    #[test]
2989    fn find_similar_returns_nearest_first() {
2990        let tmp = tempfile::tempdir().unwrap();
2991        let store = MemoryStore::open_default(tmp.path()).unwrap();
2992
2993        let mut logic_mem = Memory::new(
2994            Galaxy::Codex,
2995            "The algorithm computes data using systematic logic and analysis".to_string(),
2996        );
2997        store.put_semantic(Galaxy::Codex, &mut logic_mem).unwrap();
2998
2999        let mut emotion_mem = Memory::new(
3000            Galaxy::Codex,
3001            "I feel love and joy with deep passion and empathy in my heart".to_string(),
3002        );
3003        store.put_semantic(Galaxy::Codex, &mut emotion_mem).unwrap();
3004
3005        // Query with logic-like text should find the logic memory first
3006        let results = store
3007            .find_similar(Galaxy::Codex, "algorithm data systematic method", 10)
3008            .unwrap();
3009        assert!(!results.is_empty());
3010        assert_eq!(results[0].0.metadata.id, logic_mem.metadata.id);
3011
3012        // Query with emotion-like text should find the emotion memory first
3013        let results = store
3014            .find_similar(Galaxy::Codex, "love joy passion heart feeling", 10)
3015            .unwrap();
3016        assert!(!results.is_empty());
3017        assert_eq!(results[0].0.metadata.id, emotion_mem.metadata.id);
3018    }
3019
3020    #[test]
3021    fn find_similar_empty_galaxy() {
3022        let tmp = tempfile::tempdir().unwrap();
3023        let store = MemoryStore::open_default(tmp.path()).unwrap();
3024
3025        let results = store.find_similar(Galaxy::Codex, "anything", 10).unwrap();
3026        assert!(results.is_empty());
3027    }
3028
3029    #[test]
3030    fn find_similar_respects_limit() {
3031        let tmp = tempfile::tempdir().unwrap();
3032        let store = MemoryStore::open_default(tmp.path()).unwrap();
3033
3034        for i in 0..5 {
3035            let mut mem = Memory::new(Galaxy::Codex, format!("algorithm data method {i}"));
3036            store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3037        }
3038
3039        let results = store
3040            .find_similar(Galaxy::Codex, "algorithm data", 3)
3041            .unwrap();
3042        assert_eq!(results.len(), 3);
3043    }
3044
3045    #[test]
3046    fn semantic_encoder_accessible() {
3047        let tmp = tempfile::tempdir().unwrap();
3048        let store = MemoryStore::open_default(tmp.path()).unwrap();
3049
3050        let scores = store.semantic_encoder().encode("algorithm data logic");
3051        // Logic-heavy text → x < 0.5
3052        assert!(scores.x < 0.5);
3053    }
3054
3055    #[test]
3056    fn put_raw_batch_writes_atomically() {
3057        let tmp = tempfile::tempdir().unwrap();
3058        let store = MemoryStore::open_default(tmp.path()).unwrap();
3059
3060        let entries: &[(&[u8], &[u8])] =
3061            &[(b"key1", b"val1"), (b"key2", b"val2"), (b"key3", b"val3")];
3062        store.put_raw_batch(Galaxy::Karma, entries).unwrap();
3063
3064        assert_eq!(
3065            store.get_raw(Galaxy::Karma, b"key1").unwrap().unwrap(),
3066            b"val1"
3067        );
3068        assert_eq!(
3069            store.get_raw(Galaxy::Karma, b"key2").unwrap().unwrap(),
3070            b"val2"
3071        );
3072        assert_eq!(
3073            store.get_raw(Galaxy::Karma, b"key3").unwrap().unwrap(),
3074            b"val3"
3075        );
3076    }
3077
3078    #[test]
3079    fn put_raw_batch_empty_is_noop() {
3080        let tmp = tempfile::tempdir().unwrap();
3081        let store = MemoryStore::open_default(tmp.path()).unwrap();
3082
3083        store.put_raw_batch(Galaxy::Karma, &[]).unwrap();
3084        assert_eq!(store.count(Galaxy::Karma).unwrap(), 0);
3085    }
3086
3087    #[test]
3088    fn entry_limit_rejects_excess_writes() {
3089        let tmp = tempfile::tempdir().unwrap();
3090        let store = MemoryStore::open_default(tmp.path())
3091            .unwrap()
3092            .with_entry_limit(3);
3093
3094        for i in 0..3 {
3095            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3096            store.put(Galaxy::Codex, &mem).unwrap();
3097        }
3098
3099        // 4th write should be rejected
3100        let mem = Memory::new(Galaxy::Codex, "overflow memory".to_string());
3101        let result = store.put(Galaxy::Codex, &mem);
3102        assert!(result.is_err(), "write beyond limit should be rejected");
3103        let err_msg = result.unwrap_err().to_string();
3104        assert!(
3105            err_msg.contains("entry limit reached"),
3106            "error should mention entry limit: {err_msg}"
3107        );
3108        assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
3109    }
3110
3111    #[test]
3112    fn entry_limit_per_galaxy_independent() {
3113        let tmp = tempfile::tempdir().unwrap();
3114        let store = MemoryStore::open_default(tmp.path())
3115            .unwrap()
3116            .with_entry_limit(2);
3117
3118        // Fill Codex to limit
3119        for i in 0..2 {
3120            let mem = Memory::new(Galaxy::Codex, format!("codex {i}"));
3121            store.put(Galaxy::Codex, &mem).unwrap();
3122        }
3123
3124        // Writing to a different galaxy should still work
3125        let mem = Memory::new(Galaxy::Research, "science memory".to_string());
3126        let result = store.put(Galaxy::Research, &mem);
3127        assert!(
3128            result.is_ok(),
3129            "different galaxy should not be affected by limit"
3130        );
3131    }
3132
3133    #[test]
3134    fn entry_limit_none_allows_unlimited() {
3135        let tmp = tempfile::tempdir().unwrap();
3136        let store = MemoryStore::open_default(tmp.path()).unwrap();
3137
3138        // No limit set — should allow many writes
3139        for i in 0..50 {
3140            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3141            store.put(Galaxy::Codex, &mem).unwrap();
3142        }
3143        assert_eq!(store.count(Galaxy::Codex).unwrap(), 50);
3144    }
3145
3146    #[test]
3147    fn map_full_error_is_graceful() {
3148        // Small map: opening succeeds (galaxy + sidecar databases fit), but
3149        // the padded write loop fills it and MapFull surfaces from put().
3150        // 64KB proved too tight for eager create_db on macOS/arm64.
3151        let tmp = tempfile::tempdir().unwrap();
3152        let store = MemoryStore::open(tmp.path(), 512 * 1024).unwrap();
3153
3154        // Write memories until map is full
3155        let mut written = 0;
3156        let mut got_map_full = false;
3157        for i in 0..1000 {
3158            let mem = Memory::new(
3159                Galaxy::Codex,
3160                format!("memory content {i} {}", "with padding ".repeat(50)),
3161            );
3162            match store.put(Galaxy::Codex, &mem) {
3163                Ok(()) => written += 1,
3164                Err(e) => {
3165                    let msg = e.to_string();
3166                    if msg.contains("map full") {
3167                        got_map_full = true;
3168                        break;
3169                    }
3170                    // Other errors are fine too (e.g., serialize failed)
3171                    break;
3172                }
3173            }
3174        }
3175
3176        assert!(
3177            got_map_full || written < 1000,
3178            "should eventually hit map full or error"
3179        );
3180        assert!(written > 0, "should have written at least some memories");
3181    }
3182
3183    #[test]
3184    fn test_find_across_galaxies() {
3185        let tmp = tempfile::tempdir().unwrap();
3186        let store = MemoryStore::open_default(tmp.path()).unwrap();
3187
3188        let mem = Memory::new(Galaxy::Research, "Cross-galaxy research memo".into());
3189        let id = mem.metadata.id;
3190        store.put(Galaxy::Research, &mem).unwrap();
3191
3192        let found = store.find_across_galaxies(id).unwrap();
3193        assert!(found.is_some());
3194        let (galaxy, retrieved) = found.unwrap();
3195        assert_eq!(galaxy, Galaxy::Research);
3196        assert_eq!(retrieved.metadata.id, id);
3197        assert_eq!(retrieved.content, "Cross-galaxy research memo");
3198
3199        // Non-existent id returns None
3200        assert!(
3201            store
3202                .find_across_galaxies(uuid::Uuid::new_v4())
3203                .unwrap()
3204                .is_none()
3205        );
3206    }
3207}