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::{Cursor, Database, DatabaseFlags, Environment, Transaction, WriteFlags};
7use std::collections::HashMap;
8use std::path::Path;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, RwLock};
11use wm_core::{CoreError, Galaxy, Result};
12
13#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15
16use crate::episodic::EpisodicStore;
17use crate::indexes::IndexDbs;
18use crate::memory::{Memory, MemoryId, decode_embedding, encode_embedding};
19use crate::semantic::SemanticEncoder;
20
21/// Query filter for memories.
22#[derive(Debug, Clone, Default)]
23pub struct MemoryQuery {
24    /// Filter by tags (memory must contain ALL specified tags).
25    pub tags: Vec<String>,
26    /// Minimum importance (inclusive).
27    pub min_importance: Option<f32>,
28    /// Maximum importance (inclusive).
29    pub max_importance: Option<f32>,
30    /// Only memories created after this timestamp.
31    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
32    /// Only memories created before this timestamp.
33    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
34    /// Case-insensitive substring filter over content (literal match —
35    /// not tokenized or ranked; that is what the search engine is for).
36    pub content_substring: Option<String>,
37    /// Maximum number of results.
38    pub limit: usize,
39}
40
41impl MemoryQuery {
42    /// Create an empty query (matches all, limit 100).
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            limit: 100,
47            ..Default::default()
48        }
49    }
50
51    /// Set tag filter.
52    #[must_use]
53    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
54        self.tags = tags;
55        self
56    }
57
58    /// Set importance range.
59    #[must_use]
60    pub const fn with_importance_range(mut self, min: f32, max: f32) -> Self {
61        self.min_importance = Some(min);
62        self.max_importance = Some(max);
63        self
64    }
65
66    /// Set temporal range.
67    #[must_use]
68    pub const fn with_time_range(
69        mut self,
70        after: chrono::DateTime<chrono::Utc>,
71        before: chrono::DateTime<chrono::Utc>,
72    ) -> Self {
73        self.created_after = Some(after);
74        self.created_before = Some(before);
75        self
76    }
77
78    /// One-sided temporal bound: only memories created at or after this
79    /// timestamp (`created_after` API passthrough).
80    #[must_use]
81    pub const fn with_created_after(mut self, after: chrono::DateTime<chrono::Utc>) -> Self {
82        self.created_after = Some(after);
83        self
84    }
85
86    /// One-sided temporal bound: only memories created at or before this
87    /// timestamp (`created_before` API passthrough).
88    #[must_use]
89    pub const fn with_created_before(mut self, before: chrono::DateTime<chrono::Utc>) -> Self {
90        self.created_before = Some(before);
91        self
92    }
93
94    /// Set limit.
95    #[must_use]
96    pub const fn with_limit(mut self, limit: usize) -> Self {
97        self.limit = limit;
98        self
99    }
100
101    /// Set a case-insensitive substring filter over content.
102    #[must_use]
103    pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
104        self.content_substring = Some(substring.into().to_lowercase());
105        self
106    }
107
108    /// Check if a memory matches this query.
109    #[must_use]
110    pub fn matches(&self, mem: &Memory) -> bool {
111        // Tag filter: memory must contain all specified tags
112        if !self.tags.is_empty() {
113            for tag in &self.tags {
114                if !mem.metadata.tags.iter().any(|t| t == tag) {
115                    return false;
116                }
117            }
118        }
119
120        // Importance filter
121        if let Some(min) = self.min_importance {
122            if mem.metadata.importance < min {
123                return false;
124            }
125        }
126        if let Some(max) = self.max_importance {
127            if mem.metadata.importance > max {
128                return false;
129            }
130        }
131
132        // Temporal filter
133        if let Some(after) = self.created_after {
134            if mem.metadata.created_at < after {
135                return false;
136            }
137        }
138        if let Some(before) = self.created_before {
139            if mem.metadata.created_at > before {
140                return false;
141            }
142        }
143
144        // Substring filter (literal, case-insensitive — never ranked).
145        if let Some(sub) = &self.content_substring {
146            if !mem.content.to_lowercase().contains(sub) {
147                return false;
148            }
149        }
150
151        true
152    }
153}
154
155/// The LMDB environment containing all 14 galaxy sub-databases plus 4 index DBs.
156pub struct MemoryStore {
157    /// Path to the LMDB file
158    path: std::path::PathBuf,
159    /// LMDB environment (opened once, shared across threads)
160    env: Environment,
161    /// Cached handles to the 4 secondary index sub-databases
162    index_dbs: IndexDbs,
163    /// Semantic encoder for content-derived coordinates
164    semantic_encoder: SemanticEncoder,
165    /// Optional per-galaxy entry limit (DoS prevention)
166    max_entries_per_galaxy: Option<usize>,
167    /// Monotonic counter of successful mutations since this handle was
168    /// opened. Lets the dispatch pipeline cheaply detect actual store
169    /// writes (write-audit journal) without scanning galaxies.
170    mutation_count: AtomicU64,
171    /// Dedicated database for lossless v6 episodic records.
172    episodic_db: Database,
173    /// DUP_SORT term→id postings for bounded episodic search (v2 sidecar).
174    episodic_terms_v2_db: Database,
175    /// Content-hash → vector cache (v26 "Tier 2" idea, finally wired):
176    /// warm-start for re-ingest and re-runs. Keyed by the embedder
177    /// namespace + content hash, so switching models never serves stale
178    /// vectors.
179    embedding_cache_db: Database,
180    /// Per-memory revision chains (V8 S11c): append-only content-change
181    /// history, self-verifying (seq continuity + hash linkage + head
182    /// match). See [`crate::revision`].
183    revisions_db: Database,
184    /// Per-memory creation attestations (Track F Slice A, D5): one signed
185    /// record per created memory, keyed `att:{galaxy}:{memory_id}`.
186    /// See [`crate::attestation`].
187    attestations_db: Database,
188    /// Warm term-posting cache shared by episodic search views.
189    episodic_term_cache: std::sync::Arc<RwLock<HashMap<String, Vec<uuid::Uuid>>>>,
190    /// Optional embedder for episodic vector reranking.
191    episodic_embedder:
192        std::sync::OnceLock<Option<Arc<dyn crate::embedder::Embedder + Send + Sync>>>,
193    /// One-shot guard: rebuild the v2 episodic sidecar once per process.
194    episodic_sidecar_ensured: std::sync::OnceLock<()>,
195    /// Optional adaptive aliases for episodic key expansion.
196    episodic_aliases: std::sync::OnceLock<Option<crate::episodic_keys::AdaptiveAliases>>,
197    /// Optional vocabulary enrichment for episodic index-time term expansion.
198    episodic_enrichment: std::sync::OnceLock<Option<crate::enrichment::VocabularyEnrichment>>,
199}
200
201impl MemoryStore {
202    /// Open or create an LMDB store at the given path.
203    ///
204    /// On Unix, the store directory is created with mode 0o700 (owner-only
205    /// access) if it does not already exist. Existing directories are
206    /// left untouched.
207    pub fn open(path: impl AsRef<Path>, map_size: usize) -> Result<Self> {
208        let path = path.as_ref().to_path_buf();
209
210        // Ensure the directory exists with restrictive permissions.
211        std::fs::create_dir_all(&path)
212            .map_err(|e| CoreError::Memory(format!("Cannot create store dir: {e}")))?;
213        #[cfg(unix)]
214        {
215            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
216                .map_err(|e| CoreError::Memory(format!("Cannot set store dir permissions: {e}")))?;
217        }
218
219        let env = Environment::new()
220            .set_map_size(map_size)
221            .set_max_dbs(32)
222            .open(&path)
223            .map_err(|e| CoreError::Memory(format!("LMDB open failed: {e}")))?;
224
225        // Create all 14 galaxy sub-databases
226        for galaxy in Galaxy::all() {
227            let db = env
228                .create_db(Some(galaxy.db_name()), DatabaseFlags::default())
229                .map_err(|e| {
230                    CoreError::Memory(format!(
231                        "LMDB create_db failed for {}: {e}",
232                        galaxy.db_name()
233                    ))
234                })?;
235            let _ = db;
236        }
237
238        // Create 4 secondary index sub-databases
239        for (name, flags) in crate::indexes::INDEX_DBS {
240            let db = env
241                .create_db(Some(name), *flags)
242                .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for {name}: {e}")))?;
243            let _ = db;
244        }
245
246        let index_dbs = IndexDbs::open(&env)?;
247        let episodic_db = env
248            .create_db(Some("episodic_records"), DatabaseFlags::default())
249            .map_err(|e| {
250                CoreError::Memory(format!("LMDB create_db failed for episodic_records: {e}"))
251            })?;
252        // v2 sidecar: DUP_SORT postings (term -> set of record ids). Append of
253        // a record touches only the (term, id) pairs it introduces instead of
254        // rewriting whole posting lists, so ingest cost stays O(new records)
255        // as the store grows. The v1 msgpack-Vec database is retained unused
256        // on legacy stores; v2 is rebuilt from the authoritative records when
257        // found empty.
258        let episodic_terms_v2_db = env
259            .create_db(Some("episodic_terms_v2"), DatabaseFlags::DUP_SORT)
260            .map_err(|e| {
261                CoreError::Memory(format!("LMDB create_db failed for episodic_terms_v2: {e}"))
262            })?;
263        let embedding_cache_db = env
264            .create_db(Some("embedding_cache"), DatabaseFlags::default())
265            .map_err(|e| {
266                CoreError::Memory(format!("LMDB create_db failed for embedding_cache: {e}"))
267            })?;
268        let revisions_db = env
269            .create_db(Some("revisions"), DatabaseFlags::default())
270            .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for revisions: {e}")))?;
271        let attestations_db = env
272            .create_db(
273                Some(crate::attestation::ATTESTATIONS_DB),
274                DatabaseFlags::default(),
275            )
276            .map_err(|e| {
277                CoreError::Memory(format!("LMDB create_db failed for attestations: {e}"))
278            })?;
279        Ok(Self {
280            path,
281            env,
282            index_dbs,
283            semantic_encoder: SemanticEncoder::new(),
284            max_entries_per_galaxy: None,
285            mutation_count: AtomicU64::new(0),
286            episodic_db,
287            episodic_terms_v2_db,
288            embedding_cache_db,
289            revisions_db,
290            attestations_db,
291            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
292            episodic_embedder: std::sync::OnceLock::new(),
293            episodic_sidecar_ensured: std::sync::OnceLock::new(),
294            episodic_aliases: std::sync::OnceLock::new(),
295            episodic_enrichment: std::sync::OnceLock::new(),
296        })
297    }
298
299    /// Open with the default map size.
300    ///
301    /// 4 GB on Unix: LMDB truncates the data file sparsely (ftruncate), so
302    /// reservation costs nothing until pages are written. On Windows NTFS
303    /// materializes the file at full map size immediately — a 4 GB default
304    /// would allocate 4 GB on disk per store the moment it opens — so the
305    /// Windows default is smaller; pass an explicit size to `open()` for
306    /// large stores. (Auto-grow on MapFull is a planned follow-up.)
307    pub fn open_default(path: impl AsRef<Path>) -> Result<Self> {
308        // Deployment knob: override the platform default explicitly (bytes).
309        // CI uses this on Windows, where NTFS materializes the map file at
310        // full size and hundreds of parallel test stores would exhaust the
311        // runner disk even at the 256MB Windows default.
312        let platform_default = if cfg!(windows) {
313            256 * 1024 * 1024
314        } else {
315            4 * 1024 * 1024 * 1024
316        };
317        let size = std::env::var("WM_DEFAULT_MAP_SIZE")
318            .ok()
319            .and_then(|v| v.parse::<usize>().ok())
320            .filter(|&v| v > 0)
321            .unwrap_or(platform_default);
322        Self::open(path, size)
323    }
324
325    /// Set a per-galaxy entry limit for DoS prevention.
326    ///
327    /// When set, `put` will reject writes that would exceed the limit.
328    /// This prevents a single galaxy from exhausting the LMDB map.
329    #[must_use]
330    pub const fn with_entry_limit(mut self, limit: usize) -> Self {
331        self.max_entries_per_galaxy = Some(limit);
332        self
333    }
334
335    /// Path to the LMDB file.
336    pub fn path(&self) -> &Path {
337        &self.path
338    }
339
340    /// Get the LMDB environment handle.
341    pub const fn env(&self) -> &Environment {
342        &self.env
343    }
344
345    /// Monotonic counter of successful mutations since this handle was
346    /// opened (puts, deletes, clears, raw writes). Used by the dispatch
347    /// pipeline's write-audit journal to detect actual store writes.
348    pub fn mutation_count(&self) -> u64 {
349        self.mutation_count.load(Ordering::Relaxed)
350    }
351
352    /// Get the cached index database handles.
353    pub const fn index_dbs(&self) -> &IndexDbs {
354        &self.index_dbs
355    }
356
357    /// Get the semantic encoder.
358    pub const fn semantic_encoder(&self) -> &SemanticEncoder {
359        &self.semantic_encoder
360    }
361
362    /// Rebuild the episodic DUP_SORT sidecar once per process when it is
363    /// empty while authoritative records exist (legacy v1 stores and
364    /// lost-sidecar recovery). Raw records are never modified; a failed
365    /// rebuild leaves search on its raw-scan fallback.
366    fn ensure_episodic_sidecar(&self) {
367        if self.episodic_sidecar_ensured.get().is_some() {
368            return;
369        }
370        let _ = self.episodic_sidecar_ensured.set(());
371        let view = EpisodicStore::new(
372            &self.env,
373            self.episodic_db,
374            self.episodic_terms_v2_db,
375            self.episodic_term_cache.clone(),
376            &self.mutation_count,
377        );
378        let needs_rebuild = matches!(
379            (view.sidecar_is_empty(), view.record_count()),
380            (Ok(true), Ok(n)) if n > 0
381        );
382        if needs_rebuild {
383            match view.rebuild_sidecar() {
384                Ok(n) => tracing::info!("episodic sidecar rebuilt from {n} records"),
385                Err(e) => {
386                    tracing::warn!("episodic sidecar rebuild failed: {e}");
387                }
388            }
389        }
390    }
391
392    /// Open the v6 lossless episodic record view.
393    #[must_use]
394    pub fn episodic(&self) -> EpisodicStore<'_> {
395        self.ensure_episodic_sidecar();
396        let mut store = EpisodicStore::new(
397            &self.env,
398            self.episodic_db,
399            self.episodic_terms_v2_db,
400            self.episodic_term_cache.clone(),
401            &self.mutation_count,
402        );
403        if let Some(Some(embedder)) = self.episodic_embedder.get() {
404            store = store.with_embedder(embedder.clone());
405        }
406        if let Some(Some(aliases)) = self.episodic_aliases.get() {
407            store = store.with_adaptive_aliases(aliases.clone());
408        }
409        if let Some(Some(enrichment)) = self.episodic_enrichment.get() {
410            store = store.with_enrichment(enrichment.clone());
411        }
412        store
413    }
414
415    /// Attach an embedder for episodic vector reranking.
416    pub fn set_episodic_embedder(
417        &self,
418        embedder: Arc<dyn crate::embedder::Embedder + Send + Sync>,
419    ) {
420        let _ = self.episodic_embedder.set(Some(embedder));
421    }
422
423    /// Attach adaptive aliases for episodic key expansion.
424    pub fn set_episodic_aliases(&self, aliases: crate::episodic_keys::AdaptiveAliases) {
425        let _ = self.episodic_aliases.set(Some(aliases));
426    }
427
428    /// Attach vocabulary enrichment for episodic index-time term expansion.
429    pub fn set_episodic_enrichment(&self, enrichment: crate::enrichment::VocabularyEnrichment) {
430        let _ = self.episodic_enrichment.set(Some(enrichment));
431    }
432
433    /// Get a named database handle for a galaxy.
434    pub fn galaxy_db(&self, galaxy: Galaxy) -> Result<Database> {
435        self.env.open_db(Some(galaxy.db_name())).map_err(|e| {
436            CoreError::Memory(format!("LMDB open_db failed for {}: {e}", galaxy.db_name()))
437        })
438    }
439
440    // ── Memory CRUD ───────────────────────────────────────────────────
441
442    /// Store a memory in the given galaxy. Keyed by memory.metadata.id.
443    /// Also updates all secondary indexes.
444    ///
445    /// Returns a clear error if the per-galaxy entry limit is exceeded
446    /// or if the LMDB map is full.
447    pub fn put(&self, galaxy: Galaxy, memory: &Memory) -> Result<()> {
448        // Check per-galaxy entry limit (DoS prevention)
449        if let Some(limit) = self.max_entries_per_galaxy {
450            let current = self.count(galaxy)?;
451            if current >= limit {
452                return Err(CoreError::Memory(format!(
453                    "galaxy {} entry limit reached ({current}/{limit}), write rejected",
454                    galaxy.db_name()
455                )));
456            }
457        }
458
459        let db = self.galaxy_db(galaxy)?;
460        let key = memory.metadata.id.as_bytes();
461        let val = rmp_serde::to_vec_named(memory)
462            .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
463
464        let mut tx = self
465            .env
466            .begin_rw_txn()
467            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
468
469        // Overwrite semantics: capture the previous record (if any) so its
470        // index entries can be removed before the new ones are added.
471        // Otherwise stale tags, importance values, timestamps, and content
472        // hashes stay queryable after updates.
473        let existing = tx
474            .get(db, key)
475            .ok()
476            .and_then(|bytes| crate::codec::decode(bytes).ok());
477
478        match tx.put(db, key, &val, lmdb::WriteFlags::default()) {
479            Ok(()) => {}
480            Err(lmdb::Error::MapFull) => {
481                tx.abort();
482                return Err(CoreError::Memory(format!(
483                    "LMDB map full: galaxy {}, consider growing map size or pruning old memories",
484                    galaxy.db_name()
485                )));
486            }
487            Err(e) => {
488                tx.abort();
489                return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
490            }
491        }
492        if let Some(existing) = existing {
493            self.index_dbs.remove(&mut tx, galaxy, &existing)?;
494        }
495        self.index_dbs.add(&mut tx, galaxy, memory)?;
496        tx.commit()
497            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
498        self.mutation_count.fetch_add(1, Ordering::Relaxed);
499        Ok(())
500    }
501
502    /// Retrieve a memory by ID from the given galaxy.
503    pub fn get(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<Option<Memory>> {
504        let db = self.galaxy_db(galaxy)?;
505        let key = id.as_bytes();
506
507        let tx = self
508            .env
509            .begin_ro_txn()
510            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
511        let result = tx.get(db, key);
512        match result {
513            Ok(bytes) => {
514                let memory: Memory = crate::codec::decode(bytes)
515                    .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")))?;
516                tx.commit()
517                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
518                Ok(Some(memory))
519            }
520            Err(lmdb::Error::NotFound) => {
521                // ReadOnly transactions don't strictly need commit, but it's good practice
522                tx.commit()
523                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
524                Ok(None)
525            }
526            Err(e) => Err(CoreError::Memory(format!("LMDB get failed: {e}"))),
527        }
528    }
529
530    /// Retrieve a memory by ID searching across all memory galaxies (S9 cross-galaxy traversal).
531    ///
532    /// Cross-galaxy associations and edges reference galaxy-blind UUIDs.
533    /// This searches through all memory galaxies in canonical order and returns
534    /// the first matching (Galaxy, Memory) pair, or None if not found.
535    pub fn find_across_galaxies(&self, id: uuid::Uuid) -> Result<Option<(Galaxy, Memory)>> {
536        for galaxy in Galaxy::memory_galaxies() {
537            if let Some(mem) = self.get(galaxy, id)? {
538                return Ok(Some((galaxy, mem)));
539            }
540        }
541        Ok(None)
542    }
543
544    /// Delete a memory by ID from the given galaxy. Returns true if a key was removed.
545    /// Also removes all secondary index entries for the memory.
546    pub fn delete(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<bool> {
547        let db = self.galaxy_db(galaxy)?;
548        let key = id.as_bytes();
549
550        let mut tx = self
551            .env
552            .begin_rw_txn()
553            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
554
555        // Check if key exists and deserialize for index cleanup
556        let exists = tx.get(db, key).is_ok();
557        if exists {
558            // Read memory to get index values for cleanup
559            if let Ok(bytes) = tx.get(db, key) {
560                if let Ok(memory) = crate::codec::decode(bytes) {
561                    let _ = self.index_dbs.remove(&mut tx, galaxy, &memory);
562                }
563            }
564            tx.del(db, key, None)
565                .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
566        }
567        tx.commit()
568            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
569        if exists {
570            self.mutation_count.fetch_add(1, Ordering::Relaxed);
571        }
572        Ok(exists)
573    }
574
575    /// Scan up to `limit` memories from the given galaxy (unordered by LMDB page layout).
576    pub fn scan(&self, galaxy: Galaxy, limit: usize) -> Result<Vec<Memory>> {
577        let db = self.galaxy_db(galaxy)?;
578        let tx = self
579            .env
580            .begin_ro_txn()
581            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
582
583        let mut cursor = tx
584            .open_ro_cursor(db)
585            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
586
587        let mut memories = Vec::with_capacity(limit.min(256));
588        for (i, (_key, val)) in cursor.iter().enumerate() {
589            if memories.len() >= limit {
590                break;
591            }
592            match crate::codec::decode(val) {
593                Ok(memory) => memories.push(memory),
594                Err(e) => {
595                    tracing::warn!(
596                        "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
597                        galaxy
598                    );
599                }
600            }
601        }
602
603        drop(cursor);
604        tx.commit()
605            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
606        Ok(memories)
607    }
608
609    /// Scan every memory in the galaxy (unordered by LMDB page layout).
610    ///
611    /// Used by maintenance tooling (e.g. index rebuild). The full galaxy is
612    /// materialized in memory — prefer [`Self::scan`] for bounded reads.
613    pub fn scan_all(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
614        self.scan_all_impl(galaxy, false)
615    }
616
617    /// Maintenance scan that refuses to omit an undecodable source record.
618    /// Use before replacing derived indexes; a tolerant scan is not a complete
619    /// authoritative snapshot when any record fails decoding.
620    pub fn scan_all_strict(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
621        self.scan_all_impl(galaxy, true)
622    }
623
624    fn scan_all_impl(&self, galaxy: Galaxy, strict: bool) -> Result<Vec<Memory>> {
625        let db = self.galaxy_db(galaxy)?;
626        let tx = self
627            .env
628            .begin_ro_txn()
629            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
630
631        let mut cursor = tx
632            .open_ro_cursor(db)
633            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
634
635        let mut memories = Vec::new();
636        for (i, (_key, val)) in cursor.iter().enumerate() {
637            match crate::codec::decode(val) {
638                Ok(memory) => memories.push(memory),
639                Err(e) => {
640                    if strict {
641                        return Err(CoreError::Memory(format!(
642                            "refusing incomplete scan of {}: record {i} cannot be decoded: {e}",
643                            galaxy.db_name()
644                        )));
645                    }
646                    tracing::warn!(
647                        "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
648                        galaxy
649                    );
650                }
651            }
652        }
653
654        drop(cursor);
655        tx.commit()
656            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
657        Ok(memories)
658    }
659
660    /// Count entries in a galaxy.
661    pub fn count(&self, galaxy: Galaxy) -> Result<usize> {
662        let db = self.galaxy_db(galaxy)?;
663        let tx = self
664            .env
665            .begin_ro_txn()
666            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
667        let mut cursor = tx
668            .open_ro_cursor(db)
669            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
670        let count = cursor.iter().count();
671        drop(cursor);
672        tx.commit()
673            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
674        Ok(count)
675    }
676
677    /// Clear all memories from a galaxy in a single transaction.
678    /// Returns the number of entries cleared.
679    /// Also removes all secondary index entries.
680    pub fn clear_galaxy(&self, galaxy: Galaxy) -> Result<usize> {
681        let db = self.galaxy_db(galaxy)?;
682
683        let mut tx = self
684            .env
685            .begin_rw_txn()
686            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
687
688        let mut cursor = tx
689            .open_ro_cursor(db)
690            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
691
692        let mut count = 0usize;
693        let keys_to_delete: Vec<(Vec<u8>, Memory)> = cursor
694            .iter()
695            .filter_map(|(key, val)| {
696                if let Ok(memory) = crate::codec::decode(val) {
697                    Some((key.to_vec(), memory))
698                } else {
699                    None
700                }
701            })
702            .collect();
703
704        drop(cursor);
705
706        for (key, memory) in &keys_to_delete {
707            let _ = self.index_dbs.remove(&mut tx, galaxy, memory);
708            tx.del(db, &key, None)
709                .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
710            count += 1;
711        }
712
713        tx.commit()
714            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
715        self.mutation_count
716            .fetch_add(count as u64, Ordering::Relaxed);
717        Ok(count)
718    }
719
720    /// Put multiple memories into a galaxy in a single transaction.
721    /// Returns the number of memories written.
722    pub fn batch_put(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<usize> {
723        if memories.is_empty() {
724            return Ok(0);
725        }
726
727        let db = self.galaxy_db(galaxy)?;
728
729        let mut tx = self
730            .env
731            .begin_rw_txn()
732            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
733
734        let mut count = 0usize;
735        for memory in memories {
736            let key = memory.metadata.id.as_bytes();
737            let val = rmp_serde::to_vec_named(memory)
738                .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
739            match tx.put(db, key, &val, WriteFlags::default()) {
740                Ok(()) => {}
741                Err(lmdb::Error::MapFull) => {
742                    tx.abort();
743                    return Err(CoreError::Memory(format!(
744                        "LMDB map full: galaxy {}, consider growing map size",
745                        galaxy.db_name()
746                    )));
747                }
748                Err(e) => {
749                    tx.abort();
750                    return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
751                }
752            }
753            self.index_dbs.add(&mut tx, galaxy, memory)?;
754            count += 1;
755        }
756
757        tx.commit()
758            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
759        self.mutation_count
760            .fetch_add(count as u64, Ordering::Relaxed);
761        Ok(count)
762    }
763
764    /// Get raw key-value bytes (for advanced use cases).
765    pub fn get_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<Option<Vec<u8>>> {
766        let db = self.galaxy_db(galaxy)?;
767        let tx = self
768            .env
769            .begin_ro_txn()
770            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
771        match tx.get(db, &key) {
772            Ok(bytes) => {
773                let data = bytes.to_vec();
774                tx.commit()
775                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
776                Ok(Some(data))
777            }
778            Err(lmdb::Error::NotFound) => {
779                tx.commit()
780                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
781                Ok(None)
782            }
783            Err(e) => Err(CoreError::Memory(format!("LMDB get_raw failed: {e}"))),
784        }
785    }
786
787    /// Put raw key-value bytes (for advanced use cases).
788    pub fn put_raw(&self, galaxy: Galaxy, key: &[u8], val: &[u8]) -> Result<()> {
789        let db = self.galaxy_db(galaxy)?;
790        let mut tx = self
791            .env
792            .begin_rw_txn()
793            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
794        tx.put(db, &key, &val, lmdb::WriteFlags::default())
795            .map_err(|e| CoreError::Memory(format!("LMDB put_raw failed: {e}")))?;
796        tx.commit()
797            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
798        self.mutation_count.fetch_add(1, Ordering::Relaxed);
799        Ok(())
800    }
801
802    /// Delete raw key-value bytes (for advanced use cases).
803    /// Returns true if a key was removed.
804    pub fn delete_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<bool> {
805        let db = self.galaxy_db(galaxy)?;
806        let mut tx = self
807            .env
808            .begin_rw_txn()
809            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
810        let deleted = tx.del(db, &key, None).is_ok();
811        tx.commit()
812            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
813        if deleted {
814            self.mutation_count.fetch_add(1, Ordering::Relaxed);
815        }
816        Ok(deleted)
817    }
818
819    /// Batch multiple raw key-value writes in a single LMDB transaction.
820    /// All writes succeed or fail atomically.
821    pub fn put_raw_batch(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
822        self.put_raw_batch_impl(galaxy, entries)?;
823        self.mutation_count
824            .fetch_add(entries.len() as u64, Ordering::Relaxed);
825        Ok(())
826    }
827
828    /// Batch multiple raw key-value writes without advancing the mutation
829    /// counter.
830    ///
831    /// For governance bookkeeping (karma chain, write-audit journal): these
832    /// writes are metadata *about* dispatches, not memory mutations. Letting
833    /// them tick the counter attributes a whole batch flush to whichever
834    /// dispatch happens to be in flight when the threshold trips — the
835    /// 2026-08-28 restore-drill false-positive class ("read-only" tools
836    /// flagged with the previous batch's size as their write delta).
837    pub fn put_raw_batch_untracked(
838        &self,
839        galaxy: Galaxy,
840        entries: &[(&[u8], &[u8])],
841    ) -> Result<()> {
842        self.put_raw_batch_impl(galaxy, entries)
843    }
844
845    fn put_raw_batch_impl(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
846        let db = self.galaxy_db(galaxy)?;
847        let mut tx = self
848            .env
849            .begin_rw_txn()
850            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
851        for (key, val) in entries {
852            tx.put(db, key, val, WriteFlags::default())
853                .map_err(|e| CoreError::Memory(format!("LMDB put_raw_batch failed: {e}")))?;
854        }
855        tx.commit()
856            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
857        Ok(())
858    }
859
860    // ── Content-hash Deduplication ────────────────────────────────────
861
862    /// Check if a memory with the same content hash already exists in the galaxy.
863    /// Uses the content_hash index for O(1) lookup.
864    /// Returns the existing memory's ID if found.
865    pub fn find_by_content_hash(&self, galaxy: Galaxy, hash: &str) -> Result<Option<uuid::Uuid>> {
866        let tx = self
867            .env
868            .begin_ro_txn()
869            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
870        let result = self.index_dbs.find_by_content_hash(&tx, galaxy, hash)?;
871        tx.commit()
872            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
873        Ok(result)
874    }
875
876    /// Scan-based content hash lookup (O(n) fallback, used for testing index correctness).
877    pub fn find_by_content_hash_scan(
878        &self,
879        galaxy: Galaxy,
880        hash: &str,
881    ) -> Result<Option<uuid::Uuid>> {
882        let memories = self.scan(galaxy, 10_000)?;
883        for mem in memories {
884            if mem.metadata.content_hash == hash {
885                return Ok(Some(mem.metadata.id));
886            }
887        }
888        Ok(None)
889    }
890
891    /// Store a memory with content-hash deduplication.
892    /// If a memory with the same content already exists in the galaxy,
893    /// returns the existing memory's ID without creating a duplicate.
894    pub fn put_dedup(&self, galaxy: Galaxy, memory: &Memory) -> Result<uuid::Uuid> {
895        if let Some(existing_id) =
896            self.find_by_content_hash(galaxy, &memory.metadata.content_hash)?
897        {
898            return Ok(existing_id);
899        }
900        let id = memory.metadata.id;
901        self.put(galaxy, memory)?;
902        Ok(id)
903    }
904
905    // ── Write Batching ─────────────────────────────────────────────────
906
907    /// Store multiple memories in a single LMDB transaction (batch write).
908    /// All writes and index updates succeed or fail atomically.
909    pub fn put_batch(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<()> {
910        let db = self.galaxy_db(galaxy)?;
911        let mut tx = self
912            .env
913            .begin_rw_txn()
914            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
915
916        for memory in memories {
917            let key = memory.metadata.id.as_bytes();
918            let val = rmp_serde::to_vec_named(memory)
919                .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
920            tx.put(db, key, &val, WriteFlags::default())
921                .map_err(|e| CoreError::Memory(format!("LMDB put_batch failed: {e}")))?;
922            self.index_dbs.add(&mut tx, galaxy, memory)?;
923        }
924
925        tx.commit()
926            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
927        self.mutation_count
928            .fetch_add(memories.len() as u64, Ordering::Relaxed);
929        Ok(())
930    }
931
932    // ── Query API ──────────────────────────────────────────────────────
933
934    /// Query memories in a galaxy with filtering.
935    /// Uses secondary indexes when the query is a pure single-dimension filter
936    /// (single tag, importance range, or time range with no other filters).
937    /// Falls back to scan for complex multi-dimensional queries.
938    pub fn query(&self, galaxy: Galaxy, query: &MemoryQuery) -> Result<Vec<Memory>> {
939        // Try indexed fast paths for single-dimension queries. A substring
940        // filter forces the full scan — the indexes cannot evaluate it.
941        if query.content_substring.is_none()
942            && query.tags.len() == 1
943            && query.min_importance.is_none()
944            && query.max_importance.is_none()
945            && query.created_after.is_none()
946            && query.created_before.is_none()
947        {
948            return self.query_by_tag_indexed(galaxy, &query.tags[0], query.limit);
949        }
950
951        if query.content_substring.is_none()
952            && query.tags.is_empty()
953            && let Some(min) = query.min_importance
954            && let Some(max) = query.max_importance
955            && query.created_after.is_none()
956            && query.created_before.is_none()
957        {
958            return self.query_by_importance_indexed(galaxy, min, max, query.limit);
959        }
960
961        if query.content_substring.is_none()
962            && query.tags.is_empty()
963            && query.min_importance.is_none()
964            && query.max_importance.is_none()
965            && let Some(after) = query.created_after
966            && let Some(before) = query.created_before
967        {
968            return self.query_by_time_indexed(galaxy, after, before, query.limit);
969        }
970
971        // Fallback: full scan with in-memory filter
972        let memories = self.scan(galaxy, 10_000)?;
973        let mut results = Vec::new();
974        for mem in memories {
975            if query.matches(&mem) {
976                results.push(mem);
977                if results.len() >= query.limit {
978                    break;
979                }
980            }
981        }
982        Ok(results)
983    }
984
985    /// Tag-based indexed query → memories with the given tag.
986    fn query_by_tag_indexed(&self, galaxy: Galaxy, tag: &str, limit: usize) -> Result<Vec<Memory>> {
987        let db = self.galaxy_db(galaxy)?;
988        let tx = self
989            .env
990            .begin_ro_txn()
991            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
992        let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
993        let mut results = Vec::new();
994        for id in &ids {
995            if results.len() >= limit {
996                break;
997            }
998            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
999                if let Ok(mem) = crate::codec::decode(bytes) {
1000                    results.push(mem);
1001                }
1002            }
1003        }
1004        tx.commit()
1005            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1006        Ok(results)
1007    }
1008
1009    /// Importance-range indexed query → memories with importance in [min, max].
1010    fn query_by_importance_indexed(
1011        &self,
1012        galaxy: Galaxy,
1013        min: f32,
1014        max: f32,
1015        limit: usize,
1016    ) -> Result<Vec<Memory>> {
1017        let db = self.galaxy_db(galaxy)?;
1018        let tx = self
1019            .env
1020            .begin_ro_txn()
1021            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1022        let ids = self
1023            .index_dbs
1024            .find_by_importance_range(&tx, galaxy, min, max)?;
1025        let mut results = Vec::new();
1026        for id in &ids {
1027            if results.len() >= limit {
1028                break;
1029            }
1030            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1031                if let Ok(mem) = crate::codec::decode(bytes) {
1032                    results.push(mem);
1033                }
1034            }
1035        }
1036        tx.commit()
1037            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1038        Ok(results)
1039    }
1040
1041    /// Time-range indexed query → memories created in [after, before].
1042    fn query_by_time_indexed(
1043        &self,
1044        galaxy: Galaxy,
1045        after: chrono::DateTime<chrono::Utc>,
1046        before: chrono::DateTime<chrono::Utc>,
1047        limit: usize,
1048    ) -> Result<Vec<Memory>> {
1049        let db = self.galaxy_db(galaxy)?;
1050        let tx = self
1051            .env
1052            .begin_ro_txn()
1053            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1054        let ids = self
1055            .index_dbs
1056            .find_by_time_range(&tx, galaxy, after, before)?;
1057        let mut results = Vec::new();
1058        for id in &ids {
1059            if results.len() >= limit {
1060                break;
1061            }
1062            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1063                if let Ok(mem) = crate::codec::decode(bytes) {
1064                    results.push(mem);
1065                }
1066            }
1067        }
1068        tx.commit()
1069            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1070        Ok(results)
1071    }
1072
1073    // ── Semantic Coordinate Encoding ─────────────────────────────────────
1074
1075    /// Store a memory with semantically-derived 5D coordinates.
1076    ///
1077    /// Replaces the SHA-256 hash-based `Coordinate5D::encode()` with
1078    /// anchor-based TF projection. The memory's `coord5d` field is updated
1079    /// with semantically meaningful x/y/z values before storage.
1080    pub fn put_semantic(&self, galaxy: Galaxy, memory: &mut Memory) -> Result<()> {
1081        let temporal_weight = memory.metadata.coord5d.w;
1082        let importance = memory.metadata.importance;
1083        memory.metadata.coord5d =
1084            self.semantic_encoder
1085                .encode_coordinate(&memory.content, temporal_weight, importance);
1086        self.put(galaxy, memory)
1087    }
1088
1089    /// Find memories in a galaxy with content semantically similar to the query text.
1090    ///
1091    /// Encodes the query text into a 5D coordinate and scans the galaxy,
1092    /// returning memories sorted by semantic distance (nearest first).
1093    pub fn find_similar(
1094        &self,
1095        galaxy: Galaxy,
1096        query_text: &str,
1097        limit: usize,
1098    ) -> Result<Vec<(Memory, f32)>> {
1099        let query_coord = self
1100            .semantic_encoder
1101            .encode_coordinate(query_text, 0.5, 0.5);
1102        let memories = self.scan(galaxy, 10_000)?;
1103        let mut results: Vec<(Memory, f32)> = memories
1104            .into_iter()
1105            .map(|m| {
1106                let dist = query_coord.semantic_distance_to(&m.metadata.coord5d);
1107                (m, dist)
1108            })
1109            .collect();
1110        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1111        results.truncate(limit);
1112        Ok(results)
1113    }
1114
1115    // ── Embedding Storage ──────────────────────────────────────────────
1116
1117    /// Store an embedding vector for a memory in the Embeddings galaxy.
1118    /// Keyed by the memory's UUID.
1119    pub fn put_embedding(&self, memory_id: uuid::Uuid, embedding: &[f32]) -> Result<()> {
1120        let db = self.galaxy_db(Galaxy::Embeddings)?;
1121        let key = memory_id.as_bytes();
1122        let val = encode_embedding(embedding);
1123
1124        let mut tx = self
1125            .env
1126            .begin_rw_txn()
1127            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1128        tx.put(db, key, &val, WriteFlags::default())
1129            .map_err(|e| CoreError::Memory(format!("LMDB put_embedding failed: {e}")))?;
1130        tx.commit()
1131            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1132        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1133        Ok(())
1134    }
1135
1136    /// Retrieve an embedding vector for a memory from the Embeddings galaxy.
1137    pub fn get_embedding(&self, memory_id: uuid::Uuid) -> Result<Option<Vec<f32>>> {
1138        let db = self.galaxy_db(Galaxy::Embeddings)?;
1139        let key = memory_id.as_bytes();
1140
1141        let tx = self
1142            .env
1143            .begin_ro_txn()
1144            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1145        match tx.get(db, key) {
1146            Ok(bytes) => {
1147                let embedding = decode_embedding(bytes);
1148                tx.commit()
1149                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1150                Ok(Some(embedding))
1151            }
1152            Err(lmdb::Error::NotFound) => {
1153                tx.commit()
1154                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1155                Ok(None)
1156            }
1157            Err(e) => Err(CoreError::Memory(format!("LMDB get_embedding failed: {e}"))),
1158        }
1159    }
1160
1161    /// Delete an embedding vector from the Embeddings galaxy.
1162    pub fn delete_embedding(&self, memory_id: uuid::Uuid) -> Result<bool> {
1163        let db = self.galaxy_db(Galaxy::Embeddings)?;
1164        let key = memory_id.as_bytes();
1165
1166        let mut tx = self
1167            .env
1168            .begin_rw_txn()
1169            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1170        let exists = tx.get(db, key).is_ok();
1171        if exists {
1172            tx.del(db, key, None)
1173                .map_err(|e| CoreError::Memory(format!("LMDB del_embedding failed: {e}")))?;
1174        }
1175        tx.commit()
1176            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1177        if exists {
1178            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1179        }
1180        Ok(exists)
1181    }
1182
1183    // ── Embedding cache (content-hash → vector) ────────────────────────
1184
1185    /// Store a cached embedding under a caller-computed cache key
1186    /// (embedder namespace + content hash). Vectors for the same content
1187    /// differ across models, so the key must carry the namespace.
1188    pub fn put_embedding_cache(&self, cache_key: &str, embedding: &[f32]) -> Result<()> {
1189        let mut tx = self
1190            .env
1191            .begin_rw_txn()
1192            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1193        tx.put(
1194            self.embedding_cache_db,
1195            &cache_key.as_bytes().to_vec(),
1196            &encode_embedding(embedding),
1197            WriteFlags::default(),
1198        )
1199        .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1200        tx.commit()
1201            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1202        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1203        Ok(())
1204    }
1205
1206    /// Batched cache write: one transaction for the whole ingest chunk.
1207    pub fn put_embedding_cache_batch(&self, entries: &[(String, Vec<f32>)]) -> Result<()> {
1208        if entries.is_empty() {
1209            return Ok(());
1210        }
1211        let mut tx = self
1212            .env
1213            .begin_rw_txn()
1214            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1215        for (key, embedding) in entries {
1216            tx.put(
1217                self.embedding_cache_db,
1218                &key.as_bytes().to_vec(),
1219                &encode_embedding(embedding),
1220                WriteFlags::default(),
1221            )
1222            .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1223        }
1224        tx.commit()
1225            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1226        self.mutation_count
1227            .fetch_add(entries.len() as u64, Ordering::Relaxed);
1228        Ok(())
1229    }
1230
1231    /// Look up a cached embedding. `Ok(None)` = miss; the caller embeds.
1232    pub fn get_embedding_cache(&self, cache_key: &str) -> Result<Option<Vec<f32>>> {
1233        let tx = self
1234            .env
1235            .begin_ro_txn()
1236            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1237        match tx.get(self.embedding_cache_db, &cache_key.as_bytes().to_vec()) {
1238            Ok(bytes) => {
1239                let embedding = decode_embedding(bytes);
1240                tx.commit()
1241                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1242                Ok(Some(embedding))
1243            }
1244            Err(lmdb::Error::NotFound) => {
1245                tx.commit()
1246                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1247                Ok(None)
1248            }
1249            Err(e) => Err(CoreError::Memory(format!(
1250                "LMDB get_embedding_cache failed: {e}"
1251            ))),
1252        }
1253    }
1254
1255    /// Batched lookup: one read transaction for the whole ingest chunk.
1256    /// Result aligns 1:1 with `keys`.
1257    pub fn get_embedding_cache_batch(&self, keys: &[String]) -> Result<Vec<Option<Vec<f32>>>> {
1258        let tx = self
1259            .env
1260            .begin_ro_txn()
1261            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1262        let mut out = Vec::with_capacity(keys.len());
1263        for key in keys {
1264            out.push(
1265                tx.get(self.embedding_cache_db, &key.as_bytes().to_vec())
1266                    .ok()
1267                    .map(decode_embedding),
1268            );
1269        }
1270        tx.commit()
1271            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1272        Ok(out)
1273    }
1274
1275    /// Number of cached vectors (doctor / honesty surfaces).
1276    pub fn embedding_cache_count(&self) -> Result<u64> {
1277        let tx = self
1278            .env
1279            .begin_ro_txn()
1280            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1281        let mut cursor = tx
1282            .open_ro_cursor(self.embedding_cache_db)
1283            .map_err(|e| CoreError::Memory(format!("LMDB cursor embedding_cache failed: {e}")))?;
1284        let mut count = 0u64;
1285        for _ in cursor.iter() {
1286            count += 1;
1287        }
1288        Ok(count)
1289    }
1290
1291    // ── Revision chain (V8 S11c) ────────────────────────────────────────
1292
1293    /// Append one content revision to a memory's chain. Seq is derived
1294    /// from the current chain tail (append-only by convention); the write
1295    /// bumps the store mutation counter so dispatch windows attribute it.
1296    pub fn record_revision(
1297        &self,
1298        galaxy: Galaxy,
1299        id: MemoryId,
1300        old_hash: &str,
1301        new_hash: &str,
1302        actor: crate::revision::RevisionActor,
1303    ) -> Result<crate::revision::MemoryRevision> {
1304        let seq = self.revisions(galaxy, id)?.len() as u32;
1305        let entry = crate::revision::MemoryRevision {
1306            seq,
1307            timestamp: wm_core::time::now_unix_secs(),
1308            old_hash: old_hash.to_string(),
1309            new_hash: new_hash.to_string(),
1310            actor_session: actor.session,
1311            actor_user: actor.user,
1312            actor_compartment: actor.compartment,
1313        };
1314        let key = crate::revision::revision_key(galaxy, id, seq);
1315        let val = serde_json::to_vec(&entry)
1316            .map_err(|e| CoreError::Memory(format!("revision serialize failed: {e}")))?;
1317        let mut tx = self
1318            .env
1319            .begin_rw_txn()
1320            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1321        tx.put(self.revisions_db, &key, &val, WriteFlags::default())
1322            .map_err(|e| CoreError::Memory(format!("LMDB put revision failed: {e}")))?;
1323        tx.commit()
1324            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1325        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1326        Ok(entry)
1327    }
1328
1329    /// Full revision chain for a memory, ordered by seq. Empty for
1330    /// memories never content-updated (or pre-S11c).
1331    pub fn revisions(
1332        &self,
1333        galaxy: Galaxy,
1334        id: MemoryId,
1335    ) -> Result<Vec<crate::revision::MemoryRevision>> {
1336        // Cursor-op constants from lmdb.h (frozen LMDB ABI): the `lmdb`
1337        // crate's `iter_from` panics on a SetRange miss, and a miss is a
1338        // normal state here (a memory with no revisions yet sorts after
1339        // every existing key), so the cursor is driven manually.
1340        const MDB_GET_CURRENT: u32 = 4;
1341        const MDB_NEXT: u32 = 8;
1342        const MDB_SET_RANGE: u32 = 17;
1343        let prefix = crate::revision::revision_prefix(galaxy, id);
1344        let tx = self
1345            .env
1346            .begin_ro_txn()
1347            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1348        let cursor = tx
1349            .open_ro_cursor(self.revisions_db)
1350            .map_err(|e| CoreError::Memory(format!("LMDB cursor revisions failed: {e}")))?;
1351        let mut out = Vec::new();
1352        if cursor.get(Some(&prefix), None, MDB_SET_RANGE).is_ok() {
1353            while let Ok((key, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1354                // `key` is None only for ops that return no key — GET_CURRENT
1355                // after a positioned read always carries one.
1356                if !key.is_some_and(|k| k.starts_with(&prefix)) {
1357                    break;
1358                }
1359                let entry: crate::revision::MemoryRevision = serde_json::from_slice(val)
1360                    .map_err(|e| CoreError::Memory(format!("revision deserialize failed: {e}")))?;
1361                out.push(entry);
1362                // Bounded walk — corrupt data can never spin this loop.
1363                if out.len() >= 10_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1364                    break;
1365                }
1366            }
1367        }
1368        drop(cursor);
1369        tx.commit()
1370            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1371        Ok(out)
1372    }
1373
1374    /// Walk one memory's revision chain and grade it against the memory's
1375    /// current content hash (seq continuity, hash linkage, head match).
1376    pub fn verify_revision_chain(
1377        &self,
1378        galaxy: Galaxy,
1379        id: MemoryId,
1380        current_hash: &str,
1381    ) -> Result<crate::revision::RevisionChainReport> {
1382        let entries = self.revisions(galaxy, id)?;
1383        Ok(crate::revision::verify_chain(&entries, current_hash))
1384    }
1385
1386    // ── Record attestations (Track F Slice A, D5) ───────────────────────
1387
1388    /// Record one creation attestation for a memory. Upsert by key
1389    /// (`att:{galaxy}:{memory_id}`): re-attestation overwrites, which is
1390    /// safe because attestation covers the *creation* event and memory ids
1391    /// are unique per create. The write bumps the mutation counter like
1392    /// every other store mutation.
1393    pub fn record_attestation(
1394        &self,
1395        galaxy: Galaxy,
1396        id: MemoryId,
1397        entry: &crate::attestation::RecordAttestation,
1398    ) -> Result<()> {
1399        let key = crate::attestation::attestation_key(galaxy, id);
1400        let val = serde_json::to_vec(entry)
1401            .map_err(|e| CoreError::Memory(format!("attestation serialize failed: {e}")))?;
1402        let mut tx = self
1403            .env
1404            .begin_rw_txn()
1405            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1406        tx.put(self.attestations_db, &key, &val, WriteFlags::default())
1407            .map_err(|e| CoreError::Memory(format!("LMDB put attestation failed: {e}")))?;
1408        tx.commit()
1409            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1410        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1411        Ok(())
1412    }
1413
1414    /// This memory's creation attestation, if the creating dispatch signed
1415    /// one (key-available creates only — absence is honest, not an error).
1416    pub fn attestation(
1417        &self,
1418        galaxy: Galaxy,
1419        id: MemoryId,
1420    ) -> Result<Option<crate::attestation::RecordAttestation>> {
1421        let key = crate::attestation::attestation_key(galaxy, id);
1422        let tx = self
1423            .env
1424            .begin_ro_txn()
1425            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1426        let out =
1427            match tx.get(self.attestations_db, &key) {
1428                Ok(val) => Some(serde_json::from_slice(val).map_err(|e| {
1429                    CoreError::Memory(format!("attestation deserialize failed: {e}"))
1430                })?),
1431                Err(lmdb::Error::NotFound) => None,
1432                Err(e) => {
1433                    return Err(CoreError::Memory(format!(
1434                        "LMDB get attestation failed: {e}"
1435                    )));
1436                }
1437            };
1438        drop(tx);
1439        Ok(out)
1440    }
1441
1442    /// Every attestation in the store (for `wm anchor`). Bounded walk —
1443    /// corrupt data can never spin this loop.
1444    pub fn scan_attestations(&self) -> Result<Vec<crate::attestation::RecordAttestation>> {
1445        const MDB_GET_CURRENT: u32 = 4;
1446        const MDB_NEXT: u32 = 8;
1447        const MDB_FIRST: u32 = 9;
1448        let tx = self
1449            .env
1450            .begin_ro_txn()
1451            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1452        let cursor = tx
1453            .open_ro_cursor(self.attestations_db)
1454            .map_err(|e| CoreError::Memory(format!("LMDB cursor attestations failed: {e}")))?;
1455        let mut out = Vec::new();
1456        if cursor.get(None, None, MDB_FIRST).is_ok() {
1457            while let Ok((_, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1458                let entry: crate::attestation::RecordAttestation = serde_json::from_slice(val)
1459                    .map_err(|e| {
1460                        CoreError::Memory(format!("attestation deserialize failed: {e}"))
1461                    })?;
1462                out.push(entry);
1463                // Bounded walk — corrupt data can never spin this loop.
1464                if out.len() >= 1_000_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1465                    break;
1466                }
1467            }
1468        }
1469        drop(cursor);
1470        tx.commit()
1471            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1472        Ok(out)
1473    }
1474
1475    /// Grade one memory's attestation: presence, signature validity, and
1476    /// whether the attested hash still matches the live content hash.
1477    /// A content *update* after creation flips `matches_head` to false by
1478    /// design — updates are covered by the revisions chain, not by
1479    /// re-attestation.
1480    pub fn verify_attestation(
1481        &self,
1482        galaxy: Galaxy,
1483        id: MemoryId,
1484    ) -> Result<crate::attestation::AttestationReport> {
1485        use crate::attestation::AttestationReport;
1486        let Some(att) = self.attestation(galaxy, id)? else {
1487            return Ok(AttestationReport {
1488                attested: false,
1489                signature_valid: false,
1490                matches_head: false,
1491                memory_present: self.get(galaxy, id)?.is_some(),
1492                breaks: vec!["no attestation recorded for this memory".to_string()],
1493            });
1494        };
1495        let mut breaks = Vec::new();
1496        let signature_valid = crate::attestation::verify_attestation(&att);
1497        if !signature_valid {
1498            breaks.push("signature does not verify against recorded pubkey".to_string());
1499        }
1500        let (matches_head, memory_present) = if let Some(memory) = self.get(galaxy, id)? {
1501            let matches = memory.metadata.content_hash == att.record_hash;
1502            if !matches {
1503                breaks.push(
1504                    "attested record_hash != live content_hash (memory updated after attestation)"
1505                        .to_string(),
1506                );
1507            }
1508            (matches, true)
1509        } else {
1510            breaks.push("attested memory id not present in galaxy".to_string());
1511            (false, false)
1512        };
1513        Ok(AttestationReport {
1514            attested: true,
1515            signature_valid,
1516            matches_head,
1517            memory_present,
1518            breaks,
1519        })
1520    }
1521
1522    /// Grade every attestation in the store (for `wm anchor`). Entries
1523    /// whose galaxy/id no longer parse are reported as broken — never
1524    /// skipped silently.
1525    pub fn attestation_sweep(
1526        &self,
1527    ) -> Result<
1528        Vec<(
1529            crate::attestation::RecordAttestation,
1530            crate::attestation::AttestationReport,
1531        )>,
1532    > {
1533        use crate::attestation::AttestationReport;
1534        let mut out = Vec::new();
1535        for att in self.scan_attestations()? {
1536            let parsed = match (
1537                Galaxy::from_db_name(&att.galaxy),
1538                uuid::Uuid::parse_str(&att.memory_id),
1539            ) {
1540                (Some(galaxy), Ok(id)) => Some((galaxy, id)),
1541                _ => None,
1542            };
1543            match parsed {
1544                Some((galaxy, id)) => out.push((att, self.verify_attestation(galaxy, id)?)),
1545                None => out.push((
1546                    att,
1547                    AttestationReport {
1548                        attested: true,
1549                        signature_valid: false,
1550                        matches_head: false,
1551                        memory_present: false,
1552                        breaks: vec![
1553                            "attestation row has unparseable galaxy or memory id".to_string(),
1554                        ],
1555                    },
1556                )),
1557            }
1558        }
1559        Ok(out)
1560    }
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566    use crate::content_hash;
1567
1568    #[test]
1569    fn open_and_create_galaxies() {
1570        let tmp = tempfile::tempdir().unwrap();
1571        let store = MemoryStore::open_default(tmp.path()).unwrap();
1572        for galaxy in Galaxy::all() {
1573            let _db = store.galaxy_db(galaxy).unwrap();
1574        }
1575    }
1576
1577    /// Substring filter (memory.query trap fix, 2026-08-29): literal
1578    /// case-insensitive content match, galaxy-wide — never an arbitrary
1579    /// page, never routed through the indexed fast paths.
1580    #[test]
1581    fn query_substring_filters_galaxy_wide() {
1582        let tmp = tempfile::tempdir().unwrap();
1583        let store = MemoryStore::open_default(tmp.path()).unwrap();
1584
1585        for (i, content) in [
1586            "the mesh joins at dawn",
1587            "unrelated content entirely",
1588            "MESH joins at dusk",
1589        ]
1590        .iter()
1591        .enumerate()
1592        {
1593            let mut m = Memory::new(Galaxy::Codex, content.to_string());
1594            m.metadata.importance = 0.5 + i as f32 / 10.0;
1595            store.put(Galaxy::Codex, &m).unwrap();
1596        }
1597
1598        let hits = store
1599            .query(
1600                Galaxy::Codex,
1601                &MemoryQuery::new().with_content_substring("mesh joins"),
1602            )
1603            .unwrap();
1604        assert_eq!(hits.len(), 2, "CI substring must match both: {hits:?}");
1605        assert!(
1606            hits.iter()
1607                .all(|m| m.content.to_lowercase().contains("mesh joins"))
1608        );
1609
1610        let none = store
1611            .query(
1612                Galaxy::Codex,
1613                &MemoryQuery::new().with_content_substring("quantum calendar"),
1614            )
1615            .unwrap();
1616        assert!(none.is_empty(), "no match must be an honest empty set");
1617
1618        // Substring + tag combined still applies (no fast-path bypass).
1619        let mut tagged = Memory::new(Galaxy::Codex, "mesh joins again".to_string());
1620        tagged.metadata.tags = vec!["mesh".into()];
1621        store.put(Galaxy::Codex, &tagged).unwrap();
1622        let combined = store
1623            .query(
1624                Galaxy::Codex,
1625                &MemoryQuery::new()
1626                    .with_tags(vec!["mesh".into()])
1627                    .with_content_substring("again"),
1628            )
1629            .unwrap();
1630        assert_eq!(combined.len(), 1);
1631        assert_eq!(combined[0].content, "mesh joins again");
1632    }
1633
1634    #[cfg(unix)]
1635    #[test]
1636    fn store_dir_has_restrictive_permissions() {
1637        let tmp = tempfile::tempdir().unwrap();
1638        let store_path = tmp.path().join("lmdb");
1639        let _store = MemoryStore::open_default(&store_path).unwrap();
1640        let perms = std::fs::metadata(&store_path).unwrap().permissions().mode();
1641        assert_eq!(
1642            perms & 0o777,
1643            0o700,
1644            "store directory should have 0700 permissions, got {:o}",
1645            perms & 0o777
1646        );
1647    }
1648
1649    #[test]
1650    fn put_get_delete_memory() {
1651        let tmp = tempfile::tempdir().unwrap();
1652        let store = MemoryStore::open_default(tmp.path()).unwrap();
1653
1654        let mem = Memory::new(Galaxy::Codex, "Hello world".to_string());
1655        let id = mem.metadata.id;
1656
1657        store.put(Galaxy::Codex, &mem).unwrap();
1658        let retrieved = store.get(Galaxy::Codex, id).unwrap();
1659        assert!(retrieved.is_some());
1660        assert_eq!(retrieved.unwrap().content, "Hello world");
1661
1662        let deleted = store.delete(Galaxy::Codex, id).unwrap();
1663        assert!(deleted);
1664
1665        let gone = store.get(Galaxy::Codex, id).unwrap();
1666        assert!(gone.is_none());
1667    }
1668
1669    #[test]
1670    fn scan_memories() {
1671        let tmp = tempfile::tempdir().unwrap();
1672        let store = MemoryStore::open_default(tmp.path()).unwrap();
1673
1674        for i in 0..5 {
1675            let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
1676            store.put(Galaxy::Codex, &mem).unwrap();
1677        }
1678
1679        let all = store.scan(Galaxy::Codex, 100).unwrap();
1680        assert_eq!(all.len(), 5);
1681
1682        let limited = store.scan(Galaxy::Codex, 3).unwrap();
1683        assert_eq!(limited.len(), 3);
1684    }
1685
1686    #[test]
1687    fn overwrite_removes_stale_index_entries() {
1688        let tmp = tempfile::tempdir().unwrap();
1689        let store = MemoryStore::open_default(tmp.path()).unwrap();
1690
1691        // Original record: tag "alpha", importance 0.9.
1692        let mut mem = Memory::new(Galaxy::Codex, "overwrite target".to_string());
1693        mem.metadata.tags = vec!["alpha".to_string()];
1694        mem.metadata.importance = 0.9;
1695        let id = mem.metadata.id;
1696        store.put(Galaxy::Codex, &mem).unwrap();
1697
1698        // Overwrite with tag "beta", importance 0.1, new content hash.
1699        let mut updated = Memory::new(Galaxy::Codex, "overwritten content".to_string());
1700        updated.metadata.id = id;
1701        updated.metadata.tags = vec!["beta".to_string()];
1702        updated.metadata.importance = 0.1;
1703        store.put(Galaxy::Codex, &updated).unwrap();
1704
1705        // Stale entries must be gone, new entries must be queryable.
1706        let tx = store.env().begin_ro_txn().unwrap();
1707        let by_alpha = store
1708            .index_dbs()
1709            .find_by_tag(&tx, Galaxy::Codex, "alpha")
1710            .unwrap();
1711        let by_beta = store
1712            .index_dbs()
1713            .find_by_tag(&tx, Galaxy::Codex, "beta")
1714            .unwrap();
1715        assert!(
1716            by_alpha.is_empty(),
1717            "stale tag index entries must be removed on overwrite"
1718        );
1719        assert_eq!(by_beta, vec![id]);
1720
1721        let by_importance = store
1722            .index_dbs()
1723            .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 0.2)
1724            .unwrap();
1725        assert!(
1726            by_importance.contains(&id),
1727            "new importance must be indexed"
1728        );
1729        let by_high = store
1730            .index_dbs()
1731            .find_by_importance_range(&tx, Galaxy::Codex, 0.8, 1.0)
1732            .unwrap();
1733        assert!(
1734            !by_high.contains(&id),
1735            "stale importance index entries must be removed on overwrite"
1736        );
1737
1738        let old_hash = content_hash("overwrite target");
1739        let new_hash = content_hash("overwritten content");
1740        assert_eq!(
1741            store
1742                .index_dbs()
1743                .find_by_content_hash(&tx, Galaxy::Codex, &old_hash)
1744                .unwrap(),
1745            None,
1746            "stale content-hash index entry must be removed"
1747        );
1748        assert_eq!(
1749            store
1750                .index_dbs()
1751                .find_by_content_hash(&tx, Galaxy::Codex, &new_hash)
1752                .unwrap(),
1753            Some(id)
1754        );
1755    }
1756
1757    #[test]
1758    fn count_memories() {
1759        let tmp = tempfile::tempdir().unwrap();
1760        let store = MemoryStore::open_default(tmp.path()).unwrap();
1761
1762        assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
1763
1764        for i in 0..3 {
1765            let mem = Memory::new(Galaxy::Codex, format!("count-{i}"));
1766            store.put(Galaxy::Codex, &mem).unwrap();
1767        }
1768
1769        assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
1770    }
1771
1772    #[test]
1773    fn get_nonexistent_returns_none() {
1774        let tmp = tempfile::tempdir().unwrap();
1775        let store = MemoryStore::open_default(tmp.path()).unwrap();
1776        let result = store.get(Galaxy::Codex, uuid::Uuid::new_v4()).unwrap();
1777        assert!(result.is_none());
1778    }
1779
1780    #[test]
1781    fn raw_put_get() {
1782        let tmp = tempfile::tempdir().unwrap();
1783        let store = MemoryStore::open_default(tmp.path()).unwrap();
1784
1785        store
1786            .put_raw(Galaxy::Substrate, b"config:key", b"value123")
1787            .unwrap();
1788        let val = store.get_raw(Galaxy::Substrate, b"config:key").unwrap();
1789        assert_eq!(val, Some(b"value123".to_vec()));
1790    }
1791
1792    #[test]
1793    fn put_dedup_prevents_duplicates() {
1794        let tmp = tempfile::tempdir().unwrap();
1795        let store = MemoryStore::open_default(tmp.path()).unwrap();
1796
1797        let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
1798        let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
1799
1800        let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
1801        let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
1802
1803        assert_eq!(id1, id2, "dedup should return same ID for same content");
1804        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
1805    }
1806
1807    #[test]
1808    fn put_dedup_allows_different_content() {
1809        let tmp = tempfile::tempdir().unwrap();
1810        let store = MemoryStore::open_default(tmp.path()).unwrap();
1811
1812        let mem1 = Memory::new(Galaxy::Codex, "content A".into());
1813        store.put_dedup(Galaxy::Codex, &mem1).unwrap();
1814
1815        let mem2 = Memory::new(Galaxy::Codex, "content B".into());
1816        store.put_dedup(Galaxy::Codex, &mem2).unwrap();
1817
1818        assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
1819    }
1820
1821    #[test]
1822    fn put_batch_atomic_write() {
1823        let tmp = tempfile::tempdir().unwrap();
1824        let store = MemoryStore::open_default(tmp.path()).unwrap();
1825
1826        let memories: Vec<Memory> = (0..10)
1827            .map(|i| Memory::new(Galaxy::Codex, format!("batch-{i}")))
1828            .collect();
1829
1830        store.put_batch(Galaxy::Codex, &memories).unwrap();
1831        assert_eq!(store.count(Galaxy::Codex).unwrap(), 10);
1832    }
1833
1834    #[test]
1835    fn query_by_tags() {
1836        let tmp = tempfile::tempdir().unwrap();
1837        let store = MemoryStore::open_default(tmp.path()).unwrap();
1838
1839        let mem1 = Memory::new(Galaxy::Codex, "tagged memory".into())
1840            .with_tags(vec!["rust".into(), "memory".into()]);
1841        let mem2 =
1842            Memory::new(Galaxy::Codex, "other memory".into()).with_tags(vec!["python".into()]);
1843        store.put(Galaxy::Codex, &mem1).unwrap();
1844        store.put(Galaxy::Codex, &mem2).unwrap();
1845
1846        let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
1847        let results = store.query(Galaxy::Codex, &query).unwrap();
1848        assert_eq!(results.len(), 1);
1849        assert_eq!(results[0].content, "tagged memory");
1850    }
1851
1852    #[test]
1853    fn query_by_importance_range() {
1854        let tmp = tempfile::tempdir().unwrap();
1855        let store = MemoryStore::open_default(tmp.path()).unwrap();
1856
1857        store
1858            .put(
1859                Galaxy::Codex,
1860                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
1861            )
1862            .unwrap();
1863        store
1864            .put(
1865                Galaxy::Codex,
1866                &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
1867            )
1868            .unwrap();
1869        store
1870            .put(
1871                Galaxy::Codex,
1872                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
1873            )
1874            .unwrap();
1875
1876        let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
1877        let results = store.query(Galaxy::Codex, &query).unwrap();
1878        assert_eq!(results.len(), 1);
1879        assert_eq!(results[0].content, "mid");
1880    }
1881
1882    #[test]
1883    fn memory_query_one_sided_time_bounds() {
1884        // `created_after` / `created_before` map onto the temporal filter
1885        // one side at a time (the API passthrough for memory.query).
1886        let old = Memory::new(Galaxy::Codex, "old".into());
1887        let mut recent = Memory::new(Galaxy::Codex, "recent".into());
1888        recent.metadata.created_at = old.metadata.created_at + chrono::Duration::days(30);
1889
1890        let cutoff = old.metadata.created_at + chrono::Duration::days(10);
1891        let after = MemoryQuery::new().with_created_after(cutoff);
1892        assert!(!after.matches(&old), "pre-cutoff memory must not match");
1893        assert!(after.matches(&recent), "post-cutoff memory must match");
1894
1895        let before = MemoryQuery::new().with_created_before(cutoff);
1896        assert!(before.matches(&old), "pre-cutoff memory must match");
1897        assert!(
1898            !before.matches(&recent),
1899            "post-cutoff memory must not match"
1900        );
1901
1902        // Bounds are inclusive.
1903        let edge = MemoryQuery::new().with_created_after(cutoff);
1904        let mut at = Memory::new(Galaxy::Codex, "at cutoff".into());
1905        at.metadata.created_at = cutoff;
1906        assert!(edge.matches(&at), "created_at == after bound is inclusive");
1907    }
1908
1909    #[test]
1910    fn embedding_put_get_delete() {
1911        let tmp = tempfile::tempdir().unwrap();
1912        let store = MemoryStore::open_default(tmp.path()).unwrap();
1913
1914        let id = uuid::Uuid::new_v4();
1915        let embedding = vec![0.1, 0.2, 0.3, 0.4, 0.5];
1916
1917        store.put_embedding(id, &embedding).unwrap();
1918        let retrieved = store.get_embedding(id).unwrap();
1919        assert!(retrieved.is_some());
1920        let retrieved = retrieved.unwrap();
1921        assert_eq!(retrieved.len(), 5);
1922        assert!((retrieved[0] - 0.1).abs() < f32::EPSILON);
1923
1924        assert!(store.delete_embedding(id).unwrap());
1925        assert!(store.get_embedding(id).unwrap().is_none());
1926    }
1927
1928    #[test]
1929    fn embedding_cache_roundtrip_batch_and_count() {
1930        let tmp = tempfile::tempdir().unwrap();
1931        let store = MemoryStore::open_default(tmp.path()).unwrap();
1932
1933        let entries: Vec<(String, Vec<f32>)> = (0..5)
1934            .map(|i| (format!("ns:model:{i:016x}"), vec![i as f32; 8]))
1935            .collect();
1936        store.put_embedding_cache_batch(&entries).unwrap();
1937        assert_eq!(store.embedding_cache_count().unwrap(), 5);
1938
1939        // Single read
1940        let hit = store
1941            .get_embedding_cache("ns:model:0000000000000003")
1942            .unwrap();
1943        assert_eq!(hit.unwrap(), vec![3.0f32; 8]);
1944        assert!(
1945            store
1946                .get_embedding_cache("ns:model:absent")
1947                .unwrap()
1948                .is_none()
1949        );
1950
1951        // Batched read aligns 1:1, misses are None
1952        let keys: Vec<String> = (0..6).map(|i| format!("ns:model:{i:016x}")).collect();
1953        let batch = store.get_embedding_cache_batch(&keys).unwrap();
1954        assert_eq!(batch.len(), 6);
1955        assert!(batch[0..5].iter().all(Option::is_some));
1956        assert!(batch[5].is_none());
1957
1958        // Overwrite is a put, not a duplicate
1959        store
1960            .put_embedding_cache("ns:model:0000000000000001", &[9.0; 8])
1961            .unwrap();
1962        assert_eq!(store.embedding_cache_count().unwrap(), 5);
1963        assert_eq!(
1964            store
1965                .get_embedding_cache("ns:model:0000000000000001")
1966                .unwrap()
1967                .unwrap(),
1968            vec![9.0f32; 8]
1969        );
1970    }
1971
1972    #[test]
1973    fn embedding_cache_survives_store_reopen() {
1974        // V8 ship list #2 acceptance shape: vectors persist across restart.
1975        let tmp = tempfile::tempdir().unwrap();
1976        {
1977            let store = MemoryStore::open_default(tmp.path()).unwrap();
1978            store
1979                .put_embedding_cache("onnx:bge-small:abc", &[0.5; 384])
1980                .unwrap();
1981        }
1982        let reopened = MemoryStore::open_default(tmp.path()).unwrap();
1983        let cached = reopened.get_embedding_cache("onnx:bge-small:abc").unwrap();
1984        assert_eq!(cached.unwrap(), vec![0.5f32; 384]);
1985    }
1986
1987    #[test]
1988    fn content_hash_is_sha256() {
1989        let hash1 = content_hash("test content");
1990        let hash2 = content_hash("test content");
1991        let hash3 = content_hash("different content");
1992
1993        assert_eq!(hash1, hash2, "same content should produce same hash");
1994        assert_ne!(
1995            hash1, hash3,
1996            "different content should produce different hash"
1997        );
1998        assert_eq!(hash1.len(), 64, "SHA-256 hex should be 64 chars");
1999    }
2000
2001    #[test]
2002    fn query_by_tag_uses_index() {
2003        let tmp = tempfile::tempdir().unwrap();
2004        let store = MemoryStore::open_default(tmp.path()).unwrap();
2005
2006        let mem1 = Memory::new(Galaxy::Codex, "tagged".into())
2007            .with_tags(vec!["rust".into(), "memory".into()]);
2008        let mem2 = Memory::new(Galaxy::Codex, "other".into()).with_tags(vec!["python".into()]);
2009        store.put(Galaxy::Codex, &mem1).unwrap();
2010        store.put(Galaxy::Codex, &mem2).unwrap();
2011
2012        let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2013        let results = store.query(Galaxy::Codex, &query).unwrap();
2014        assert_eq!(results.len(), 1);
2015        assert_eq!(results[0].content, "tagged");
2016    }
2017
2018    #[test]
2019    fn query_by_importance_uses_index() {
2020        let tmp = tempfile::tempdir().unwrap();
2021        let store = MemoryStore::open_default(tmp.path()).unwrap();
2022
2023        store
2024            .put(
2025                Galaxy::Codex,
2026                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
2027            )
2028            .unwrap();
2029        store
2030            .put(
2031                Galaxy::Codex,
2032                &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
2033            )
2034            .unwrap();
2035        store
2036            .put(
2037                Galaxy::Codex,
2038                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
2039            )
2040            .unwrap();
2041
2042        let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
2043        let results = store.query(Galaxy::Codex, &query).unwrap();
2044        assert_eq!(results.len(), 1);
2045        assert_eq!(results[0].content, "mid");
2046    }
2047
2048    #[test]
2049    fn query_by_time_uses_index() {
2050        let tmp = tempfile::tempdir().unwrap();
2051        let store = MemoryStore::open_default(tmp.path()).unwrap();
2052
2053        let t0 = chrono::Utc::now();
2054        std::thread::sleep(std::time::Duration::from_millis(10));
2055        let mem = Memory::new(Galaxy::Codex, "timed".into());
2056        store.put(Galaxy::Codex, &mem).unwrap();
2057        std::thread::sleep(std::time::Duration::from_millis(10));
2058        let t2 = chrono::Utc::now();
2059
2060        let query = MemoryQuery::new().with_time_range(t0, t2);
2061        let results = store.query(Galaxy::Codex, &query).unwrap();
2062        assert_eq!(results.len(), 1);
2063        assert_eq!(results[0].content, "timed");
2064    }
2065
2066    #[test]
2067    fn delete_removes_index_entries() {
2068        let tmp = tempfile::tempdir().unwrap();
2069        let store = MemoryStore::open_default(tmp.path()).unwrap();
2070
2071        let mem = Memory::new(Galaxy::Codex, "test".into())
2072            .with_tags(vec!["tag1".into()])
2073            .with_importance(0.7);
2074        let id = mem.metadata.id;
2075        let hash = mem.metadata.content_hash.clone();
2076        store.put(Galaxy::Codex, &mem).unwrap();
2077
2078        // Verify index entries exist
2079        assert!(
2080            store
2081                .find_by_content_hash(Galaxy::Codex, &hash)
2082                .unwrap()
2083                .is_some()
2084        );
2085
2086        // Delete
2087        store.delete(Galaxy::Codex, id).unwrap();
2088
2089        // Verify index entries are gone
2090        assert!(
2091            store
2092                .find_by_content_hash(Galaxy::Codex, &hash)
2093                .unwrap()
2094                .is_none()
2095        );
2096
2097        // Tag query should return 0
2098        let query = MemoryQuery::new().with_tags(vec!["tag1".into()]);
2099        let results = store.query(Galaxy::Codex, &query).unwrap();
2100        assert!(results.is_empty());
2101    }
2102
2103    #[test]
2104    fn put_batch_updates_indexes() {
2105        let tmp = tempfile::tempdir().unwrap();
2106        let store = MemoryStore::open_default(tmp.path()).unwrap();
2107
2108        let memories: Vec<Memory> = (0..5)
2109            .map(|i| {
2110                Memory::new(Galaxy::Codex, format!("batch-{i}"))
2111                    .with_tags(vec![format!("tag{i}")])
2112                    .with_importance(i as f32 * 0.2)
2113            })
2114            .collect();
2115        store.put_batch(Galaxy::Codex, &memories).unwrap();
2116
2117        for i in 0..5 {
2118            let query = MemoryQuery::new().with_tags(vec![format!("tag{i}")]);
2119            let results = store.query(Galaxy::Codex, &query).unwrap();
2120            assert_eq!(results.len(), 1, "tag{i} should have 1 result");
2121        }
2122    }
2123
2124    #[test]
2125    fn find_by_content_hash_indexed_matches_scan() {
2126        let tmp = tempfile::tempdir().unwrap();
2127        let store = MemoryStore::open_default(tmp.path()).unwrap();
2128
2129        let mem = Memory::new(Galaxy::Codex, "dedup test".into());
2130        let id = mem.metadata.id;
2131        let hash = mem.metadata.content_hash.clone();
2132        store.put(Galaxy::Codex, &mem).unwrap();
2133
2134        let indexed = store.find_by_content_hash(Galaxy::Codex, &hash).unwrap();
2135        let scanned = store
2136            .find_by_content_hash_scan(Galaxy::Codex, &hash)
2137            .unwrap();
2138
2139        assert_eq!(indexed, scanned);
2140        assert_eq!(indexed, Some(id));
2141    }
2142
2143    #[test]
2144    fn put_dedup_uses_index() {
2145        let tmp = tempfile::tempdir().unwrap();
2146        let store = MemoryStore::open_default(tmp.path()).unwrap();
2147
2148        let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2149        let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2150
2151        let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2152        let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2153
2154        assert_eq!(id1, id2, "dedup should return same ID for same content");
2155        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2156    }
2157
2158    #[test]
2159    fn put_semantic_updates_coord5d() {
2160        let tmp = tempfile::tempdir().unwrap();
2161        let store = MemoryStore::open_default(tmp.path()).unwrap();
2162
2163        let mut mem = Memory::new(
2164            Galaxy::Codex,
2165            "The algorithm computes data using a systematic method".to_string(),
2166        );
2167        let original_coord = mem.metadata.coord5d.clone();
2168        store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2169
2170        // coord5d should have changed from the SHA-256 hash-based encoding
2171        assert_ne!(
2172            mem.metadata.coord5d.x, original_coord.x,
2173            "semantic encoding should change x"
2174        );
2175        assert_ne!(
2176            mem.metadata.coord5d.y, original_coord.y,
2177            "semantic encoding should change y"
2178        );
2179
2180        // Verify it was stored with the semantic coordinate
2181        let retrieved = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
2182        assert_eq!(retrieved.metadata.coord5d.x, mem.metadata.coord5d.x);
2183    }
2184
2185    #[test]
2186    fn put_semantic_preserves_temporal_and_importance() {
2187        let tmp = tempfile::tempdir().unwrap();
2188        let store = MemoryStore::open_default(tmp.path()).unwrap();
2189
2190        let mut mem = Memory::new(Galaxy::Codex, "test content".into()).with_importance(0.8);
2191        mem.metadata.coord5d.w = 0.6;
2192        store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2193
2194        assert!((mem.metadata.coord5d.w - 0.6).abs() < f32::EPSILON);
2195        assert!((mem.metadata.coord5d.v - 0.8).abs() < f32::EPSILON);
2196    }
2197
2198    #[test]
2199    fn find_similar_returns_nearest_first() {
2200        let tmp = tempfile::tempdir().unwrap();
2201        let store = MemoryStore::open_default(tmp.path()).unwrap();
2202
2203        let mut logic_mem = Memory::new(
2204            Galaxy::Codex,
2205            "The algorithm computes data using systematic logic and analysis".to_string(),
2206        );
2207        store.put_semantic(Galaxy::Codex, &mut logic_mem).unwrap();
2208
2209        let mut emotion_mem = Memory::new(
2210            Galaxy::Codex,
2211            "I feel love and joy with deep passion and empathy in my heart".to_string(),
2212        );
2213        store.put_semantic(Galaxy::Codex, &mut emotion_mem).unwrap();
2214
2215        // Query with logic-like text should find the logic memory first
2216        let results = store
2217            .find_similar(Galaxy::Codex, "algorithm data systematic method", 10)
2218            .unwrap();
2219        assert!(!results.is_empty());
2220        assert_eq!(results[0].0.metadata.id, logic_mem.metadata.id);
2221
2222        // Query with emotion-like text should find the emotion memory first
2223        let results = store
2224            .find_similar(Galaxy::Codex, "love joy passion heart feeling", 10)
2225            .unwrap();
2226        assert!(!results.is_empty());
2227        assert_eq!(results[0].0.metadata.id, emotion_mem.metadata.id);
2228    }
2229
2230    #[test]
2231    fn find_similar_empty_galaxy() {
2232        let tmp = tempfile::tempdir().unwrap();
2233        let store = MemoryStore::open_default(tmp.path()).unwrap();
2234
2235        let results = store.find_similar(Galaxy::Codex, "anything", 10).unwrap();
2236        assert!(results.is_empty());
2237    }
2238
2239    #[test]
2240    fn find_similar_respects_limit() {
2241        let tmp = tempfile::tempdir().unwrap();
2242        let store = MemoryStore::open_default(tmp.path()).unwrap();
2243
2244        for i in 0..5 {
2245            let mut mem = Memory::new(Galaxy::Codex, format!("algorithm data method {i}"));
2246            store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
2247        }
2248
2249        let results = store
2250            .find_similar(Galaxy::Codex, "algorithm data", 3)
2251            .unwrap();
2252        assert_eq!(results.len(), 3);
2253    }
2254
2255    #[test]
2256    fn semantic_encoder_accessible() {
2257        let tmp = tempfile::tempdir().unwrap();
2258        let store = MemoryStore::open_default(tmp.path()).unwrap();
2259
2260        let scores = store.semantic_encoder().encode("algorithm data logic");
2261        // Logic-heavy text → x < 0.5
2262        assert!(scores.x < 0.5);
2263    }
2264
2265    #[test]
2266    fn put_raw_batch_writes_atomically() {
2267        let tmp = tempfile::tempdir().unwrap();
2268        let store = MemoryStore::open_default(tmp.path()).unwrap();
2269
2270        let entries: &[(&[u8], &[u8])] =
2271            &[(b"key1", b"val1"), (b"key2", b"val2"), (b"key3", b"val3")];
2272        store.put_raw_batch(Galaxy::Karma, entries).unwrap();
2273
2274        assert_eq!(
2275            store.get_raw(Galaxy::Karma, b"key1").unwrap().unwrap(),
2276            b"val1"
2277        );
2278        assert_eq!(
2279            store.get_raw(Galaxy::Karma, b"key2").unwrap().unwrap(),
2280            b"val2"
2281        );
2282        assert_eq!(
2283            store.get_raw(Galaxy::Karma, b"key3").unwrap().unwrap(),
2284            b"val3"
2285        );
2286    }
2287
2288    #[test]
2289    fn put_raw_batch_empty_is_noop() {
2290        let tmp = tempfile::tempdir().unwrap();
2291        let store = MemoryStore::open_default(tmp.path()).unwrap();
2292
2293        store.put_raw_batch(Galaxy::Karma, &[]).unwrap();
2294        assert_eq!(store.count(Galaxy::Karma).unwrap(), 0);
2295    }
2296
2297    #[test]
2298    fn entry_limit_rejects_excess_writes() {
2299        let tmp = tempfile::tempdir().unwrap();
2300        let store = MemoryStore::open_default(tmp.path())
2301            .unwrap()
2302            .with_entry_limit(3);
2303
2304        for i in 0..3 {
2305            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
2306            store.put(Galaxy::Codex, &mem).unwrap();
2307        }
2308
2309        // 4th write should be rejected
2310        let mem = Memory::new(Galaxy::Codex, "overflow memory".to_string());
2311        let result = store.put(Galaxy::Codex, &mem);
2312        assert!(result.is_err(), "write beyond limit should be rejected");
2313        let err_msg = result.unwrap_err().to_string();
2314        assert!(
2315            err_msg.contains("entry limit reached"),
2316            "error should mention entry limit: {err_msg}"
2317        );
2318        assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
2319    }
2320
2321    #[test]
2322    fn entry_limit_per_galaxy_independent() {
2323        let tmp = tempfile::tempdir().unwrap();
2324        let store = MemoryStore::open_default(tmp.path())
2325            .unwrap()
2326            .with_entry_limit(2);
2327
2328        // Fill Codex to limit
2329        for i in 0..2 {
2330            let mem = Memory::new(Galaxy::Codex, format!("codex {i}"));
2331            store.put(Galaxy::Codex, &mem).unwrap();
2332        }
2333
2334        // Writing to a different galaxy should still work
2335        let mem = Memory::new(Galaxy::Research, "science memory".to_string());
2336        let result = store.put(Galaxy::Research, &mem);
2337        assert!(
2338            result.is_ok(),
2339            "different galaxy should not be affected by limit"
2340        );
2341    }
2342
2343    #[test]
2344    fn entry_limit_none_allows_unlimited() {
2345        let tmp = tempfile::tempdir().unwrap();
2346        let store = MemoryStore::open_default(tmp.path()).unwrap();
2347
2348        // No limit set — should allow many writes
2349        for i in 0..50 {
2350            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
2351            store.put(Galaxy::Codex, &mem).unwrap();
2352        }
2353        assert_eq!(store.count(Galaxy::Codex).unwrap(), 50);
2354    }
2355
2356    #[test]
2357    fn map_full_error_is_graceful() {
2358        // Small map: opening succeeds (galaxy + sidecar databases fit), but
2359        // the padded write loop fills it and MapFull surfaces from put().
2360        // 64KB proved too tight for eager create_db on macOS/arm64.
2361        let tmp = tempfile::tempdir().unwrap();
2362        let store = MemoryStore::open(tmp.path(), 512 * 1024).unwrap();
2363
2364        // Write memories until map is full
2365        let mut written = 0;
2366        let mut got_map_full = false;
2367        for i in 0..1000 {
2368            let mem = Memory::new(
2369                Galaxy::Codex,
2370                format!("memory content {i} {}", "with padding ".repeat(50)),
2371            );
2372            match store.put(Galaxy::Codex, &mem) {
2373                Ok(()) => written += 1,
2374                Err(e) => {
2375                    let msg = e.to_string();
2376                    if msg.contains("map full") {
2377                        got_map_full = true;
2378                        break;
2379                    }
2380                    // Other errors are fine too (e.g., serialize failed)
2381                    break;
2382                }
2383            }
2384        }
2385
2386        assert!(
2387            got_map_full || written < 1000,
2388            "should eventually hit map full or error"
2389        );
2390        assert!(written > 0, "should have written at least some memories");
2391    }
2392
2393    #[test]
2394    fn test_find_across_galaxies() {
2395        let tmp = tempfile::tempdir().unwrap();
2396        let store = MemoryStore::open_default(tmp.path()).unwrap();
2397
2398        let mem = Memory::new(Galaxy::Research, "Cross-galaxy research memo".into());
2399        let id = mem.metadata.id;
2400        store.put(Galaxy::Research, &mem).unwrap();
2401
2402        let found = store.find_across_galaxies(id).unwrap();
2403        assert!(found.is_some());
2404        let (galaxy, retrieved) = found.unwrap();
2405        assert_eq!(galaxy, Galaxy::Research);
2406        assert_eq!(retrieved.metadata.id, id);
2407        assert_eq!(retrieved.content, "Cross-galaxy research memo");
2408
2409        // Non-existent id returns None
2410        assert!(
2411            store
2412                .find_across_galaxies(uuid::Uuid::new_v4())
2413                .unwrap()
2414                .is_none()
2415        );
2416    }
2417}