Skip to main content

wm_memory/
store.rs

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