Skip to main content

mentedb/
lib.rs

1//! # MenteDB: The Mind Database for AI Agents
2//!
3//! MenteDB is a purpose-built database engine for AI agent memory.
4//! It's a cognition preparation engine that pre-digests knowledge
5//! for single-pass transformer consumption.
6//!
7//! ## Core Concepts
8//!
9//! - **MemoryNode**: The atomic unit of knowledge (embeddings, graph, temporal, attributes)
10//! - **MemoryEdge**: Typed, weighted relationships between memories
11//! - **MemoryTier**: Cognitive inspired storage hierarchy (working, episodic, semantic, procedural, archival)
12//! - **Context Assembly**: Token budget aware context building that respects attention patterns
13//! - **MQL**: Mente Query Language for memory retrieval and manipulation
14//!
15//! ## Quick Start
16//!
17//! ```rust,no_run
18//! use mentedb::prelude::*;
19//! use mentedb::MenteDb;
20//! use std::path::Path;
21//!
22//! let mut db = MenteDb::open(Path::new("./my-agent-memory")).unwrap();
23//! // store, recall, relate, forget...
24//! db.close().unwrap();
25//! ```
26//!
27//! ## Feature Highlights
28//!
29//! - **Unified `process_turn`** pipeline: single call handles context retrieval,
30//!   pain signals, episodic storage, write inference, action detection, sentiment,
31//!   phantom tracking, trajectory, speculative caching, fact extraction, and
32//!   auto-maintenance (decay / archival / consolidation)
33//! - Seven cognitive features: interference detection, pain signals, phantom tracking,
34//!   speculative caching, stream monitoring, trajectory tracking, write inference
35//! - HNSW vector index with hybrid search (vector + tags + temporal + salience)
36//! - CSR/CSC knowledge graph with belief propagation
37//! - Token budget aware context assembly with attention curve optimization
38//! - MQL query language with vector, tag, temporal, and graph traversal support
39//! - WAL based crash recovery with LZ4 compressed pages
40//!
41//! ## Repository
42//!
43//! Source code: <https://github.com/nambok/mentedb>
44
45use std::path::{Path, PathBuf};
46
47use mentedb_cognitive::EntityResolver;
48use mentedb_cognitive::interference::{InterferenceDetector, InterferencePair};
49use mentedb_cognitive::llm::EntityMergeGroup;
50use mentedb_cognitive::pain::{PainRegistry, PainSignal};
51use mentedb_cognitive::phantom::{PhantomConfig, PhantomMemory, PhantomTracker};
52use mentedb_cognitive::speculative::{CacheEntry, CacheStats, SpeculativeCache};
53use mentedb_cognitive::stream::{CognitionStream, StreamAlert, StreamConfig};
54use mentedb_cognitive::trajectory::{TrajectoryNode, TrajectoryTracker};
55use mentedb_cognitive::write_inference::{
56    InferredAction, WriteInferenceConfig, WriteInferenceEngine,
57};
58use mentedb_consolidation::archival::{ArchivalConfig, ArchivalDecision, ArchivalPipeline};
59use mentedb_consolidation::compression::{CompressedMemory, MemoryCompressor};
60use mentedb_consolidation::consolidation::{ConsolidationCandidate, ConsolidationEngine};
61use mentedb_consolidation::decay::{DecayConfig, DecayEngine};
62use mentedb_context::{AssemblyConfig, ContextAssembler, ContextWindow, ScoredMemory};
63use mentedb_core::edge::EdgeType;
64use mentedb_core::error::MenteResult;
65use mentedb_core::memory::MemoryType;
66use mentedb_core::types::{AgentId, MemoryId, Timestamp, UserId};
67use mentedb_core::{MemoryEdge, MemoryNode, MenteError};
68use mentedb_embedding::provider::EmbeddingProvider;
69use mentedb_graph::GraphManager;
70use mentedb_index::IndexManager;
71use mentedb_query::{Condition, Field, Filter, Mql, Operator, QueryPlan, Value};
72use mentedb_storage::StorageEngine;
73use parking_lot::RwLock;
74use tracing::{debug, info, warn};
75
76// Re-export sub-crates for direct access.
77
78/// Engine version, derived from Cargo.toml at compile time.
79pub const VERSION: &str = env!("CARGO_PKG_VERSION");
80
81/// Cognitive pipeline: speculative caching, trajectory tracking, inference.
82pub use mentedb_cognitive as cognitive;
83/// Consolidation, decay, and memory lifecycle management.
84pub use mentedb_consolidation as consolidation;
85/// Context assembly engine.
86pub use mentedb_context as context;
87/// Core types: MemoryNode, MemoryEdge, errors, config.
88pub use mentedb_core as core;
89/// Knowledge graph engine.
90pub use mentedb_graph as graph;
91/// Index structures for vector, tag, temporal, and salience search.
92pub use mentedb_index as index;
93/// MQL parser and query planner.
94pub use mentedb_query as query;
95/// Page based storage engine with WAL and buffer pool.
96pub use mentedb_storage as storage;
97
98/// Unified process_turn orchestration.
99pub mod process_turn;
100
101/// Engine-native injection attention (selection policy for context injection).
102pub mod injection;
103
104/// LLM-driven memory consolidation (semantic dedup via a pluggable LlmJudge).
105pub mod llm_consolidation;
106pub use llm_consolidation::ConsolidationParams;
107
108/// Sleeptime enrichment pipeline (requires `enrichment` feature).
109#[cfg(feature = "enrichment")]
110pub mod enrichment;
111
112/// Optional second-pass reranking of recall results (off by default).
113pub mod reranker;
114
115/// Structured export: fill a JSON schema from memories via an embedder-supplied LLM.
116pub mod export;
117
118/// Lease-based elastic sharding: places each account on exactly one node and
119/// coordinates ownership so a fleet can scale horizontally. The engine owns the
120/// placement and coordination logic; the embedder supplies the lease and
121/// membership storage via the `LeaseStore` and `NodeRegistry` traits.
122pub mod sharding;
123
124/// Commonly used types, re-exported for convenience.
125pub mod prelude {
126    pub use mentedb_core::edge::EdgeType;
127    pub use mentedb_core::error::MenteResult;
128    pub use mentedb_core::memory::MemoryType;
129    pub use mentedb_core::types::*;
130    pub use mentedb_core::{MemoryEdge, MemoryNode, MemoryTier, MenteError};
131
132    pub use crate::MenteDb;
133}
134
135use mentedb_storage::PageId;
136/// Mapping from MemoryId to the storage PageId where it lives.
137use std::collections::HashMap;
138
139/// Configuration for sleeptime enrichment pipeline.
140///
141/// Enrichment runs BETWEEN conversations, never in the hot path.
142/// The engine tracks state and provides candidates; callers invoke
143/// the async LLM pipeline when ready.
144#[derive(Debug, Clone)]
145pub struct EnrichmentConfig {
146    /// Whether enrichment is enabled. Default: false (opt-in).
147    pub enabled: bool,
148    /// Run enrichment after this many process_turn calls. Default: 50.
149    pub trigger_interval: u64,
150    /// Minimum confidence for extracted memories to be stored. Default: 0.6.
151    pub min_confidence: f32,
152    /// Maximum confidence for enrichment-generated memories. Default: 0.7.
153    pub max_enrichment_confidence: f32,
154    /// Whether to generate a user model summary. Default: false.
155    pub enable_user_model: bool,
156    /// Embedding similarity threshold to merge entities. Default: 0.7.
157    pub entity_merge_threshold: f32,
158    /// Embedding similarity below which entities are kept separate. Default: 0.4.
159    pub entity_separate_threshold: f32,
160}
161
162impl Default for EnrichmentConfig {
163    fn default() -> Self {
164        Self {
165            enabled: false,
166            trigger_interval: 50,
167            min_confidence: 0.6,
168            max_enrichment_confidence: 0.7,
169            enable_user_model: false,
170            entity_merge_threshold: 0.7,
171            entity_separate_threshold: 0.4,
172        }
173    }
174}
175
176/// Result of running the enrichment pipeline.
177#[derive(Debug, Clone, Default)]
178pub struct EnrichmentResult {
179    /// Number of new memories stored from extraction.
180    pub memories_stored: usize,
181    /// Number of entity nodes created or updated.
182    pub entities_processed: usize,
183    /// Number of edges created (Derived, Related, PartOf).
184    pub edges_created: usize,
185    /// Number of memories skipped as duplicates.
186    pub duplicates_skipped: usize,
187    /// Number of contradictions detected.
188    pub contradictions_found: usize,
189    /// Turn ID at which enrichment was completed.
190    pub completed_at_turn: u64,
191    /// Number of entity links created (Related edges between same-name entities).
192    pub entities_linked: usize,
193    /// Number of entity pairs left ambiguous (below merge threshold).
194    pub entities_ambiguous: usize,
195}
196
197/// Result of a single entity linking run.
198#[derive(Debug, Clone, Default)]
199pub struct EntityLinkResult {
200    /// Number of entity pairs linked with Related edges.
201    pub linked: usize,
202    /// Number of entity pairs tagged as ambiguous (MaybeRelated).
203    pub ambiguous: usize,
204    /// Number of edges created.
205    pub edges_created: usize,
206}
207
208/// Outcome of a standing-rules (`scope:always`) cleanup pass.
209#[derive(Debug, Clone, Default)]
210pub struct PruneReport {
211    /// Total `scope:always` memories found before cleanup.
212    pub total_always: usize,
213    /// Number of exact-content duplicate groups collapsed.
214    pub duplicate_groups: usize,
215    /// Exact-duplicate always-rules removed (the healthiest copy was kept).
216    pub pruned: Vec<MemoryId>,
217    /// Auto-pinned always-rules un-pinned (the `scope:always` tag removed, the
218    /// memory kept so it is still recalled by relevance).
219    pub unpinned: Vec<MemoryId>,
220}
221
222/// A confirmed entity resolution from an external resolver (LLM).
223///
224/// Used to feed LLM entity resolution results back into the engine
225/// so it can create graph edges and update the EntityResolver cache.
226#[derive(Debug, Clone)]
227pub struct EntityLinkResolution {
228    /// The canonical entity name decided by the resolver.
229    pub canonical: String,
230    /// All aliases that map to this canonical name.
231    pub aliases: Vec<String>,
232    /// Confidence in this resolution (0.0 to 1.0).
233    pub confidence: f32,
234}
235
236/// A pair of entity names that the LLM confirmed are DIFFERENT entities.
237#[derive(Debug, Clone)]
238pub struct EntitySeparation {
239    pub name_a: String,
240    pub name_b: String,
241}
242
243/// Configuration for the cognitive engine subsystems.
244#[derive(Debug, Clone)]
245pub struct CognitiveConfig {
246    /// Whether write inference (auto-edges, contradiction detection) is enabled on store.
247    pub write_inference: bool,
248    /// Whether salience decay is applied during retrieval.
249    pub decay_on_recall: bool,
250    /// Whether pain tracking is enabled.
251    pub pain_tracking: bool,
252    /// Whether interference detection is available.
253    pub interference_detection: bool,
254    /// Whether phantom tracking is enabled.
255    pub phantom_tracking: bool,
256    /// Whether speculative caching is enabled.
257    pub speculative_cache: bool,
258    /// Whether archival evaluation is available.
259    pub archival_evaluation: bool,
260    /// Configuration for the write inference engine.
261    pub inference_config: WriteInferenceConfig,
262    /// Configuration for the decay engine.
263    pub decay_config: DecayConfig,
264    /// Configuration for phantom tracking.
265    pub phantom_config: PhantomConfig,
266    /// Configuration for the archival pipeline.
267    pub archival_config: ArchivalConfig,
268    /// Configuration for the cognition stream.
269    pub stream_config: StreamConfig,
270    /// Configuration for sleeptime enrichment.
271    pub enrichment_config: EnrichmentConfig,
272    /// Similarity threshold for interference detection.
273    pub interference_threshold: f32,
274    /// Maximum trajectory turns to track.
275    pub trajectory_max_turns: usize,
276    /// Maximum speculative cache entries.
277    pub speculative_cache_size: usize,
278    /// Maximum pain signals to retain.
279    pub pain_max_warnings: usize,
280    /// Configuration for injection attention selection.
281    pub injection_config: injection::InjectionConfig,
282    /// How many hot flushes may pass before index and graph snapshots are
283    /// rewritten. Durability comes from the WAL checkpoint on every flush;
284    /// snapshots only accelerate reopen, and open reconciles stale ones, so
285    /// rewriting them per flush just multiplies fsync cost.
286    pub flush_snapshot_interval: u32,
287    /// Whether recall boosts memories linked (in the graph) to an entity named in
288    /// the query. Off by default: it only helps corpora that have run enrichment
289    /// (so entity nodes and their `Derived` edges exist), and its ranking effect
290    /// should be A/B measured before it is turned on broadly.
291    pub entity_boost_enabled: bool,
292    /// Score added to a candidate that a query-named entity links to. Applied on
293    /// top of the fused recall score, so it is calibrated against that scale.
294    pub entity_boost_weight: f32,
295}
296
297impl Default for CognitiveConfig {
298    fn default() -> Self {
299        Self {
300            write_inference: true,
301            decay_on_recall: true,
302            pain_tracking: true,
303            interference_detection: true,
304            phantom_tracking: true,
305            speculative_cache: true,
306            archival_evaluation: true,
307            inference_config: WriteInferenceConfig::default(),
308            decay_config: DecayConfig::default(),
309            phantom_config: PhantomConfig::default(),
310            archival_config: ArchivalConfig::default(),
311            stream_config: StreamConfig::default(),
312            enrichment_config: EnrichmentConfig::default(),
313            interference_threshold: 0.8,
314            trajectory_max_turns: 100,
315            speculative_cache_size: 10,
316            pain_max_warnings: 5,
317            injection_config: injection::InjectionConfig::default(),
318            flush_snapshot_interval: 8,
319            entity_boost_enabled: false,
320            entity_boost_weight: 0.15,
321        }
322    }
323}
324
325/// The unified database facade for MenteDB.
326///
327/// `MenteDb` coordinates storage, indexing, graph relationships, query parsing,
328/// context assembly, and cognitive subsystems into a single coherent API.
329///
330/// All internal state is protected by fine-grained locks, so every public method
331/// takes `&self`. This allows `Arc<MenteDb>` to be shared across threads without
332/// an external `RwLock`.
333pub struct MenteDb {
334    storage: StorageEngine,
335    index: IndexManager,
336    graph: GraphManager,
337    /// Maps memory IDs to their storage page IDs for retrieval.
338    page_map: RwLock<HashMap<MemoryId, PageId>>,
339    /// Hot flushes since the last snapshot write; see flush_snapshot_interval.
340    flushes_since_snapshot: std::sync::atomic::AtomicU32,
341    /// Expected embedding dimension (0 = no validation).
342    embedding_dim: usize,
343    /// Database directory path for persistence.
344    path: PathBuf,
345    /// Optional embedding provider for auto-embedding on store and search.
346    embedder: Option<Box<dyn EmbeddingProvider>>,
347    /// Cognitive engine configuration.
348    cognitive_config: CognitiveConfig,
349    /// Write inference engine for auto-edge creation and contradiction detection.
350    write_inference: WriteInferenceEngine,
351    /// Decay engine for salience management.
352    decay: DecayEngine,
353    /// Consolidation engine for memory merging.
354    consolidation: ConsolidationEngine,
355    /// Pain registry for tracking recurring failures.
356    pain: RwLock<PainRegistry>,
357    /// Trajectory tracker for conversation patterns.
358    trajectory: RwLock<TrajectoryTracker>,
359    /// Cognition stream for token-level monitoring.
360    stream: CognitionStream,
361    /// Phantom tracker for detecting referenced-but-missing knowledge.
362    phantom: RwLock<PhantomTracker>,
363    /// Speculative cache for pre-fetching likely-needed memories.
364    speculative: RwLock<SpeculativeCache>,
365    /// Interference detector for finding confusable memories.
366    interference: InterferenceDetector,
367    /// Entity resolver for canonical name resolution.
368    entity_resolver: RwLock<EntityResolver>,
369    /// Memory compressor for content summarization.
370    compressor: MemoryCompressor,
371    /// Archival pipeline for lifecycle evaluation.
372    archival: ArchivalPipeline,
373    /// Turn ID of the last completed enrichment cycle.
374    last_enrichment_turn: RwLock<u64>,
375    /// Whether enrichment is currently pending (set by maintenance trigger).
376    enrichment_pending: RwLock<bool>,
377}
378
379/// Agent visibility rule for scoped retrieval: a node is visible to an agent
380/// when it is owned by that agent or owned by no agent (nil, shared
381/// knowledge). No scope means global visibility.
382pub(crate) fn agent_visible(owner: AgentId, scope: Option<AgentId>) -> bool {
383    match scope {
384        None => true,
385        Some(a) => owner == a || owner.is_nil(),
386    }
387}
388
389/// User visibility rule for scoped retrieval, orthogonal to [`agent_visible`]:
390/// a node is visible to a user when it is owned by that user or owned by no
391/// user (nil, shared knowledge). No scope means global visibility. A scoped
392/// query at (user U, agent A) requires BOTH `user_visible(owner_user, U)` and
393/// `agent_visible(owner_agent, A)`.
394pub(crate) fn user_visible(owner: UserId, scope: Option<UserId>) -> bool {
395    match scope {
396        None => true,
397        Some(u) => owner == u || owner.is_nil(),
398    }
399}
400
401impl MenteDb {
402    /// Opens (or creates) a MenteDB instance at the given path.
403    pub fn open(path: &Path) -> MenteResult<Self> {
404        Self::open_with_config(path, CognitiveConfig::default())
405    }
406
407    /// Opens a MenteDB instance with custom cognitive configuration.
408    pub fn open_with_config(path: &Path, cognitive_config: CognitiveConfig) -> MenteResult<Self> {
409        info!("Opening MenteDB at {}", path.display());
410        let storage = StorageEngine::open(path)?;
411
412        let index_dir = path.join("indexes");
413        let graph_dir = path.join("graph");
414
415        let index = if index_dir.join("hnsw.bin").exists() || index_dir.join("hnsw.json").exists() {
416            debug!("Loading indexes from {}", index_dir.display());
417            IndexManager::load(&index_dir)?
418        } else {
419            IndexManager::default()
420        };
421
422        // Directory-backed graph: loads the snapshot and replays the edge log,
423        // so edges created since the last flush survive a crash.
424        let graph = GraphManager::open(&graph_dir)?;
425
426        // Rebuild page map by scanning all pages
427        let entries = storage.scan_all_memories();
428        let mut page_map = HashMap::new();
429        for (memory_id, page_id) in &entries {
430            page_map.insert(*memory_id, *page_id);
431        }
432        if !page_map.is_empty() {
433            info!(memories = page_map.len(), "rebuilt page map from storage");
434        }
435
436        // Every stored memory must be a graph node, even when the graph
437        // snapshot/log is missing or behind storage (e.g. after a crash);
438        // otherwise relate() and write inference fail for surviving memories.
439        for memory_id in page_map.keys() {
440            if !graph.read_graph().contains_node(*memory_id) {
441                graph.add_memory(*memory_id);
442            }
443        }
444
445        // Snapshots are written every few flushes, not every flush, so they
446        // can trail storage. Heal both directions: index memories the
447        // snapshot missed, and tombstone vectors whose pages are gone
448        // (forgotten after the last snapshot).
449        let mut reindexed = 0usize;
450        for (memory_id, page_id) in &page_map {
451            if !index.contains_vector(*memory_id)
452                && let Ok(node) = storage.load_memory(*page_id)
453                && !node.embedding.is_empty()
454            {
455                index.index_memory(&node);
456                reindexed += 1;
457            }
458        }
459        let mut retired = 0usize;
460        for id in index.vector_ids() {
461            if !page_map.contains_key(&id) {
462                index.remove_vector_only(id);
463                retired += 1;
464            }
465        }
466        if reindexed > 0 || retired > 0 {
467            info!(reindexed, retired, "reconciled index with storage");
468        }
469
470        let write_inference =
471            WriteInferenceEngine::with_config(cognitive_config.inference_config.clone());
472        let decay = DecayEngine::new(cognitive_config.decay_config.clone());
473        let consolidation = ConsolidationEngine::new();
474        let pain = RwLock::new(PainRegistry::new(cognitive_config.pain_max_warnings));
475        let trajectory = RwLock::new(TrajectoryTracker::new(
476            cognitive_config.trajectory_max_turns,
477        ));
478        let stream = CognitionStream::with_config(cognitive_config.stream_config.clone());
479        let phantom = RwLock::new(PhantomTracker::new(cognitive_config.phantom_config.clone()));
480        let speculative = RwLock::new(SpeculativeCache::new(
481            cognitive_config.speculative_cache_size,
482            0.5,
483            0.4,
484        ));
485        let interference = InterferenceDetector::new(cognitive_config.interference_threshold);
486        let entity_resolver = RwLock::new(EntityResolver::new());
487        let compressor = MemoryCompressor::new();
488        let archival = ArchivalPipeline::new(cognitive_config.archival_config.clone());
489
490        // Load persisted state for subsystems that support it.
491        let cognitive_dir = path.join("cognitive");
492        if cognitive_dir.exists() {
493            let _ = trajectory
494                .write()
495                .transitions
496                .load(&cognitive_dir.join("transitions.json"));
497            let _ = speculative
498                .write()
499                .load(&cognitive_dir.join("speculative.json"));
500            let _ = entity_resolver
501                .write()
502                .load(&cognitive_dir.join("entities.json"));
503        }
504
505        Ok(Self {
506            storage,
507            index,
508            graph,
509            page_map: RwLock::new(page_map),
510            flushes_since_snapshot: std::sync::atomic::AtomicU32::new(0),
511            embedding_dim: 0,
512            path: path.to_path_buf(),
513            embedder: None,
514            cognitive_config,
515            write_inference,
516            decay,
517            consolidation,
518            pain,
519            trajectory,
520            stream,
521            phantom,
522            speculative,
523            interference,
524            entity_resolver,
525            compressor,
526            archival,
527            last_enrichment_turn: RwLock::new(0),
528            enrichment_pending: RwLock::new(false),
529        })
530    }
531
532    /// Opens a MenteDB instance with a configured embedding provider.
533    pub fn open_with_embedder(
534        path: &Path,
535        embedder: Box<dyn EmbeddingProvider>,
536    ) -> MenteResult<Self> {
537        let mut db = Self::open(path)?;
538        db.embedding_dim = embedder.dimensions();
539        db.embedder = Some(embedder);
540        Ok(db)
541    }
542
543    /// Opens a MenteDB instance with both embedder and cognitive config.
544    pub fn open_with_embedder_and_config(
545        path: &Path,
546        embedder: Box<dyn EmbeddingProvider>,
547        cognitive_config: CognitiveConfig,
548    ) -> MenteResult<Self> {
549        let mut db = Self::open_with_config(path, cognitive_config)?;
550        db.embedding_dim = embedder.dimensions();
551        db.embedder = Some(embedder);
552        Ok(db)
553    }
554
555    /// Set the embedding provider after construction.
556    pub fn set_embedder(&mut self, embedder: Box<dyn EmbeddingProvider>) {
557        self.embedding_dim = embedder.dimensions();
558        self.embedder = Some(embedder);
559    }
560
561    /// Generate an embedding for the given text using the configured provider.
562    /// Returns None if no provider is configured.
563    pub fn embed_text(&self, text: &str) -> MenteResult<Option<Vec<f32>>> {
564        match &self.embedder {
565            Some(e) => Ok(Some(e.embed(text)?)),
566            None => Ok(None),
567        }
568    }
569
570    /// Stores a memory node into the database.
571    ///
572    /// The node is persisted to storage, added to all indexes, and registered
573    /// in the graph for relationship traversal.
574    ///
575    /// When cognitive features are enabled (the default), write inference
576    /// automatically runs to:
577    /// - Detect contradictions with existing memories
578    /// - Create relationship edges (Related, Supersedes, Contradicts)
579    /// - Invalidate superseded memories
580    /// - Propagate confidence changes through the graph
581    pub fn store(&self, node: MemoryNode) -> MenteResult<()> {
582        let id = node.id;
583        debug!("Storing memory {}", id);
584
585        // Validate embedding dimension when configured.
586        if self.embedding_dim > 0
587            && !node.embedding.is_empty()
588            && node.embedding.len() != self.embedding_dim
589        {
590            return Err(MenteError::EmbeddingDimensionMismatch {
591                got: node.embedding.len(),
592                expected: self.embedding_dim,
593            });
594        }
595
596        let page_id = self.storage.store_memory(&node)?;
597        self.page_map.write().insert(id, page_id);
598        self.index.index_memory(&node);
599        self.graph.add_memory(id);
600
601        // Run write inference to auto-create edges and detect contradictions.
602        if self.cognitive_config.write_inference {
603            self.run_write_inference(&node);
604        }
605
606        Ok(())
607    }
608
609    /// Store multiple memories in a single batch transaction.
610    ///
611    /// Uses a single WAL lock for all writes, avoiding per-write overhead of
612    /// flock acquisition, header reload, and LSN scan. Significantly faster
613    /// for bulk inserts.
614    pub fn store_batch(&self, nodes: Vec<MemoryNode>) -> MenteResult<Vec<MemoryId>> {
615        // Validate all embeddings upfront
616        for node in &nodes {
617            if self.embedding_dim > 0
618                && !node.embedding.is_empty()
619                && node.embedding.len() != self.embedding_dim
620            {
621                return Err(MenteError::EmbeddingDimensionMismatch {
622                    got: node.embedding.len(),
623                    expected: self.embedding_dim,
624                });
625            }
626        }
627
628        let page_ids = self.storage.store_memory_batch(&nodes)?;
629
630        let mut ids = Vec::with_capacity(nodes.len());
631        let mut page_map = self.page_map.write();
632        for (node, page_id) in nodes.iter().zip(page_ids.iter()) {
633            page_map.insert(node.id, *page_id);
634            self.index.index_memory(node);
635            self.graph.add_memory(node.id);
636            ids.push(node.id);
637        }
638        drop(page_map);
639
640        // Batch inserts get the same auto-linking, contradiction detection,
641        // and invalidation as single stores.
642        if self.cognitive_config.write_inference {
643            for node in &nodes {
644                self.run_write_inference(node);
645            }
646        }
647
648        Ok(ids)
649    }
650
651    /// Recalls memories using an MQL query string.
652    ///
653    /// Parses the query, builds an execution plan, runs it against the
654    /// appropriate indexes/graph, and assembles the results into a
655    /// token-budget-aware context window.
656    pub fn recall(&self, query: &str) -> MenteResult<ContextWindow> {
657        debug!("Recalling with query: {}", query);
658        let plan = Mql::parse(query)?;
659
660        let scored = self.execute_plan(&plan)?;
661        let config = AssemblyConfig::default();
662        let window = ContextAssembler::assemble(scored, vec![], &config);
663        Ok(window)
664    }
665
666    /// Run an MQL query and return the scored matches directly, without assembling
667    /// a context window. This is the raw query primitive behind `recall` (which
668    /// additionally packs the results into a token-budgeted context), and it powers
669    /// the `mentedb` CLI and the admin query endpoint.
670    pub fn query(&self, mql: &str) -> MenteResult<Vec<ScoredMemory>> {
671        let plan = Mql::parse(mql)?;
672        self.execute_plan(&plan)
673    }
674
675    /// Like [`recall`](Self::recall), but runs an optional second pass through a
676    /// [`Reranker`](crate::reranker::Reranker) before assembling the context
677    /// window. The reranker reorders the first-pass candidates by `query_text`
678    /// (typically the natural-language query behind the MQL), so exact-term or
679    /// model-scored relevance can lift results that pure vector/BM25 ranking
680    /// buried. Reranking is entirely opt-in: plain `recall` never invokes it.
681    pub fn recall_reranked(
682        &self,
683        query: &str,
684        query_text: &str,
685        reranker: &dyn crate::reranker::Reranker,
686    ) -> MenteResult<ContextWindow> {
687        use crate::reranker::RerankCandidate;
688        let plan = Mql::parse(query)?;
689        let mut scored = self.execute_plan(&plan)?;
690
691        let candidates: Vec<RerankCandidate<'_>> = scored
692            .iter()
693            .map(|s| RerankCandidate {
694                id: s.memory.id,
695                content: &s.memory.content,
696                score: s.score,
697            })
698            .collect();
699        let new_scores: std::collections::HashMap<MemoryId, f32> = reranker
700            .rerank(query_text, &candidates)
701            .into_iter()
702            .collect();
703        for s in &mut scored {
704            if let Some(&ns) = new_scores.get(&s.memory.id) {
705                s.score = ns;
706            }
707        }
708        scored.sort_by(|a, b| b.score.total_cmp(&a.score));
709
710        let config = AssemblyConfig::default();
711        Ok(ContextAssembler::assemble(scored, vec![], &config))
712    }
713
714    /// Shortcut for vector similarity search.
715    ///
716    /// Returns the top-k most similar memory IDs with their scores.
717    /// Memories that have been superseded, contradicted, or temporally
718    /// invalidated are automatically excluded from results.
719    pub fn recall_similar(&self, embedding: &[f32], k: usize) -> MenteResult<Vec<(MemoryId, f32)>> {
720        self.recall_similar_filtered(embedding, k, None, None)
721    }
722
723    /// Vector similarity search with optional tag and time range filters.
724    pub fn recall_similar_filtered(
725        &self,
726        embedding: &[f32],
727        k: usize,
728        tags: Option<&[&str]>,
729        time_range: Option<(Timestamp, Timestamp)>,
730    ) -> MenteResult<Vec<(MemoryId, f32)>> {
731        let now = std::time::SystemTime::now()
732            .duration_since(std::time::UNIX_EPOCH)
733            .unwrap_or_default()
734            .as_micros() as u64;
735        self.recall_similar_filtered_at(embedding, k, now, tags, time_range)
736    }
737
738    /// Vector similarity search at a specific point in time.
739    ///
740    /// Only returns memories that were temporally valid at the given timestamp.
741    /// Superseded/contradicted memories are excluded unless the edge itself
742    /// was not yet valid at that time.
743    pub fn recall_similar_at(
744        &self,
745        embedding: &[f32],
746        k: usize,
747        at: Timestamp,
748    ) -> MenteResult<Vec<(MemoryId, f32)>> {
749        self.recall_similar_filtered_at(embedding, k, at, None, None)
750    }
751
752    /// Vector similarity search at a specific point in time with optional filters.
753    ///
754    /// Only returns memories that were temporally valid at the given timestamp.
755    /// Superseded/contradicted memories are excluded unless the edge itself
756    /// was not yet valid at that time. Optionally filters by tags and time range.
757    pub fn recall_similar_filtered_at(
758        &self,
759        embedding: &[f32],
760        k: usize,
761        at: Timestamp,
762        tags: Option<&[&str]>,
763        time_range: Option<(Timestamp, Timestamp)>,
764    ) -> MenteResult<Vec<(MemoryId, f32)>> {
765        self.recall_hybrid_at(embedding, None, k, at, tags, time_range)
766    }
767
768    /// Hybrid search combining vector similarity and BM25 keyword matching.
769    ///
770    /// When `query_text` is provided, BM25 results are fused with vector
771    /// results via Reciprocal Rank Fusion (RRF) for better recall on
772    /// exact entity names, dates, and specific terms.
773    pub fn recall_hybrid_at(
774        &self,
775        embedding: &[f32],
776        query_text: Option<&str>,
777        k: usize,
778        at: Timestamp,
779        tags: Option<&[&str]>,
780        time_range: Option<(Timestamp, Timestamp)>,
781    ) -> MenteResult<Vec<(MemoryId, f32)>> {
782        self.recall_hybrid_at_mode(embedding, query_text, k, at, tags, false, time_range)
783    }
784
785    /// Hybrid recall with configurable tag mode (AND vs OR).
786    #[allow(clippy::too_many_arguments)]
787    pub fn recall_hybrid_at_mode(
788        &self,
789        embedding: &[f32],
790        query_text: Option<&str>,
791        k: usize,
792        at: Timestamp,
793        tags: Option<&[&str]>,
794        tags_or: bool,
795        time_range: Option<(Timestamp, Timestamp)>,
796    ) -> MenteResult<Vec<(MemoryId, f32)>> {
797        self.recall_hybrid_scoped_at_mode(
798            embedding, query_text, k, at, tags, tags_or, time_range, None, None,
799        )
800    }
801
802    /// Hybrid recall visible to one (user, agent) scope: a node passes only
803    /// when it is visible on BOTH axes, i.e. owned by `agent` or no agent AND
804    /// owned by `user` or no user (nil, shared knowledge). `agent: None` /
805    /// `user: None` recall globally on that axis, preserving single-owner
806    /// behavior. The two axes are orthogonal: agent scoping never widens or
807    /// narrows user scoping, and vice versa.
808    #[allow(clippy::too_many_arguments)]
809    pub fn recall_hybrid_scoped_at_mode(
810        &self,
811        embedding: &[f32],
812        query_text: Option<&str>,
813        k: usize,
814        at: Timestamp,
815        tags: Option<&[&str]>,
816        tags_or: bool,
817        time_range: Option<(Timestamp, Timestamp)>,
818        agent: Option<AgentId>,
819        user: Option<UserId>,
820    ) -> MenteResult<Vec<(MemoryId, f32)>> {
821        debug!(
822            "Recall hybrid, k={}, at={}, bm25={}, tags_or={}",
823            k,
824            at,
825            query_text.is_some(),
826            tags_or
827        );
828        // Over-fetch to account for filtered-out results
829        let results = self.index.hybrid_search_with_query_mode(
830            embedding,
831            query_text,
832            tags,
833            tags_or,
834            time_range,
835            k * 3,
836        );
837        let graph = self.graph.graph();
838        let pm = self.page_map.read();
839        let mut scored: Vec<(MemoryId, f32)> = results
840            .into_iter()
841            .filter_map(|(id, raw_score)| {
842                // Exclude memories that an active Supersedes/Contradicts edge
843                // has invalidated.
844                let incoming = graph.incoming(id);
845                let has_active_supersede = incoming.iter().any(|(_, e)| {
846                    (e.edge_type == EdgeType::Supersedes || e.edge_type == EdgeType::Contradicts)
847                        && e.is_valid_at(at)
848                });
849                if has_active_supersede {
850                    return None;
851                }
852                // Without a page entry we cannot check validity or decay; keep
853                // the raw score rather than silently dropping the hit.
854                let Some(&page_id) = pm.get(&id) else {
855                    return Some((id, raw_score));
856                };
857                let Ok(node) = self.storage.load_memory(page_id) else {
858                    return Some((id, raw_score));
859                };
860                // Drop memories outside their validity window or not visible to
861                // this (user, agent) scope. Both owner axes must pass.
862                let visible =
863                    agent_visible(node.agent_id, agent) && user_visible(node.user_id, user);
864                if !node.is_valid_at(at) || !visible {
865                    return None;
866                }
867                // Decay at recall time: the index salience cache is frozen at
868                // insert, so recomputing decayed salience here is what actually
869                // lets decay affect ranking on this hot path. Blend 70%
870                // similarity with 30% freshly decayed salience, matching the
871                // MQL path, so old, unaccessed memories rank lower.
872                let score = if self.cognitive_config.decay_on_recall {
873                    let decayed = self.decay.compute_decay(
874                        node.salience,
875                        node.created_at,
876                        node.accessed_at,
877                        node.access_count,
878                        at,
879                    );
880                    raw_score * 0.7 + decayed * 0.3
881                } else {
882                    raw_score
883                };
884                Some((id, score))
885            })
886            .collect();
887        // Re-rank by the decay-adjusted score before cutting to k, so the decay
888        // blend actually reorders results rather than just relabeling them.
889        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
890        scored.truncate(k);
891        Ok(scored)
892    }
893
894    /// Multi-query search with Reciprocal Rank Fusion (RRF).
895    ///
896    /// Runs multiple vector searches (one per embedding) and merges results
897    /// using RRF: score = Σ 1/(k + rank_i). This improves recall by matching
898    /// on different semantic aspects of a query.
899    /// When `query_texts` is provided, each search also runs BM25 matching.
900    pub fn recall_similar_multi(
901        &self,
902        embeddings: &[Vec<f32>],
903        k: usize,
904        tags: Option<&[&str]>,
905        time_range: Option<(Timestamp, Timestamp)>,
906    ) -> MenteResult<Vec<(MemoryId, f32)>> {
907        self.recall_hybrid_multi(embeddings, None, k, tags, time_range)
908    }
909
910    /// Multi-query hybrid search with BM25 + vector fusion.
911    ///
912    /// Each query text is searched via both BM25 and vector, then all results
913    /// are merged via RRF.
914    pub fn recall_hybrid_multi(
915        &self,
916        embeddings: &[Vec<f32>],
917        query_texts: Option<&[String]>,
918        k: usize,
919        tags: Option<&[&str]>,
920        time_range: Option<(Timestamp, Timestamp)>,
921    ) -> MenteResult<Vec<(MemoryId, f32)>> {
922        self.recall_hybrid_multi_mode(embeddings, query_texts, k, tags, false, time_range)
923    }
924
925    /// Multi-query hybrid search with configurable tag mode.
926    pub fn recall_hybrid_multi_mode(
927        &self,
928        embeddings: &[Vec<f32>],
929        query_texts: Option<&[String]>,
930        k: usize,
931        tags: Option<&[&str]>,
932        tags_or: bool,
933        time_range: Option<(Timestamp, Timestamp)>,
934    ) -> MenteResult<Vec<(MemoryId, f32)>> {
935        use std::collections::HashMap;
936
937        let rrf_k: f32 = 60.0;
938        let mut rrf_scores: HashMap<MemoryId, f32> = HashMap::new();
939
940        let now = std::time::SystemTime::now()
941            .duration_since(std::time::UNIX_EPOCH)
942            .unwrap_or_default()
943            .as_micros() as u64;
944
945        for (i, emb) in embeddings.iter().enumerate() {
946            let qt = query_texts.and_then(|texts| texts.get(i).map(|s| s.as_str()));
947            let results = self.recall_hybrid_at_mode(emb, qt, k, now, tags, tags_or, time_range)?;
948            for (rank, (id, _score)) in results.iter().enumerate() {
949                *rrf_scores.entry(*id).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
950            }
951        }
952
953        let mut merged: Vec<(MemoryId, f32)> = rrf_scores.into_iter().collect();
954        merged.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
955        merged.truncate(k);
956        Ok(merged)
957    }
958
959    /// Invalidate a memory by setting its valid_until timestamp.
960    ///
961    /// The memory remains in storage for historical queries but is excluded
962    /// from current recall results.
963    pub fn invalidate_memory(&self, id: MemoryId, at: Timestamp) -> MenteResult<()> {
964        debug!("Invalidating memory {} at {}", id, at);
965        let page_id = self
966            .page_map
967            .read()
968            .get(&id)
969            .copied()
970            .ok_or(MenteError::MemoryNotFound(id))?;
971        let mut node = self.storage.load_memory(page_id)?;
972        node.invalidate(at);
973        self.storage.update_memory(page_id, &node)?;
974        Ok(())
975    }
976
977    /// Adds a typed, weighted edge between two memories in the graph.
978    pub fn relate(&self, edge: MemoryEdge) -> MenteResult<()> {
979        debug!("Relating {} -> {}", edge.source, edge.target);
980        self.graph.add_relationship(&edge)?;
981        Ok(())
982    }
983
984    /// Retrieves a single memory by its ID.
985    pub fn get_memory(&self, id: MemoryId) -> MenteResult<MemoryNode> {
986        let page_id = self
987            .page_map
988            .read()
989            .get(&id)
990            .copied()
991            .ok_or(MenteError::MemoryNotFound(id))?;
992        self.storage.load_memory(page_id)
993    }
994
995    /// Returns all memory IDs currently stored in the database.
996    pub fn memory_ids(&self) -> Vec<MemoryId> {
997        self.page_map.read().keys().copied().collect()
998    }
999
1000    /// Returns the number of memories currently stored.
1001    pub fn memory_count(&self) -> usize {
1002        self.page_map.read().len()
1003    }
1004
1005    /// A bounded, paginated page of stored memories for admin browsing, ordered by
1006    /// id so pagination is stable. Only `limit` nodes are loaded from storage per
1007    /// call (paginate over the id set first, then load), so it stays cheap even on
1008    /// a large store. Optional filters narrow the loaded page by owning agent,
1009    /// memory type, and a case-insensitive content substring. Returns the total
1010    /// memory count (for the pager) alongside the page.
1011    pub fn list_memories(
1012        &self,
1013        limit: usize,
1014        offset: usize,
1015        agent: Option<AgentId>,
1016        memory_type: Option<MemoryType>,
1017        content_query: Option<&str>,
1018    ) -> MenteResult<(usize, Vec<MemoryNode>)> {
1019        let pm = self.page_map.read();
1020        let total = pm.len();
1021        let mut ids: Vec<MemoryId> = pm.keys().copied().collect();
1022        ids.sort();
1023        let needle = content_query.map(|q| q.to_lowercase());
1024        let mut out = Vec::new();
1025        for id in ids.into_iter().skip(offset).take(limit) {
1026            if let Some(&page_id) = pm.get(&id)
1027                && let Ok(node) = self.storage.load_memory(page_id)
1028            {
1029                if agent.is_some_and(|a| node.agent_id != a) {
1030                    continue;
1031                }
1032                if memory_type.is_some_and(|t| node.memory_type != t) {
1033                    continue;
1034                }
1035                if let Some(n) = &needle
1036                    && !node.content.to_lowercase().contains(n)
1037                {
1038                    continue;
1039                }
1040                out.push(node);
1041            }
1042        }
1043        Ok((total, out))
1044    }
1045
1046    /// Removes a memory from storage, indexes, and the graph.
1047    pub fn forget(&self, id: MemoryId) -> MenteResult<()> {
1048        debug!("Forgetting memory {}", id);
1049
1050        if let Some(&page_id) = self.page_map.read().get(&id) {
1051            if let Ok(node) = self.storage.load_memory(page_id) {
1052                self.index.remove_memory(id, &node);
1053            }
1054            // Durable delete: WAL-logged page free, so the memory does not
1055            // resurrect when the page map is rebuilt on reopen.
1056            self.storage.delete_memory(page_id)?;
1057        }
1058
1059        self.graph.remove_memory(id);
1060        self.page_map.write().remove(&id);
1061        Ok(())
1062    }
1063
1064    /// Clean up the standing-rules (`scope:always`) set: remove exact-content
1065    /// duplicates (keeping the healthiest copy) and un-pin every auto-pinned
1066    /// always-rule, keeping only rules the user explicitly pinned
1067    /// (`source:manual`) and the user profile (which the engine pins deliberately
1068    /// so it injects every session).
1069    ///
1070    /// `scope:always` force-injects a memory into every assembled context
1071    /// regardless of relevance, so the set must stay tiny; distilled facts belong
1072    /// in relevance-based recall, not a fixed always-list. This is the single
1073    /// source of truth for the policy, shared by the hosted platform, the local
1074    /// daemon, and any SDK consumer, so behavior cannot drift between them.
1075    pub fn prune_standing_rules(&self) -> MenteResult<PruneReport> {
1076        use std::collections::{HashMap, HashSet};
1077
1078        let always: Vec<MemoryNode> = self
1079            .memory_ids()
1080            .into_iter()
1081            .filter_map(|id| self.get_memory(id).ok())
1082            .filter(|n| n.tags.iter().any(|t| t == "scope:always"))
1083            .collect();
1084
1085        let mut report = PruneReport {
1086            total_always: always.len(),
1087            ..Default::default()
1088        };
1089
1090        // Collapse exact-content duplicates: keep the healthiest copy, forget
1091        // the rest.
1092        let mut by_content: HashMap<&str, Vec<&MemoryNode>> = HashMap::new();
1093        for n in &always {
1094            by_content.entry(n.content.as_str()).or_default().push(n);
1095        }
1096        let mut prune_ids: HashSet<MemoryId> = HashSet::new();
1097        for (_content, mut group) in by_content {
1098            if group.len() > 1 {
1099                report.duplicate_groups += 1;
1100                group.sort_by(|a, b| {
1101                    b.salience
1102                        .partial_cmp(&a.salience)
1103                        .unwrap_or(std::cmp::Ordering::Equal)
1104                });
1105                for n in group.into_iter().skip(1) {
1106                    prune_ids.insert(n.id);
1107                }
1108            }
1109        }
1110
1111        // Un-pin every auto-pinned always-rule (not source:manual, not the
1112        // profile). Keep the memory, drop the pin.
1113        let mut to_unpin: Vec<MemoryNode> = Vec::new();
1114        for n in &always {
1115            if prune_ids.contains(&n.id) {
1116                continue;
1117            }
1118            let is_manual = n.tags.iter().any(|t| t == "source:manual");
1119            let is_profile = n.tags.iter().any(|t| t == "user_profile");
1120            if !is_manual && !is_profile {
1121                to_unpin.push(n.clone());
1122            }
1123        }
1124
1125        for id in prune_ids {
1126            self.forget(id)?;
1127            report.pruned.push(id);
1128        }
1129        for mut n in to_unpin {
1130            n.tags.retain(|t| t != "scope:always");
1131            let id = n.id;
1132            self.store(n)?;
1133            report.unpinned.push(id);
1134        }
1135
1136        Ok(report)
1137    }
1138
1139    /// Returns a reference to the underlying graph manager.
1140    pub fn graph(&self) -> &GraphManager {
1141        &self.graph
1142    }
1143
1144    /// Returns a mutable reference to the underlying graph manager.
1145    #[deprecated(note = "GraphManager now uses interior mutability; use graph() instead")]
1146    pub fn graph_mut(&mut self) -> &mut GraphManager {
1147        &mut self.graph
1148    }
1149
1150    /// Returns a reference to the cognitive configuration.
1151    pub fn cognitive_config(&self) -> &CognitiveConfig {
1152        &self.cognitive_config
1153    }
1154
1155    // -----------------------------------------------------------------------
1156    // Cognitive Engine: Write Inference
1157    // -----------------------------------------------------------------------
1158
1159    /// Run write inference on a newly stored memory.
1160    ///
1161    /// Finds semantically similar existing memories, runs the inference engine
1162    /// to detect contradictions and relationships, then applies the actions
1163    /// (creating edges, invalidating superseded memories, etc.).
1164    fn run_write_inference(&self, new_memory: &MemoryNode) {
1165        // Find candidate memories to compare against via vector search.
1166        // We load a small set of the most similar memories.
1167        let candidates = if !new_memory.embedding.is_empty() {
1168            let now = std::time::SystemTime::now()
1169                .duration_since(std::time::UNIX_EPOCH)
1170                .unwrap_or_default()
1171                .as_micros() as u64;
1172            // Scope to the new memory's owner on BOTH axes: contradiction and
1173            // relationship inference must never compare against another user's
1174            // or another agent's memories. Nil owned (shared/global) memories
1175            // stay in scope.
1176            self.recall_hybrid_scoped_at_mode(
1177                &new_memory.embedding,
1178                None,
1179                20,
1180                now,
1181                None,
1182                false,
1183                None,
1184                Some(new_memory.agent_id),
1185                Some(new_memory.user_id),
1186            )
1187            .unwrap_or_default()
1188        } else {
1189            vec![]
1190        };
1191
1192        if candidates.is_empty() {
1193            return;
1194        }
1195
1196        // Load the actual MemoryNode data for each candidate.
1197        let pm = self.page_map.read();
1198        let existing: Vec<MemoryNode> = candidates
1199            .iter()
1200            .filter(|(id, _)| *id != new_memory.id)
1201            .filter_map(|(id, _)| {
1202                pm.get(id)
1203                    .and_then(|&pid| self.storage.load_memory(pid).ok())
1204            })
1205            .collect();
1206        drop(pm);
1207
1208        if existing.is_empty() {
1209            return;
1210        }
1211
1212        let actions = self
1213            .write_inference
1214            .infer_on_write(new_memory, &existing, &[]);
1215
1216        let action_count = actions.len();
1217        for action in actions {
1218            if let Err(e) = self.apply_inferred_action(action) {
1219                warn!("Failed to apply inferred action: {}", e);
1220            }
1221        }
1222        if action_count > 0 {
1223            debug!(
1224                "Write inference for {} produced {} actions",
1225                new_memory.id, action_count
1226            );
1227        }
1228    }
1229
1230    /// Apply a single inferred action from the write inference engine.
1231    fn apply_inferred_action(&self, action: InferredAction) -> MenteResult<()> {
1232        match action {
1233            InferredAction::CreateEdge {
1234                source,
1235                target,
1236                edge_type,
1237                weight,
1238            } => {
1239                let now = std::time::SystemTime::now()
1240                    .duration_since(std::time::UNIX_EPOCH)
1241                    .unwrap_or_default()
1242                    .as_micros() as u64;
1243                let edge = MemoryEdge {
1244                    source,
1245                    target,
1246                    edge_type,
1247                    weight,
1248                    created_at: now,
1249                    valid_from: None,
1250                    valid_until: None,
1251                    label: None,
1252                };
1253                debug!(
1254                    "Auto-creating {:?} edge {} -> {}",
1255                    edge_type, source, target
1256                );
1257                self.graph.add_relationship(&edge)?;
1258            }
1259            InferredAction::InvalidateMemory {
1260                memory,
1261                superseded_by,
1262                valid_until,
1263            } => {
1264                debug!(
1265                    "Invalidating memory {} (superseded by {})",
1266                    memory, superseded_by
1267                );
1268                self.invalidate_memory(memory, valid_until)?;
1269                // Also create the Supersedes edge.
1270                let now = std::time::SystemTime::now()
1271                    .duration_since(std::time::UNIX_EPOCH)
1272                    .unwrap_or_default()
1273                    .as_micros() as u64;
1274                let edge = MemoryEdge {
1275                    source: superseded_by,
1276                    target: memory,
1277                    edge_type: EdgeType::Supersedes,
1278                    weight: 1.0,
1279                    created_at: now,
1280                    valid_from: None,
1281                    valid_until: None,
1282                    label: None,
1283                };
1284                self.graph.add_relationship(&edge)?;
1285            }
1286            InferredAction::DeduplicateExact { duplicate, keeper } => {
1287                debug!("Deduplicating exact copy {duplicate} into {keeper}");
1288                let now = std::time::SystemTime::now()
1289                    .duration_since(std::time::UNIX_EPOCH)
1290                    .unwrap_or_default()
1291                    .as_micros() as u64;
1292                self.invalidate_memory(duplicate, now)?;
1293                // Derived edge (dedup lineage), NOT Supersedes: an exact copy is
1294                // deduplicated, not a meaningful supersession, so it must not
1295                // surface in the contradiction/supersession view.
1296                let edge = MemoryEdge {
1297                    source: keeper,
1298                    target: duplicate,
1299                    edge_type: EdgeType::Derived,
1300                    weight: 1.0,
1301                    created_at: now,
1302                    valid_from: None,
1303                    valid_until: None,
1304                    label: None,
1305                };
1306                self.graph.add_relationship(&edge)?;
1307            }
1308            InferredAction::MarkObsolete {
1309                memory,
1310                superseded_by,
1311            } => {
1312                debug!(
1313                    "Marking {} obsolete (superseded by {})",
1314                    memory, superseded_by
1315                );
1316                let now = std::time::SystemTime::now()
1317                    .duration_since(std::time::UNIX_EPOCH)
1318                    .unwrap_or_default()
1319                    .as_micros() as u64;
1320                self.invalidate_memory(memory, now)?;
1321                let edge = MemoryEdge {
1322                    source: superseded_by,
1323                    target: memory,
1324                    edge_type: EdgeType::Supersedes,
1325                    weight: 1.0,
1326                    created_at: now,
1327                    valid_from: None,
1328                    valid_until: None,
1329                    label: None,
1330                };
1331                self.graph.add_relationship(&edge)?;
1332            }
1333            InferredAction::FlagContradiction {
1334                existing,
1335                new,
1336                reason,
1337            } => {
1338                debug!(
1339                    "Contradiction detected: {} vs {} — {}",
1340                    existing, new, reason
1341                );
1342                let now = std::time::SystemTime::now()
1343                    .duration_since(std::time::UNIX_EPOCH)
1344                    .unwrap_or_default()
1345                    .as_micros() as u64;
1346                let edge = MemoryEdge {
1347                    source: new,
1348                    target: existing,
1349                    edge_type: EdgeType::Contradicts,
1350                    weight: 1.0,
1351                    created_at: now,
1352                    valid_from: None,
1353                    valid_until: None,
1354                    label: Some(reason),
1355                };
1356                self.graph.add_relationship(&edge)?;
1357            }
1358            InferredAction::UpdateConfidence {
1359                memory,
1360                new_confidence,
1361            } => {
1362                debug!("Updating confidence for {} to {}", memory, new_confidence);
1363                if let Ok(mut node) = self.get_memory(memory) {
1364                    node.confidence = new_confidence;
1365                    if let Some(&pid) = self.page_map.read().get(&memory) {
1366                        self.storage.update_memory(pid, &node)?;
1367                    }
1368                }
1369            }
1370            InferredAction::PropagateBeliefChange { root, delta } => {
1371                debug!("Propagating belief change from {} (delta={})", root, delta);
1372                if let Ok(node) = self.get_memory(root) {
1373                    let new_confidence = (node.confidence + delta).clamp(0.0, 1.0);
1374                    let affected = self.graph.propagate_belief_change(root, new_confidence);
1375                    for (affected_id, new_conf) in affected {
1376                        if let Ok(mut affected_node) = self.get_memory(affected_id) {
1377                            affected_node.confidence = new_conf;
1378                            if let Some(&pid) = self.page_map.read().get(&affected_id)
1379                                && let Err(e) = self.storage.update_memory(pid, &affected_node)
1380                            {
1381                                warn!("Failed to persist belief update for {affected_id}: {e}");
1382                            }
1383                        }
1384                    }
1385                }
1386            }
1387            InferredAction::UpdateContent {
1388                memory,
1389                new_content,
1390                reason,
1391            } => {
1392                debug!("Updating content of {}: {}", memory, reason);
1393                if let Ok(mut node) = self.get_memory(memory) {
1394                    node.content = new_content;
1395                    if let Some(&pid) = self.page_map.read().get(&memory) {
1396                        self.storage.update_memory(pid, &node)?;
1397                    }
1398                    self.index.remove_memory(memory, &node);
1399                    self.index.index_memory(&node);
1400                }
1401            }
1402        }
1403        Ok(())
1404    }
1405
1406    // -----------------------------------------------------------------------
1407    // Cognitive Engine: Salience Decay
1408    // -----------------------------------------------------------------------
1409
1410    /// Apply salience decay to a batch of memories in-place.
1411    ///
1412    /// Call this during retrieval to ensure scores reflect temporal relevance,
1413    /// or periodically to maintain salience accuracy across the database.
1414    pub fn apply_decay(&self, memories: &mut [MemoryNode]) {
1415        let now = std::time::SystemTime::now()
1416            .duration_since(std::time::UNIX_EPOCH)
1417            .unwrap_or_default()
1418            .as_micros() as u64;
1419        self.decay.apply_decay_batch(memories, now);
1420    }
1421
1422    /// Compute the decayed salience for a single memory at the current time.
1423    pub fn compute_decayed_salience(&self, memory: &MemoryNode) -> f32 {
1424        let now = std::time::SystemTime::now()
1425            .duration_since(std::time::UNIX_EPOCH)
1426            .unwrap_or_default()
1427            .as_micros() as u64;
1428        self.decay.compute_decay(
1429            memory.salience,
1430            memory.created_at,
1431            memory.accessed_at,
1432            memory.access_count,
1433            now,
1434        )
1435    }
1436
1437    /// Retained for API compatibility. Does nothing and persists nothing.
1438    ///
1439    /// Salience decay is derived on read, not materialized. The stored
1440    /// `salience` field is the base value as of a memory's last reinforcement
1441    /// (`accessed_at`); the current, decayed strength is computed on demand via
1442    /// `compute_decayed_salience` (used by recall scoring and archival).
1443    ///
1444    /// This method previously recomputed decay from the stored salience and
1445    /// wrote the result back. That fed each pass's output in as the next pass's
1446    /// input, so the effective decay rate scaled with how often maintenance ran
1447    /// rather than with elapsed time: frequent passes aged and forgot memories
1448    /// far faster than the configured half-life. Deriving on read removes that
1449    /// coupling entirely, so it is safe to run maintenance at any cadence.
1450    pub fn apply_decay_global(&self) -> MenteResult<usize> {
1451        Ok(0)
1452    }
1453
1454    /// One-time repair for databases whose stored salience was corrupted by the
1455    /// old compounding decay pass, which overwrote each memory's base salience
1456    /// with a repeatedly re-decayed value. Reset every memory's salience to the
1457    /// configured maximum and restart its decay clock (`accessed_at = now`), in
1458    /// place, without re-running write inference, so every surviving memory gets
1459    /// a fresh, correct decay lease from now. Access counts and every other field
1460    /// are preserved. Returns the number of memories reset.
1461    ///
1462    /// This is idempotent in effect (running it twice just resets an already
1463    /// healthy base again) and safe to gate behind a one-shot migration flag.
1464    pub fn reset_decay_state(&self) -> MenteResult<usize> {
1465        let now = std::time::SystemTime::now()
1466            .duration_since(std::time::UNIX_EPOCH)
1467            .unwrap_or_default()
1468            .as_micros() as u64;
1469        let max_salience = self.decay.config.max_salience;
1470        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
1471
1472        let mut reset = 0;
1473        for pid in &page_ids {
1474            if let Ok(mut node) = self.storage.load_memory(*pid) {
1475                node.salience = max_salience;
1476                node.accessed_at = now;
1477                self.storage.update_memory(*pid, &node)?;
1478                reset += 1;
1479            }
1480        }
1481        if reset > 0 {
1482            info!("Reset decay state for {} memories", reset);
1483        }
1484        Ok(reset)
1485    }
1486
1487    /// Re-embed every memory at the embedder's current dimension and reindex it.
1488    ///
1489    /// This migrates a database whose stored vectors were produced by an
1490    /// embedder of a different dimension (for example 256 to 1024): each memory's
1491    /// content is re-embedded with the currently configured embedder, its stored
1492    /// vector is replaced, and it is removed from and re-added to the vector
1493    /// index. Memories already at the current dimension are skipped, so it is
1494    /// idempotent and safe to re-run.
1495    ///
1496    /// The migration is safe to run on a live database: the HNSW index checks
1497    /// vector dimension at query time, so a memory that has not been re-embedded
1498    /// yet is simply skipped by recall (never returned, never crashing) until
1499    /// this reaches it. Runs synchronously and re-embeds one memory at a time;
1500    /// the caller should run it on a blocking pool.
1501    pub fn reembed_all(&self) -> MenteResult<usize> {
1502        let target = self.embedding_dim;
1503        if target == 0 {
1504            return Ok(0);
1505        }
1506        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
1507
1508        let mut reembedded = 0usize;
1509        for pid in &page_ids {
1510            let Ok(mut node) = self.storage.load_memory(*pid) else {
1511                continue;
1512            };
1513            // Already at the target dimension: nothing to do.
1514            if node.embedding.len() == target || node.content.is_empty() {
1515                continue;
1516            }
1517            let Some(embedding) = self.embed_text(&node.content)? else {
1518                continue;
1519            };
1520            // The embedder is not yet producing the target dimension; leave the
1521            // memory as-is rather than store a second wrong-dimension vector.
1522            if embedding.len() != target {
1523                continue;
1524            }
1525            self.index.remove_vector_only(node.id);
1526            node.embedding = embedding;
1527            self.storage.update_memory(*pid, &node)?;
1528            self.index.index_memory(&node);
1529            reembedded += 1;
1530        }
1531        if reembedded > 0 {
1532            info!("Re-embedded {reembedded} memories at dimension {target}");
1533        }
1534        Ok(reembedded)
1535    }
1536
1537    // -----------------------------------------------------------------------
1538    // Cognitive Engine: Consolidation
1539    // -----------------------------------------------------------------------
1540
1541    /// Find groups of similar memories that are candidates for consolidation.
1542    ///
1543    /// Returns clusters of memories that share high semantic similarity and
1544    /// could be merged into unified knowledge.
1545    pub fn find_consolidation_candidates(
1546        &self,
1547        min_cluster_size: usize,
1548        similarity_threshold: f32,
1549    ) -> MenteResult<Vec<ConsolidationCandidate>> {
1550        let now = std::time::SystemTime::now()
1551            .duration_since(std::time::UNIX_EPOCH)
1552            .unwrap_or_default()
1553            .as_micros() as u64;
1554
1555        // Load all memories eligible for consolidation.
1556        let pm = self.page_map.read();
1557        let eligible: Vec<MemoryNode> = pm
1558            .values()
1559            .filter_map(|pid| self.storage.load_memory(*pid).ok())
1560            .filter(|node| ConsolidationEngine::should_consolidate(node, now))
1561            .collect();
1562        drop(pm);
1563
1564        if eligible.is_empty() {
1565            return Ok(vec![]);
1566        }
1567
1568        Ok(self
1569            .consolidation
1570            .find_candidates(&eligible, min_cluster_size, similarity_threshold))
1571    }
1572
1573    /// Consolidate a cluster of memories into a single merged memory.
1574    ///
1575    /// The source memories are invalidated (not deleted) and a new consolidated
1576    /// semantic memory is stored with Derived edges back to the sources.
1577    pub fn consolidate_cluster(&self, memory_ids: &[MemoryId]) -> MenteResult<MemoryId> {
1578        let pm = self.page_map.read();
1579        let cluster: Vec<MemoryNode> = memory_ids
1580            .iter()
1581            .filter_map(|id| {
1582                pm.get(id)
1583                    .and_then(|&pid| self.storage.load_memory(pid).ok())
1584            })
1585            .collect();
1586        drop(pm);
1587
1588        if cluster.len() < 2 {
1589            return Err(MenteError::Query(
1590                "consolidation requires at least 2 memories".into(),
1591            ));
1592        }
1593
1594        // Never merge across owners. find_candidates already groups by both
1595        // owner axes, but if a caller hands us a hand-built mixed cluster,
1596        // refuse rather than leak one owner's content into another's
1597        // consolidated memory. Both axes are checked independently.
1598        let owner_agent = cluster[0].agent_id;
1599        if cluster.iter().any(|m| m.agent_id != owner_agent) {
1600            return Err(MenteError::Query(
1601                "consolidation cluster mixes agents; refusing to merge across owners".into(),
1602            ));
1603        }
1604        let owner_user = cluster[0].user_id;
1605        if cluster.iter().any(|m| m.user_id != owner_user) {
1606            return Err(MenteError::Query(
1607                "consolidation cluster mixes users; refusing to merge across owners".into(),
1608            ));
1609        }
1610
1611        let result = self.consolidation.consolidate(&cluster);
1612
1613        // Create the consolidated memory node, stamped with BOTH owner axes of
1614        // the cluster so the derived memory stays scoped exactly as its sources.
1615        let agent_id = cluster[0].agent_id;
1616        let user_id = cluster[0].user_id;
1617        let mut consolidated = MemoryNode::new(
1618            agent_id,
1619            result.new_type,
1620            result.summary,
1621            result.combined_embedding,
1622        )
1623        .with_user_id(user_id);
1624        consolidated.confidence = result.combined_confidence;
1625
1626        let consolidated_id = consolidated.id;
1627        self.store(consolidated)?;
1628
1629        // Invalidate source memories and create Derived edges.
1630        let now = std::time::SystemTime::now()
1631            .duration_since(std::time::UNIX_EPOCH)
1632            .unwrap_or_default()
1633            .as_micros() as u64;
1634        for source_id in &result.source_memories {
1635            let _ = self.invalidate_memory(*source_id, now);
1636            let edge = MemoryEdge {
1637                source: consolidated_id,
1638                target: *source_id,
1639                edge_type: EdgeType::Derived,
1640                weight: 1.0,
1641                created_at: now,
1642                valid_from: None,
1643                valid_until: None,
1644                label: None,
1645            };
1646            let _ = self.graph.add_relationship(&edge);
1647        }
1648
1649        info!(
1650            "Consolidated {} memories into {}",
1651            result.source_memories.len(),
1652            consolidated_id
1653        );
1654        Ok(consolidated_id)
1655    }
1656
1657    /// Flushes all data and closes the database.
1658    pub fn close(&self) -> MenteResult<()> {
1659        info!("Closing MenteDB");
1660        self.flush_full()?;
1661        self.storage.close()?;
1662        Ok(())
1663    }
1664
1665    /// Close with durability only: checkpoint the WAL and release the
1666    /// process lock without rewriting snapshots. Reopen reconciles stale
1667    /// snapshots, so this trades a slower next open for a shutdown fast
1668    /// enough to release every user's lock inside a deploy drain window.
1669    pub fn close_quick(&self) -> MenteResult<()> {
1670        info!("Quick closing MenteDB");
1671        self.storage.checkpoint()?;
1672        self.storage.close()?;
1673        Ok(())
1674    }
1675
1676    /// Simulate a process crash for tests: releases the storage process lock
1677    /// exactly as the operating system would when a process dies, then drops
1678    /// the instance without flushing or closing anything.
1679    #[doc(hidden)]
1680    pub fn simulate_crash(self) {
1681        self.storage.release_process_lock();
1682        std::mem::forget(self);
1683    }
1684
1685    /// Rebuild all indexes by scanning every memory in storage.
1686    ///
1687    /// Use this after index corruption or when index files were overwritten.
1688    /// Returns the number of memories re-indexed.
1689    pub fn rebuild_indexes(&self) -> MenteResult<usize> {
1690        info!("Rebuilding indexes from storage...");
1691        let ids: Vec<MemoryId> = self.page_map.read().keys().copied().collect();
1692        let total = ids.len();
1693        let mut indexed = 0usize;
1694        for id in ids {
1695            if let Ok(node) = self.get_memory(id) {
1696                self.index.index_memory(&node);
1697                indexed += 1;
1698            }
1699        }
1700        self.index.save(&self.path.join("indexes"))?;
1701        info!(indexed, total, "index rebuild complete");
1702        Ok(indexed)
1703    }
1704
1705    /// Flush indexes, graph, and storage to disk without closing.
1706    ///
1707    /// Call this periodically to ensure cross-session persistence.
1708    /// Unlike `close()`, the database remains usable after flushing.
1709    pub fn flush(&self) -> MenteResult<()> {
1710        debug!("Flushing MenteDB to disk");
1711        // Durability is the WAL checkpoint, paid on every flush. Index,
1712        // graph, and cognitive snapshots only accelerate reopen (open
1713        // reconciles stale ones against storage), so they are amortized
1714        // across flush_snapshot_interval hot flushes instead of multiplying
1715        // fsync cost on every write batch.
1716        self.storage.checkpoint()?;
1717
1718        let n = self
1719            .flushes_since_snapshot
1720            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1721            + 1;
1722        if n >= self.cognitive_config.flush_snapshot_interval {
1723            self.write_snapshots()?;
1724        }
1725        Ok(())
1726    }
1727
1728    /// Flush everything including index, graph, and cognitive snapshots.
1729    /// Used by close and by maintenance, where reopen speed matters more
1730    /// than write latency.
1731    pub fn flush_full(&self) -> MenteResult<()> {
1732        debug!("Full flush of MenteDB to disk");
1733        self.storage.checkpoint()?;
1734        self.write_snapshots()
1735    }
1736
1737    fn write_snapshots(&self) -> MenteResult<()> {
1738        self.index.save(&self.path.join("indexes"))?;
1739        self.graph.save(&self.path.join("graph"))?;
1740
1741        // Persist cognitive subsystem state.
1742        let cognitive_dir = self.path.join("cognitive");
1743        if std::fs::create_dir_all(&cognitive_dir).is_ok() {
1744            let _ = self
1745                .trajectory
1746                .read()
1747                .transitions
1748                .save(&cognitive_dir.join("transitions.json"), 1);
1749            let _ = self
1750                .speculative
1751                .read()
1752                .save(&cognitive_dir.join("speculative.json"), 0);
1753            let _ = self
1754                .entity_resolver
1755                .read()
1756                .save(&cognitive_dir.join("entities.json"));
1757        }
1758        self.flushes_since_snapshot
1759            .store(0, std::sync::atomic::Ordering::Relaxed);
1760        Ok(())
1761    }
1762
1763    /// Executes a query plan against the indexes and graph, returning scored memories.
1764    fn execute_plan(&self, plan: &QueryPlan) -> MenteResult<Vec<ScoredMemory>> {
1765        match plan {
1766            QueryPlan::VectorSearch {
1767                query,
1768                k,
1769                filters,
1770                condition,
1771            } => {
1772                // `content ~> "text"` arrives with an empty vector and the
1773                // SimilarTo filter left in place for us to embed now (no embedder
1774                // at plan time). NEAR arrives with the vector already filled in.
1775                let embedded;
1776                let qvec: &[f32] = if query.is_empty() {
1777                    match filters
1778                        .iter()
1779                        .find(|f| f.op == Operator::SimilarTo)
1780                        .map(|f| &f.value)
1781                    {
1782                        Some(Value::Text(text)) => {
1783                            match self.embedder.as_ref().and_then(|e| e.embed(text).ok()) {
1784                                Some(v) => {
1785                                    embedded = v;
1786                                    &embedded
1787                                }
1788                                // No embedder configured: a semantic query can't run.
1789                                None => return Ok(vec![]),
1790                            }
1791                        }
1792                        _ => query,
1793                    }
1794                } else {
1795                    query
1796                };
1797                let hits = self.index.hybrid_search(qvec, None, None, *k);
1798                let mut scored = self.load_scored_memories(&hits)?;
1799                // The SimilarTo filter is the query itself; apply any others. A
1800                // boolean tree (NEAR combined with OR/NOT) takes over the whole
1801                // post-filter when present.
1802                match condition {
1803                    Some(cond) => {
1804                        scored.retain(|sm| Self::condition_matches(cond, &sm.memory));
1805                    }
1806                    None => {
1807                        scored.retain(|sm| {
1808                            filters
1809                                .iter()
1810                                .filter(|f| f.op != Operator::SimilarTo)
1811                                .all(|f| Self::filter_matches(f, &sm.memory))
1812                        });
1813                    }
1814                }
1815                // Optional entity leg: boost candidates linked to an entity the
1816                // query names. The SimilarTo filter carries the query text.
1817                if self.cognitive_config.entity_boost_enabled
1818                    && let Some(qtext) = filters
1819                        .iter()
1820                        .find(|f| f.op == Operator::SimilarTo)
1821                        .and_then(|f| match &f.value {
1822                            Value::Text(t) => Some(t.clone()),
1823                            _ => None,
1824                        })
1825                {
1826                    self.apply_entity_boost(&mut scored, &qtext);
1827                }
1828                Ok(scored)
1829            }
1830            QueryPlan::TagScan {
1831                tags,
1832                filters,
1833                limit,
1834                condition,
1835            } => {
1836                let k = limit.unwrap_or(20);
1837                let mut scored = if tags.is_empty() {
1838                    // No tag constraint: a bare RECALL, or a metadata-only filter
1839                    // like `WHERE type = semantic`. Full-scan and let the filters
1840                    // below narrow it, instead of returning nothing.
1841                    let pm = self.page_map.read();
1842                    pm.values()
1843                        .filter_map(|&pid| self.storage.load_memory(pid).ok())
1844                        .map(|memory| ScoredMemory { memory, score: 1.0 })
1845                        .collect::<Vec<_>>()
1846                } else {
1847                    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
1848                    // Zero-vector for tag-only search; salience+bitmap still apply.
1849                    let hits = self.index.hybrid_search(&[], Some(&tag_refs), None, k);
1850                    self.load_scored_memories(&hits)?
1851                };
1852                // Apply the WHERE clause: a boolean tree when present (OR/NOT/
1853                // grouping), otherwise the flat AND of metadata filters.
1854                match condition {
1855                    Some(cond) => {
1856                        scored.retain(|sm| Self::condition_matches(cond, &sm.memory));
1857                    }
1858                    None => {
1859                        scored.retain(|sm| {
1860                            filters.iter().all(|f| Self::filter_matches(f, &sm.memory))
1861                        });
1862                    }
1863                }
1864                scored.truncate(k);
1865                Ok(scored)
1866            }
1867            QueryPlan::TemporalScan { start, end, .. } => {
1868                let hits = self
1869                    .index
1870                    .hybrid_search(&[], None, Some((*start, *end)), 100);
1871                self.load_scored_memories(&hits)
1872            }
1873            QueryPlan::GraphTraversal { start, depth, .. } => {
1874                let (ids, _edges) = self.graph.get_context_subgraph(*start, *depth);
1875                let pm = self.page_map.read();
1876                let scored: Vec<ScoredMemory> = ids
1877                    .iter()
1878                    .filter_map(|id| {
1879                        pm.get(id).and_then(|&pid| {
1880                            self.storage.load_memory(pid).ok().map(|node| ScoredMemory {
1881                                memory: node,
1882                                score: 1.0,
1883                            })
1884                        })
1885                    })
1886                    .collect();
1887                Ok(scored)
1888            }
1889            QueryPlan::PointLookup { id } => {
1890                let page_id = self
1891                    .page_map
1892                    .read()
1893                    .get(id)
1894                    .copied()
1895                    .ok_or(MenteError::MemoryNotFound(*id))?;
1896                let node = self.storage.load_memory(page_id)?;
1897                Ok(vec![ScoredMemory {
1898                    memory: node,
1899                    score: 1.0,
1900                }])
1901            }
1902            _ => Ok(vec![]),
1903        }
1904    }
1905
1906    /// Memory ids that an entity named in the query links to. Tokenizes the query,
1907    /// looks up each token as an `entity:<name>` node in the tag index, and
1908    /// collects the memories those entity nodes were derived from (their outgoing
1909    /// `Derived` neighbors). Cheap: a bitmap lookup per token plus one graph
1910    /// adjacency read per hit, no LLM. Only non-empty once enrichment has created
1911    /// entity nodes and linked them, so a raw store simply gets no boost.
1912    fn entity_boost_ids(&self, query_text: &str) -> std::collections::HashSet<MemoryId> {
1913        let mut ids = std::collections::HashSet::new();
1914        let graph = self.graph.graph();
1915        let mut seen_tokens = std::collections::HashSet::new();
1916        for token in query_text
1917            .split(|c: char| !c.is_alphanumeric())
1918            .map(|w| w.to_lowercase())
1919            .filter(|w| w.len() >= 2)
1920        {
1921            if !seen_tokens.insert(token.clone()) {
1922                continue;
1923            }
1924            for entity_id in self.index.bitmap.query_tag(&format!("entity:{token}")) {
1925                for (mid, edge) in graph.outgoing(entity_id) {
1926                    if edge.edge_type == EdgeType::Derived {
1927                        ids.insert(mid);
1928                    }
1929                }
1930            }
1931        }
1932        ids
1933    }
1934
1935    /// Add `entity_boost_weight` to any candidate a query-named entity links to,
1936    /// then re-sort. A no-op when nothing in the query resolves to an entity.
1937    fn apply_entity_boost(&self, scored: &mut [ScoredMemory], query_text: &str) {
1938        let ids = self.entity_boost_ids(query_text);
1939        if ids.is_empty() {
1940            return;
1941        }
1942        let w = self.cognitive_config.entity_boost_weight;
1943        for sm in scored.iter_mut() {
1944            if ids.contains(&sm.memory.id) {
1945                sm.score += w;
1946            }
1947        }
1948        scored.sort_by(|a, b| {
1949            b.score
1950                .partial_cmp(&a.score)
1951                .unwrap_or(std::cmp::Ordering::Equal)
1952        });
1953    }
1954
1955    /// Whether a memory satisfies a boolean WHERE tree (OR/NOT/grouping). Leaves
1956    /// defer to `filter_matches`, so a leaf that `filter_matches` treats as
1957    /// permissive stays permissive; `And`/`Or`/`Not` compose those results.
1958    fn condition_matches(c: &Condition, node: &MemoryNode) -> bool {
1959        match c {
1960            Condition::Leaf(f) => Self::filter_matches(f, node),
1961            Condition::And(children) => children.iter().all(|ch| Self::condition_matches(ch, node)),
1962            Condition::Or(children) => children.iter().any(|ch| Self::condition_matches(ch, node)),
1963            Condition::Not(inner) => !Self::condition_matches(inner, node),
1964        }
1965    }
1966
1967    /// Whether a memory satisfies a single MQL filter. Applies the metadata
1968    /// filters the planner attaches to a plan (type, tag, content, time), so
1969    /// `WHERE type = semantic` and similar clauses actually narrow results
1970    /// instead of being ignored. Unhandled field/value combinations are
1971    /// permissive (they do not exclude), so a supported query is never dropped
1972    /// by a clause this does not understand.
1973    fn filter_matches(f: &Filter, node: &MemoryNode) -> bool {
1974        // IN: the field matches any element of the list (reusing per-field
1975        // equality, so `type IN [...]`, `tag IN [...]`, `content IN [...]` all work).
1976        if let Value::List(items) = &f.value {
1977            return items.iter().any(|v| {
1978                Self::filter_matches(
1979                    &Filter {
1980                        field: f.field,
1981                        op: Operator::Eq,
1982                        value: v.clone(),
1983                    },
1984                    node,
1985                )
1986            });
1987        }
1988        match (&f.field, &f.value) {
1989            (Field::Type, Value::MemoryType(t)) => {
1990                if f.op == Operator::Neq {
1991                    node.memory_type != *t
1992                } else {
1993                    node.memory_type == *t
1994                }
1995            }
1996            (Field::Tag, Value::Text(tag)) => {
1997                let has = node.tags.iter().any(|x| x == tag);
1998                if f.op == Operator::Neq { !has } else { has }
1999            }
2000            (Field::Content, Value::Text(s)) => {
2001                let hit = node.content.to_lowercase().contains(&s.to_lowercase());
2002                if f.op == Operator::Neq { !hit } else { hit }
2003            }
2004            (Field::Created, v) => Self::num_cmp(node.created_at as f64, f.op, Self::value_f64(v)),
2005            (Field::Accessed, v) => {
2006                Self::num_cmp(node.accessed_at as f64, f.op, Self::value_f64(v))
2007            }
2008            // AS OF <t>: keep only memories whose validity window contains t.
2009            (Field::ValidAt, v) => Self::value_f64(v)
2010                .map(|t| node.is_valid_at(t as u64))
2011                .unwrap_or(true),
2012            _ => true,
2013        }
2014    }
2015
2016    fn value_f64(v: &Value) -> Option<f64> {
2017        match v {
2018            Value::Number(n) => Some(*n),
2019            Value::Integer(i) => Some(*i as f64),
2020            _ => None,
2021        }
2022    }
2023
2024    fn num_cmp(a: f64, op: Operator, b: Option<f64>) -> bool {
2025        let Some(b) = b else { return true };
2026        match op {
2027            Operator::Gt => a > b,
2028            Operator::Gte => a >= b,
2029            Operator::Lt => a < b,
2030            Operator::Lte => a <= b,
2031            Operator::Eq => (a - b).abs() < f64::EPSILON,
2032            Operator::Neq => (a - b).abs() >= f64::EPSILON,
2033            Operator::SimilarTo => true,
2034            // Set/substring operators do not apply to a numeric comparison; the
2035            // `IN` list case is handled before num_cmp is ever reached.
2036            Operator::In | Operator::Contains => false,
2037        }
2038    }
2039
2040    /// Loads MemoryNodes from storage and pairs them with their search scores.
2041    ///
2042    /// When decay is enabled, salience is recomputed and factored into the
2043    /// final score to prioritize temporally relevant memories.
2044    fn load_scored_memories(&self, hits: &[(MemoryId, f32)]) -> MenteResult<Vec<ScoredMemory>> {
2045        let pm = self.page_map.read();
2046        let now = if self.cognitive_config.decay_on_recall {
2047            std::time::SystemTime::now()
2048                .duration_since(std::time::UNIX_EPOCH)
2049                .unwrap_or_default()
2050                .as_micros() as u64
2051        } else {
2052            0
2053        };
2054
2055        let mut scored = Vec::with_capacity(hits.len());
2056        for &(id, score) in hits {
2057            if let Some(&page_id) = pm.get(&id)
2058                && let Ok(node) = self.storage.load_memory(page_id)
2059            {
2060                let final_score = if self.cognitive_config.decay_on_recall {
2061                    let decayed_salience = self.decay.compute_decay(
2062                        node.salience,
2063                        node.created_at,
2064                        node.accessed_at,
2065                        node.access_count,
2066                        now,
2067                    );
2068                    // Blend search similarity with decayed salience.
2069                    // 70% similarity, 30% salience — keeps search relevance
2070                    // primary but rewards recently active memories.
2071                    score * 0.7 + decayed_salience * 0.3
2072                } else {
2073                    score
2074                };
2075                scored.push(ScoredMemory {
2076                    memory: node,
2077                    score: final_score,
2078                });
2079            }
2080        }
2081        // Re-sort by blended score when decay is applied.
2082        if self.cognitive_config.decay_on_recall {
2083            scored.sort_unstable_by(|a, b| {
2084                b.score
2085                    .partial_cmp(&a.score)
2086                    .unwrap_or(std::cmp::Ordering::Equal)
2087            });
2088        }
2089        Ok(scored)
2090    }
2091
2092    // -----------------------------------------------------------------------
2093    // Cognitive Engine: Pain Registry
2094    // -----------------------------------------------------------------------
2095
2096    /// Record a pain signal — a recurring failure or frustration pattern.
2097    ///
2098    /// Pain signals are tracked by keywords and surfaced as warnings when
2099    /// similar contexts arise in future queries.
2100    pub fn record_pain(&self, signal: PainSignal) {
2101        if self.cognitive_config.pain_tracking {
2102            self.pain.write().record_pain(signal);
2103        }
2104    }
2105
2106    /// Get pain warnings relevant to the given context keywords.
2107    ///
2108    /// Returns formatted warning text if any pain signals match the keywords.
2109    /// Use this before answering to warn about past failures.
2110    pub fn get_pain_warnings(&self, context_keywords: &[String]) -> Vec<PainSignal> {
2111        if !self.cognitive_config.pain_tracking {
2112            return vec![];
2113        }
2114        let registry = self.pain.read();
2115        registry
2116            .get_pain_for_context(context_keywords)
2117            .into_iter()
2118            .cloned()
2119            .collect()
2120    }
2121
2122    /// Format pain warnings as a human-readable string.
2123    pub fn format_pain_warnings(&self, signals: &[&PainSignal]) -> String {
2124        self.pain.read().format_pain_warnings(signals)
2125    }
2126
2127    /// Decay all pain signals to reduce intensity over time.
2128    pub fn decay_pain(&self) {
2129        let now = std::time::SystemTime::now()
2130            .duration_since(std::time::UNIX_EPOCH)
2131            .unwrap_or_default()
2132            .as_micros() as u64;
2133        self.pain.write().decay_all(now);
2134    }
2135
2136    /// Get all recorded pain signals.
2137    pub fn all_pain_signals(&self) -> Vec<PainSignal> {
2138        self.pain.read().all_signals().to_vec()
2139    }
2140
2141    // -----------------------------------------------------------------------
2142    // Cognitive Engine: Trajectory Tracking
2143    // -----------------------------------------------------------------------
2144
2145    /// Record a conversation turn in the trajectory tracker.
2146    ///
2147    /// Tracks the evolution of topics, decisions, and open questions across
2148    /// a conversation. Used for resume context and topic prediction.
2149    pub fn record_trajectory_turn(&self, turn: TrajectoryNode) {
2150        self.trajectory.write().record_turn(turn);
2151    }
2152
2153    /// Canonicalize recent trajectory topics via the LLM, populating the learned
2154    /// topic cache so future turns collapse phrasings to one canonical label.
2155    /// That label then flows into topic prediction, resume context, and the
2156    /// speculative pre-assembly cache, which is why raw message text stops
2157    /// showing up as a "topic".
2158    ///
2159    /// Runs OFF the per-turn hot path: call it from an async post-turn step or
2160    /// the maintenance sweep. No lock is held across the LLM calls. The engine
2161    /// stays LLM-optional: pass any judge, or do not call this and topics simply
2162    /// stay at their normalized-raw form. Returns the number of new canonical
2163    /// labels learned this pass.
2164    pub async fn canonicalize_trajectory_topics<J: mentedb_cognitive::llm::LlmJudge>(
2165        &self,
2166        judge: J,
2167        limit: usize,
2168    ) -> usize {
2169        // 1. Collect recent uncached topics + the known label set (read lock).
2170        let (pending, mut known) = {
2171            let traj = self.trajectory.read();
2172            (traj.pending_canonicalization(limit), traj.known_topics())
2173        };
2174        if pending.is_empty() {
2175            return 0;
2176        }
2177        // 2. Ask the LLM for a canonical label per topic (no lock held).
2178        let svc = mentedb_cognitive::llm::CognitiveLlmService::new(judge);
2179        let mut learned = Vec::new();
2180        for raw in pending {
2181            if let Ok(label) = svc.canonicalize_topic(&raw, &known).await {
2182                known.push(label.topic.clone());
2183                learned.push((raw, label.topic));
2184            }
2185        }
2186        // 3. Store the learned mappings (write lock).
2187        let n = learned.len();
2188        if n > 0 {
2189            let mut traj = self.trajectory.write();
2190            for (raw, canonical) in learned {
2191                traj.learn_canonical(&raw, &canonical);
2192            }
2193        }
2194        n
2195    }
2196
2197    /// Get a resume context string summarizing the conversation so far.
2198    ///
2199    /// Returns None if no trajectory has been recorded.
2200    pub fn get_resume_context(&self) -> Option<String> {
2201        self.trajectory.read().get_resume_context()
2202    }
2203
2204    /// Predict the next likely topics based on conversation trajectory.
2205    ///
2206    /// Returns up to 3 predicted topic strings based on transition patterns.
2207    pub fn predict_next_topics(&self) -> Vec<String> {
2208        self.trajectory.read().predict_next_topics()
2209    }
2210
2211    /// Get the full trajectory of recorded turns.
2212    pub fn get_trajectory(&self) -> Vec<TrajectoryNode> {
2213        self.trajectory.read().get_trajectory().to_vec()
2214    }
2215
2216    /// Reinforce a transition that led to a speculative cache hit.
2217    pub fn reinforce_transition(&self, hit_topic: &str) {
2218        self.trajectory.write().reinforce_transition(hit_topic);
2219    }
2220
2221    // -----------------------------------------------------------------------
2222    // Cognitive Engine: Cognition Stream
2223    // -----------------------------------------------------------------------
2224
2225    /// Feed a token to the cognition stream for real-time monitoring.
2226    ///
2227    /// Tokens are buffered and analyzed for contradictions with known facts
2228    /// when `check_stream_alerts()` is called.
2229    pub fn feed_stream_token(&self, token: &str) {
2230        self.stream.feed_token(token);
2231    }
2232
2233    /// Check for stream alerts against known facts.
2234    ///
2235    /// Compares the buffered token stream against the provided known facts
2236    /// to detect contradictions, corrections, and reinforcements.
2237    pub fn check_stream_alerts(&self, known_facts: &[(MemoryId, String)]) -> Vec<StreamAlert> {
2238        self.stream.check_alerts(known_facts)
2239    }
2240
2241    /// Drain the token buffer, returning accumulated text.
2242    pub fn drain_stream_buffer(&self) -> String {
2243        self.stream.drain_buffer()
2244    }
2245
2246    // -----------------------------------------------------------------------
2247    // Cognitive Engine: Phantom Tracking
2248    // -----------------------------------------------------------------------
2249
2250    /// Detect phantom memories — entities referenced in content but not stored.
2251    ///
2252    /// Scans content for entity mentions that don't exist in the known entities
2253    /// list, flagging them as knowledge gaps that should be filled.
2254    pub fn detect_phantoms(
2255        &self,
2256        content: &str,
2257        known_entities: &[String],
2258        turn_id: u64,
2259    ) -> Vec<PhantomMemory> {
2260        if !self.cognitive_config.phantom_tracking {
2261            return vec![];
2262        }
2263        self.phantom
2264            .write()
2265            .detect_gaps(content, known_entities, turn_id)
2266    }
2267
2268    /// Resolve a phantom memory (mark it as no longer a gap).
2269    pub fn resolve_phantom(&self, phantom_id: MemoryId) {
2270        self.phantom.write().resolve(phantom_id.into());
2271    }
2272
2273    /// Get all active (unresolved) phantom memories, sorted by priority.
2274    pub fn get_active_phantoms(&self) -> Vec<PhantomMemory> {
2275        self.phantom
2276            .read()
2277            .get_active_phantoms()
2278            .into_iter()
2279            .cloned()
2280            .collect()
2281    }
2282
2283    /// Format phantom warnings as a human-readable string.
2284    pub fn format_phantom_warnings(&self) -> String {
2285        self.phantom.read().format_phantom_warnings()
2286    }
2287
2288    /// Register an entity so the phantom tracker knows it exists.
2289    pub fn register_entity(&self, entity: &str) {
2290        self.phantom.write().register_entity(entity);
2291    }
2292
2293    /// Register multiple entities at once.
2294    pub fn register_entities(&self, entities: &[&str]) {
2295        self.phantom.write().register_entities(entities);
2296    }
2297
2298    // -----------------------------------------------------------------------
2299    // Cognitive Engine: Speculative Cache
2300    // -----------------------------------------------------------------------
2301
2302    /// Try to hit the speculative cache for a query.
2303    ///
2304    /// If a previous prediction matches the current query (by keyword overlap
2305    /// or embedding similarity), returns the pre-assembled context.
2306    pub fn try_speculative_hit(
2307        &self,
2308        query: &str,
2309        query_embedding: Option<&[f32]>,
2310    ) -> Option<CacheEntry> {
2311        if !self.cognitive_config.speculative_cache {
2312            return None;
2313        }
2314        self.speculative.write().try_hit(query, query_embedding)
2315    }
2316
2317    /// Pre-assemble speculative cache entries for predicted topics.
2318    ///
2319    /// The builder function should return `(context_text, memory_ids, optional_embedding)`
2320    /// for each topic prediction.
2321    pub fn pre_assemble_speculative<F>(&self, predictions: Vec<String>, builder: F)
2322    where
2323        F: Fn(&str) -> Option<(String, Vec<MemoryId>, Option<Vec<f32>>)>,
2324    {
2325        if self.cognitive_config.speculative_cache {
2326            self.speculative.write().pre_assemble(predictions, builder);
2327        }
2328    }
2329
2330    /// Evict stale entries from the speculative cache.
2331    pub fn evict_stale_speculative(&self, max_age_us: u64) {
2332        let now = std::time::SystemTime::now()
2333            .duration_since(std::time::UNIX_EPOCH)
2334            .unwrap_or_default()
2335            .as_micros() as u64;
2336        self.speculative.write().evict_stale(max_age_us, now);
2337    }
2338
2339    /// Get speculative cache statistics.
2340    pub fn speculative_cache_stats(&self) -> CacheStats {
2341        self.speculative.read().stats()
2342    }
2343
2344    /// Get the current speculative cache entries (predicted topic, pre-assembled
2345    /// context, source memory ids, hit count, and age), for introspection and
2346    /// dashboards. This is the live pre-assembly cache, not aggregate stats.
2347    pub fn speculative_cache_entries(&self) -> Vec<CacheEntry> {
2348        self.speculative.read().entries().to_vec()
2349    }
2350
2351    // -----------------------------------------------------------------------
2352    // Cognitive Engine: Interference Detection
2353    // -----------------------------------------------------------------------
2354
2355    /// Detect interference between a set of memories.
2356    ///
2357    /// Returns pairs of memories that are similar enough to cause confusion,
2358    /// along with disambiguation hints. Use this during context assembly to
2359    /// add disambiguation notes or separate confusable memories.
2360    pub fn detect_interference(&self, memories: &[MemoryNode]) -> Vec<InterferencePair> {
2361        if !self.cognitive_config.interference_detection {
2362            return vec![];
2363        }
2364        self.interference.detect_interference(memories)
2365    }
2366
2367    /// Generate a disambiguation hint for two confusable memories.
2368    pub fn generate_disambiguation(&self, a: &MemoryNode, b: &MemoryNode) -> String {
2369        self.interference.generate_disambiguation(a, b)
2370    }
2371
2372    /// Arrange memory IDs to maximize separation between interfering pairs.
2373    pub fn arrange_with_separation(
2374        memories: Vec<MemoryId>,
2375        pairs: &[InterferencePair],
2376    ) -> Vec<MemoryId> {
2377        InterferenceDetector::arrange_with_separation(memories, pairs)
2378    }
2379
2380    // -----------------------------------------------------------------------
2381    // Cognitive Engine: Entity Resolution
2382    // -----------------------------------------------------------------------
2383
2384    /// Resolve an entity name to its canonical form.
2385    ///
2386    /// Uses cached aliases and rule-based matching (no LLM).
2387    pub fn resolve_entity(&self, name: &str) -> mentedb_cognitive::ResolvedEntity {
2388        self.entity_resolver.read().resolve(name)
2389    }
2390
2391    /// Add an alias mapping for entity resolution.
2392    pub fn add_entity_alias(&self, alias: &str, canonical: &str, confidence: f32) {
2393        self.entity_resolver
2394            .write()
2395            .add_alias(alias, canonical, confidence);
2396    }
2397
2398    /// Get the canonical name for an entity, if known.
2399    pub fn get_canonical_entity(&self, name: &str) -> Option<String> {
2400        self.entity_resolver.read().get_canonical(name).cloned()
2401    }
2402
2403    /// List all known entities in the resolver.
2404    pub fn known_entities(&self) -> Vec<String> {
2405        self.entity_resolver.read().known_entities()
2406    }
2407
2408    // -----------------------------------------------------------------------
2409    // Cognitive Engine: Memory Compression
2410    // -----------------------------------------------------------------------
2411
2412    /// Compress a memory's content, extracting key facts and removing filler.
2413    ///
2414    /// Returns a compressed representation with the original ID, compressed text,
2415    /// compression ratio, and extracted key facts.
2416    pub fn compress_memory(&self, memory: &MemoryNode) -> CompressedMemory {
2417        self.compressor.compress(memory)
2418    }
2419
2420    /// Compress a batch of memories.
2421    pub fn compress_memories(&self, memories: &[MemoryNode]) -> Vec<CompressedMemory> {
2422        self.compressor.compress_batch(memories)
2423    }
2424
2425    /// Estimate token count for a text string.
2426    pub fn estimate_tokens(text: &str) -> usize {
2427        MemoryCompressor::estimate_tokens(text)
2428    }
2429
2430    // -----------------------------------------------------------------------
2431    // Cognitive Engine: Archival Evaluation
2432    // -----------------------------------------------------------------------
2433
2434    /// Evaluate whether a memory should be kept, archived, or deleted.
2435    ///
2436    /// Uses age, salience, and access patterns to make lifecycle decisions.
2437    pub fn evaluate_archival(&self, memory: &MemoryNode) -> ArchivalDecision {
2438        if !self.cognitive_config.archival_evaluation {
2439            return ArchivalDecision::Keep;
2440        }
2441        let now = std::time::SystemTime::now()
2442            .duration_since(std::time::UNIX_EPOCH)
2443            .unwrap_or_default()
2444            .as_micros() as u64;
2445        // Judge on the salience decayed to `now`, not the stored base. Stored
2446        // salience only changes on reinforcement, so a never-reinforced memory
2447        // keeps its base value and archival must derive the current strength.
2448        let effective = self.decay.compute_decay(
2449            memory.salience,
2450            memory.created_at,
2451            memory.accessed_at,
2452            memory.access_count,
2453            now,
2454        );
2455        self.archival.evaluate_effective(
2456            effective,
2457            memory.memory_type,
2458            memory.created_at,
2459            memory.access_count,
2460            now,
2461        )
2462    }
2463
2464    /// Evaluate archival decisions for a batch of memories.
2465    pub fn evaluate_archival_batch(
2466        &self,
2467        memories: &[MemoryNode],
2468    ) -> Vec<(MemoryId, ArchivalDecision)> {
2469        if !self.cognitive_config.archival_evaluation {
2470            return memories
2471                .iter()
2472                .map(|m| (m.id, ArchivalDecision::Keep))
2473                .collect();
2474        }
2475        let now = std::time::SystemTime::now()
2476            .duration_since(std::time::UNIX_EPOCH)
2477            .unwrap_or_default()
2478            .as_micros() as u64;
2479        memories
2480            .iter()
2481            .map(|m| {
2482                let effective = self.decay.compute_decay(
2483                    m.salience,
2484                    m.created_at,
2485                    m.accessed_at,
2486                    m.access_count,
2487                    now,
2488                );
2489                (
2490                    m.id,
2491                    self.archival.evaluate_effective(
2492                        effective,
2493                        m.memory_type,
2494                        m.created_at,
2495                        m.access_count,
2496                        now,
2497                    ),
2498                )
2499            })
2500            .collect()
2501    }
2502
2503    /// Run archival evaluation on all memories in the database.
2504    ///
2505    /// Returns decisions for each memory. Does NOT apply them — call
2506    /// `invalidate_memory` or `forget` to act on the decisions.
2507    pub fn evaluate_archival_global(&self) -> MenteResult<Vec<(MemoryId, ArchivalDecision)>> {
2508        let pm = self.page_map.read();
2509        let memories: Vec<MemoryNode> = pm
2510            .values()
2511            .filter_map(|pid| self.storage.load_memory(*pid).ok())
2512            .collect();
2513        drop(pm);
2514        // Delegate so decisions use each memory's salience decayed to now, not
2515        // its stored base (see evaluate_archival_batch).
2516        Ok(self.evaluate_archival_batch(&memories))
2517    }
2518
2519    // -----------------------------------------------------------------------
2520    // Sleeptime Enrichment Pipeline
2521    // -----------------------------------------------------------------------
2522
2523    /// Check whether enrichment is pending (triggered by turn count or manual).
2524    pub fn needs_enrichment(&self) -> bool {
2525        if !self.cognitive_config.enrichment_config.enabled {
2526            return false;
2527        }
2528        *self.enrichment_pending.read()
2529    }
2530
2531    /// Get the turn ID when enrichment last completed.
2532    pub fn last_enrichment_turn(&self) -> u64 {
2533        *self.last_enrichment_turn.read()
2534    }
2535
2536    /// Manually trigger enrichment on the next check.
2537    pub fn request_enrichment(&self) {
2538        *self.enrichment_pending.write() = true;
2539    }
2540
2541    /// Distinct (user_id, agent_id) owner pairs that own at least one memory.
2542    ///
2543    /// Enrichment partitions by BOTH owner axes: it runs once per distinct pair
2544    /// so derived knowledge (extracted facts, entities, communities, profile)
2545    /// stays scoped to exactly the user AND agent that owned the source
2546    /// memories, never mixing across either axis.
2547    fn distinct_owner_pairs(&self) -> Vec<(UserId, AgentId)> {
2548        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
2549        let mut pairs: Vec<(UserId, AgentId)> = Vec::new();
2550        for pid in &page_ids {
2551            if let Ok(mem) = self.storage.load_memory(*pid) {
2552                let pair = (mem.user_id, mem.agent_id);
2553                if !pairs.contains(&pair) {
2554                    pairs.push(pair);
2555                }
2556            }
2557        }
2558        pairs
2559    }
2560
2561    /// Get episodic memories that haven't been enriched yet.
2562    ///
2563    /// Returns all Episodic memories created after the last enrichment turn,
2564    /// owned by exactly this (`user`, `agent`) pair (exact match on BOTH owner
2565    /// axes, so one owner's episodics are never enriched into another's
2566    /// knowledge), sorted by creation time. These are the candidates for LLM
2567    /// extraction.
2568    pub fn enrichment_candidates(&self, user: UserId, agent: AgentId) -> Vec<MemoryNode> {
2569        let last_turn = *self.last_enrichment_turn.read();
2570        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
2571        let mut candidates: Vec<MemoryNode> = page_ids
2572            .iter()
2573            .filter_map(|pid| self.storage.load_memory(*pid).ok())
2574            .filter(|m| {
2575                m.agent_id == agent
2576                    && m.user_id == user
2577                    && m.memory_type == mentedb_core::memory::MemoryType::Episodic
2578                    && !m.tags.contains(&"source:enrichment".to_string())
2579                    && m.created_at > last_turn
2580            })
2581            .collect();
2582        candidates.sort_by_key(|m| m.created_at);
2583        candidates
2584    }
2585
2586    /// All unenriched episodic candidates across every owner, sorted by creation
2587    /// time. This is an owner-agnostic introspection view (the enrichment
2588    /// pipeline itself scopes per owner via [`Self::enrichment_candidates`]); it
2589    /// exists so SDK callers can list pending work without an agent argument.
2590    pub fn all_enrichment_candidates(&self) -> Vec<MemoryNode> {
2591        let mut candidates: Vec<MemoryNode> = self
2592            .distinct_owner_pairs()
2593            .into_iter()
2594            .flat_map(|(user, agent)| self.enrichment_candidates(user, agent))
2595            .collect();
2596        candidates.sort_by_key(|m| m.created_at);
2597        candidates
2598    }
2599
2600    /// Store enrichment results: extracted memories with provenance tracking.
2601    ///
2602    /// Each stored memory gets:
2603    /// - `source:enrichment` tag for identification
2604    /// - Confidence capped at `max_enrichment_confidence`
2605    /// - `Derived` edges back to source episodic memories
2606    ///
2607    /// Returns (memories_stored, edges_created).
2608    pub fn store_enrichment_memories(
2609        &self,
2610        memories: Vec<MemoryNode>,
2611        source_ids: &[MemoryId],
2612    ) -> MenteResult<(usize, usize)> {
2613        let max_conf = self
2614            .cognitive_config
2615            .enrichment_config
2616            .max_enrichment_confidence;
2617        let mut stored = 0usize;
2618        let mut edges = 0usize;
2619
2620        for mut mem in memories {
2621            // Tag as enrichment-generated
2622            if !mem.tags.contains(&"source:enrichment".to_string()) {
2623                mem.tags.push("source:enrichment".to_string());
2624            }
2625            // Cap confidence
2626            if mem.confidence > max_conf {
2627                mem.confidence = max_conf;
2628            }
2629
2630            let mem_id = mem.id;
2631            self.store(mem)?;
2632            stored += 1;
2633
2634            // Create Derived edges back to source episodics
2635            let now = std::time::SystemTime::now()
2636                .duration_since(std::time::UNIX_EPOCH)
2637                .unwrap_or_default()
2638                .as_micros() as u64;
2639            for src_id in source_ids {
2640                let edge = MemoryEdge {
2641                    source: mem_id,
2642                    target: *src_id,
2643                    edge_type: EdgeType::Derived,
2644                    weight: 0.8,
2645                    created_at: now,
2646                    valid_from: None,
2647                    valid_until: None,
2648                    label: Some("enrichment".to_string()),
2649                };
2650                if self.relate(edge).is_ok() {
2651                    edges += 1;
2652                }
2653            }
2654        }
2655
2656        debug!(stored, edges, "enrichment memories stored");
2657        Ok((stored, edges))
2658    }
2659
2660    /// Mark enrichment as complete for the given turn.
2661    pub fn mark_enrichment_complete(&self, turn_id: u64) {
2662        *self.last_enrichment_turn.write() = turn_id;
2663        *self.enrichment_pending.write() = false;
2664        debug!(turn_id, "enrichment cycle complete");
2665    }
2666
2667    /// Get the enrichment configuration.
2668    pub fn enrichment_config(&self) -> &EnrichmentConfig {
2669        &self.cognitive_config.enrichment_config
2670    }
2671
2672    /// Get all unique entity names from stored entity memories owned by `agent`.
2673    ///
2674    /// Returns deduplicated, normalized entity names extracted from
2675    /// `entity:{name}` tags across this agent's stored memories only, so one
2676    /// user's entities never bleed into another's resolution.
2677    pub fn all_entity_names(&self, user: UserId, agent: AgentId) -> Vec<String> {
2678        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
2679        let mut names = std::collections::HashSet::new();
2680        for pid in &page_ids {
2681            if let Ok(mem) = self.storage.load_memory(*pid) {
2682                if mem.agent_id != agent || mem.user_id != user {
2683                    continue;
2684                }
2685                for tag in &mem.tags {
2686                    if let Some(name) = tag.strip_prefix("entity:") {
2687                        names.insert(name.to_lowercase().trim().to_string());
2688                    }
2689                }
2690            }
2691        }
2692        let mut sorted: Vec<String> = names.into_iter().collect();
2693        sorted.sort();
2694        sorted
2695    }
2696
2697    /// Get entity names that the EntityResolver hasn't resolved yet.
2698    ///
2699    /// These are the entities that need LLM resolution. The EntityResolver
2700    /// cache handles known entities for free.
2701    pub fn unresolved_entity_names(&self, user: UserId, agent: AgentId) -> Vec<String> {
2702        let all_names = self.all_entity_names(user, agent);
2703        self.entity_resolver.read().unresolved_names(&all_names)
2704    }
2705
2706    /// Get entity names with their memory content for LLM context.
2707    ///
2708    /// Returns (name, content) pairs for entities that need resolution.
2709    /// The content helps the LLM disambiguate (e.g., "Python" near
2710    /// "web framework" vs "Python" near "Monty Python").
2711    pub fn entity_names_with_context(
2712        &self,
2713        user: UserId,
2714        agent: AgentId,
2715    ) -> Vec<(String, Option<String>)> {
2716        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
2717        let mut entity_contexts: HashMap<String, String> = HashMap::new();
2718
2719        for pid in &page_ids {
2720            if let Ok(mem) = self.storage.load_memory(*pid) {
2721                if mem.agent_id != agent || mem.user_id != user {
2722                    continue;
2723                }
2724                for tag in &mem.tags {
2725                    if let Some(name) = tag.strip_prefix("entity:") {
2726                        let normalized = name.to_lowercase().trim().to_string();
2727                        // One entity tag per memory (enrichment creates separate memories per entity)
2728                        entity_contexts
2729                            .entry(normalized)
2730                            .and_modify(|existing| {
2731                                // Append content from multiple mentions, cap at ~500 chars
2732                                if existing.len() < 300 {
2733                                    existing.push_str(" | ");
2734                                    let remaining = 500usize.saturating_sub(existing.len());
2735                                    existing.push_str(
2736                                        mentedb_core::text::truncate_on_char_boundary(
2737                                            &mem.content,
2738                                            remaining,
2739                                        ),
2740                                    );
2741                                }
2742                            })
2743                            .or_insert_with(|| {
2744                                mentedb_core::text::truncate_on_char_boundary(&mem.content, 300)
2745                                    .to_string()
2746                            });
2747                        break;
2748                    }
2749                }
2750            }
2751        }
2752
2753        entity_contexts
2754            .into_iter()
2755            .map(|(name, ctx)| (name, Some(ctx)))
2756            .collect()
2757    }
2758
2759    /// Apply LLM entity resolution results: create graph edges and update cache.
2760    ///
2761    /// Takes merge groups from the LLM (via `CognitiveLlmService.resolve_entities()`)
2762    /// and confirmed-different pairs. Creates `entity_link:` edges between entity
2763    /// memories that belong to the same group, learns aliases in the EntityResolver,
2764    /// and negative-caches confirmed-different pairs.
2765    pub fn apply_entity_link_resolutions(
2766        &self,
2767        merge_groups: &[EntityLinkResolution],
2768        separations: &[EntitySeparation],
2769        user: UserId,
2770        agent: AgentId,
2771    ) -> MenteResult<EntityLinkResult> {
2772        let mut result = EntityLinkResult::default();
2773        let now = std::time::SystemTime::now()
2774            .duration_since(std::time::UNIX_EPOCH)
2775            .unwrap_or_default()
2776            .as_micros() as u64;
2777
2778        // Build a map: normalized entity name → list of memory IDs (this
2779        // (user, agent) pair's entities only, so links never span either owner
2780        // axis).
2781        let entity_memory_map = self.build_entity_memory_map(user, agent);
2782
2783        let mut resolver = self.entity_resolver.write();
2784
2785        for group in merge_groups {
2786            // Learn the group in the resolver cache
2787            let mut aliases: Vec<String> = group.aliases.clone();
2788            aliases.retain(|a| a.to_lowercase() != group.canonical.to_lowercase());
2789            resolver.learn_group(&EntityMergeGroup {
2790                canonical: group.canonical.clone(),
2791                aliases,
2792                confidence: group.confidence,
2793            });
2794
2795            // Collect all memory IDs for this merge group
2796            let mut group_memory_ids: Vec<MemoryId> = Vec::new();
2797
2798            // Add memories for the canonical name
2799            let canonical_norm = group.canonical.to_lowercase();
2800            if let Some(ids) = entity_memory_map.get(&canonical_norm) {
2801                group_memory_ids.extend(ids);
2802            }
2803
2804            // Add memories for each alias
2805            for alias in &group.aliases {
2806                let alias_norm = alias.to_lowercase();
2807                if let Some(ids) = entity_memory_map.get(&alias_norm) {
2808                    group_memory_ids.extend(ids);
2809                }
2810            }
2811
2812            group_memory_ids.sort();
2813            group_memory_ids.dedup();
2814
2815            // Create edges between all pairs in the group
2816            let label = format!("entity_link:{}", canonical_norm);
2817            for i in 0..group_memory_ids.len() {
2818                for j in (i + 1)..group_memory_ids.len() {
2819                    let a_id = group_memory_ids[i];
2820                    let b_id = group_memory_ids[j];
2821
2822                    // Check for existing edge
2823                    let graph = self.graph.read_graph();
2824                    let already_linked = graph.outgoing(a_id).iter().any(|(tid, e)| {
2825                        *tid == b_id
2826                            && e.edge_type == EdgeType::Related
2827                            && e.label
2828                                .as_ref()
2829                                .is_some_and(|l| l.starts_with("entity_link:"))
2830                    });
2831                    drop(graph);
2832
2833                    if already_linked {
2834                        continue;
2835                    }
2836
2837                    let edge = MemoryEdge {
2838                        source: a_id,
2839                        target: b_id,
2840                        edge_type: EdgeType::Related,
2841                        weight: group.confidence,
2842                        created_at: now,
2843                        valid_from: None,
2844                        valid_until: None,
2845                        label: Some(label.clone()),
2846                    };
2847                    if self.relate(edge).is_ok() {
2848                        result.edges_created += 1;
2849                    }
2850                    result.linked += 1;
2851                }
2852            }
2853
2854            debug!(
2855                canonical = group.canonical,
2856                aliases = ?group.aliases,
2857                memories = group_memory_ids.len(),
2858                "entity resolution: merged group"
2859            );
2860        }
2861
2862        // Process negative cache entries
2863        for sep in separations {
2864            resolver.mark_different(&sep.name_a, &sep.name_b);
2865            debug!(
2866                a = sep.name_a,
2867                b = sep.name_b,
2868                "entity resolution: confirmed different"
2869            );
2870        }
2871
2872        // Persist resolver state
2873        let cognitive_dir = self.path.join("cognitive");
2874        if cognitive_dir.exists() || std::fs::create_dir_all(&cognitive_dir).is_ok() {
2875            let _ = resolver.save(&cognitive_dir.join("entities.json"));
2876        }
2877
2878        debug!(
2879            linked = result.linked,
2880            edges = result.edges_created,
2881            groups = merge_groups.len(),
2882            separations = separations.len(),
2883            "entity link resolutions applied"
2884        );
2885        Ok(result)
2886    }
2887
2888    /// Link entities using only the sync EntityResolver (cache + rules, no LLM).
2889    ///
2890    /// This is the fast path — links entities that are already known to be
2891    /// the same from previous LLM resolutions. Runs once per owner so entity
2892    /// links never span users; each owner's entities are linked in isolation
2893    /// (nil owned / global entities are linked among themselves).
2894    pub fn link_entities(&self) -> MenteResult<EntityLinkResult> {
2895        let mut total = EntityLinkResult::default();
2896        for (user, agent) in self.distinct_owner_pairs() {
2897            let r = self.link_entities_for(user, agent)?;
2898            total.linked += r.linked;
2899            total.edges_created += r.edges_created;
2900        }
2901        Ok(total)
2902    }
2903
2904    /// Sync entity linking scoped to a single (user, agent) owner pair. See
2905    /// [`Self::link_entities`].
2906    pub(crate) fn link_entities_for(
2907        &self,
2908        user: UserId,
2909        agent: AgentId,
2910    ) -> MenteResult<EntityLinkResult> {
2911        let entity_memory_map = self.build_entity_memory_map(user, agent);
2912        let resolver = self.entity_resolver.read();
2913
2914        // Group entity names by their resolved canonical name
2915        let mut canonical_groups: HashMap<String, Vec<String>> = HashMap::new();
2916        for entity_name in entity_memory_map.keys() {
2917            let resolved = resolver.resolve(entity_name);
2918            if resolved.source != mentedb_cognitive::ResolutionSource::Identity {
2919                canonical_groups
2920                    .entry(resolved.canonical.clone())
2921                    .or_default()
2922                    .push(entity_name.clone());
2923            }
2924        }
2925
2926        drop(resolver);
2927
2928        let mut result = EntityLinkResult::default();
2929        let now = std::time::SystemTime::now()
2930            .duration_since(std::time::UNIX_EPOCH)
2931            .unwrap_or_default()
2932            .as_micros() as u64;
2933
2934        for (canonical, names) in &canonical_groups {
2935            // Collect all memory IDs across all aliases in this group
2936            let mut group_memory_ids: Vec<MemoryId> = Vec::new();
2937            for name in names {
2938                if let Some(ids) = entity_memory_map.get(name) {
2939                    group_memory_ids.extend(ids);
2940                }
2941            }
2942            // Also include the canonical name itself
2943            if let Some(ids) = entity_memory_map.get(canonical) {
2944                group_memory_ids.extend(ids);
2945            }
2946            group_memory_ids.sort();
2947            group_memory_ids.dedup();
2948
2949            if group_memory_ids.len() < 2 {
2950                continue;
2951            }
2952
2953            let label = format!("entity_link:{}", canonical);
2954            for i in 0..group_memory_ids.len() {
2955                for j in (i + 1)..group_memory_ids.len() {
2956                    let a_id = group_memory_ids[i];
2957                    let b_id = group_memory_ids[j];
2958
2959                    let graph = self.graph.read_graph();
2960                    let already_linked = graph.outgoing(a_id).iter().any(|(tid, e)| {
2961                        *tid == b_id
2962                            && e.edge_type == EdgeType::Related
2963                            && e.label
2964                                .as_ref()
2965                                .is_some_and(|l| l.starts_with("entity_link:"))
2966                    });
2967                    drop(graph);
2968
2969                    if already_linked {
2970                        continue;
2971                    }
2972
2973                    let edge = MemoryEdge {
2974                        source: a_id,
2975                        target: b_id,
2976                        edge_type: EdgeType::Related,
2977                        weight: 1.0,
2978                        created_at: now,
2979                        valid_from: None,
2980                        valid_until: None,
2981                        label: Some(label.clone()),
2982                    };
2983                    if self.relate(edge).is_ok() {
2984                        result.edges_created += 1;
2985                    }
2986                    result.linked += 1;
2987                }
2988            }
2989        }
2990
2991        debug!(
2992            linked = result.linked,
2993            edges = result.edges_created,
2994            groups = canonical_groups.len(),
2995            "sync entity linking complete"
2996        );
2997        Ok(result)
2998    }
2999
3000    /// Build a map of normalized entity name → list of MemoryIds, restricted to
3001    /// entity memories owned by exactly this (`user`, `agent`) pair so links and
3002    /// community membership never cross either owner axis.
3003    fn build_entity_memory_map(
3004        &self,
3005        user: UserId,
3006        agent: AgentId,
3007    ) -> HashMap<String, Vec<MemoryId>> {
3008        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3009        let mut map: HashMap<String, Vec<MemoryId>> = HashMap::new();
3010        for pid in &page_ids {
3011            if let Ok(mem) = self.storage.load_memory(*pid) {
3012                if mem.agent_id != agent || mem.user_id != user {
3013                    continue;
3014                }
3015                for tag in &mem.tags {
3016                    if let Some(name) = tag.strip_prefix("entity:") {
3017                        let normalized = name.to_lowercase().trim().to_string();
3018                        // One entity tag per memory (enrichment creates separate memories per entity)
3019                        map.entry(normalized).or_default().push(mem.id);
3020                        break;
3021                    }
3022                }
3023            }
3024        }
3025        map
3026    }
3027
3028    /// Get all entity memory nodes (memories tagged with `entity:{name}`).
3029    pub fn entity_memories(&self) -> Vec<MemoryNode> {
3030        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3031        page_ids
3032            .iter()
3033            .filter_map(|pid| self.storage.load_memory(*pid).ok())
3034            .filter(|m| m.tags.iter().any(|t| t.starts_with("entity:")))
3035            .collect()
3036    }
3037
3038    /// Get entity categories with their member entities for community detection.
3039    ///
3040    /// Returns a map of category → list of (entity_name, context_snippet).
3041    /// Categories come from `entity_type:` tags on entity memories.
3042    pub fn entity_communities(
3043        &self,
3044        user: UserId,
3045        agent: AgentId,
3046    ) -> HashMap<String, Vec<(String, String)>> {
3047        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3048        let mut categories: HashMap<String, Vec<(String, String)>> = HashMap::new();
3049
3050        for pid in &page_ids {
3051            if let Ok(mem) = self.storage.load_memory(*pid) {
3052                // Only cluster this (user, agent) pair's entities so communities
3053                // never mix across either owner axis.
3054                if mem.agent_id != agent || mem.user_id != user {
3055                    continue;
3056                }
3057                // Skip non-entity memories and existing community summaries
3058                if mem.tags.iter().any(|t| t == "community_summary") {
3059                    continue;
3060                }
3061
3062                let entity_name = mem
3063                    .tags
3064                    .iter()
3065                    .find_map(|t| t.strip_prefix("entity:"))
3066                    .map(|n| n.to_string());
3067
3068                if let Some(name) = entity_name {
3069                    let entity_type = mem
3070                        .tags
3071                        .iter()
3072                        .find_map(|t| t.strip_prefix("entity_type:"))
3073                        .unwrap_or("general")
3074                        .to_lowercase();
3075
3076                    let context: String = mem.content.chars().take(200).collect();
3077                    categories
3078                        .entry(entity_type)
3079                        .or_default()
3080                        .push((name, context));
3081                }
3082            }
3083        }
3084
3085        // Only return categories with 2+ entities (meaningful clusters)
3086        categories.retain(|_, members| members.len() >= 2);
3087        categories
3088    }
3089
3090    /// Store a community summary memory with edges to member entities.
3091    ///
3092    /// Creates a `community_summary` tagged memory and `Derived` edges
3093    /// from the summary to each member entity in the category.
3094    pub fn store_community_summary(
3095        &self,
3096        category: &str,
3097        summary: &str,
3098        member_names: &[String],
3099        user: UserId,
3100        agent: AgentId,
3101    ) -> MenteResult<MemoryId> {
3102        if category.is_empty() {
3103            return Err(MenteError::Storage(
3104                "community category cannot be empty".into(),
3105            ));
3106        }
3107
3108        let now = std::time::SystemTime::now()
3109            .duration_since(std::time::UNIX_EPOCH)
3110            .unwrap_or_default()
3111            .as_micros() as u64;
3112
3113        // Check if a community summary already exists for this category AND both
3114        // owner axes, so one owner's summary never overwrites another's.
3115        let community_tag = format!("community:{}", category);
3116        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3117        let mut existing_id = None;
3118        for pid in &page_ids {
3119            if let Ok(mem) = self.storage.load_memory(*pid)
3120                && mem.agent_id == agent
3121                && mem.user_id == user
3122                && mem.tags.iter().any(|t| t == &community_tag)
3123            {
3124                // Update existing summary content
3125                let mut updated = mem.clone();
3126                updated.content = summary.to_string();
3127                if let Some(ref embedder) = self.embedder {
3128                    updated.embedding = embedder
3129                        .embed(summary)
3130                        .unwrap_or_else(|_| updated.embedding.clone());
3131                }
3132                self.storage.update_memory(*pid, &updated)?;
3133                existing_id = Some(updated.id);
3134                break;
3135            }
3136        }
3137
3138        let node_id = if let Some(id) = existing_id {
3139            id
3140        } else {
3141            // Create new community summary
3142            let embedding = self
3143                .embedder
3144                .as_ref()
3145                .and_then(|e| e.embed(summary).ok())
3146                .unwrap_or_default();
3147
3148            let mut node =
3149                MemoryNode::new(agent, MemoryType::Semantic, summary.to_string(), embedding)
3150                    .with_user_id(user);
3151            node.tags = vec![
3152                "community_summary".to_string(),
3153                community_tag,
3154                "source:enrichment".to_string(),
3155            ];
3156            node.confidence = 0.7;
3157            let id = node.id;
3158            self.store(node)?;
3159            id
3160        };
3161
3162        // (Re)create Derived edges from summary to member entity memories.
3163        // On update this refreshes edges to reflect current membership.
3164        let entity_map = self.build_entity_memory_map(user, agent);
3165        for name in member_names {
3166            let normalized = name.to_lowercase();
3167            if let Some(member_ids) = entity_map.get(&normalized) {
3168                for member_id in member_ids {
3169                    self.relate(MemoryEdge {
3170                        source: node_id,
3171                        target: *member_id,
3172                        edge_type: EdgeType::Derived,
3173                        weight: 0.8,
3174                        created_at: now,
3175                        valid_from: None,
3176                        valid_until: None,
3177                        label: Some(format!("community_member:{}", category)),
3178                    })?;
3179                }
3180            }
3181        }
3182
3183        Ok(node_id)
3184    }
3185
3186    /// Get existing community summaries owned by exactly this (`user`, `agent`)
3187    /// pair, so each owner only sees its own community summaries.
3188    pub fn community_summaries(&self, user: UserId, agent: AgentId) -> Vec<MemoryNode> {
3189        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3190        page_ids
3191            .iter()
3192            .filter_map(|pid| self.storage.load_memory(*pid).ok())
3193            .filter(|m| {
3194                m.agent_id == agent
3195                    && m.user_id == user
3196                    && m.tags.iter().any(|t| t == "community_summary")
3197            })
3198            .collect()
3199    }
3200
3201    /// Collect all semantic/procedural facts for user profile generation.
3202    ///
3203    /// Returns high-confidence memories suitable for profile building.
3204    pub fn profile_facts(&self, user: UserId, agent: AgentId) -> Vec<String> {
3205        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3206        let mut facts = Vec::new();
3207
3208        for pid in &page_ids {
3209            if let Ok(mem) = self.storage.load_memory(*pid) {
3210                // Only this (user, agent) pair's facts, so a profile is built
3211                // from one owner's knowledge and never mixes in another owner's
3212                // memories on either axis.
3213                if mem.agent_id != agent || mem.user_id != user {
3214                    continue;
3215                }
3216                // Only semantic and procedural memories with decent confidence
3217                if mem.confidence < 0.5 {
3218                    continue;
3219                }
3220                match mem.memory_type {
3221                    MemoryType::Semantic | MemoryType::Procedural => {
3222                        // Skip community summaries and entity nodes
3223                        if mem
3224                            .tags
3225                            .iter()
3226                            .any(|t| t == "community_summary" || t.starts_with("entity:"))
3227                        {
3228                            continue;
3229                        }
3230                        facts.push(mem.content.chars().take(300).collect());
3231                    }
3232                    _ => {}
3233                }
3234            }
3235        }
3236
3237        // Cap at 100 most relevant facts to fit in LLM context
3238        facts.truncate(100);
3239        facts
3240    }
3241
3242    /// Store or update the user profile as an always-scoped memory owned by
3243    /// exactly this (`user`, `agent`) pair.
3244    ///
3245    /// There is exactly one user profile memory (tagged `user_profile`) per
3246    /// owner pair. If one already exists for this pair, it's replaced entirely;
3247    /// one owner's profile never overwrites another's.
3248    pub fn store_user_profile(
3249        &self,
3250        profile: &str,
3251        user: UserId,
3252        agent: AgentId,
3253    ) -> MenteResult<MemoryId> {
3254        // Find existing profile for this owner pair
3255        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3256        for pid in &page_ids {
3257            if let Ok(mem) = self.storage.load_memory(*pid)
3258                && mem.agent_id == agent
3259                && mem.user_id == user
3260                && mem.tags.iter().any(|t| t == "user_profile")
3261            {
3262                // Update in place, bumping created_at so it reflects when the
3263                // profile was last rebuilt. `profile_updated_at` reads created_at;
3264                // without this the "updated" time stayed frozen at first creation
3265                // (a regenerated profile still showed "updated N days ago").
3266                let mut updated = mem.clone();
3267                updated.content = profile.to_string();
3268                updated.created_at = std::time::SystemTime::now()
3269                    .duration_since(std::time::UNIX_EPOCH)
3270                    .unwrap_or_default()
3271                    .as_micros() as u64;
3272                if let Some(ref embedder) = self.embedder {
3273                    updated.embedding = embedder
3274                        .embed(profile)
3275                        .unwrap_or_else(|_| updated.embedding.clone());
3276                }
3277                self.storage.update_memory(*pid, &updated)?;
3278                return Ok(updated.id);
3279            }
3280        }
3281
3282        // Create new profile
3283        let embedding = self
3284            .embedder
3285            .as_ref()
3286            .and_then(|e| e.embed(profile).ok())
3287            .unwrap_or_default();
3288
3289        let mut node = MemoryNode::new(agent, MemoryType::Semantic, profile.to_string(), embedding)
3290            .with_user_id(user);
3291        node.tags = vec![
3292            "user_profile".to_string(),
3293            "scope:always".to_string(),
3294            "source:enrichment".to_string(),
3295        ];
3296        node.confidence = 0.8;
3297        let node_id = node.id;
3298        self.store(node)?;
3299
3300        Ok(node_id)
3301    }
3302
3303    /// Get the account-level user profile: the one owned by neither a user nor
3304    /// an agent (both owner axes nil).
3305    ///
3306    /// Deterministic. Since enrichment now builds one profile per (user, agent)
3307    /// owner pair, an account may hold many; this returns only the shared,
3308    /// unowned one, never an arbitrary owner's. A multi-user app that keeps only
3309    /// per-owner profiles gets `None` here; use [`user_profile_for`] to read a
3310    /// specific owner's, and [`profile_owners`] to list which owners have one.
3311    pub fn user_profile(&self) -> Option<MemoryNode> {
3312        self.user_profile_for(UserId::nil(), AgentId::nil())
3313    }
3314
3315    /// Get the user profile owned by exactly this (`user`, `agent`) pair, if one
3316    /// exists. One owner's profile is never returned for another, so a dashboard
3317    /// or a per-turn context primer can show the right profile for a scope.
3318    pub fn user_profile_for(&self, user: UserId, agent: AgentId) -> Option<MemoryNode> {
3319        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3320        for pid in &page_ids {
3321            if let Ok(mem) = self.storage.load_memory(*pid)
3322                && mem.user_id == user
3323                && mem.agent_id == agent
3324                && mem.tags.iter().any(|t| t == "user_profile")
3325            {
3326                return Some(mem);
3327            }
3328        }
3329        None
3330    }
3331
3332    /// List the owner pairs that have a user profile, most recently built first.
3333    /// Lets a dashboard enumerate the profiles it can show without scanning
3334    /// memory bodies itself.
3335    pub fn profile_owners(&self) -> Vec<(UserId, AgentId)> {
3336        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3337        let mut owners: Vec<(UserId, AgentId, u64)> = Vec::new();
3338        for pid in &page_ids {
3339            if let Ok(mem) = self.storage.load_memory(*pid)
3340                && mem.tags.iter().any(|t| t == "user_profile")
3341            {
3342                owners.push((mem.user_id, mem.agent_id, mem.created_at));
3343            }
3344        }
3345        owners.sort_by_key(|(_, _, ts)| std::cmp::Reverse(*ts));
3346        owners.into_iter().map(|(u, a, _)| (u, a)).collect()
3347    }
3348}
3349
3350#[cfg(test)]
3351mod mql_execution_tests {
3352    use super::*;
3353
3354    /// Regression: a bare `RECALL` and a `WHERE type = ...` filter must return
3355    /// the matching memories. Before the executor fix these produced an empty
3356    /// TagScan (empty tags, filters ignored) and returned nothing.
3357    #[test]
3358    fn recall_bare_and_by_type_return_matches() {
3359        let dir = tempfile::tempdir().unwrap();
3360        let db = MenteDb::open(dir.path()).unwrap();
3361        db.store(MemoryNode::new(
3362            AgentId::nil(),
3363            MemoryType::Semantic,
3364            "the sky is blue".into(),
3365            vec![0.1_f32; 8],
3366        ))
3367        .unwrap();
3368        db.store(MemoryNode::new(
3369            AgentId::nil(),
3370            MemoryType::Semantic,
3371            "water is wet".into(),
3372            vec![0.2_f32; 8],
3373        ))
3374        .unwrap();
3375        db.store(MemoryNode::new(
3376            AgentId::nil(),
3377            MemoryType::Procedural,
3378            "how to tie a knot".into(),
3379            vec![0.3_f32; 8],
3380        ))
3381        .unwrap();
3382
3383        // Bare RECALL returns all memories (previously empty).
3384        let all = db.recall("RECALL memories LIMIT 50").unwrap();
3385        let all_n: usize = all.blocks.iter().map(|b| b.memories.len()).sum();
3386        assert_eq!(all_n, 3, "bare RECALL should return all three memories");
3387
3388        // A type filter narrows to just that type (previously empty).
3389        let sem = db
3390            .recall("RECALL memories WHERE type = semantic LIMIT 50")
3391            .unwrap();
3392        let sem_mems: Vec<&ScoredMemory> = sem.blocks.iter().flat_map(|b| &b.memories).collect();
3393        assert_eq!(
3394            sem_mems.len(),
3395            2,
3396            "type = semantic should return two memories"
3397        );
3398        assert!(
3399            sem_mems
3400                .iter()
3401                .all(|sm| sm.memory.memory_type == MemoryType::Semantic),
3402            "only semantic memories should come back"
3403        );
3404    }
3405
3406    /// Build a small fixture: three types, two of them tagged "keep", so boolean
3407    /// WHERE clauses have something to include and exclude.
3408    fn boolean_fixture() -> (tempfile::TempDir, MenteDb) {
3409        let dir = tempfile::tempdir().unwrap();
3410        let db = MenteDb::open(dir.path()).unwrap();
3411        let mut sem_keep = MemoryNode::new(
3412            AgentId::nil(),
3413            MemoryType::Semantic,
3414            "semantic kept".into(),
3415            vec![0.1_f32; 8],
3416        );
3417        sem_keep.tags = vec!["keep".into()];
3418        let sem_plain = MemoryNode::new(
3419            AgentId::nil(),
3420            MemoryType::Semantic,
3421            "semantic plain".into(),
3422            vec![0.2_f32; 8],
3423        );
3424        let mut proc_keep = MemoryNode::new(
3425            AgentId::nil(),
3426            MemoryType::Procedural,
3427            "procedural kept".into(),
3428            vec![0.3_f32; 8],
3429        );
3430        proc_keep.tags = vec!["keep".into()];
3431        let anti = MemoryNode::new(
3432            AgentId::nil(),
3433            MemoryType::AntiPattern,
3434            "antipattern plain".into(),
3435            vec![0.4_f32; 8],
3436        );
3437        for n in [sem_keep, sem_plain, proc_keep, anti] {
3438            db.store(n).unwrap();
3439        }
3440        (dir, db)
3441    }
3442
3443    fn contents(db: &MenteDb, mql: &str) -> Vec<String> {
3444        let mut v: Vec<String> = db
3445            .query(mql)
3446            .unwrap()
3447            .into_iter()
3448            .map(|s| s.memory.content)
3449            .collect();
3450        v.sort();
3451        v
3452    }
3453
3454    /// OR must union the branches: `type = semantic OR type = procedural` returns
3455    /// exactly the semantic and procedural rows, never the antipattern.
3456    #[test]
3457    fn or_unions_branches() {
3458        let (_d, db) = boolean_fixture();
3459        let got = contents(
3460            &db,
3461            "RECALL WHERE type = semantic OR type = procedural LIMIT 50",
3462        );
3463        assert_eq!(
3464            got,
3465            vec![
3466                "procedural kept".to_string(),
3467                "semantic kept".to_string(),
3468                "semantic plain".to_string()
3469            ],
3470            "OR should return semantic and procedural, excluding antipattern"
3471        );
3472    }
3473
3474    /// NOT must exclude: `NOT type = semantic` returns everything that is not
3475    /// semantic.
3476    #[test]
3477    fn not_excludes() {
3478        let (_d, db) = boolean_fixture();
3479        let got = contents(&db, "RECALL WHERE NOT type = semantic LIMIT 50");
3480        assert_eq!(
3481            got,
3482            vec![
3483                "antipattern plain".to_string(),
3484                "procedural kept".to_string()
3485            ],
3486            "NOT type = semantic should drop both semantic rows"
3487        );
3488    }
3489
3490    /// AND with NOT: `type = semantic AND NOT tag = keep` isolates the untagged
3491    /// semantic row, proving NOT composes under AND.
3492    #[test]
3493    fn and_not_composes() {
3494        let (_d, db) = boolean_fixture();
3495        let got = contents(
3496            &db,
3497            r#"RECALL WHERE type = semantic AND NOT tag = "keep" LIMIT 50"#,
3498        );
3499        assert_eq!(
3500            got,
3501            vec!["semantic plain".to_string()],
3502            "only the untagged semantic row should remain"
3503        );
3504    }
3505
3506    /// Grouping changes meaning: `(type = semantic OR type = procedural) AND tag =
3507    /// keep` must return only the kept rows of those two types, not the plain
3508    /// semantic one. This is the precedence case a flat AND list cannot express.
3509    #[test]
3510    fn grouping_binds_before_and() {
3511        let (_d, db) = boolean_fixture();
3512        let got = contents(
3513            &db,
3514            r#"RECALL WHERE (type = semantic OR type = procedural) AND tag = "keep" LIMIT 50"#,
3515        );
3516        assert_eq!(
3517            got,
3518            vec!["procedural kept".to_string(), "semantic kept".to_string()],
3519            "grouping must apply the tag filter to both OR branches"
3520        );
3521    }
3522
3523    /// Regression: a pure AND clause still returns the intersection (and stays on
3524    /// the flat-filter fast path, which this exercises end to end).
3525    #[test]
3526    fn pure_and_still_intersects() {
3527        let (_d, db) = boolean_fixture();
3528        let got = contents(
3529            &db,
3530            r#"RECALL WHERE type = semantic AND tag = "keep" LIMIT 50"#,
3531        );
3532        assert_eq!(got, vec!["semantic kept".to_string()]);
3533    }
3534}
3535
3536#[cfg(test)]
3537mod entity_boost_tests {
3538    use super::*;
3539
3540    /// With a query-named entity linked (via a `Derived` edge) to a lower-ranked
3541    /// memory, the boost lifts that memory above an unrelated higher-scored one.
3542    /// This exercises the whole leg: `entity:<name>` tag lookup, graph adjacency,
3543    /// score bump, and re-sort, on a synthetic corpus with no LLM.
3544    #[test]
3545    fn boost_lifts_entity_linked_memory() {
3546        let dir = tempfile::tempdir().unwrap();
3547        let db = MenteDb::open(dir.path()).unwrap();
3548
3549        let kafka_mem = MemoryNode::new(
3550            AgentId::nil(),
3551            MemoryType::Episodic,
3552            "we chose kafka for the pipeline".into(),
3553            vec![0.1_f32; 8],
3554        );
3555        let kafka_id = kafka_mem.id;
3556        let other_mem = MemoryNode::new(
3557            AgentId::nil(),
3558            MemoryType::Episodic,
3559            "the sky is blue".into(),
3560            vec![0.2_f32; 8],
3561        );
3562        let other_id = other_mem.id;
3563        let mut entity = MemoryNode::new(
3564            AgentId::nil(),
3565            MemoryType::Semantic,
3566            "Kafka, a message broker".into(),
3567            vec![0.3_f32; 8],
3568        );
3569        entity.tags.push("entity:kafka".into());
3570        let entity_id = entity.id;
3571
3572        db.store(kafka_mem.clone()).unwrap();
3573        db.store(other_mem.clone()).unwrap();
3574        db.store(entity).unwrap();
3575        // Entity node -> Derived -> the episodic it was extracted from, exactly as
3576        // enrichment records it.
3577        db.relate(MemoryEdge {
3578            source: entity_id,
3579            target: kafka_id,
3580            edge_type: EdgeType::Derived,
3581            weight: 1.0,
3582            created_at: 0,
3583            valid_from: None,
3584            valid_until: None,
3585            label: Some("enrichment".into()),
3586        })
3587        .unwrap();
3588
3589        // Baseline ranking puts the unrelated memory ahead.
3590        let mut scored = vec![
3591            ScoredMemory {
3592                memory: other_mem,
3593                score: 0.50,
3594            },
3595            ScoredMemory {
3596                memory: kafka_mem,
3597                score: 0.40,
3598            },
3599        ];
3600        db.apply_entity_boost(&mut scored, "did we pick kafka");
3601
3602        assert_eq!(
3603            scored[0].memory.id, kafka_id,
3604            "the kafka-linked memory should rank first after the entity boost"
3605        );
3606        let boosted = scored.iter().find(|s| s.memory.id == kafka_id).unwrap();
3607        assert!(
3608            (boosted.score - 0.55).abs() < 1e-6,
3609            "the linked memory should gain exactly the boost weight"
3610        );
3611        assert!(
3612            scored.iter().any(|s| s.memory.id == other_id),
3613            "the unrelated memory is kept, just reordered"
3614        );
3615    }
3616
3617    /// A query that names no known entity leaves ranking untouched.
3618    #[test]
3619    fn no_entity_in_query_is_a_noop() {
3620        let dir = tempfile::tempdir().unwrap();
3621        let db = MenteDb::open(dir.path()).unwrap();
3622        let a = MemoryNode::new(
3623            AgentId::nil(),
3624            MemoryType::Episodic,
3625            "alpha".into(),
3626            vec![0.1_f32; 8],
3627        );
3628        let b = MemoryNode::new(
3629            AgentId::nil(),
3630            MemoryType::Episodic,
3631            "beta".into(),
3632            vec![0.2_f32; 8],
3633        );
3634        let (aid, bid) = (a.id, b.id);
3635        let mut scored = vec![
3636            ScoredMemory {
3637                memory: a,
3638                score: 0.50,
3639            },
3640            ScoredMemory {
3641                memory: b,
3642                score: 0.40,
3643            },
3644        ];
3645        db.apply_entity_boost(&mut scored, "nothing here resolves to an entity");
3646        assert_eq!(scored[0].memory.id, aid);
3647        assert_eq!(scored[1].memory.id, bid);
3648    }
3649}
3650
3651#[cfg(test)]
3652mod distinct_owner_tests {
3653    use super::*;
3654
3655    /// `distinct_owner_pairs` must enumerate every (user, agent) pair that holds
3656    /// a memory, since enrichment loops over its result to scope derived
3657    /// knowledge per owner pair. This is a unit test (not an integration test)
3658    /// because the method is private.
3659    #[test]
3660    fn distinct_owner_pairs_returns_all_owner_agents() {
3661        let dir = tempfile::tempdir().unwrap();
3662        let db = MenteDb::open(dir.path()).unwrap();
3663        let alice = AgentId::new();
3664        let bob = AgentId::new();
3665
3666        db.store(MemoryNode::new(
3667            alice,
3668            MemoryType::Semantic,
3669            "alice likes falcons".into(),
3670            vec![0.1_f32; 8],
3671        ))
3672        .unwrap();
3673        db.store(MemoryNode::new(
3674            bob,
3675            MemoryType::Semantic,
3676            "bob likes turtles".into(),
3677            vec![0.2_f32; 8],
3678        ))
3679        .unwrap();
3680
3681        let pairs = db.distinct_owner_pairs();
3682        let agents: Vec<AgentId> = pairs.iter().map(|(_, a)| *a).collect();
3683        assert!(
3684            agents.contains(&alice),
3685            "distinct owners must include alice"
3686        );
3687        assert!(agents.contains(&bob), "distinct owners must include bob");
3688    }
3689
3690    /// Owner pairs are distinguished on BOTH axes: the same agent with two
3691    /// different users yields two distinct pairs, so enrichment partitions per
3692    /// user even under a shared agent.
3693    #[test]
3694    fn distinct_owner_pairs_splits_by_user_under_same_agent() {
3695        let dir = tempfile::tempdir().unwrap();
3696        let db = MenteDb::open(dir.path()).unwrap();
3697        let agent = AgentId::new();
3698        let ua = UserId::new();
3699        let ub = UserId::new();
3700
3701        db.store(
3702            MemoryNode::new(
3703                agent,
3704                MemoryType::Semantic,
3705                "user a fact".into(),
3706                vec![0.1_f32; 8],
3707            )
3708            .with_user_id(ua),
3709        )
3710        .unwrap();
3711        db.store(
3712            MemoryNode::new(
3713                agent,
3714                MemoryType::Semantic,
3715                "user b fact".into(),
3716                vec![0.2_f32; 8],
3717            )
3718            .with_user_id(ub),
3719        )
3720        .unwrap();
3721
3722        let pairs = db.distinct_owner_pairs();
3723        assert!(
3724            pairs.contains(&(ua, agent)),
3725            "must include (user a, agent) pair"
3726        );
3727        assert!(
3728            pairs.contains(&(ub, agent)),
3729            "must include (user b, agent) pair"
3730        );
3731        assert_ne!(
3732            (ua, agent),
3733            (ub, agent),
3734            "same agent, different users are distinct owner pairs"
3735        );
3736    }
3737
3738    /// A user profile belongs to exactly one (user, agent) owner. Reading one
3739    /// owner's profile must never return another's, the account-level
3740    /// `user_profile()` must return only the unowned/global one (never an
3741    /// arbitrary owner's), and `profile_owners` must enumerate them all. This is
3742    /// the fix for the owner-blind `user_profile()` that returned whichever
3743    /// profile happened to be scanned first once several owners had one.
3744    #[test]
3745    fn profile_is_owner_scoped_and_enumerable() {
3746        let dir = tempfile::tempdir().unwrap();
3747        let db = MenteDb::open(dir.path()).unwrap();
3748        let agent = AgentId::new();
3749        let alice = UserId::new();
3750        let bob = UserId::new();
3751
3752        db.store_user_profile("alice's profile", alice, agent)
3753            .unwrap();
3754        db.store_user_profile("bob's profile", bob, agent).unwrap();
3755        db.store_user_profile("the shared profile", UserId::nil(), AgentId::nil())
3756            .unwrap();
3757
3758        // Each owner reads exactly its own, never the other's.
3759        assert_eq!(
3760            db.user_profile_for(alice, agent).unwrap().content,
3761            "alice's profile"
3762        );
3763        assert_eq!(
3764            db.user_profile_for(bob, agent).unwrap().content,
3765            "bob's profile"
3766        );
3767
3768        // The account-level accessor returns only the unowned/global profile,
3769        // deterministically, not alice's or bob's.
3770        assert_eq!(db.user_profile().unwrap().content, "the shared profile");
3771
3772        // An owner with no profile gets None, never a fallback to someone else's.
3773        assert!(db.user_profile_for(UserId::new(), agent).is_none());
3774
3775        // Every owner with a profile is enumerable.
3776        let owners = db.profile_owners();
3777        assert_eq!(owners.len(), 3);
3778        assert!(owners.contains(&(alice, agent)));
3779        assert!(owners.contains(&(bob, agent)));
3780        assert!(owners.contains(&(UserId::nil(), AgentId::nil())));
3781    }
3782}