Skip to main content

wm_memory/
store.rs

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