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