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    ContradictionPolicy, 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::{FusionConfig, IndexManager};
71use mentedb_query::{Condition, Field, Filter, Mql, Operator, OrderBy, 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/// Embedder self-calibration: cosine thresholds derived from measured score
105/// distributions instead of embedder-coupled absolute constants.
106pub mod calibration;
107
108/// LLM-driven memory consolidation (semantic dedup via a pluggable LlmJudge).
109pub mod llm_consolidation;
110pub use llm_consolidation::ConsolidationParams;
111
112/// Sleeptime enrichment pipeline (requires `enrichment` feature).
113#[cfg(feature = "enrichment")]
114pub mod enrichment;
115
116/// Optional second-pass reranking of recall results (off by default).
117pub mod reranker;
118
119/// Maximal Marginal Relevance: an optional diversity pass over recall results
120/// (off by default; enabled by `CognitiveConfig::mmr_lambda` below 1.0).
121pub(crate) mod mmr;
122
123/// Structured export: fill a JSON schema from memories via an embedder-supplied LLM.
124pub mod export;
125
126/// Agent file ingestion: instruction files become on demand memories.
127pub mod agent_file;
128
129/// Lease-based elastic sharding: places each account on exactly one node and
130/// coordinates ownership so a fleet can scale horizontally. The engine owns the
131/// placement and coordination logic; the embedder supplies the lease and
132/// membership storage via the `LeaseStore` and `NodeRegistry` traits.
133pub mod sharding;
134
135/// Commonly used types, re-exported for convenience.
136pub mod prelude {
137    pub use mentedb_core::edge::EdgeType;
138    pub use mentedb_core::error::MenteResult;
139    pub use mentedb_core::memory::MemoryType;
140    pub use mentedb_core::types::*;
141    pub use mentedb_core::{MemoryEdge, MemoryNode, MemoryTier, MenteError};
142
143    pub use crate::MenteDb;
144}
145
146use mentedb_storage::PageId;
147/// Mapping from MemoryId to the storage PageId where it lives.
148use std::collections::HashMap;
149
150/// Configuration for sleeptime enrichment pipeline.
151///
152/// Enrichment runs BETWEEN conversations, never in the hot path.
153/// The engine tracks state and provides candidates; callers invoke
154/// the async LLM pipeline when ready.
155#[derive(Debug, Clone)]
156pub struct EnrichmentConfig {
157    /// Whether enrichment is enabled. Default: false (opt-in).
158    pub enabled: bool,
159    /// Run enrichment after this many process_turn calls. Default: 50.
160    pub trigger_interval: u64,
161    /// Minimum confidence for extracted memories to be stored. Default: 0.6.
162    pub min_confidence: f32,
163    /// Maximum confidence for enrichment-generated memories. Default: 0.7.
164    pub max_enrichment_confidence: f32,
165    /// Whether to generate a user model summary. Default: false.
166    pub enable_user_model: bool,
167    /// Embedding similarity threshold to merge entities. Default: 0.7.
168    pub entity_merge_threshold: f32,
169    /// Embedding similarity below which entities are kept separate. Default: 0.4.
170    pub entity_separate_threshold: f32,
171}
172
173impl Default for EnrichmentConfig {
174    fn default() -> Self {
175        Self {
176            enabled: false,
177            trigger_interval: 50,
178            min_confidence: 0.6,
179            max_enrichment_confidence: 0.7,
180            enable_user_model: false,
181            entity_merge_threshold: 0.7,
182            entity_separate_threshold: 0.4,
183        }
184    }
185}
186
187/// Result of running the enrichment pipeline.
188#[derive(Debug, Clone, Default)]
189pub struct EnrichmentResult {
190    /// Number of new memories stored from extraction.
191    pub memories_stored: usize,
192    /// Number of entity nodes created or updated.
193    pub entities_processed: usize,
194    /// Number of edges created (Derived, Related, PartOf).
195    pub edges_created: usize,
196    /// Number of memories skipped as duplicates.
197    pub duplicates_skipped: usize,
198    /// Number of contradictions detected.
199    pub contradictions_found: usize,
200    /// Turn ID at which enrichment was completed.
201    pub completed_at_turn: u64,
202    /// Number of entity links created (Related edges between same-name entities).
203    pub entities_linked: usize,
204    /// Number of entity pairs left ambiguous (below merge threshold).
205    pub entities_ambiguous: usize,
206}
207
208/// Result of a single entity linking run.
209#[derive(Debug, Clone, Default)]
210pub struct EntityLinkResult {
211    /// Number of entity pairs linked with Related edges.
212    pub linked: usize,
213    /// Number of entity pairs tagged as ambiguous (MaybeRelated).
214    pub ambiguous: usize,
215    /// Number of edges created.
216    pub edges_created: usize,
217}
218
219/// Outcome of a standing-rules (`scope:always`) cleanup pass.
220#[derive(Debug, Clone, Default)]
221pub struct PruneReport {
222    /// Total `scope:always` memories found before cleanup.
223    pub total_always: usize,
224    /// Number of exact-content duplicate groups collapsed.
225    pub duplicate_groups: usize,
226    /// Exact-duplicate always-rules removed (the healthiest copy was kept).
227    pub pruned: Vec<MemoryId>,
228    /// Auto-pinned always-rules un-pinned (the `scope:always` tag removed, the
229    /// memory kept so it is still recalled by relevance).
230    pub unpinned: Vec<MemoryId>,
231    /// Standing rules demoted because the list exceeded `always_max` (lowest
232    /// salience first). Included in `unpinned`; counted separately for visibility.
233    pub capped: usize,
234}
235
236/// EMA smoothing window for the op-latency metrics:
237/// `new = (cur * (N-1) + sample) / N`. Larger N is smoother and lets a one-off
238/// spike (e.g. a cold path) decay more slowly. Metrics-only, not a cognitive
239/// heuristic.
240const LATENCY_EMA_WINDOW: u64 = 8;
241
242/// A point-in-time snapshot of engine metrics for a `/metrics` exporter. Cheap to
243/// take: counters are atomic loads and sizes are O(1) index/graph/page lookups, so
244/// a scrape does not scan the corpus.
245#[derive(Debug, Clone, Copy, Default)]
246pub struct DbMetrics {
247    /// Memories stored on this node.
248    pub memory_count: u64,
249    /// Lifetime store operations (writes).
250    pub stores: u64,
251    /// Lifetime memories loaded from storage by id (`get_memory`).
252    pub memory_loads: u64,
253    /// Lifetime recall/query operations (reads).
254    pub recalls: u64,
255    /// EMA of recent store op latency, microseconds. 0 until the first store.
256    pub avg_store_latency_us: u64,
257    /// EMA of recent hybrid-search op latency, microseconds. 0 until the first
258    /// search.
259    pub avg_search_latency_us: u64,
260    /// Buffer-pool page cache hits.
261    pub buffer_pool_hits: u64,
262    /// Buffer-pool page cache misses (disk reads).
263    pub buffer_pool_misses: u64,
264    /// Buffer-pool CLOCK evictions.
265    pub buffer_pool_evictions: u64,
266    /// Frames currently holding a page.
267    pub buffer_pool_pages: u64,
268    /// On-disk data size in bytes (page_count * page size).
269    pub storage_bytes: u64,
270    /// Pages in the store.
271    pub page_count: u64,
272    /// Vectors in the HNSW index.
273    pub vector_index_size: u64,
274    /// Nodes in the memory graph.
275    pub graph_nodes: u64,
276    /// Pinned standing rules (scope:always).
277    pub standing_rules: u64,
278}
279
280/// A confirmed entity resolution from an external resolver (LLM).
281///
282/// Used to feed LLM entity resolution results back into the engine
283/// so it can create graph edges and update the EntityResolver cache.
284#[derive(Debug, Clone)]
285pub struct EntityLinkResolution {
286    /// The canonical entity name decided by the resolver.
287    pub canonical: String,
288    /// All aliases that map to this canonical name.
289    pub aliases: Vec<String>,
290    /// Confidence in this resolution (0.0 to 1.0).
291    pub confidence: f32,
292}
293
294/// A pair of entity names that the LLM confirmed are DIFFERENT entities.
295#[derive(Debug, Clone)]
296pub struct EntitySeparation {
297    pub name_a: String,
298    pub name_b: String,
299}
300
301/// What [`MenteDb::store_with_outcome`] did with a memory.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum StoreOutcome {
304    /// Written under its own id.
305    Stored,
306    /// Not written: it duplicates the existing memory `of`.
307    Duplicate { of: MemoryId },
308}
309
310/// Configuration for the cognitive engine subsystems.
311#[derive(Debug, Clone)]
312pub struct CognitiveConfig {
313    /// Whether write inference (auto-edges, contradiction detection) is enabled on store.
314    pub write_inference: bool,
315    /// Whether salience decay is applied during retrieval.
316    pub decay_on_recall: bool,
317    /// Whether pain tracking is enabled.
318    pub pain_tracking: bool,
319    /// Whether interference detection is available.
320    pub interference_detection: bool,
321    /// Whether phantom tracking is enabled.
322    pub phantom_tracking: bool,
323    /// Whether speculative caching is enabled.
324    pub speculative_cache: bool,
325    /// Whether archival evaluation is available.
326    pub archival_evaluation: bool,
327    /// Memories carrying any of these tags never fade and are never archived
328    /// (pinned memories). Default: `scope:always`.
329    pub decay_exempt_tags: Vec<String>,
330    /// Configuration for the write inference engine.
331    pub inference_config: WriteInferenceConfig,
332    /// Configuration for the decay engine.
333    pub decay_config: DecayConfig,
334    /// Configuration for phantom tracking.
335    pub phantom_config: PhantomConfig,
336    /// Configuration for the archival pipeline.
337    pub archival_config: ArchivalConfig,
338    /// Configuration for the cognition stream.
339    pub stream_config: StreamConfig,
340    /// Configuration for sleeptime enrichment.
341    pub enrichment_config: EnrichmentConfig,
342    /// Similarity threshold for interference detection.
343    pub interference_threshold: f32,
344    /// Maximum trajectory turns to track.
345    pub trajectory_max_turns: usize,
346    /// Maximum speculative cache entries.
347    pub speculative_cache_size: usize,
348    /// Cosine similarity threshold for a query to hit a speculative cache entry.
349    pub speculative_embedding_threshold: f32,
350    /// Keyword-overlap threshold for a query to hit a speculative cache entry.
351    pub speculative_keyword_threshold: f32,
352    /// Recall relevance penalty for a memory from a DIFFERENT project than the
353    /// query's current project (0.0 = off, 1.0 = hard isolation). A weighted
354    /// signal, not a filter: a strongly-relevant cross-project memory still
355    /// surfaces, ranked below the current project's; global (un-projected) and
356    /// same-project memories are never penalized. Applied only when the caller
357    /// passes a current project (None = search across all projects).
358    pub project_scope_penalty: f32,
359    /// Candidate-pool multiplier for the optional hybrid-recall reranker: when a
360    /// reranker is installed, recall fetches `rerank_pool_factor * k` candidates
361    /// before reranking and truncating to k, so the reranker can promote a
362    /// relevant hit that the first pass ranked outside the top k. Ignored when no
363    /// reranker is installed. Minimum effective value is 1.
364    pub rerank_pool_factor: usize,
365    /// Diversity weight for the optional MMR pass on hybrid recall, in `[0, 1]`.
366    /// `1.0` (the default) disables it, ranking on relevance alone. Below 1.0,
367    /// recall greedily balances relevance against novelty so near-duplicate
368    /// memories do not all fill the context budget: `0.7` trims obvious
369    /// duplicates, `0.5` diversifies harder. Runs after any reranker, over the
370    /// same over-fetched pool, so it can pull a distinct memory into the top k.
371    pub mmr_lambda: f32,
372    /// An injection recall at or above this many milliseconds logs its phase
373    /// timings and how many memories each phase loaded.
374    pub slow_recall_log_ms: u64,
375    /// Maximum pain signals to retain.
376    pub pain_max_warnings: usize,
377    /// Configuration for injection attention selection.
378    pub injection_config: injection::InjectionConfig,
379    /// How many hot flushes may pass before index and graph snapshots are
380    /// rewritten. Durability comes from the WAL checkpoint on every flush;
381    /// snapshots only accelerate reopen, and open reconciles stale ones, so
382    /// rewriting them per flush just multiplies fsync cost.
383    pub flush_snapshot_interval: u32,
384    /// Whether recall boosts memories linked (in the graph) to an entity named in
385    /// the query. Off by default: it only helps corpora that have run enrichment
386    /// (so entity nodes and their `Derived` edges exist), and its ranking effect
387    /// should be A/B measured before it is turned on broadly.
388    pub entity_boost_enabled: bool,
389    /// Score added to a candidate that a query-named entity links to. Applied on
390    /// top of the fused recall score, so it is calibrated against that scale.
391    pub entity_boost_weight: f32,
392    /// Whether storing a `scope:always` (standing rule) memory that is near
393    /// identical to an existing standing rule is skipped instead of inserted, so
394    /// re-pinning the same rule can never grow the always-list.
395    pub always_dedup: bool,
396    /// Cosine similarity at or above which a new standing rule counts as a
397    /// duplicate of an existing one.
398    pub always_dedup_threshold: f32,
399    /// Maximum standing rules (`scope:always`) to keep pinned. `prune_standing_rules`
400    /// demotes the excess (lowest salience first) to ordinary memories, and
401    /// injection never force-injects more than this, so the always-list, and the
402    /// prompt budget it consumes, stay bounded. The `user_profile` rule is always
403    /// kept and does not count against this.
404    pub always_max: usize,
405    /// Whether storing a regular memory that is a near-identical paraphrase of
406    /// an existing same-owner memory is skipped instead of inserted. Repeated
407    /// extraction of the same conversation produces reworded copies of one fact
408    /// ("User is building TaskPilot with Next.js" / "The user builds TaskPilot
409    /// using Next.js"); without this they pile up, dilute recall, and waste
410    /// context tokens. Two gates must BOTH pass, so a genuine value update
411    /// (high cosine but a changed value token) is never swallowed and still
412    /// reaches the write-inference supersession rule.
413    pub memory_dedup: bool,
414    /// Cosine similarity at or above which a regular memory can count as a
415    /// paraphrase duplicate (first gate).
416    pub memory_dedup_threshold: f32,
417    /// Minimum token-set jaccard overlap between the two contents (second
418    /// gate). Value updates share a frame but swap a value token, which drops
419    /// them below this and keeps them storable.
420    pub memory_dedup_token_overlap: f32,
421    /// Token-set jaccard overlap gate for the OFFLINE dedup sweep, looser than
422    /// the write-time gate so it catches reworded paraphrases that already
423    /// accumulated. Safe to loosen here only because the sweep also applies a
424    /// value-update structural guard, so a correction is never merged even
425    /// when its overlap clears this bar. The write hot path keeps the strict
426    /// `memory_dedup_token_overlap`.
427    pub sweep_dedup_token_overlap: f32,
428    /// Whether to derive the cosine-based thresholds (paraphrase dedup,
429    /// standing-rule dedup, value-update floor) from the installed embedder's
430    /// measured score distributions at open. Absolute cosine constants are
431    /// embedder-coupled and have twice silently disabled cognition in
432    /// production; calibration replaces them with distribution-derived values
433    /// and keeps the configured defaults when the embedder cannot separate
434    /// paraphrase from noise.
435    pub calibration_enabled: bool,
436    /// Action recall: minimum semantic similarity between the meaning of an
437    /// action about to happen and a memory, below which the memory does not
438    /// bear on the action. `recall_for_action` is ordinary semantic recall
439    /// re-ranked by how strongly a memory governs behavior, never a tag lookup.
440    pub action_min_similarity: f32,
441    /// Action recall: candidate over-fetch multiple. Recall pulls
442    /// `action_overfetch * k` by raw similarity, then re-ranks by governing
443    /// weight and truncates to k, so a strongly-governing memory ranked just
444    /// outside the top-k by cosine alone can still surface.
445    pub action_overfetch: usize,
446    /// Action recall: per memory-type weight for how strongly that kind of
447    /// memory governs an action about to happen. Procedures, corrections, and
448    /// anti-patterns govern; facts inform; past events least. This encodes
449    /// "what I know that bears on doing this" as cognition over the type
450    /// system, not a hardcoded action vocabulary. Order matches `MemoryType`:
451    /// [episodic, semantic, procedural, anti_pattern, reasoning, correction].
452    pub action_type_weights: [f32; 6],
453    /// Action recall: relevance added to a candidate that shares a keyword
454    /// with the action text (a BM25/lexical signal). This is what keeps action
455    /// recall working when the embedder is weak or a non-semantic fallback: a
456    /// rule about "git commit" is still found by the words when the vector is
457    /// noise. A candidate is admitted on EITHER a semantic signal (cosine over
458    /// `action_min_similarity`) OR a lexical one (a shared keyword).
459    pub action_keyword_weight: f32,
460    /// Action recall: minimum token length for a word to count as a shared
461    /// keyword, so short glue tokens ("to", "-c") do not create matches.
462    pub action_keyword_min_len: usize,
463    /// Tags that mark a memory as internal working material to leave out of the
464    /// dashboard scope counts (raw turns, action captures, entity index nodes).
465    /// The host supplies its own spellings; the engine does not hardcode them.
466    pub hidden_count_tags: Vec<String>,
467}
468
469impl Default for CognitiveConfig {
470    fn default() -> Self {
471        Self {
472            write_inference: true,
473            decay_on_recall: true,
474            pain_tracking: true,
475            interference_detection: true,
476            phantom_tracking: true,
477            speculative_cache: true,
478            archival_evaluation: true,
479            decay_exempt_tags: vec!["scope:always".to_string()],
480            inference_config: WriteInferenceConfig::default(),
481            decay_config: DecayConfig::default(),
482            phantom_config: PhantomConfig::default(),
483            archival_config: ArchivalConfig::default(),
484            stream_config: StreamConfig::default(),
485            enrichment_config: EnrichmentConfig::default(),
486            interference_threshold: 0.8,
487            trajectory_max_turns: 100,
488            speculative_cache_size: 32,
489            speculative_embedding_threshold: 0.5,
490            speculative_keyword_threshold: 0.4,
491            project_scope_penalty: 0.5,
492            rerank_pool_factor: 4,
493            mmr_lambda: 1.0,
494            slow_recall_log_ms: 1000,
495            pain_max_warnings: 5,
496            injection_config: injection::InjectionConfig::default(),
497            flush_snapshot_interval: 8,
498            entity_boost_enabled: false,
499            entity_boost_weight: 0.15,
500            always_dedup: true,
501            always_dedup_threshold: 0.95,
502            always_max: 30,
503            memory_dedup: true,
504            memory_dedup_threshold: 0.97,
505            memory_dedup_token_overlap: 0.85,
506            sweep_dedup_token_overlap: 0.55,
507            calibration_enabled: true,
508            action_min_similarity: 0.3,
509            action_overfetch: 5,
510            // episodic, semantic, procedural, anti_pattern, reasoning, correction
511            action_type_weights: [0.3, 0.5, 1.0, 1.0, 0.7, 1.0],
512            action_keyword_weight: 0.5,
513            action_keyword_min_len: 3,
514            hidden_count_tags: Vec::new(),
515        }
516    }
517}
518
519/// The unified database facade for MenteDB.
520///
521/// `MenteDb` coordinates storage, indexing, graph relationships, query parsing,
522/// context assembly, and cognitive subsystems into a single coherent API.
523///
524/// All internal state is protected by fine-grained locks, so every public method
525/// takes `&self`. This allows `Arc<MenteDb>` to be shared across threads without
526/// an external `RwLock`.
527/// Per-owner/type/scope memory counts, excluding hidden (internal working)
528/// tags, maintained incrementally so the dashboard scope tree and stats read
529/// them in O(1) rather than scanning every memory. String keys keep it
530/// JSON-persistable; it is grounded by a full recount only when no snapshot
531/// exists (owner and type are not otherwise indexed, so a recount costs a full
532/// load and must be avoided on the read path and on every open).
533#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
534pub struct ScopeCounts {
535    /// Non-hidden memories in total.
536    pub total: u64,
537    /// Memories with no owner (nil user and agent): the shared/global pool.
538    pub global: u64,
539    /// Memories tagged `scope:always`.
540    pub always: u64,
541    /// Count by memory type name (lowercased, `anti_pattern` normalized).
542    pub by_type: HashMap<String, u64>,
543    /// Count by end-user owner (uuid string).
544    pub by_user: HashMap<String, u64>,
545    /// Count by agent owner (uuid string).
546    pub by_agent: HashMap<String, u64>,
547    /// Count by `"user_uuid|agent_uuid"` pair.
548    pub by_user_agent: HashMap<String, u64>,
549    /// Count by project name (from `scope:project:` tags).
550    pub by_project: HashMap<String, u64>,
551    /// Count by creation month (`YYYY-MM`, UTC) for the growth timeline. A
552    /// memory's creation month never changes, so this is exact and maintained
553    /// incrementally like the rest, giving the dashboard an O(1) growth chart
554    /// and an exact "this month" figure without a scan.
555    pub growth: HashMap<String, u64>,
556}
557
558impl ScopeCounts {
559    fn type_name(t: MemoryType) -> String {
560        format!("{t:?}")
561            .to_lowercase()
562            .replace("antipattern", "anti_pattern")
563    }
564
565    /// `YYYY-MM` (UTC) for a microsecond timestamp, computed without a calendar
566    /// dependency via Howard Hinnant's `civil_from_days`. Matches the platform's
567    /// `chrono` `%Y-%m` formatting so the growth keys line up across the wire.
568    fn month_key(micros: u64) -> String {
569        let days = (micros / 1_000_000) as i64 / 86_400;
570        let z = days + 719_468;
571        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
572        let doe = z - era * 146_097;
573        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
574        let y = yoe + era * 400;
575        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
576        let mp = (5 * doy + 2) / 153;
577        let m = if mp < 10 { mp + 3 } else { mp - 9 };
578        let y = if m <= 2 { y + 1 } else { y };
579        format!("{y:04}-{m:02}")
580    }
581
582    /// Fold one node into the counts with `sign` (+1 on store, -1 on forget).
583    /// Nodes carrying any `hidden` tag are internal working material and are
584    /// never counted, exactly as the dashboard excludes them.
585    fn apply(&mut self, node: &MemoryNode, sign: i64, hidden: &[String]) {
586        if node.tags.iter().any(|t| hidden.iter().any(|h| h == t)) {
587            return;
588        }
589        fn bump(m: &mut HashMap<String, u64>, k: String, sign: i64) {
590            let e = m.entry(k).or_insert(0);
591            *e = (*e as i64 + sign).max(0) as u64;
592            if *e == 0 {
593                // Keep the map bounded: a dimension that empties is dropped.
594            }
595        }
596        let adj = |v: u64| -> u64 { (v as i64 + sign).max(0) as u64 };
597        self.total = adj(self.total);
598        bump(&mut self.by_type, Self::type_name(node.memory_type), sign);
599        bump(&mut self.growth, Self::month_key(node.created_at), sign);
600        if node.user_id.is_nil() && node.agent_id.is_nil() {
601            self.global = adj(self.global);
602        } else {
603            bump(&mut self.by_user, node.user_id.to_string(), sign);
604            bump(&mut self.by_agent, node.agent_id.to_string(), sign);
605            bump(
606                &mut self.by_user_agent,
607                format!("{}|{}", node.user_id, node.agent_id),
608                sign,
609            );
610        }
611        for tag in &node.tags {
612            if tag == "scope:always" {
613                self.always = adj(self.always);
614            } else if let Some(p) = tag.strip_prefix("scope:project:") {
615                bump(&mut self.by_project, p.to_string(), sign);
616            }
617        }
618    }
619}
620
621/// The whole-tenant analytics that cannot be a pure count: the decayed-salience
622/// health distribution (a continuous function of now, so it drifts with no write
623/// event and has no exact sub-linear real-time aggregate under the decay +
624/// access-bonus + clamp formula), the recall-count usage sums, and the entity and
625/// community totals. These require one pass over the tenant, so instead of
626/// scanning on the read path they are materialized by the background maintenance
627/// sweep (which already walks every node for archival) and served O(1). With a
628/// multi-day salience half-life the histogram moves under a percent per hour, so a
629/// snapshot refreshed each sweep is exact on the chart. Persisted per tenant so a
630/// cold reopen serves it from disk rather than rescanning.
631#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
632pub struct StatsSnapshot {
633    /// When this snapshot was computed (micros since epoch).
634    pub computed_at_us: u64,
635    /// Non-hidden memories counted at snapshot time (for staleness/debug).
636    pub count: u64,
637    /// Mean decayed salience across non-hidden memories.
638    pub avg_health: f64,
639    /// Decayed-salience histogram: 5 buckets from 0.0-0.2 up to 0.8-1.0.
640    pub health_distribution: Vec<u64>,
641    /// Total recalls (access counts summed) per project scope.
642    pub project_usage: HashMap<String, u64>,
643    /// Total recalls per scope kind (always vs contextual).
644    pub scope_usage: HashMap<String, u64>,
645    /// Distinct entity names in the tenant (nil-owner pool).
646    pub entities: u64,
647    /// Community summary memories in the tenant (nil-owner pool).
648    pub communities: u64,
649}
650
651pub struct MenteDb {
652    storage: StorageEngine,
653    index: IndexManager,
654    graph: GraphManager,
655    /// O(1) dashboard counts (scope tree, stats, non-internal total),
656    /// maintained on store/forget. See [`ScopeCounts`].
657    scope_counts: RwLock<ScopeCounts>,
658    /// False until [`Self::scope_counts`] has grounded the counts by a one-time
659    /// recount; store/forget only adjust the counts once they are ready, so a
660    /// write before the first read is folded in by that recount, not lost.
661    scope_counts_ready: std::sync::atomic::AtomicBool,
662    /// Serializes the grounding recount: concurrent first readers wait for one
663    /// recount instead of each walking every memory at once.
664    scope_recount: parking_lot::Mutex<()>,
665    /// Maps memory IDs to their storage page IDs for retrieval.
666    page_map: RwLock<HashMap<MemoryId, PageId>>,
667    /// Embedding, validity and owner of the memories that decide whether an
668    /// activity mode is active (`trigger:` rules and `mode-exemplar:` turns),
669    /// so mode activation on every injection recall is dot products in memory
670    /// instead of a storage load per memory (thousands on large accounts).
671    /// Filled on first use, dropped for a memory whenever it is written.
672    mode_probes: RwLock<HashMap<MemoryId, crate::injection::ProbeSlot>>,
673    /// Hot flushes since the last snapshot write; see flush_snapshot_interval.
674    flushes_since_snapshot: std::sync::atomic::AtomicU32,
675    /// Lifetime count of store operations (metrics).
676    stores: std::sync::atomic::AtomicU64,
677    memory_loads: std::sync::atomic::AtomicU64,
678    /// Lifetime count of recall/query operations (metrics).
679    recalls: std::sync::atomic::AtomicU64,
680    /// EMA of store op latency, microseconds (metrics). 0 until the first store.
681    store_latency_us: std::sync::atomic::AtomicU64,
682    /// EMA of hybrid-search op latency, microseconds (metrics). 0 until the first
683    /// search.
684    search_latency_us: std::sync::atomic::AtomicU64,
685    /// Expected embedding dimension (0 = no validation).
686    embedding_dim: usize,
687    /// Database directory path for persistence.
688    path: PathBuf,
689    /// Optional embedding provider for auto-embedding on store and search.
690    embedder: Option<Box<dyn EmbeddingProvider>>,
691    /// Distribution-derived thresholds for the installed embedder, when
692    /// calibration succeeded. None means configured defaults are in effect.
693    calibration: Option<calibration::EmbedderCalibration>,
694    /// Optional second-pass reranker applied to the fused candidate pool on the
695    /// hybrid recall path. None (the default) leaves recall a pure vector/BM25 +
696    /// decay ranking; when set, a larger pool is fetched and reordered by this
697    /// reranker before truncating to k. See [`reranker`](crate::reranker).
698    reranker: Option<Box<dyn crate::reranker::Reranker>>,
699    /// Cognitive engine configuration.
700    cognitive_config: CognitiveConfig,
701    /// Write inference engine for auto-edge creation and contradiction detection.
702    write_inference: WriteInferenceEngine,
703    /// Decay engine for salience management.
704    decay: DecayEngine,
705    /// Consolidation engine for memory merging.
706    consolidation: ConsolidationEngine,
707    /// Pain registry for tracking recurring failures.
708    pain: RwLock<PainRegistry>,
709    /// Trajectory tracker for conversation patterns.
710    trajectory: RwLock<TrajectoryTracker>,
711    /// Cognition stream for token-level monitoring.
712    stream: CognitionStream,
713    /// Phantom tracker for detecting referenced-but-missing knowledge.
714    phantom: RwLock<PhantomTracker>,
715    /// Speculative cache for pre-fetching likely-needed memories.
716    speculative: RwLock<SpeculativeCache>,
717    /// Interference detector for finding confusable memories.
718    interference: InterferenceDetector,
719    /// Entity resolver for canonical name resolution.
720    entity_resolver: RwLock<EntityResolver>,
721    /// Memory compressor for content summarization.
722    compressor: MemoryCompressor,
723    /// Archival pipeline for lifecycle evaluation.
724    archival: ArchivalPipeline,
725    /// Turn ID of the last completed enrichment cycle.
726    last_enrichment_turn: RwLock<u64>,
727    /// Whether enrichment is currently pending (set by maintenance trigger).
728    enrichment_pending: RwLock<bool>,
729}
730
731/// Agent visibility rule for scoped retrieval: a node is visible to an agent
732/// when it is owned by that agent or owned by no agent (nil, shared
733/// knowledge). No scope means global visibility.
734/// Split text into lowercase alphanumeric tokens of at least `min_len`, the
735/// unit of the lexical (keyword) admit signal for action recall.
736fn keyword_tokens(text: &str, min_len: usize) -> Vec<String> {
737    text.split(|c: char| !c.is_alphanumeric())
738        .filter(|t| t.chars().count() >= min_len)
739        .map(str::to_lowercase)
740        .collect()
741}
742
743/// True when `content` contains any of `action_tokens`, so a rule about "git
744/// commit" is found by the words even when the vector similarity is noise.
745fn shares_keyword(action_tokens: &[String], content: &str, min_len: usize) -> bool {
746    let set: std::collections::HashSet<&str> = action_tokens.iter().map(String::as_str).collect();
747    content
748        .split(|c: char| !c.is_alphanumeric())
749        .filter(|t| t.chars().count() >= min_len)
750        .any(|t| set.contains(t.to_lowercase().as_str()))
751}
752
753/// Index into `CognitiveConfig::action_type_weights` for a memory type. The
754/// order is fixed alongside the weights array; keep the two in lockstep.
755fn action_type_index(t: MemoryType) -> usize {
756    match t {
757        MemoryType::Episodic => 0,
758        MemoryType::Semantic => 1,
759        MemoryType::Procedural => 2,
760        MemoryType::AntiPattern => 3,
761        MemoryType::Reasoning => 4,
762        MemoryType::Correction => 5,
763    }
764}
765
766pub(crate) fn agent_visible(owner: AgentId, scope: Option<AgentId>) -> bool {
767    match scope {
768        None => true,
769        Some(a) => owner == a || owner.is_nil(),
770    }
771}
772
773/// User visibility rule for scoped retrieval, orthogonal to [`agent_visible`]:
774/// a node is visible to a user when it is owned by that user or owned by no
775/// user (nil, shared knowledge). No scope means global visibility. A scoped
776/// query at (user U, agent A) requires BOTH `user_visible(owner_user, U)` and
777/// `agent_visible(owner_agent, A)`.
778pub(crate) fn user_visible(owner: UserId, scope: Option<UserId>) -> bool {
779    match scope {
780        None => true,
781        Some(u) => owner == u || owner.is_nil(),
782    }
783}
784
785impl MenteDb {
786    /// Opens (or creates) a MenteDB instance at the given path.
787    pub fn open(path: &Path) -> MenteResult<Self> {
788        Self::open_with_config(path, CognitiveConfig::default())
789    }
790
791    /// Set the hybrid-search fusion strategy for this instance. The engine opens
792    /// with rank-only fusion; a host calls this after `open` to enable the
793    /// magnitude-aware path from its own configuration, e.g. to A/B it in prod.
794    pub fn set_fusion(&mut self, fusion: FusionConfig) {
795        self.index.set_fusion(fusion);
796    }
797
798    /// Opens a MenteDB instance with custom cognitive configuration.
799    pub fn open_with_config(path: &Path, cognitive_config: CognitiveConfig) -> MenteResult<Self> {
800        info!("Opening MenteDB at {}", path.display());
801        let storage = StorageEngine::open(path)?;
802
803        let index_dir = path.join("indexes");
804        let graph_dir = path.join("graph");
805
806        // The indexes are derived data: every entry is rebuilt from storage by
807        // the reconcile pass below. So a snapshot that fails to load (left
808        // truncated by a process killed mid-write, before snapshots were
809        // written atomically) costs a rebuild, never the whole database.
810        let index = if index_dir.join("hnsw.bin").exists() || index_dir.join("hnsw.json").exists() {
811            debug!("Loading indexes from {}", index_dir.display());
812            match IndexManager::load(&index_dir) {
813                Ok(index) => index,
814                Err(e) => {
815                    warn!(
816                        path = %index_dir.display(),
817                        error = %e,
818                        "index snapshot unreadable, rebuilding the indexes from storage"
819                    );
820                    IndexManager::default()
821                }
822            }
823        } else {
824            IndexManager::default()
825        };
826
827        // Directory-backed graph: loads the snapshot and replays the edge log,
828        // so edges created since the last flush survive a crash.
829        let graph = GraphManager::open(&graph_dir)?;
830
831        // Rebuild page map by scanning all pages
832        let entries = storage.scan_all_memories();
833        let mut page_map = HashMap::new();
834        for (memory_id, page_id) in &entries {
835            page_map.insert(*memory_id, *page_id);
836        }
837        if !page_map.is_empty() {
838            info!(memories = page_map.len(), "rebuilt page map from storage");
839        }
840
841        // Every stored memory must be a graph node, even when the graph
842        // snapshot/log is missing or behind storage (e.g. after a crash);
843        // otherwise relate() and write inference fail for surviving memories.
844        for memory_id in page_map.keys() {
845            if !graph.read_graph().contains_node(*memory_id) {
846                graph.add_memory(*memory_id);
847            }
848        }
849
850        // Snapshots are written every few flushes, not every flush, so they
851        // can trail storage. Heal both directions: index memories the
852        // snapshot missed, and tombstone vectors whose pages are gone
853        // (forgotten after the last snapshot).
854        let mut reindexed = 0usize;
855        for (memory_id, page_id) in &page_map {
856            if !index.contains_vector(*memory_id)
857                && let Ok(node) = storage.load_memory(*page_id)
858                && !node.embedding.is_empty()
859            {
860                index.index_memory(&node);
861                reindexed += 1;
862            }
863        }
864        let mut retired = 0usize;
865        for id in index.vector_ids() {
866            if !page_map.contains_key(&id) {
867                index.remove_vector_only(id);
868                retired += 1;
869            }
870        }
871        if reindexed > 0 || retired > 0 {
872            info!(reindexed, retired, "reconciled index with storage");
873        }
874
875        // One-time backfill of the reserved browse keys (memory type, owner user,
876        // owner agent). Memories stored before this index existed carry their tags
877        // in the bitmap but none of the reserved keys, so a type or owner browse
878        // filter would come back empty. If no reserved type key is present while
879        // memories are, the persisted bitmap predates the browse index: rebuild
880        // the reserved keys for every memory once. The keys then persist with the
881        // next snapshot, so this pass is skipped on subsequent opens.
882        if !page_map.is_empty() && index.bitmap.tags_with_prefix("\0type:").is_empty() {
883            let mut backfilled = 0usize;
884            for page_id in page_map.values() {
885                if let Ok(node) = storage.load_memory(*page_id) {
886                    index.bitmap.add_tag(
887                        node.id,
888                        &mentedb_index::manager::type_index_key(node.memory_type),
889                    );
890                    index.bitmap.add_tag(
891                        node.id,
892                        &mentedb_index::manager::user_index_key(node.user_id),
893                    );
894                    index.bitmap.add_tag(
895                        node.id,
896                        &mentedb_index::manager::agent_index_key(node.agent_id),
897                    );
898                    backfilled += 1;
899                }
900            }
901            if backfilled > 0 {
902                info!(backfilled, "backfilled browse index reserved keys");
903            }
904        }
905
906        let write_inference =
907            WriteInferenceEngine::with_config(cognitive_config.inference_config.clone());
908        let decay = DecayEngine::new(cognitive_config.decay_config.clone());
909        let consolidation = ConsolidationEngine::new();
910        let pain = RwLock::new(PainRegistry::new(cognitive_config.pain_max_warnings));
911        let trajectory = RwLock::new(TrajectoryTracker::new(
912            cognitive_config.trajectory_max_turns,
913        ));
914        let stream = CognitionStream::with_config(cognitive_config.stream_config.clone());
915        let phantom = RwLock::new(PhantomTracker::new(cognitive_config.phantom_config.clone()));
916        let speculative = RwLock::new(SpeculativeCache::new(
917            cognitive_config.speculative_cache_size,
918            cognitive_config.speculative_embedding_threshold,
919            cognitive_config.speculative_keyword_threshold,
920        ));
921        let interference = InterferenceDetector::new(cognitive_config.interference_threshold);
922        let entity_resolver = RwLock::new(EntityResolver::new());
923        let compressor = MemoryCompressor::new();
924        let archival = ArchivalPipeline::new(cognitive_config.archival_config.clone());
925
926        // Load persisted state for subsystems that support it.
927        let cognitive_dir = path.join("cognitive");
928        if cognitive_dir.exists() {
929            let _ = trajectory
930                .write()
931                .transitions
932                .load(&cognitive_dir.join("transitions.json"));
933            let _ = speculative
934                .write()
935                .load(&cognitive_dir.join("speculative.json"));
936            let _ = entity_resolver
937                .write()
938                .load(&cognitive_dir.join("entities.json"));
939        }
940
941        // Load the persisted scope counts so a cold reopen is O(1). A snapshot
942        // whose stamped memory count no longer matches (writes landed after it
943        // via WAL replay) is kept as the approximate value for
944        // `scope_counts_approx` but left ungrounded, so the next grounding read
945        // recounts and re-persists. A missing snapshot starts at zero.
946        let current_count = page_map.len();
947        let (scope_counts, scope_counts_ready) = std::fs::read(path.join("scope_counts.json"))
948            .ok()
949            .and_then(|b| serde_json::from_slice::<(usize, ScopeCounts)>(&b).ok())
950            .map(|(count, c)| (c, count == current_count))
951            .unwrap_or_else(|| (ScopeCounts::default(), false));
952
953        Ok(Self {
954            storage,
955            index,
956            graph,
957            scope_counts: RwLock::new(scope_counts),
958            scope_counts_ready: std::sync::atomic::AtomicBool::new(scope_counts_ready),
959            scope_recount: parking_lot::Mutex::new(()),
960            page_map: RwLock::new(page_map),
961            mode_probes: RwLock::new(HashMap::new()),
962            flushes_since_snapshot: std::sync::atomic::AtomicU32::new(0),
963            stores: std::sync::atomic::AtomicU64::new(0),
964            memory_loads: std::sync::atomic::AtomicU64::new(0),
965            recalls: std::sync::atomic::AtomicU64::new(0),
966            store_latency_us: std::sync::atomic::AtomicU64::new(0),
967            search_latency_us: std::sync::atomic::AtomicU64::new(0),
968            embedding_dim: 0,
969            path: path.to_path_buf(),
970            embedder: None,
971            calibration: None,
972            reranker: None,
973            cognitive_config,
974            write_inference,
975            decay,
976            consolidation,
977            pain,
978            trajectory,
979            stream,
980            phantom,
981            speculative,
982            interference,
983            entity_resolver,
984            compressor,
985            archival,
986            last_enrichment_turn: RwLock::new(0),
987            enrichment_pending: RwLock::new(false),
988        })
989    }
990
991    /// Opens a MenteDB instance with a configured embedding provider.
992    pub fn open_with_embedder(
993        path: &Path,
994        embedder: Box<dyn EmbeddingProvider>,
995    ) -> MenteResult<Self> {
996        let mut db = Self::open(path)?;
997        db.embedding_dim = embedder.dimensions();
998        db.embedder = Some(embedder);
999        db.apply_calibration();
1000        Ok(db)
1001    }
1002
1003    /// Opens a MenteDB instance with both embedder and cognitive config.
1004    pub fn open_with_embedder_and_config(
1005        path: &Path,
1006        embedder: Box<dyn EmbeddingProvider>,
1007        cognitive_config: CognitiveConfig,
1008    ) -> MenteResult<Self> {
1009        let mut db = Self::open_with_config(path, cognitive_config)?;
1010        db.embedding_dim = embedder.dimensions();
1011        db.embedder = Some(embedder);
1012        db.apply_calibration();
1013        Ok(db)
1014    }
1015
1016    /// Set the embedding provider after construction.
1017    pub fn set_embedder(&mut self, embedder: Box<dyn EmbeddingProvider>) {
1018        self.embedding_dim = embedder.dimensions();
1019        self.embedder = Some(embedder);
1020        self.apply_calibration();
1021    }
1022
1023    /// The distribution-derived thresholds in effect for the installed
1024    /// embedder, when calibration succeeded. None means configured defaults.
1025    pub fn calibration(&self) -> Option<&calibration::EmbedderCalibration> {
1026        self.calibration.as_ref()
1027    }
1028
1029    /// Replace the cosine-coupled thresholds with values derived from the
1030    /// installed embedder's measured score distributions (see [`calibration`]).
1031    /// The stored file makes this a one-time cost per embedder; a swap to a
1032    /// different model or dimension recomputes automatically. On any failure
1033    /// (embedder down, or a provider whose paraphrase and noise distributions
1034    /// overlap, like the hash test provider) the configured defaults stay in
1035    /// effect, so tests and degraded environments keep deterministic behavior.
1036    fn apply_calibration(&mut self) {
1037        if !self.cognitive_config.calibration_enabled {
1038            return;
1039        }
1040        let Some(embedder) = self.embedder.as_deref() else {
1041            return;
1042        };
1043        match calibration::load_or_calibrate(&self.path, embedder) {
1044            Ok(cal) => {
1045                self.cognitive_config.memory_dedup_threshold = cal.dedup_cosine;
1046                self.cognitive_config.always_dedup_threshold = cal.dedup_cosine;
1047                self.cognitive_config
1048                    .inference_config
1049                    .value_update_min_similarity = cal.topical_floor;
1050                self.write_inference = WriteInferenceEngine::with_config(
1051                    self.cognitive_config.inference_config.clone(),
1052                );
1053                self.calibration = Some(cal);
1054            }
1055            Err(e) => {
1056                tracing::warn!(error = %e, "embedder calibration unavailable, keeping configured thresholds");
1057            }
1058        }
1059    }
1060
1061    /// Install a second-pass [`Reranker`](crate::reranker::Reranker) applied to
1062    /// the fused candidate pool on the hybrid recall path. Once set, hybrid recall
1063    /// over-fetches `rerank_pool_factor * k` candidates, reorders them by the
1064    /// reranker over the query text, then truncates to k, so exact-term or
1065    /// model-scored relevance can lift results that vector/BM25 ranking buried.
1066    /// The engine ships a dependency-free [`LexicalReranker`](crate::reranker::LexicalReranker);
1067    /// an embedder can supply a stronger model without the engine taking the dependency.
1068    pub fn set_reranker(&mut self, reranker: Box<dyn crate::reranker::Reranker>) {
1069        self.reranker = Some(reranker);
1070    }
1071
1072    /// Remove any installed reranker, returning hybrid recall to its first-pass
1073    /// vector/BM25 + decay ranking.
1074    pub fn clear_reranker(&mut self) {
1075        self.reranker = None;
1076    }
1077
1078    /// Whether a second-pass reranker is currently installed.
1079    pub fn has_reranker(&self) -> bool {
1080        self.reranker.is_some()
1081    }
1082
1083    /// Generate an embedding for the given text using the configured provider.
1084    /// Returns None if no provider is configured.
1085    pub fn embed_text(&self, text: &str) -> MenteResult<Option<Vec<f32>>> {
1086        match &self.embedder {
1087            Some(e) => Ok(Some(e.embed(text)?)),
1088            None => Ok(None),
1089        }
1090    }
1091
1092    /// Generate embeddings for a batch of texts in a single provider call.
1093    ///
1094    /// Remote embedders accept many inputs per request, so batching amortizes
1095    /// the network round trip that otherwise dominates per item insert latency.
1096    /// The returned vectors are in the same order as the inputs. Returns None
1097    /// if no provider is configured.
1098    pub fn embed_batch(&self, texts: &[&str]) -> MenteResult<Option<Vec<Vec<f32>>>> {
1099        match &self.embedder {
1100            Some(e) => Ok(Some(e.embed_batch(texts)?)),
1101            None => Ok(None),
1102        }
1103    }
1104
1105    /// Persist an updated memory. Every in place update goes through here so
1106    /// derived in memory state (the mode activation probes) stays current.
1107    fn update_node(&self, page_id: PageId, node: &MemoryNode) -> MenteResult<()> {
1108        self.storage.update_memory(page_id, node)?;
1109        self.note_write(node.id);
1110        Ok(())
1111    }
1112
1113    /// Record that memory `id` was written: drop its cached mode probe.
1114    pub(crate) fn note_write(&self, id: MemoryId) {
1115        // Removing the slot also cancels a load in flight for this memory,
1116        // so a copy read before this write is never cached.
1117        self.mode_probes.write().remove(&id);
1118    }
1119
1120    /// Stores a memory node into the database.
1121    ///
1122    /// The node is persisted to storage, added to all indexes, and registered
1123    /// in the graph for relationship traversal.
1124    ///
1125    /// When cognitive features are enabled (the default), write inference
1126    /// automatically runs to:
1127    /// - Detect contradictions with existing memories
1128    /// - Create relationship edges (Related, Supersedes, Contradicts)
1129    /// - Invalidate superseded memories
1130    /// - Propagate confidence changes through the graph
1131    pub fn store(&self, node: MemoryNode) -> MenteResult<()> {
1132        self.store_with_outcome(node).map(|_| ())
1133    }
1134
1135    /// [`Self::store`], reporting whether the memory was written or skipped as
1136    /// a duplicate of an existing one. Callers that hand the id back to a user
1137    /// must use this: a skipped memory's own id does not exist afterwards.
1138    pub fn store_with_outcome(&self, mut node: MemoryNode) -> MenteResult<StoreOutcome> {
1139        let started = std::time::Instant::now();
1140        // A memory stored without a vector is invisible to semantic search,
1141        // contradiction checks and dedup. Embed it here when an embedder is
1142        // configured, so callers that send only text are still searchable.
1143        if node.embedding.is_empty()
1144            && !node.content.is_empty()
1145            && let Some(e) = &self.embedder
1146        {
1147            node.embedding = e.embed(&node.content)?;
1148        }
1149        let id = node.id;
1150        debug!("Storing memory {}", id);
1151
1152        // Validate embedding dimension when configured.
1153        if self.embedding_dim > 0
1154            && !node.embedding.is_empty()
1155            && node.embedding.len() != self.embedding_dim
1156        {
1157            return Err(MenteError::EmbeddingDimensionMismatch {
1158                got: node.embedding.len(),
1159                expected: self.embedding_dim,
1160            });
1161        }
1162
1163        // Standing-rule dedup: re-pinning a rule that already exists must not grow
1164        // the always-list. Only the rare scope:always write pays for the lookup.
1165        if self.cognitive_config.always_dedup
1166            && !node.embedding.is_empty()
1167            && node.tags.iter().any(|t| t == "scope:always")
1168            && let Some(of) = self.find_duplicate_standing_rule(&node)
1169        {
1170            debug!("scope:always dedup: {id} matches an existing standing rule, skipping insert");
1171            return Ok(StoreOutcome::Duplicate { of });
1172        }
1173
1174        // Regular-memory paraphrase dedup: repeated extraction produces reworded
1175        // copies of the same fact; skip the insert when a near-identical
1176        // same-owner memory already exists. Both gates (embedding cosine AND
1177        // token overlap) must pass, so a value update, which shares the frame
1178        // but swaps a value token, stays storable and reaches the
1179        // write-inference supersession rule instead.
1180        if self.cognitive_config.memory_dedup
1181            && !node.embedding.is_empty()
1182            && !node.tags.iter().any(|t| t == "scope:always")
1183            && let Some(of) = self.find_paraphrase_duplicate_of(&node)
1184        {
1185            debug!("memory dedup: {id} is a paraphrase of an existing memory, skipping insert");
1186            return Ok(StoreOutcome::Duplicate { of });
1187        }
1188
1189        let is_new = !self.page_map.read().contains_key(&id);
1190        let page_id = self.storage.store_memory(&node)?;
1191        self.note_write(id);
1192        self.page_map.write().insert(id, page_id);
1193        self.index.index_memory(&node);
1194        self.graph.add_memory(id);
1195        // Keep the O(1) dashboard counts current for genuinely new memories.
1196        // Re-stores (updates) are left to the read-time drift check, which
1197        // recounts if the maintained total ever diverges from the store.
1198        if is_new
1199            && self
1200                .scope_counts_ready
1201                .load(std::sync::atomic::Ordering::Relaxed)
1202        {
1203            self.scope_counts
1204                .write()
1205                .apply(&node, 1, &self.cognitive_config.hidden_count_tags);
1206        }
1207        self.stores
1208            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1209
1210        // Run write inference to auto-create edges and detect contradictions.
1211        if self.cognitive_config.write_inference {
1212            self.run_write_inference(&node);
1213        }
1214
1215        Self::record_latency(&self.store_latency_us, started.elapsed());
1216        Ok(StoreOutcome::Stored)
1217    }
1218
1219    /// Whether `node` (a `scope:always` memory) duplicates an existing standing
1220    /// rule, by exact content or embedding cosine at/above the dedup threshold.
1221    /// Compared only against the standing-rule set (fetched directly from the tag
1222    /// index), which the cap keeps small, so this stays cheap and exact rather
1223    /// than relying on approximate recall ranking.
1224    /// The existing standing rule `node` duplicates, if any.
1225    fn find_duplicate_standing_rule(&self, node: &MemoryNode) -> Option<MemoryId> {
1226        let threshold = self.cognitive_config.always_dedup_threshold;
1227        for id in self.index.bitmap.query_tag("scope:always") {
1228            if id == node.id {
1229                continue;
1230            }
1231            let Ok(existing) = self.get_memory(id) else {
1232                continue;
1233            };
1234            // Dedup only within the same owner: another user or agent pinning the
1235            // same rule is a separate standing rule, and collapsing across owners
1236            // would break isolation.
1237            if existing.user_id != node.user_id || existing.agent_id != node.agent_id {
1238                continue;
1239            }
1240            if existing.content.trim() == node.content.trim() {
1241                return Some(id);
1242            }
1243            if !existing.embedding.is_empty()
1244                && Self::cosine(&node.embedding, &existing.embedding) >= threshold
1245                && Self::number_sequence(&node.content) == Self::number_sequence(&existing.content)
1246            {
1247                return Some(id);
1248            }
1249        }
1250        None
1251    }
1252
1253    /// The stored memory `node` duplicates, if any: same owner, same scope
1254    /// tags, cosine at the (calibrated) dedup gate AND token-set jaccard at
1255    /// the overlap gate. Shared by the write-time skip and the retroactive
1256    /// sweep so both apply identical policy.
1257    fn find_paraphrase_duplicate_of(&self, node: &MemoryNode) -> Option<MemoryId> {
1258        self.find_duplicate_of(
1259            node,
1260            self.cognitive_config.memory_dedup_token_overlap,
1261            false,
1262        )
1263    }
1264
1265    /// Offline dedup sweep variant of the paraphrase finder: a looser
1266    /// token-overlap gate catches the reworded paraphrases that pile up below
1267    /// the strict write-time gate ("User builds X with Y" vs "the user is
1268    /// building X using Y"), while a value-update structural guard ensures a
1269    /// correction is never merged into the fact it corrects.
1270    fn find_sweep_duplicate_of(&self, node: &MemoryNode) -> Option<MemoryId> {
1271        self.find_duplicate_of(node, self.cognitive_config.sweep_dedup_token_overlap, true)
1272    }
1273
1274    /// Shared duplicate finder for the write-time paraphrase gate and the
1275    /// offline sweep: a same-owner, same-scope candidate whose embedding cosine
1276    /// reaches the (calibrated) dedup threshold AND whose token-set jaccard
1277    /// reaches `min_overlap`. With `guard_value_update` set, a candidate that
1278    /// reads as a value update (shared frame, changed value tail) is skipped,
1279    /// so a correction is never collapsed into the fact it supersedes.
1280    fn find_duplicate_of(
1281        &self,
1282        node: &MemoryNode,
1283        min_overlap: f32,
1284        guard_value_update: bool,
1285    ) -> Option<MemoryId> {
1286        let now = std::time::SystemTime::now()
1287            .duration_since(std::time::UNIX_EPOCH)
1288            .unwrap_or_default()
1289            .as_micros() as u64;
1290        let Ok(candidates) = self.recall_hybrid_scoped_at_mode(
1291            &node.embedding,
1292            None,
1293            5,
1294            now,
1295            None,
1296            false,
1297            None,
1298            Some(node.agent_id),
1299            Some(node.user_id),
1300            None,
1301        ) else {
1302            return None;
1303        };
1304        let new_tokens = Self::token_set(&node.content);
1305        if new_tokens.is_empty() {
1306            return None;
1307        }
1308        for (id, _) in candidates {
1309            if id == node.id {
1310                continue;
1311            }
1312            let Ok(existing) = self.get_memory(id) else {
1313                continue;
1314            };
1315            // Exact-owner only: a shared/global memory must never absorb an
1316            // owned one, or vice versa.
1317            if existing.agent_id != node.agent_id || existing.user_id != node.user_id {
1318                continue;
1319            }
1320            // Scope tags are part of a memory's identity: the same sentence
1321            // scoped globally and scoped to a project are two memories, never
1322            // duplicates of each other.
1323            let existing_scopes: std::collections::BTreeSet<&str> = existing
1324                .tags
1325                .iter()
1326                .filter(|t| t.starts_with("scope:"))
1327                .map(|t| t.as_str())
1328                .collect();
1329            let node_scopes: std::collections::BTreeSet<&str> = node
1330                .tags
1331                .iter()
1332                .filter(|t| t.starts_with("scope:"))
1333                .map(|t| t.as_str())
1334                .collect();
1335            if existing_scopes != node_scopes {
1336                continue;
1337            }
1338            if existing.embedding.is_empty()
1339                || Self::cosine(&node.embedding, &existing.embedding)
1340                    < self.cognitive_config.memory_dedup_threshold
1341            {
1342                continue;
1343            }
1344            let old_tokens = Self::token_set(&existing.content);
1345            if old_tokens.is_empty() {
1346                continue;
1347            }
1348            // Same words with different numbers ("May 3 at 4pm" and "May 4
1349            // at 3pm", "record 3-4" and "record 4-3") are different facts: the
1350            // token set ignores order, so require the numbers to match in
1351            // order before calling two memories the same.
1352
1353            if Self::number_sequence(&node.content) != Self::number_sequence(&existing.content) {
1354                continue;
1355            }
1356            let inter = new_tokens.intersection(&old_tokens).count() as f32;
1357            let union = new_tokens.union(&old_tokens).count() as f32;
1358            if union > 0.0 && inter / union >= min_overlap {
1359                if guard_value_update
1360                    && self
1361                        .write_inference
1362                        .looks_like_value_update(&node.content, &existing.content)
1363                {
1364                    continue;
1365                }
1366                return Some(id);
1367            }
1368        }
1369        None
1370    }
1371
1372    /// Retroactively merge paraphrase duplicates among the given memories,
1373    /// returning how many were merged away. For each memory that still exists,
1374    /// the same two-gate policy as write-time dedup finds a duplicate; the
1375    /// OLDER memory is kept (stable identity for edges and references), the
1376    /// newer one is forgotten after folding its tags, access count, and peak
1377    /// salience into the keeper. Idempotent, and work is bounded by the slice,
1378    /// so callers migrating a whole database chunk `memory_ids()` and release
1379    /// any external write lock between chunks, the same pattern as
1380    /// `reembed_ids`. Requires an embedder-backed index only insofar as the
1381    /// stored embeddings exist; no new embeddings are computed.
1382    pub fn dedup_sweep_ids(&self, ids: &[MemoryId]) -> MenteResult<usize> {
1383        let mut merged = 0usize;
1384        for id in ids {
1385            let Ok(node) = self.get_memory(*id) else {
1386                continue;
1387            };
1388            let Some(dup_id) = self.find_sweep_duplicate_of(&node) else {
1389                continue;
1390            };
1391            let Ok(dup) = self.get_memory(dup_id) else {
1392                continue;
1393            };
1394            // Keep the older memory; forget the newer restatement.
1395            let (keep, drop) = if node.created_at <= dup.created_at {
1396                (node, dup)
1397            } else {
1398                (dup, node)
1399            };
1400            let keep_pid = {
1401                let pm = self.page_map.read();
1402                match pm.get(&keep.id) {
1403                    Some(p) => *p,
1404                    None => continue,
1405                }
1406            };
1407            let mut keeper = match self.storage.load_memory(keep_pid) {
1408                Ok(k) => k,
1409                Err(_) => continue,
1410            };
1411            for tag in &drop.tags {
1412                if !keeper.tags.contains(tag) {
1413                    keeper.tags.push(tag.clone());
1414                }
1415            }
1416            keeper.access_count = keeper.access_count.saturating_add(drop.access_count);
1417            if drop.salience > keeper.salience {
1418                keeper.salience = drop.salience;
1419            }
1420            self.update_node(keep_pid, &keeper)?;
1421            self.forget(drop.id)?;
1422            merged += 1;
1423        }
1424        if merged > 0 {
1425            info!("dedup sweep merged {merged} paraphrase duplicates");
1426        }
1427        Ok(merged)
1428    }
1429
1430    /// Lowercased alphanumeric token set of a content string, for the
1431    /// paraphrase-dedup overlap gate.
1432    /// The numbers in `s`, in order of appearance.
1433    fn number_sequence(s: &str) -> Vec<&str> {
1434        s.split(|c: char| !(c.is_ascii_digit() || c == '.'))
1435            .map(|t| t.trim_matches('.'))
1436            .filter(|t| t.chars().any(|c| c.is_ascii_digit()))
1437            .collect()
1438    }
1439
1440    fn token_set(s: &str) -> std::collections::HashSet<String> {
1441        s.split(|c: char| !c.is_alphanumeric())
1442            .filter(|t| !t.is_empty())
1443            .map(|t| t.to_lowercase())
1444            .collect()
1445    }
1446
1447    /// Cosine similarity of two equal-length vectors; 0 for mismatched or zero
1448    /// vectors. Used for standing-rule dedup where a true similarity (not the
1449    /// fused recall score) is needed.
1450    fn cosine(a: &[f32], b: &[f32]) -> f32 {
1451        if a.len() != b.len() || a.is_empty() {
1452            return 0.0;
1453        }
1454        let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
1455        for i in 0..a.len() {
1456            dot += a[i] * b[i];
1457            na += a[i] * a[i];
1458            nb += b[i] * b[i];
1459        }
1460        if na == 0.0 || nb == 0.0 {
1461            return 0.0;
1462        }
1463        dot / (na.sqrt() * nb.sqrt())
1464    }
1465
1466    /// Store multiple memories in a single batch transaction.
1467    ///
1468    /// Uses a single WAL lock for all writes, avoiding per-write overhead of
1469    /// flock acquisition, header reload, and LSN scan. Significantly faster
1470    /// for bulk inserts.
1471    pub fn store_batch(&self, mut nodes: Vec<MemoryNode>) -> MenteResult<Vec<MemoryId>> {
1472        // Embed nodes sent without a vector in one provider call, as `store`
1473        // does one at a time.
1474        if let Some(e) = &self.embedder {
1475            let missing: Vec<usize> = nodes
1476                .iter()
1477                .enumerate()
1478                .filter(|(_, n)| n.embedding.is_empty() && !n.content.is_empty())
1479                .map(|(i, _)| i)
1480                .collect();
1481            if !missing.is_empty() {
1482                let texts: Vec<&str> = missing.iter().map(|&i| nodes[i].content.as_str()).collect();
1483                let vectors = e.embed_batch(&texts)?;
1484                if vectors.len() != missing.len() {
1485                    return Err(MenteError::Storage(format!(
1486                        "embedder returned {} vectors for {} texts",
1487                        vectors.len(),
1488                        missing.len()
1489                    )));
1490                }
1491                for (i, v) in missing.into_iter().zip(vectors) {
1492                    nodes[i].embedding = v;
1493                }
1494            }
1495        }
1496        // Validate all embeddings upfront
1497        for node in &nodes {
1498            if self.embedding_dim > 0
1499                && !node.embedding.is_empty()
1500                && node.embedding.len() != self.embedding_dim
1501            {
1502                return Err(MenteError::EmbeddingDimensionMismatch {
1503                    got: node.embedding.len(),
1504                    expected: self.embedding_dim,
1505                });
1506            }
1507        }
1508
1509        let page_ids = self.storage.store_memory_batch(&nodes)?;
1510        for node in &nodes {
1511            self.note_write(node.id);
1512        }
1513
1514        // Lock order: never hold `page_map` while taking the graph lock. Recall
1515        // holds both for reading, so a writer holding `page_map` while waiting
1516        // on the graph deadlocks against it (and every later call queues).
1517        {
1518            let mut page_map = self.page_map.write();
1519            for (node, page_id) in nodes.iter().zip(page_ids.iter()) {
1520                page_map.insert(node.id, *page_id);
1521            }
1522        }
1523        let mut ids = Vec::with_capacity(nodes.len());
1524        for node in &nodes {
1525            self.index.index_memory(node);
1526            self.graph.add_memory(node.id);
1527            ids.push(node.id);
1528        }
1529        self.stores
1530            .fetch_add(ids.len() as u64, std::sync::atomic::Ordering::Relaxed);
1531
1532        // Batch inserts get the same auto-linking, contradiction detection,
1533        // and invalidation as single stores.
1534        if self.cognitive_config.write_inference {
1535            for node in &nodes {
1536                self.run_write_inference(node);
1537            }
1538        }
1539
1540        Ok(ids)
1541    }
1542
1543    /// Recalls memories using an MQL query string.
1544    ///
1545    /// Parses the query, builds an execution plan, runs it against the
1546    /// appropriate indexes/graph, and assembles the results into a
1547    /// token-budget-aware context window.
1548    pub fn recall(&self, query: &str) -> MenteResult<ContextWindow> {
1549        debug!("Recalling with query: {}", query);
1550        self.recalls
1551            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1552        let plan = Mql::parse(query)?;
1553
1554        let scored = self.execute_plan(&plan)?;
1555        let config = AssemblyConfig::default();
1556        let window = ContextAssembler::assemble(scored, vec![], &config);
1557        Ok(window)
1558    }
1559
1560    /// Run an MQL query and return the scored matches directly, without assembling
1561    /// a context window. This is the raw query primitive behind `recall` (which
1562    /// additionally packs the results into a token-budgeted context), and it powers
1563    /// the `mentedb` CLI and the admin query endpoint.
1564    pub fn query(&self, mql: &str) -> MenteResult<Vec<ScoredMemory>> {
1565        self.recalls
1566            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1567        let plan = Mql::parse(mql)?;
1568        self.execute_plan(&plan)
1569    }
1570
1571    /// Like [`recall`](Self::recall), but runs an optional second pass through a
1572    /// [`Reranker`](crate::reranker::Reranker) before assembling the context
1573    /// window. The reranker reorders the first-pass candidates by `query_text`
1574    /// (typically the natural-language query behind the MQL), so exact-term or
1575    /// model-scored relevance can lift results that pure vector/BM25 ranking
1576    /// buried. Reranking is entirely opt-in: plain `recall` never invokes it.
1577    pub fn recall_reranked(
1578        &self,
1579        query: &str,
1580        query_text: &str,
1581        reranker: &dyn crate::reranker::Reranker,
1582    ) -> MenteResult<ContextWindow> {
1583        use crate::reranker::RerankCandidate;
1584        let plan = Mql::parse(query)?;
1585        let mut scored = self.execute_plan(&plan)?;
1586
1587        let candidates: Vec<RerankCandidate<'_>> = scored
1588            .iter()
1589            .map(|s| RerankCandidate {
1590                id: s.memory.id,
1591                content: &s.memory.content,
1592                score: s.score,
1593            })
1594            .collect();
1595        let new_scores: std::collections::HashMap<MemoryId, f32> = reranker
1596            .rerank(query_text, &candidates)
1597            .into_iter()
1598            .collect();
1599        for s in &mut scored {
1600            if let Some(&ns) = new_scores.get(&s.memory.id) {
1601                s.score = ns;
1602            }
1603        }
1604        scored.sort_by(|a, b| b.score.total_cmp(&a.score));
1605
1606        let config = AssemblyConfig::default();
1607        Ok(ContextAssembler::assemble(scored, vec![], &config))
1608    }
1609
1610    /// Shortcut for vector similarity search.
1611    ///
1612    /// Returns the top-k most similar memory IDs with their scores.
1613    /// Memories that have been superseded, contradicted, or temporally
1614    /// invalidated are automatically excluded from results.
1615    pub fn recall_similar(&self, embedding: &[f32], k: usize) -> MenteResult<Vec<(MemoryId, f32)>> {
1616        self.recall_similar_filtered(embedding, k, None, None)
1617    }
1618
1619    /// Vector similarity search with optional tag and time range filters.
1620    pub fn recall_similar_filtered(
1621        &self,
1622        embedding: &[f32],
1623        k: usize,
1624        tags: Option<&[&str]>,
1625        time_range: Option<(Timestamp, Timestamp)>,
1626    ) -> MenteResult<Vec<(MemoryId, f32)>> {
1627        let now = std::time::SystemTime::now()
1628            .duration_since(std::time::UNIX_EPOCH)
1629            .unwrap_or_default()
1630            .as_micros() as u64;
1631        self.recall_similar_filtered_at(embedding, k, now, tags, time_range)
1632    }
1633
1634    /// Standing rules for a class of agent actions: memories tagged
1635    /// `trigger:<action>` (for example `trigger:git-commit`), scoped to the
1636    /// querying agent and user, temporally valid now, newest first, capped
1637    /// at `k`.
1638    ///
1639    /// This is the action-cued recall channel. Preferences about how to
1640    /// perform an action ("no co-author trailers in commits") share no
1641    /// semantic overlap with the conversation topic, so similarity recall
1642    /// cannot surface them at the moment they apply, and `scope:always` would
1643    /// inject them into every turn. Clients call this right before
1644    /// performing the action (for example from a pre-tool hook) and inject
1645    /// the results.
1646    ///
1647    /// Guarantees, chosen so the call is safe on any path:
1648    /// - Read only: no counters or decay clocks are touched.
1649    /// - O(matching rules), not a corpus scan: candidates come from the tag
1650    ///   index and only candidates are loaded from storage.
1651    /// - Same visibility semantics as every other scoped recall
1652    ///   (`agent_visible` and `user_visible`): the agent's own rules plus
1653    ///   account-global (nil-owned) rules, never another agent's.
1654    /// - Superseded or invalidated rules are excluded via temporal validity,
1655    ///   so a corrected preference stops firing the moment the correction
1656    ///   lands.
1657    /// - Newest first (tie-broken by id) so when write-time reconciliation
1658    ///   kept both an old rule and its correction, the correction leads.
1659    ///
1660    /// An unknown or empty trigger returns an empty vec.
1661    /// Recall the memories that bear on an action the agent is about to take.
1662    ///
1663    /// This is ordinary semantic recall, not a tag lookup: the caller passes the
1664    /// embedding of the MEANING of the action about to happen (the command, the
1665    /// tool call, the intent), and the engine returns memories that are both
1666    /// semantically near that meaning and of a kind that GOVERNS behavior. A
1667    /// procedure ("sign commits with 1Password"), a correction, or an
1668    /// anti-pattern outranks a merely topical fact or a past event, because the
1669    /// type system says which memories govern an action, not a hardcoded
1670    /// vocabulary of action names. There is no `trigger:` tag, no command
1671    /// classifier, and no string equality anywhere in the path: a git commit
1672    /// surfaces the commit-signing rule by understanding, and `ls` surfaces
1673    /// nothing because nothing is meaningfully about listing files.
1674    ///
1675    /// The caller passes both the embedding of the action's meaning AND the
1676    /// action text. Candidates come from HYBRID recall (vector nearest
1677    /// neighbors fused with BM25 keyword matches on the text), and a candidate
1678    /// is admitted on EITHER a semantic signal (true cosine over
1679    /// `action_min_similarity`) OR a lexical one (a shared keyword). The
1680    /// lexical path is deliberate robustness: when the embedder is weak or a
1681    /// non-semantic fallback, the vector is noise and only the words find the
1682    /// rule, so the old tag lookup's embedder-independence is preserved without
1683    /// its hardcoded action vocabulary. Ranking is
1684    /// `(cosine + keyword_bonus) * type_weight`, truncated to `k`. All knobs in
1685    /// [`CognitiveConfig`].
1686    pub fn recall_for_action(
1687        &self,
1688        action_embedding: &[f32],
1689        action_text: Option<&str>,
1690        agent_id: Option<AgentId>,
1691        user_id: Option<UserId>,
1692        k: usize,
1693    ) -> MenteResult<Vec<MemoryNode>> {
1694        if action_embedding.is_empty() || k == 0 {
1695            return Ok(Vec::new());
1696        }
1697        let cfg = &self.cognitive_config;
1698        let now = std::time::SystemTime::now()
1699            .duration_since(std::time::UNIX_EPOCH)
1700            .unwrap_or_default()
1701            .as_micros() as u64;
1702
1703        let pool = k.saturating_mul(cfg.action_overfetch.max(1));
1704        let hits =
1705            self.recall_hybrid_at(action_embedding, action_text, pool, now, None, None, None)?;
1706
1707        let action_tokens: Vec<String> = action_text
1708            .map(|t| keyword_tokens(t, cfg.action_keyword_min_len))
1709            .unwrap_or_default();
1710
1711        let mut scored: Vec<(MemoryNode, f32)> = Vec::new();
1712        for (id, _fused) in hits {
1713            let Ok(node) = self.get_memory(id) else {
1714                continue;
1715            };
1716            if !node.is_valid_at(now)
1717                || !agent_visible(node.agent_id, agent_id)
1718                || !user_visible(node.user_id, user_id)
1719            {
1720                continue;
1721            }
1722            let weight = cfg.action_type_weights[action_type_index(node.memory_type)];
1723            if weight <= 0.0 {
1724                continue;
1725            }
1726            // True cosine is the semantic signal; a shared keyword is the
1727            // lexical one. Absolute cosine thresholds are embedder-coupled, so
1728            // the keyword path is what carries recall when the vector is weak.
1729            let sim = Self::cosine(action_embedding, &node.embedding);
1730            let keyword = !action_tokens.is_empty()
1731                && shares_keyword(&action_tokens, &node.content, cfg.action_keyword_min_len);
1732            if sim < cfg.action_min_similarity && !keyword {
1733                continue;
1734            }
1735            let relevance = sim
1736                + if keyword {
1737                    cfg.action_keyword_weight
1738                } else {
1739                    0.0
1740                };
1741            scored.push((node, relevance * weight));
1742        }
1743        scored.sort_by(|a, b| {
1744            b.1.partial_cmp(&a.1)
1745                .unwrap_or(std::cmp::Ordering::Equal)
1746                .then(b.0.created_at.cmp(&a.0.created_at))
1747                .then(b.0.id.cmp(&a.0.id))
1748        });
1749        scored.truncate(k);
1750        Ok(scored.into_iter().map(|(node, _)| node).collect())
1751    }
1752
1753    /// Vector similarity search at a specific point in time.
1754    ///
1755    /// Only returns memories that were temporally valid at the given timestamp.
1756    /// Superseded/contradicted memories are excluded unless the edge itself
1757    /// was not yet valid at that time.
1758    pub fn recall_similar_at(
1759        &self,
1760        embedding: &[f32],
1761        k: usize,
1762        at: Timestamp,
1763    ) -> MenteResult<Vec<(MemoryId, f32)>> {
1764        self.recall_similar_filtered_at(embedding, k, at, None, None)
1765    }
1766
1767    /// Vector similarity search at a specific point in time with optional filters.
1768    ///
1769    /// Only returns memories that were temporally valid at the given timestamp.
1770    /// Superseded/contradicted memories are excluded unless the edge itself
1771    /// was not yet valid at that time. Optionally filters by tags and time range.
1772    pub fn recall_similar_filtered_at(
1773        &self,
1774        embedding: &[f32],
1775        k: usize,
1776        at: Timestamp,
1777        tags: Option<&[&str]>,
1778        time_range: Option<(Timestamp, Timestamp)>,
1779    ) -> MenteResult<Vec<(MemoryId, f32)>> {
1780        self.recall_hybrid_at(embedding, None, k, at, tags, time_range, None)
1781    }
1782
1783    /// Hybrid search combining vector similarity and BM25 keyword matching.
1784    ///
1785    /// When `query_text` is provided, BM25 results are fused with vector
1786    /// results via Reciprocal Rank Fusion (RRF) for better recall on
1787    /// exact entity names, dates, and specific terms.
1788    #[allow(clippy::too_many_arguments)]
1789    pub fn recall_hybrid_at(
1790        &self,
1791        embedding: &[f32],
1792        query_text: Option<&str>,
1793        k: usize,
1794        at: Timestamp,
1795        tags: Option<&[&str]>,
1796        time_range: Option<(Timestamp, Timestamp)>,
1797        current_project: Option<&str>,
1798    ) -> MenteResult<Vec<(MemoryId, f32)>> {
1799        self.recall_hybrid_at_mode(
1800            embedding,
1801            query_text,
1802            k,
1803            at,
1804            tags,
1805            false,
1806            time_range,
1807            current_project,
1808        )
1809    }
1810
1811    /// Hybrid recall with configurable tag mode (AND vs OR).
1812    #[allow(clippy::too_many_arguments)]
1813    pub fn recall_hybrid_at_mode(
1814        &self,
1815        embedding: &[f32],
1816        query_text: Option<&str>,
1817        k: usize,
1818        at: Timestamp,
1819        tags: Option<&[&str]>,
1820        tags_or: bool,
1821        time_range: Option<(Timestamp, Timestamp)>,
1822        current_project: Option<&str>,
1823    ) -> MenteResult<Vec<(MemoryId, f32)>> {
1824        self.recall_hybrid_scoped_at_mode(
1825            embedding,
1826            query_text,
1827            k,
1828            at,
1829            tags,
1830            tags_or,
1831            time_range,
1832            None,
1833            None,
1834            current_project,
1835        )
1836    }
1837
1838    /// Hybrid recall visible to one (user, agent) scope: a node passes only
1839    /// when it is visible on BOTH axes, i.e. owned by `agent` or no agent AND
1840    /// owned by `user` or no user (nil, shared knowledge). `agent: None` /
1841    /// `user: None` recall globally on that axis, preserving single-owner
1842    /// behavior. The two axes are orthogonal: agent scoping never widens or
1843    /// narrows user scoping, and vice versa.
1844    #[allow(clippy::too_many_arguments)]
1845    pub fn recall_hybrid_scoped_at_mode(
1846        &self,
1847        embedding: &[f32],
1848        query_text: Option<&str>,
1849        k: usize,
1850        at: Timestamp,
1851        tags: Option<&[&str]>,
1852        tags_or: bool,
1853        time_range: Option<(Timestamp, Timestamp)>,
1854        agent: Option<AgentId>,
1855        user: Option<UserId>,
1856        current_project: Option<&str>,
1857    ) -> MenteResult<Vec<(MemoryId, f32)>> {
1858        let started = std::time::Instant::now();
1859        debug!(
1860            "Recall hybrid, k={}, at={}, bm25={}, tags_or={}",
1861            k,
1862            at,
1863            query_text.is_some(),
1864            tags_or
1865        );
1866        // Project scope weight: precompute the current project's scope tag and the
1867        // retained fraction for cross-project memories. None (or empty) disables
1868        // the weighting entirely, which is the "search across all projects"
1869        // override.
1870        let current_project_tag = current_project
1871            .filter(|p| !p.is_empty())
1872            .map(|p| format!("scope:project:{p}"));
1873        let cross_project_keep = 1.0 - self.cognitive_config.project_scope_penalty;
1874        // When a reranker or the MMR diversity pass is active, over-fetch a larger
1875        // candidate pool so it can promote (or diversify in) a relevant hit the
1876        // first pass ranked outside the top k; otherwise the usual 3x over-fetch
1877        // covers results filtered out below.
1878        let want_rerank =
1879            self.reranker.is_some() && query_text.map(|q| !q.is_empty()).unwrap_or(false);
1880        let want_mmr = self.cognitive_config.mmr_lambda < 1.0;
1881        let fetch_k = if want_rerank || want_mmr {
1882            k.saturating_mul(self.cognitive_config.rerank_pool_factor.max(1))
1883        } else {
1884            k * 3
1885        };
1886        let results = self.index.hybrid_search_with_query_mode(
1887            embedding, query_text, tags, tags_or, time_range, fetch_k,
1888        );
1889        let contradicts_hides = self.cognitive_config.inference_config.contradiction_policy
1890            == ContradictionPolicy::Supersede;
1891        // Lock order when both are held: `page_map` first, then the graph.
1892        let pm = self.page_map.read();
1893        let graph = self.graph.graph();
1894        // (id, score, content for a reranker, embedding for the MMR pass); the
1895        // last two are captured only when their stage is active.
1896        type ScoredCandidate = (MemoryId, f32, Option<String>, Option<Vec<f32>>);
1897        let mut scored: Vec<ScoredCandidate> = results
1898            .into_iter()
1899            .filter_map(|(id, raw_score)| {
1900                // Exclude memories that an active Supersedes/Contradicts edge
1901                // has invalidated. Under the Flag contradiction policy a
1902                // Contradicts edge is a hint for the caller, so both sides stay
1903                // recallable and only Supersedes hides a memory.
1904                let incoming = graph.incoming(id);
1905                let has_active_supersede = incoming.iter().any(|(_, e)| {
1906                    (e.edge_type == EdgeType::Supersedes
1907                        || (e.edge_type == EdgeType::Contradicts && contradicts_hides))
1908                        && e.is_valid_at(at)
1909                });
1910                if has_active_supersede {
1911                    return None;
1912                }
1913                // Without a page entry we cannot check validity or decay; keep
1914                // the raw score rather than silently dropping the hit.
1915                let Some(&page_id) = pm.get(&id) else {
1916                    return Some((id, raw_score, None, None));
1917                };
1918                let Ok(node) = self.storage.load_memory(page_id) else {
1919                    return Some((id, raw_score, None, None));
1920                };
1921                // Drop memories outside their validity window or not visible to
1922                // this (user, agent) scope. Both owner axes must pass.
1923                let visible =
1924                    agent_visible(node.agent_id, agent) && user_visible(node.user_id, user);
1925                if !node.is_valid_at(at) || !visible {
1926                    return None;
1927                }
1928                // Decay at recall time: the index salience cache is frozen at
1929                // insert, so recomputing decayed salience here is what actually
1930                // lets decay affect ranking on this hot path. Blend 70%
1931                // similarity with 30% freshly decayed salience, matching the
1932                // MQL path, so old, unaccessed memories rank lower.
1933                let score = if self.cognitive_config.decay_on_recall {
1934                    let decayed = self.decayed_salience_at(&node, at);
1935                    raw_score * 0.7 + decayed * 0.3
1936                } else {
1937                    raw_score
1938                };
1939                // Weight down a memory that belongs to a DIFFERENT project than
1940                // the query's current project. Same-project and global (no
1941                // scope:project tag) memories are unaffected, so cross-project
1942                // recall still works when a memory is strongly relevant, it is
1943                // just ranked below the current project's.
1944                let score = match &current_project_tag {
1945                    Some(cpt)
1946                        if node
1947                            .tags
1948                            .iter()
1949                            .any(|t| t.starts_with("scope:project:") && t != cpt) =>
1950                    {
1951                        score * cross_project_keep
1952                    }
1953                    _ => score,
1954                };
1955                // Capture the content only when a reranker will consume it, and
1956                // the embedding only when the MMR pass will, so the default path
1957                // pays no extra allocation.
1958                let content = if want_rerank {
1959                    Some(node.content.clone())
1960                } else {
1961                    None
1962                };
1963                let embedding = if want_mmr && !node.embedding.is_empty() {
1964                    Some(node.embedding.clone())
1965                } else {
1966                    None
1967                };
1968                Some((id, score, content, embedding))
1969            })
1970            .collect();
1971        // Optional second pass: reorder the whole fetched pool by the installed
1972        // reranker over the query text, before the sort cuts to k, so it can lift
1973        // a candidate the first pass ranked outside the top k.
1974        if want_rerank
1975            && let Some(reranker) = &self.reranker
1976            && let Some(qt) = query_text
1977        {
1978            use crate::reranker::RerankCandidate;
1979            let new_scores: std::collections::HashMap<MemoryId, f32> = {
1980                let cands: Vec<RerankCandidate<'_>> = scored
1981                    .iter()
1982                    .map(|(id, sc, content, _)| RerankCandidate {
1983                        id: *id,
1984                        content: content.as_deref().unwrap_or(""),
1985                        score: *sc,
1986                    })
1987                    .collect();
1988                reranker.rerank(qt, &cands).into_iter().collect()
1989            };
1990            for (id, sc, _, _) in scored.iter_mut() {
1991                if let Some(&ns) = new_scores.get(id) {
1992                    *sc = ns;
1993                }
1994            }
1995        }
1996        // Re-rank by the (decay-adjusted, then optionally reranked) score before
1997        // cutting to k, so the ranking reorders results rather than relabeling them.
1998        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1999        let scored: Vec<(MemoryId, f32)> = if want_mmr && scored.len() > 1 {
2000            // Diversity pass: greedily pick k relevant-yet-distinct memories from
2001            // the sorted pool, so near-duplicates do not all fill the budget.
2002            let rel: Vec<f32> = scored.iter().map(|(_, sc, _, _)| *sc).collect();
2003            let emb: Vec<Option<&[f32]>> = scored.iter().map(|(_, _, _, e)| e.as_deref()).collect();
2004            mmr::mmr_select(&rel, &emb, self.cognitive_config.mmr_lambda, k)
2005                .into_iter()
2006                .map(|i| (scored[i].0, scored[i].1))
2007                .collect()
2008        } else {
2009            scored.truncate(k);
2010            scored.into_iter().map(|(id, sc, _, _)| (id, sc)).collect()
2011        };
2012        // Time only (do not increment recalls here): recall()/query() already
2013        // count the read, and they reach this hybrid core via execute_plan, so
2014        // counting here too would double-count them.
2015        Self::record_latency(&self.search_latency_us, started.elapsed());
2016        Ok(scored)
2017    }
2018
2019    /// Multi-query search with Reciprocal Rank Fusion (RRF).
2020    ///
2021    /// Runs multiple vector searches (one per embedding) and merges results
2022    /// using RRF: score = Σ 1/(k + rank_i). This improves recall by matching
2023    /// on different semantic aspects of a query.
2024    /// When `query_texts` is provided, each search also runs BM25 matching.
2025    pub fn recall_similar_multi(
2026        &self,
2027        embeddings: &[Vec<f32>],
2028        k: usize,
2029        tags: Option<&[&str]>,
2030        time_range: Option<(Timestamp, Timestamp)>,
2031    ) -> MenteResult<Vec<(MemoryId, f32)>> {
2032        self.recall_hybrid_multi(embeddings, None, k, tags, time_range)
2033    }
2034
2035    /// Multi-query hybrid search with BM25 + vector fusion.
2036    ///
2037    /// Each query text is searched via both BM25 and vector, then all results
2038    /// are merged via RRF.
2039    pub fn recall_hybrid_multi(
2040        &self,
2041        embeddings: &[Vec<f32>],
2042        query_texts: Option<&[String]>,
2043        k: usize,
2044        tags: Option<&[&str]>,
2045        time_range: Option<(Timestamp, Timestamp)>,
2046    ) -> MenteResult<Vec<(MemoryId, f32)>> {
2047        self.recall_hybrid_multi_mode(embeddings, query_texts, k, tags, false, time_range)
2048    }
2049
2050    /// Multi-query hybrid search with configurable tag mode.
2051    pub fn recall_hybrid_multi_mode(
2052        &self,
2053        embeddings: &[Vec<f32>],
2054        query_texts: Option<&[String]>,
2055        k: usize,
2056        tags: Option<&[&str]>,
2057        tags_or: bool,
2058        time_range: Option<(Timestamp, Timestamp)>,
2059    ) -> MenteResult<Vec<(MemoryId, f32)>> {
2060        use std::collections::HashMap;
2061
2062        let rrf_k: f32 = 60.0;
2063        let mut rrf_scores: HashMap<MemoryId, f32> = HashMap::new();
2064
2065        let now = std::time::SystemTime::now()
2066            .duration_since(std::time::UNIX_EPOCH)
2067            .unwrap_or_default()
2068            .as_micros() as u64;
2069
2070        for (i, emb) in embeddings.iter().enumerate() {
2071            let qt = query_texts.and_then(|texts| texts.get(i).map(|s| s.as_str()));
2072            let results =
2073                self.recall_hybrid_at_mode(emb, qt, k, now, tags, tags_or, time_range, None)?;
2074            for (rank, (id, _score)) in results.iter().enumerate() {
2075                *rrf_scores.entry(*id).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
2076            }
2077        }
2078
2079        let mut merged: Vec<(MemoryId, f32)> = rrf_scores.into_iter().collect();
2080        merged.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2081        merged.truncate(k);
2082        Ok(merged)
2083    }
2084
2085    /// Invalidate a memory by setting its valid_until timestamp.
2086    ///
2087    /// The memory remains in storage for historical queries but is excluded
2088    /// from current recall results.
2089    pub fn invalidate_memory(&self, id: MemoryId, at: Timestamp) -> MenteResult<()> {
2090        debug!("Invalidating memory {} at {}", id, at);
2091        let page_id = self
2092            .page_map
2093            .read()
2094            .get(&id)
2095            .copied()
2096            .ok_or(MenteError::MemoryNotFound(id))?;
2097        let mut node = self.storage.load_memory(page_id)?;
2098        node.invalidate(at);
2099        self.update_node(page_id, &node)?;
2100        Ok(())
2101    }
2102
2103    /// Adds a typed, weighted edge between two memories in the graph.
2104    pub fn relate(&self, edge: MemoryEdge) -> MenteResult<()> {
2105        debug!("Relating {} -> {}", edge.source, edge.target);
2106        self.graph.add_relationship(&edge)?;
2107        Ok(())
2108    }
2109
2110    /// Retrieves a single memory by its ID.
2111    pub fn get_memory(&self, id: MemoryId) -> MenteResult<MemoryNode> {
2112        let page_id = self
2113            .page_map
2114            .read()
2115            .get(&id)
2116            .copied()
2117            .ok_or(MenteError::MemoryNotFound(id))?;
2118        self.memory_loads
2119            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2120        self.storage.load_memory(page_id)
2121    }
2122
2123    /// The supersession history of a memory: the chain of older versions this
2124    /// fact replaced, newest predecessor first, walked over `Supersedes` edges
2125    /// (source = newer, target = replaced) up to `max_depth` hops.
2126    ///
2127    /// The engine keeps superseded memories bi-temporally but recall hides
2128    /// them, which makes "what was my previous X" unanswerable downstream even
2129    /// though the answer is stored. This surfaces it: callers can attach the
2130    /// predecessors of a recalled fact ("phone number is 4321, previously
2131    /// 1234") without re-deriving graph internals. Cycle-guarded; a memory
2132    /// with no history returns an empty vec.
2133    pub fn supersession_history(
2134        &self,
2135        id: MemoryId,
2136        max_depth: usize,
2137    ) -> MenteResult<Vec<MemoryNode>> {
2138        let graph = self.graph.graph();
2139        let mut history = Vec::new();
2140        let mut seen = std::collections::HashSet::from([id]);
2141        let mut current = id;
2142        for _ in 0..max_depth {
2143            let mut next: Option<MemoryId> = None;
2144            for (target, edge) in graph.outgoing(current) {
2145                if edge.edge_type == EdgeType::Supersedes && !seen.contains(&target) {
2146                    next = Some(target);
2147                    break;
2148                }
2149            }
2150            let Some(prev) = next else { break };
2151            seen.insert(prev);
2152            match self.get_memory(prev) {
2153                Ok(node) => history.push(node),
2154                // A dangling edge (predecessor forgotten) ends the chain.
2155                Err(_) => break,
2156            }
2157            current = prev;
2158        }
2159        Ok(history)
2160    }
2161
2162    /// Returns all memory IDs currently stored in the database.
2163    pub fn memory_ids(&self) -> Vec<MemoryId> {
2164        self.page_map.read().keys().copied().collect()
2165    }
2166
2167    /// Returns the number of memories currently stored.
2168    pub fn memory_count(&self) -> usize {
2169        self.page_map.read().len()
2170    }
2171
2172    /// O(1) dashboard counts (scope tree, stats): non-internal total and counts
2173    /// by type, owner, and project. Grounded by a one-time recount on the first
2174    /// read (which also folds in any writes made before that read) and kept
2175    /// current by store/forget thereafter, so a read is a lock and a clone. The
2176    /// counts are re-grounded whenever the database is reopened, which bounds any
2177    /// drift from an edit path the incremental hook does not cover to a single
2178    /// process lifetime.
2179    pub fn scope_counts(&self) -> ScopeCounts {
2180        if !self
2181            .scope_counts_ready
2182            .load(std::sync::atomic::Ordering::Relaxed)
2183        {
2184            self.recompute_scope_counts();
2185        }
2186        self.scope_counts.read().clone()
2187    }
2188
2189    /// The dashboard counts without ever blocking on a recount: the grounded
2190    /// counts when ready, otherwise the last persisted value (possibly a little
2191    /// stale, or zero on a database that never persisted them). The bool is
2192    /// whether they are grounded. Request paths use this and ground the counts
2193    /// off the request, so an open never makes a dashboard read walk the tenant.
2194    pub fn scope_counts_approx(&self) -> (ScopeCounts, bool) {
2195        let ready = self
2196            .scope_counts_ready
2197            .load(std::sync::atomic::Ordering::Relaxed);
2198        (self.scope_counts.read().clone(), ready)
2199    }
2200
2201    /// Rebuild the scope counts from every stored memory (one load each). Runs
2202    /// only on the first read or when drift is detected, never on the hot path.
2203    /// Single flight: callers that arrive while a recount runs wait for it and
2204    /// reuse its result instead of starting their own.
2205    fn recompute_scope_counts(&self) {
2206        let _one = self.scope_recount.lock();
2207        if self
2208            .scope_counts_ready
2209            .load(std::sync::atomic::Ordering::Relaxed)
2210        {
2211            return;
2212        }
2213        let hidden = self.cognitive_config.hidden_count_tags.clone();
2214        let mut counts = ScopeCounts::default();
2215        for id in self.memory_ids() {
2216            if let Ok(node) = self.get_memory(id) {
2217                counts.apply(&node, 1, &hidden);
2218            }
2219        }
2220        *self.scope_counts.write() = counts;
2221        self.scope_counts_ready
2222            .store(true, std::sync::atomic::Ordering::Relaxed);
2223    }
2224
2225    const STATS_SNAPSHOT_BLOB: &'static str = "stats_snapshot_v1";
2226
2227    /// Recompute the materialized [`StatsSnapshot`] in one pass over the tenant
2228    /// and persist it. This is the ONLY place the whole-tenant health/usage scan
2229    /// happens: it is driven by the background maintenance sweep (which already
2230    /// walks every node for archival), never the dashboard read path, so no
2231    /// request ever pays O(N). Uses the same hidden-tag exclusion as the scope
2232    /// counts so the two agree.
2233    pub fn refresh_stats_snapshot(&self) -> StatsSnapshot {
2234        let hidden = &self.cognitive_config.hidden_count_tags;
2235        let now = std::time::SystemTime::now()
2236            .duration_since(std::time::UNIX_EPOCH)
2237            .unwrap_or_default()
2238            .as_micros() as u64;
2239        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
2240
2241        let mut total_health = 0.0f64;
2242        let mut count = 0u64;
2243        let mut health_hist = [0u64; 5];
2244        let mut project_usage: HashMap<String, u64> = HashMap::new();
2245        let mut scope_usage: HashMap<String, u64> = HashMap::new();
2246        let mut entity_names: std::collections::HashSet<String> = std::collections::HashSet::new();
2247        let mut communities = 0u64;
2248
2249        for pid in &page_ids {
2250            let Ok(node) = self.storage.load_memory(*pid) else {
2251                continue;
2252            };
2253            // Entity and community totals count the tenant's nil-owner pool, the
2254            // same scope the dashboard's entity/community reads use.
2255            if node.user_id.is_nil() && node.agent_id.is_nil() {
2256                for tag in &node.tags {
2257                    if let Some(name) = tag.strip_prefix("entity:") {
2258                        entity_names.insert(name.to_lowercase().trim().to_string());
2259                    } else if tag == "community_summary" {
2260                        communities += 1;
2261                    }
2262                }
2263            }
2264            // Analytics reflect curated knowledge, not internal working material.
2265            if node.tags.iter().any(|t| hidden.iter().any(|h| h == t)) {
2266                continue;
2267            }
2268            let health = self.decayed_salience_at(&node, now) as f64;
2269            total_health += health;
2270            count += 1;
2271            health_hist[((health * 5.0) as usize).min(4)] += 1;
2272
2273            let recalls = node.access_count as u64;
2274            let scope = if node.tags.iter().any(|t| t == "scope:always") {
2275                "always"
2276            } else {
2277                "contextual"
2278            };
2279            *scope_usage.entry(scope.to_string()).or_insert(0) += recalls;
2280            for tag in &node.tags {
2281                if let Some(project) = tag.strip_prefix("scope:project:") {
2282                    *project_usage.entry(project.to_string()).or_insert(0) += recalls;
2283                }
2284            }
2285        }
2286
2287        let snapshot = StatsSnapshot {
2288            computed_at_us: now,
2289            count,
2290            avg_health: if count > 0 {
2291                total_health / count as f64
2292            } else {
2293                1.0
2294            },
2295            health_distribution: health_hist.to_vec(),
2296            project_usage,
2297            scope_usage,
2298            entities: entity_names.len() as u64,
2299            communities,
2300        };
2301        if let Ok(bytes) = serde_json::to_vec(&snapshot) {
2302            self.write_blob(Self::STATS_SNAPSHOT_BLOB, &bytes);
2303        }
2304        snapshot
2305    }
2306
2307    /// The last materialized analytics snapshot, or None if the maintenance sweep
2308    /// has not produced one yet (a brand-new tenant). O(1): a small blob read, no
2309    /// scan. Callers serve the exact O(1) scope counts alongside this and, on a
2310    /// None, kick a background [`Self::refresh_stats_snapshot`] rather than
2311    /// blocking the request on the scan.
2312    pub fn stats_snapshot(&self) -> Option<StatsSnapshot> {
2313        self.read_blob(Self::STATS_SNAPSHOT_BLOB)
2314            .and_then(|b| serde_json::from_slice::<StatsSnapshot>(&b).ok())
2315    }
2316
2317    /// Read a persisted per-tenant blob by key (under `<path>/blobs/`), or None
2318    /// if absent. Lets the host cache an expensive derived result (e.g. the
2319    /// stats page) on the tenant's own storage, so a cold reopen serves it
2320    /// without recomputing.
2321    pub fn read_blob(&self, key: &str) -> Option<Vec<u8>> {
2322        std::fs::read(self.blob_path(key)).ok()
2323    }
2324
2325    /// Persist a per-tenant blob by key. Best effort; a failed write just makes
2326    /// the next read a miss.
2327    pub fn write_blob(&self, key: &str, data: &[u8]) {
2328        let path = self.blob_path(key);
2329        if let Some(dir) = path.parent() {
2330            let _ = std::fs::create_dir_all(dir);
2331        }
2332        let _ = std::fs::write(path, data);
2333    }
2334
2335    fn blob_path(&self, key: &str) -> PathBuf {
2336        // Collapse the key to one safe path segment so it cannot escape the dir.
2337        let safe: String = key
2338            .chars()
2339            .map(|c| {
2340                if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
2341                    c
2342                } else {
2343                    '_'
2344                }
2345            })
2346            .collect();
2347        self.path.join("blobs").join(safe)
2348    }
2349
2350    /// Ids of the newest `limit` memories (descending by creation time),
2351    /// excluding any tagged with an `exclude_tags` entry and beginning after
2352    /// `after` when given. Resolved entirely from the temporal and bitmap
2353    /// indexes: no memory content is loaded and no full scan runs, so serving a
2354    /// page of a dashboard list costs the size of the page, not the size of the
2355    /// store. The caller loads only the returned ids with [`Self::get_memory`].
2356    pub fn recent_memory_ids(
2357        &self,
2358        limit: usize,
2359        exclude_tags: &[&str],
2360        after: Option<MemoryId>,
2361    ) -> Vec<MemoryId> {
2362        let skip = self.ids_with_any_tag(exclude_tags);
2363        self.index.temporal.latest_filtered(limit, &skip, after)
2364    }
2365
2366    /// Oldest-first page of memory ids, excluding any carrying an `exclude_tags`
2367    /// tag, beginning after `after`. The ascending mirror of
2368    /// [`Self::recent_memory_ids`], served from the temporal index with no scan,
2369    /// so oldest-first browse (and contextual browse, by excluding `scope:always`)
2370    /// is bounded by the page.
2371    pub fn oldest_memory_ids(
2372        &self,
2373        limit: usize,
2374        exclude_tags: &[&str],
2375        after: Option<MemoryId>,
2376    ) -> Vec<MemoryId> {
2377        let skip = self.ids_with_any_tag(exclude_tags);
2378        self.index.temporal.oldest_filtered(limit, &skip, after)
2379    }
2380
2381    /// The most recent `k` memories, newest first, straight from the temporal
2382    /// index. Unlike browse, this includes internal turns (empty skip set): the
2383    /// point is "what just happened / where did we leave off", which is the raw
2384    /// activity. It is a separate recency channel, so it never touches semantic
2385    /// recall or its ranking; the temporal index is per tenant and persisted, so
2386    /// a fresh session sees the full history with no lag.
2387    pub fn recall_recent(&self, k: usize) -> Vec<MemoryNode> {
2388        let empty = std::collections::HashSet::new();
2389        self.index
2390            .temporal
2391            .latest_filtered(k, &empty, None)
2392            .into_iter()
2393            .filter_map(|id| self.get_memory(id).ok())
2394            .collect()
2395    }
2396
2397    /// Ids of every memory carrying `tag`, straight from the bitmap index. No
2398    /// content is loaded and no scan runs, so a small tagged set (standing
2399    /// rules, the profile node, a project) is found in time proportional to the
2400    /// set, not the store.
2401    pub fn memory_ids_with_tag(&self, tag: &str) -> Vec<MemoryId> {
2402        self.index.bitmap.query_tag(tag)
2403    }
2404
2405    /// Ids of memories of a given type, from the reserved type key in the bitmap
2406    /// index (O(matches), no scan). `type_str` is the canonical browse string
2407    /// (e.g. `semantic`, `anti_pattern`); it must match [`mentedb_index::manager::type_name`]
2408    /// so the set equals a per-node type filter's.
2409    pub fn memory_ids_of_type_str(&self, type_str: &str) -> Vec<MemoryId> {
2410        self.index
2411            .bitmap
2412            .query_tag(&mentedb_index::manager::type_index_key_str(type_str))
2413    }
2414
2415    /// Ids of memories owned by a given user, from the reserved owner key in the
2416    /// bitmap index (O(matches), no scan).
2417    pub fn memory_ids_for_user(&self, user: UserId) -> Vec<MemoryId> {
2418        self.index
2419            .bitmap
2420            .query_tag(&mentedb_index::manager::user_index_key(user))
2421    }
2422
2423    /// Ids of memories owned by a given agent, from the reserved owner key in the
2424    /// bitmap index (O(matches), no scan).
2425    pub fn memory_ids_for_agent(&self, agent: AgentId) -> Vec<MemoryId> {
2426        self.index
2427            .bitmap
2428            .query_tag(&mentedb_index::manager::agent_index_key(agent))
2429    }
2430
2431    /// Count of memories that carry none of `exclude_tags`, from the O(1) total
2432    /// and the bitmap index. Never loads content or scans; the union of the
2433    /// excluded tag sets is computed in memory.
2434    pub fn count_excluding_tags(&self, exclude_tags: &[&str]) -> usize {
2435        let excluded = self.ids_with_any_tag(exclude_tags).len();
2436        self.memory_count().saturating_sub(excluded)
2437    }
2438
2439    /// The union of the id sets for `tags`, from the bitmap index. In memory,
2440    /// no storage loads.
2441    fn ids_with_any_tag(&self, tags: &[&str]) -> std::collections::HashSet<MemoryId> {
2442        let mut set = std::collections::HashSet::new();
2443        for tag in tags {
2444            for id in self.index.bitmap.query_tag(tag) {
2445                set.insert(id);
2446            }
2447        }
2448        set
2449    }
2450
2451    /// `(contradicts, supersedes)` conflict-edge counts from the in-memory
2452    /// graph, deduping undirected contradiction pairs so A<->B counts once.
2453    /// One O(edges) pass, no node content loaded and no per-node delta-log
2454    /// rescan, so a conflict badge costs a graph tally rather than a full scan.
2455    /// Byte-identical re-saves are routed to `Derived` edges at write time, so
2456    /// they never appear here.
2457    pub fn conflict_edge_counts(&self) -> (usize, usize) {
2458        self.graph().conflict_edge_counts()
2459    }
2460
2461    /// Every live conflict-class edge as `(source, target, type, weight)`, read
2462    /// in O(conflicts) from the graph's maintained pair list. The dashboard
2463    /// conflict view uses this to enumerate the actual pairs without walking
2464    /// every node's out-edges (which rescans the delta log per node).
2465    pub fn conflict_edges(&self) -> Vec<(MemoryId, MemoryId, EdgeType, f32)> {
2466        self.graph().conflict_edges()
2467    }
2468
2469    /// Fold one op's latency into an exponentially-weighted moving average, in
2470    /// microseconds. An EMA (not a lifetime mean) so a one-off slow op, e.g. right
2471    /// after a cold open, decays out over the next few ops instead of permanently
2472    /// skewing the number; it tracks recent, warm engine performance. Lock-free.
2473    fn record_latency(ema: &std::sync::atomic::AtomicU64, elapsed: std::time::Duration) {
2474        use std::sync::atomic::Ordering::Relaxed;
2475        let sample = elapsed.as_micros() as u64;
2476        let n = LATENCY_EMA_WINDOW;
2477        let mut cur = ema.load(Relaxed);
2478        loop {
2479            let next = if cur == 0 {
2480                sample
2481            } else {
2482                (cur * (n - 1) + sample) / n
2483            };
2484            match ema.compare_exchange_weak(cur, next, Relaxed, Relaxed) {
2485                Ok(_) => break,
2486                Err(actual) => cur = actual,
2487            }
2488        }
2489    }
2490
2491    /// Snapshot the engine's operational metrics (writes, reads, cache, storage,
2492    /// index, graph, standing rules) for a `/metrics` exporter. O(1), no scan.
2493    pub fn metrics(&self) -> DbMetrics {
2494        use std::sync::atomic::Ordering::Relaxed;
2495        let bp = self.storage.buffer_stats();
2496        let page_count = self.storage.page_count();
2497        DbMetrics {
2498            memory_count: self.memory_count() as u64,
2499            stores: self.stores.load(Relaxed),
2500            memory_loads: self.memory_loads.load(Relaxed),
2501            recalls: self.recalls.load(Relaxed),
2502            avg_store_latency_us: self.store_latency_us.load(Relaxed),
2503            avg_search_latency_us: self.search_latency_us.load(Relaxed),
2504            buffer_pool_hits: bp.hits,
2505            buffer_pool_misses: bp.misses,
2506            buffer_pool_evictions: bp.evictions,
2507            buffer_pool_pages: bp.resident_pages,
2508            storage_bytes: page_count * mentedb_storage::PAGE_SIZE as u64,
2509            page_count,
2510            vector_index_size: self.index.hnsw.len() as u64,
2511            graph_nodes: self.graph.graph().node_count() as u64,
2512            standing_rules: self.count_standing_rules() as u64,
2513        }
2514    }
2515
2516    /// Accurate count of pinned standing rules. The tag index can over-count after
2517    /// an un-pin re-indexed on an older engine (the tag was removed from the node
2518    /// but lingered in the bitmap), so verify each candidate still carries the tag.
2519    /// Cheap: it loads only the (few, capped) candidates, not the whole corpus.
2520    fn count_standing_rules(&self) -> usize {
2521        self.index
2522            .bitmap
2523            .query_tag("scope:always")
2524            .into_iter()
2525            .filter(|id| {
2526                self.get_memory(*id)
2527                    .map(|n| n.tags.iter().any(|t| t == "scope:always"))
2528                    .unwrap_or(false)
2529            })
2530            .count()
2531    }
2532
2533    /// A bounded, paginated page of stored memories for admin browsing, ordered by
2534    /// id so pagination is stable. Only `limit` nodes are loaded from storage per
2535    /// call (paginate over the id set first, then load), so it stays cheap even on
2536    /// a large store. Optional filters narrow the loaded page by owning agent,
2537    /// memory type, and a case-insensitive content substring. Returns the total
2538    /// memory count (for the pager) alongside the page.
2539    pub fn list_memories(
2540        &self,
2541        limit: usize,
2542        offset: usize,
2543        agent: Option<AgentId>,
2544        memory_type: Option<MemoryType>,
2545        content_query: Option<&str>,
2546    ) -> MenteResult<(usize, Vec<MemoryNode>)> {
2547        let pm = self.page_map.read();
2548        let total = pm.len();
2549        let mut ids: Vec<MemoryId> = pm.keys().copied().collect();
2550        ids.sort();
2551        let needle = content_query.map(|q| q.to_lowercase());
2552        let mut out = Vec::new();
2553        for id in ids.into_iter().skip(offset).take(limit) {
2554            if let Some(&page_id) = pm.get(&id)
2555                && let Ok(node) = self.storage.load_memory(page_id)
2556            {
2557                if agent.is_some_and(|a| node.agent_id != a) {
2558                    continue;
2559                }
2560                if memory_type.is_some_and(|t| node.memory_type != t) {
2561                    continue;
2562                }
2563                if let Some(n) = &needle
2564                    && !node.content.to_lowercase().contains(n)
2565                {
2566                    continue;
2567                }
2568                out.push(node);
2569            }
2570        }
2571        Ok((total, out))
2572    }
2573
2574    /// Removes a memory from storage, indexes, and the graph.
2575    pub fn forget(&self, id: MemoryId) -> MenteResult<()> {
2576        debug!("Forgetting memory {}", id);
2577
2578        if let Some(&page_id) = self.page_map.read().get(&id) {
2579            if let Ok(node) = self.storage.load_memory(page_id) {
2580                self.index.remove_memory(id, &node);
2581                if self
2582                    .scope_counts_ready
2583                    .load(std::sync::atomic::Ordering::Relaxed)
2584                {
2585                    self.scope_counts.write().apply(
2586                        &node,
2587                        -1,
2588                        &self.cognitive_config.hidden_count_tags,
2589                    );
2590                }
2591            }
2592            // Durable delete: WAL-logged page free, so the memory does not
2593            // resurrect when the page map is rebuilt on reopen.
2594            self.storage.delete_memory(page_id)?;
2595        }
2596        self.note_write(id);
2597
2598        self.graph.remove_memory(id);
2599        self.page_map.write().remove(&id);
2600        Ok(())
2601    }
2602
2603    /// Clean up the standing-rules (`scope:always`) set: remove exact-content
2604    /// duplicates (keeping the healthiest copy) and un-pin every auto-pinned
2605    /// always-rule, keeping only rules the user explicitly pinned
2606    /// (`source:manual`) and the user profile (which the engine pins deliberately
2607    /// so it injects every session).
2608    ///
2609    /// `scope:always` force-injects a memory into every assembled context
2610    /// regardless of relevance, so the set must stay tiny; distilled facts belong
2611    /// in relevance-based recall, not a fixed always-list. This is the single
2612    /// source of truth for the policy, shared by the hosted platform, the local
2613    /// daemon, and any SDK consumer, so behavior cannot drift between them.
2614    pub fn prune_standing_rules(&self) -> MenteResult<PruneReport> {
2615        use std::collections::{HashMap, HashSet};
2616
2617        let always: Vec<MemoryNode> = self
2618            .memory_ids()
2619            .into_iter()
2620            .filter_map(|id| self.get_memory(id).ok())
2621            .filter(|n| n.tags.iter().any(|t| t == "scope:always"))
2622            .collect();
2623
2624        let mut report = PruneReport {
2625            total_always: always.len(),
2626            ..Default::default()
2627        };
2628
2629        // Collapse exact-content duplicates: keep the healthiest copy, forget
2630        // the rest.
2631        let mut by_content: HashMap<&str, Vec<&MemoryNode>> = HashMap::new();
2632        for n in &always {
2633            by_content.entry(n.content.as_str()).or_default().push(n);
2634        }
2635        let mut prune_ids: HashSet<MemoryId> = HashSet::new();
2636        for (_content, mut group) in by_content {
2637            if group.len() > 1 {
2638                report.duplicate_groups += 1;
2639                group.sort_by(|a, b| {
2640                    b.salience
2641                        .partial_cmp(&a.salience)
2642                        .unwrap_or(std::cmp::Ordering::Equal)
2643                });
2644                for n in group.into_iter().skip(1) {
2645                    prune_ids.insert(n.id);
2646                }
2647            }
2648        }
2649
2650        // Un-pin every auto-pinned always-rule (not source:manual, not the
2651        // profile). Keep the memory, drop the pin.
2652        let mut to_unpin: Vec<MemoryNode> = Vec::new();
2653        for n in &always {
2654            if prune_ids.contains(&n.id) {
2655                continue;
2656            }
2657            let is_manual = n.tags.iter().any(|t| t == "source:manual");
2658            let is_profile = n.tags.iter().any(|t| t == "user_profile");
2659            if !is_manual && !is_profile {
2660                to_unpin.push(n.clone());
2661            }
2662        }
2663
2664        // Cap the count. Whatever survives dedup and un-pinning is manual pins
2665        // plus the profile; the profile is always kept and does not count. If the
2666        // remaining manual pins exceed always_max, demote the lowest-salience
2667        // excess to ordinary memories so the always-list stays bounded even when a
2668        // user has pinned hundreds of rules by hand.
2669        let unpin_ids: HashSet<MemoryId> = to_unpin.iter().map(|n| n.id).collect();
2670        let mut survivors: Vec<&MemoryNode> = always
2671            .iter()
2672            .filter(|n| !prune_ids.contains(&n.id) && !unpin_ids.contains(&n.id))
2673            .filter(|n| !n.tags.iter().any(|t| t == "user_profile"))
2674            .collect();
2675        if survivors.len() > self.cognitive_config.always_max {
2676            survivors.sort_by(|a, b| {
2677                b.salience
2678                    .partial_cmp(&a.salience)
2679                    .unwrap_or(std::cmp::Ordering::Equal)
2680            });
2681            for n in survivors.into_iter().skip(self.cognitive_config.always_max) {
2682                report.capped += 1;
2683                to_unpin.push(n.clone());
2684            }
2685        }
2686
2687        for id in prune_ids {
2688            self.forget(id)?;
2689            report.pruned.push(id);
2690        }
2691        for mut n in to_unpin {
2692            n.tags.retain(|t| t != "scope:always");
2693            let id = n.id;
2694            self.store(n)?;
2695            report.unpinned.push(id);
2696        }
2697
2698        Ok(report)
2699    }
2700
2701    /// Returns a reference to the underlying graph manager.
2702    pub fn graph(&self) -> &GraphManager {
2703        &self.graph
2704    }
2705
2706    /// Returns a mutable reference to the underlying graph manager.
2707    #[deprecated(note = "GraphManager now uses interior mutability; use graph() instead")]
2708    pub fn graph_mut(&mut self) -> &mut GraphManager {
2709        &mut self.graph
2710    }
2711
2712    /// Returns a reference to the cognitive configuration.
2713    pub fn cognitive_config(&self) -> &CognitiveConfig {
2714        &self.cognitive_config
2715    }
2716
2717    // -----------------------------------------------------------------------
2718    // Cognitive Engine: Write Inference
2719    // -----------------------------------------------------------------------
2720
2721    /// Run write inference on a newly stored memory.
2722    ///
2723    /// Finds semantically similar existing memories, runs the inference engine
2724    /// to detect contradictions and relationships, then applies the actions
2725    /// (creating edges, invalidating superseded memories, etc.).
2726    fn run_write_inference(&self, new_memory: &MemoryNode) {
2727        // Find candidate memories to compare against via vector search.
2728        // We load a small set of the most similar memories.
2729        let candidates = if !new_memory.embedding.is_empty() {
2730            let now = std::time::SystemTime::now()
2731                .duration_since(std::time::UNIX_EPOCH)
2732                .unwrap_or_default()
2733                .as_micros() as u64;
2734            // Scope to the new memory's owner on BOTH axes: contradiction and
2735            // relationship inference must never compare against another user's
2736            // or another agent's memories. Nil owned (shared/global) memories
2737            // stay in scope.
2738            self.recall_hybrid_scoped_at_mode(
2739                &new_memory.embedding,
2740                None,
2741                20,
2742                now,
2743                None,
2744                false,
2745                None,
2746                Some(new_memory.agent_id),
2747                Some(new_memory.user_id),
2748                None,
2749            )
2750            .unwrap_or_default()
2751        } else {
2752            vec![]
2753        };
2754
2755        if candidates.is_empty() {
2756            return;
2757        }
2758
2759        // Load the actual MemoryNode data for each candidate.
2760        let pm = self.page_map.read();
2761        let existing: Vec<MemoryNode> = candidates
2762            .iter()
2763            .filter(|(id, _)| *id != new_memory.id)
2764            .filter_map(|(id, _)| {
2765                pm.get(id)
2766                    .and_then(|&pid| self.storage.load_memory(pid).ok())
2767            })
2768            .collect();
2769        drop(pm);
2770
2771        if existing.is_empty() {
2772            return;
2773        }
2774
2775        let actions = self
2776            .write_inference
2777            .infer_on_write(new_memory, &existing, &[]);
2778
2779        let action_count = actions.len();
2780        for action in actions {
2781            if let Err(e) = self.apply_inferred_action(action) {
2782                warn!("Failed to apply inferred action: {}", e);
2783            }
2784        }
2785        if action_count > 0 {
2786            debug!(
2787                "Write inference for {} produced {} actions",
2788                new_memory.id, action_count
2789            );
2790        }
2791    }
2792
2793    /// Apply a single inferred action from the write inference engine.
2794    fn apply_inferred_action(&self, action: InferredAction) -> MenteResult<()> {
2795        match action {
2796            InferredAction::CreateEdge {
2797                source,
2798                target,
2799                edge_type,
2800                weight,
2801            } => {
2802                let now = std::time::SystemTime::now()
2803                    .duration_since(std::time::UNIX_EPOCH)
2804                    .unwrap_or_default()
2805                    .as_micros() as u64;
2806                let edge = MemoryEdge {
2807                    source,
2808                    target,
2809                    edge_type,
2810                    weight,
2811                    created_at: now,
2812                    valid_from: None,
2813                    valid_until: None,
2814                    label: None,
2815                };
2816                debug!(
2817                    "Auto-creating {:?} edge {} -> {}",
2818                    edge_type, source, target
2819                );
2820                self.graph.add_relationship(&edge)?;
2821            }
2822            InferredAction::InvalidateMemory {
2823                memory,
2824                superseded_by,
2825                valid_until,
2826            } => {
2827                debug!(
2828                    "Invalidating memory {} (superseded by {})",
2829                    memory, superseded_by
2830                );
2831                self.invalidate_memory(memory, valid_until)?;
2832                // Also create the Supersedes edge.
2833                let now = std::time::SystemTime::now()
2834                    .duration_since(std::time::UNIX_EPOCH)
2835                    .unwrap_or_default()
2836                    .as_micros() as u64;
2837                let edge = MemoryEdge {
2838                    source: superseded_by,
2839                    target: memory,
2840                    edge_type: EdgeType::Supersedes,
2841                    weight: 1.0,
2842                    created_at: now,
2843                    valid_from: None,
2844                    valid_until: None,
2845                    label: None,
2846                };
2847                self.graph.add_relationship(&edge)?;
2848            }
2849            InferredAction::DeduplicateExact { duplicate, keeper } => {
2850                debug!("Deduplicating exact copy {duplicate} into {keeper}");
2851                let now = std::time::SystemTime::now()
2852                    .duration_since(std::time::UNIX_EPOCH)
2853                    .unwrap_or_default()
2854                    .as_micros() as u64;
2855                self.invalidate_memory(duplicate, now)?;
2856                // Derived edge (dedup lineage), NOT Supersedes: an exact copy is
2857                // deduplicated, not a meaningful supersession, so it must not
2858                // surface in the contradiction/supersession view.
2859                let edge = MemoryEdge {
2860                    source: keeper,
2861                    target: duplicate,
2862                    edge_type: EdgeType::Derived,
2863                    weight: 1.0,
2864                    created_at: now,
2865                    valid_from: None,
2866                    valid_until: None,
2867                    label: None,
2868                };
2869                self.graph.add_relationship(&edge)?;
2870            }
2871            InferredAction::MarkObsolete {
2872                memory,
2873                superseded_by,
2874            } => {
2875                debug!(
2876                    "Marking {} obsolete (superseded by {})",
2877                    memory, superseded_by
2878                );
2879                let now = std::time::SystemTime::now()
2880                    .duration_since(std::time::UNIX_EPOCH)
2881                    .unwrap_or_default()
2882                    .as_micros() as u64;
2883                self.invalidate_memory(memory, now)?;
2884                let edge = MemoryEdge {
2885                    source: superseded_by,
2886                    target: memory,
2887                    edge_type: EdgeType::Supersedes,
2888                    weight: 1.0,
2889                    created_at: now,
2890                    valid_from: None,
2891                    valid_until: None,
2892                    label: None,
2893                };
2894                self.graph.add_relationship(&edge)?;
2895            }
2896            InferredAction::FlagContradiction {
2897                existing,
2898                new,
2899                reason,
2900            } => {
2901                debug!(
2902                    "Contradiction detected: {} vs {} — {}",
2903                    existing, new, reason
2904                );
2905                let now = std::time::SystemTime::now()
2906                    .duration_since(std::time::UNIX_EPOCH)
2907                    .unwrap_or_default()
2908                    .as_micros() as u64;
2909                let edge = MemoryEdge {
2910                    source: new,
2911                    target: existing,
2912                    edge_type: EdgeType::Contradicts,
2913                    weight: 1.0,
2914                    created_at: now,
2915                    valid_from: None,
2916                    valid_until: None,
2917                    label: Some(reason),
2918                };
2919                self.graph.add_relationship(&edge)?;
2920            }
2921            InferredAction::UpdateConfidence {
2922                memory,
2923                new_confidence,
2924            } => {
2925                debug!("Updating confidence for {} to {}", memory, new_confidence);
2926                if let Ok(mut node) = self.get_memory(memory) {
2927                    node.confidence = new_confidence;
2928                    if let Some(&pid) = self.page_map.read().get(&memory) {
2929                        self.update_node(pid, &node)?;
2930                    }
2931                }
2932            }
2933            InferredAction::PropagateBeliefChange { root, delta } => {
2934                debug!("Propagating belief change from {} (delta={})", root, delta);
2935                if let Ok(node) = self.get_memory(root) {
2936                    let new_confidence = (node.confidence + delta).clamp(0.0, 1.0);
2937                    let affected = self.graph.propagate_belief_change(root, new_confidence);
2938                    for (affected_id, new_conf) in affected {
2939                        if let Ok(mut affected_node) = self.get_memory(affected_id) {
2940                            affected_node.confidence = new_conf;
2941                            if let Some(&pid) = self.page_map.read().get(&affected_id)
2942                                && let Err(e) = self.update_node(pid, &affected_node)
2943                            {
2944                                warn!("Failed to persist belief update for {affected_id}: {e}");
2945                            }
2946                        }
2947                    }
2948                }
2949            }
2950            InferredAction::UpdateContent {
2951                memory,
2952                new_content,
2953                reason,
2954            } => {
2955                debug!("Updating content of {}: {}", memory, reason);
2956                if let Ok(mut node) = self.get_memory(memory) {
2957                    node.content = new_content;
2958                    if let Some(&pid) = self.page_map.read().get(&memory) {
2959                        self.update_node(pid, &node)?;
2960                    }
2961                    self.index.remove_memory(memory, &node);
2962                    self.index.index_memory(&node);
2963                }
2964            }
2965        }
2966        Ok(())
2967    }
2968
2969    // -----------------------------------------------------------------------
2970    // Cognitive Engine: Salience Decay
2971    // -----------------------------------------------------------------------
2972
2973    /// Apply salience decay to a batch of memories in-place.
2974    ///
2975    /// Call this during retrieval to ensure scores reflect temporal relevance,
2976    /// or periodically to maintain salience accuracy across the database.
2977    pub fn apply_decay(&self, memories: &mut [MemoryNode]) {
2978        let now = std::time::SystemTime::now()
2979            .duration_since(std::time::UNIX_EPOCH)
2980            .unwrap_or_default()
2981            .as_micros() as u64;
2982        for m in memories.iter_mut() {
2983            if !self.is_decay_exempt(m) {
2984                self.decay.apply_decay_batch(std::slice::from_mut(m), now);
2985            }
2986        }
2987    }
2988
2989    /// Whether a memory is pinned against decay and archival
2990    /// (`CognitiveConfig::decay_exempt_tags`).
2991    pub fn is_decay_exempt(&self, memory: &MemoryNode) -> bool {
2992        memory.tags.iter().any(|t| {
2993            self.cognitive_config
2994                .decay_exempt_tags
2995                .iter()
2996                .any(|e| e == t)
2997        })
2998    }
2999
3000    /// Salience decayed to `at`, or the stored salience for a pinned memory.
3001    fn decayed_salience_at(&self, memory: &MemoryNode, at: u64) -> f32 {
3002        if self.is_decay_exempt(memory) {
3003            return memory.salience;
3004        }
3005        self.decay.compute_decay(
3006            memory.salience,
3007            memory.created_at,
3008            memory.accessed_at,
3009            memory.access_count,
3010            at,
3011        )
3012    }
3013
3014    /// Compute the decayed salience for a single memory at the current time.
3015    pub fn compute_decayed_salience(&self, memory: &MemoryNode) -> f32 {
3016        let now = std::time::SystemTime::now()
3017            .duration_since(std::time::UNIX_EPOCH)
3018            .unwrap_or_default()
3019            .as_micros() as u64;
3020        self.decayed_salience_at(memory, now)
3021    }
3022
3023    /// Retained for API compatibility. Does nothing and persists nothing.
3024    ///
3025    /// Salience decay is derived on read, not materialized. The stored
3026    /// `salience` field is the base value as of a memory's last reinforcement
3027    /// (`accessed_at`); the current, decayed strength is computed on demand via
3028    /// `compute_decayed_salience` (used by recall scoring and archival).
3029    ///
3030    /// This method previously recomputed decay from the stored salience and
3031    /// wrote the result back. That fed each pass's output in as the next pass's
3032    /// input, so the effective decay rate scaled with how often maintenance ran
3033    /// rather than with elapsed time: frequent passes aged and forgot memories
3034    /// far faster than the configured half-life. Deriving on read removes that
3035    /// coupling entirely, so it is safe to run maintenance at any cadence.
3036    pub fn apply_decay_global(&self) -> MenteResult<usize> {
3037        Ok(0)
3038    }
3039
3040    /// One-time repair for databases whose stored salience was corrupted by the
3041    /// old compounding decay pass, which overwrote each memory's base salience
3042    /// with a repeatedly re-decayed value. Reset every memory's salience to the
3043    /// configured maximum and restart its decay clock (`accessed_at = now`), in
3044    /// place, without re-running write inference, so every surviving memory gets
3045    /// a fresh, correct decay lease from now. Access counts and every other field
3046    /// are preserved. Returns the number of memories reset.
3047    ///
3048    /// This is idempotent in effect (running it twice just resets an already
3049    /// healthy base again) and safe to gate behind a one-shot migration flag.
3050    pub fn reset_decay_state(&self) -> MenteResult<usize> {
3051        let now = std::time::SystemTime::now()
3052            .duration_since(std::time::UNIX_EPOCH)
3053            .unwrap_or_default()
3054            .as_micros() as u64;
3055        let max_salience = self.decay.config.max_salience;
3056        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
3057
3058        let mut reset = 0;
3059        for pid in &page_ids {
3060            if let Ok(mut node) = self.storage.load_memory(*pid) {
3061                node.salience = max_salience;
3062                node.accessed_at = now;
3063                self.update_node(*pid, &node)?;
3064                reset += 1;
3065            }
3066        }
3067        if reset > 0 {
3068            info!("Reset decay state for {} memories", reset);
3069        }
3070        Ok(reset)
3071    }
3072
3073    /// Re-embed every memory at the embedder's current dimension and reindex it.
3074    ///
3075    /// This migrates a database whose stored vectors were produced by an
3076    /// embedder of a different dimension (for example 256 to 1024): each memory's
3077    /// content is re-embedded with the currently configured embedder, its stored
3078    /// vector is replaced, and it is removed from and re-added to the vector
3079    /// index. Memories already at the current dimension are skipped, so it is
3080    /// idempotent and safe to re-run.
3081    ///
3082    /// The migration is safe to run on a live database: the HNSW index checks
3083    /// vector dimension at query time, so a memory that has not been re-embedded
3084    /// yet is simply skipped by recall (never returned, never crashing) until
3085    /// this reaches it. Runs synchronously and re-embeds one memory at a time;
3086    /// the caller should run it on a blocking pool.
3087    pub fn reembed_all(&self) -> MenteResult<usize> {
3088        let reembedded = self.reembed_ids(&self.memory_ids())?;
3089        if reembedded > 0 {
3090            info!(
3091                "Re-embedded {reembedded} memories at dimension {}",
3092                self.embedding_dim
3093            );
3094        }
3095        Ok(reembedded)
3096    }
3097
3098    /// Re-embed the given memories to the current embedding dimension, returning
3099    /// how many were changed. Memories already at the target dimension, missing,
3100    /// or not yet embeddable at the target dimension are skipped, so this is
3101    /// idempotent. Each call's work and its hold on any external write lock are
3102    /// bounded by `ids.len()`.
3103    ///
3104    /// To migrate a whole database without blocking foreground writes for the
3105    /// entire migration, snapshot `memory_ids()` once and feed it here in
3106    /// fixed-size chunks, releasing the external write lock between chunks so
3107    /// foreground writes can interleave. `reembed_all` is the single-call form.
3108    pub fn reembed_ids(&self, ids: &[MemoryId]) -> MenteResult<usize> {
3109        let target = self.embedding_dim;
3110        if target == 0 {
3111            return Ok(0);
3112        }
3113        // Resolve pages up front, then drop the page-map lock before the (slow)
3114        // load / embed / update loop, matching reembed_all's old structure.
3115        let pids: Vec<PageId> = {
3116            let pm = self.page_map.read();
3117            ids.iter().filter_map(|id| pm.get(id).copied()).collect()
3118        };
3119
3120        let mut reembedded = 0usize;
3121        for pid in &pids {
3122            let Ok(mut node) = self.storage.load_memory(*pid) else {
3123                continue;
3124            };
3125            // Already at the target dimension: nothing to do.
3126            let text = node.indexed_text();
3127            if node.embedding.len() == target || text.is_empty() {
3128                continue;
3129            }
3130            let Some(embedding) = self.embed_text(&text)? else {
3131                continue;
3132            };
3133            // The embedder is not yet producing the target dimension; leave the
3134            // memory as-is rather than store a second wrong-dimension vector.
3135            if embedding.len() != target {
3136                continue;
3137            }
3138            self.index.remove_vector_only(node.id);
3139            node.embedding = embedding;
3140            self.update_node(*pid, &node)?;
3141            self.index.index_memory(&node);
3142            reembedded += 1;
3143        }
3144        Ok(reembedded)
3145    }
3146
3147    // -----------------------------------------------------------------------
3148    // Cognitive Engine: Consolidation
3149    // -----------------------------------------------------------------------
3150
3151    /// Find groups of similar memories that are candidates for consolidation.
3152    ///
3153    /// Returns clusters of memories that share high semantic similarity and
3154    /// could be merged into unified knowledge.
3155    pub fn find_consolidation_candidates(
3156        &self,
3157        min_cluster_size: usize,
3158        similarity_threshold: f32,
3159    ) -> MenteResult<Vec<ConsolidationCandidate>> {
3160        let now = std::time::SystemTime::now()
3161            .duration_since(std::time::UNIX_EPOCH)
3162            .unwrap_or_default()
3163            .as_micros() as u64;
3164
3165        // Load all memories eligible for consolidation.
3166        let pm = self.page_map.read();
3167        let eligible: Vec<MemoryNode> = pm
3168            .values()
3169            .filter_map(|pid| self.storage.load_memory(*pid).ok())
3170            .filter(|node| ConsolidationEngine::should_consolidate(node, now))
3171            .collect();
3172        drop(pm);
3173
3174        if eligible.is_empty() {
3175            return Ok(vec![]);
3176        }
3177
3178        Ok(self
3179            .consolidation
3180            .find_candidates(&eligible, min_cluster_size, similarity_threshold))
3181    }
3182
3183    /// Consolidate a cluster of memories into a single merged memory.
3184    ///
3185    /// The source memories are invalidated (not deleted) and a new consolidated
3186    /// semantic memory is stored with Derived edges back to the sources.
3187    pub fn consolidate_cluster(&self, memory_ids: &[MemoryId]) -> MenteResult<MemoryId> {
3188        let pm = self.page_map.read();
3189        let cluster: Vec<MemoryNode> = memory_ids
3190            .iter()
3191            .filter_map(|id| {
3192                pm.get(id)
3193                    .and_then(|&pid| self.storage.load_memory(pid).ok())
3194            })
3195            .collect();
3196        drop(pm);
3197
3198        if cluster.len() < 2 {
3199            return Err(MenteError::Query(
3200                "consolidation requires at least 2 memories".into(),
3201            ));
3202        }
3203
3204        // Never merge across owners. find_candidates already groups by both
3205        // owner axes, but if a caller hands us a hand-built mixed cluster,
3206        // refuse rather than leak one owner's content into another's
3207        // consolidated memory. Both axes are checked independently.
3208        let owner_agent = cluster[0].agent_id;
3209        if cluster.iter().any(|m| m.agent_id != owner_agent) {
3210            return Err(MenteError::Query(
3211                "consolidation cluster mixes agents; refusing to merge across owners".into(),
3212            ));
3213        }
3214        let owner_user = cluster[0].user_id;
3215        if cluster.iter().any(|m| m.user_id != owner_user) {
3216            return Err(MenteError::Query(
3217                "consolidation cluster mixes users; refusing to merge across owners".into(),
3218            ));
3219        }
3220
3221        let result = self.consolidation.consolidate(&cluster);
3222
3223        // Create the consolidated memory node, stamped with BOTH owner axes of
3224        // the cluster so the derived memory stays scoped exactly as its sources.
3225        let agent_id = cluster[0].agent_id;
3226        let user_id = cluster[0].user_id;
3227        let mut consolidated = MemoryNode::new(
3228            agent_id,
3229            result.new_type,
3230            result.summary,
3231            result.combined_embedding,
3232        )
3233        .with_user_id(user_id);
3234        consolidated.confidence = result.combined_confidence;
3235
3236        let consolidated_id = consolidated.id;
3237        self.store(consolidated)?;
3238
3239        // Invalidate source memories and create Derived edges.
3240        let now = std::time::SystemTime::now()
3241            .duration_since(std::time::UNIX_EPOCH)
3242            .unwrap_or_default()
3243            .as_micros() as u64;
3244        for source_id in &result.source_memories {
3245            let _ = self.invalidate_memory(*source_id, now);
3246            let edge = MemoryEdge {
3247                source: consolidated_id,
3248                target: *source_id,
3249                edge_type: EdgeType::Derived,
3250                weight: 1.0,
3251                created_at: now,
3252                valid_from: None,
3253                valid_until: None,
3254                label: None,
3255            };
3256            let _ = self.graph.add_relationship(&edge);
3257        }
3258
3259        info!(
3260            "Consolidated {} memories into {}",
3261            result.source_memories.len(),
3262            consolidated_id
3263        );
3264        Ok(consolidated_id)
3265    }
3266
3267    /// Flushes all data and closes the database.
3268    pub fn close(&self) -> MenteResult<()> {
3269        info!("Closing MenteDB");
3270        self.flush_full()?;
3271        self.storage.close()?;
3272        Ok(())
3273    }
3274
3275    /// Close with durability only: checkpoint the WAL and release the
3276    /// process lock without rewriting snapshots. Reopen reconciles stale
3277    /// snapshots, so this trades a slower next open for a shutdown fast
3278    /// enough to release every user's lock inside a deploy drain window.
3279    pub fn close_quick(&self) -> MenteResult<()> {
3280        info!("Quick closing MenteDB");
3281        self.storage.checkpoint()?;
3282        self.storage.close()?;
3283        Ok(())
3284    }
3285
3286    /// Simulate a process crash for tests: releases the storage process lock
3287    /// exactly as the operating system would when a process dies, then drops
3288    /// the instance without flushing or closing anything.
3289    #[doc(hidden)]
3290    pub fn simulate_crash(self) {
3291        self.storage.release_process_lock();
3292        std::mem::forget(self);
3293    }
3294
3295    /// Rebuild all indexes by scanning every memory in storage.
3296    ///
3297    /// Use this after index corruption or when index files were overwritten.
3298    /// Returns the number of memories re-indexed.
3299    pub fn rebuild_indexes(&self) -> MenteResult<usize> {
3300        info!("Rebuilding indexes from storage...");
3301        let ids: Vec<MemoryId> = self.page_map.read().keys().copied().collect();
3302        let total = ids.len();
3303        let mut indexed = 0usize;
3304        for id in ids {
3305            if let Ok(node) = self.get_memory(id) {
3306                self.index.index_memory(&node);
3307                indexed += 1;
3308            }
3309        }
3310        self.index.save(&self.path.join("indexes"))?;
3311        info!(indexed, total, "index rebuild complete");
3312        Ok(indexed)
3313    }
3314
3315    /// Flush indexes, graph, and storage to disk without closing.
3316    ///
3317    /// Call this periodically to ensure cross-session persistence.
3318    /// Unlike `close()`, the database remains usable after flushing.
3319    pub fn flush(&self) -> MenteResult<()> {
3320        debug!("Flushing MenteDB to disk");
3321        // Durability is the WAL checkpoint, paid on every flush. Index,
3322        // graph, and cognitive snapshots only accelerate reopen (open
3323        // reconciles stale ones against storage), so they are amortized
3324        // across flush_snapshot_interval hot flushes instead of multiplying
3325        // fsync cost on every write batch.
3326        self.storage.checkpoint()?;
3327
3328        let n = self
3329            .flushes_since_snapshot
3330            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
3331            + 1;
3332        if n >= self.cognitive_config.flush_snapshot_interval {
3333            self.write_snapshots()?;
3334        }
3335        Ok(())
3336    }
3337
3338    /// Flush everything including index, graph, and cognitive snapshots.
3339    /// Used by close and by maintenance, where reopen speed matters more
3340    /// than write latency.
3341    pub fn flush_full(&self) -> MenteResult<()> {
3342        debug!("Full flush of MenteDB to disk");
3343        self.storage.checkpoint()?;
3344        self.write_snapshots()
3345    }
3346
3347    fn write_snapshots(&self) -> MenteResult<()> {
3348        self.index.save(&self.path.join("indexes"))?;
3349        self.graph.save(&self.path.join("graph"))?;
3350
3351        // Persist the dashboard scope counts so a cold reopen loads them in O(1)
3352        // instead of recounting every memory. Only once they have been grounded;
3353        // an ungrounded snapshot would just be zeros.
3354        if self
3355            .scope_counts_ready
3356            .load(std::sync::atomic::Ordering::Relaxed)
3357        {
3358            // Stamp the memory count so a reopen can tell whether writes landed
3359            // after this snapshot (WAL replay) and the counts are stale.
3360            let stamped = (self.memory_count(), &*self.scope_counts.read());
3361            if let Ok(bytes) = serde_json::to_vec(&stamped) {
3362                let _ = std::fs::write(self.path.join("scope_counts.json"), bytes);
3363            }
3364        }
3365
3366        // Persist cognitive subsystem state.
3367        let cognitive_dir = self.path.join("cognitive");
3368        if std::fs::create_dir_all(&cognitive_dir).is_ok() {
3369            let _ = self
3370                .trajectory
3371                .read()
3372                .transitions
3373                .save(&cognitive_dir.join("transitions.json"), 1);
3374            let _ = self
3375                .speculative
3376                .read()
3377                .save(&cognitive_dir.join("speculative.json"), 0);
3378            let _ = self
3379                .entity_resolver
3380                .read()
3381                .save(&cognitive_dir.join("entities.json"));
3382        }
3383        self.flushes_since_snapshot
3384            .store(0, std::sync::atomic::Ordering::Relaxed);
3385        Ok(())
3386    }
3387
3388    /// Executes a query plan against the indexes and graph, returning scored memories.
3389    fn execute_plan(&self, plan: &QueryPlan) -> MenteResult<Vec<ScoredMemory>> {
3390        match plan {
3391            QueryPlan::VectorSearch {
3392                query,
3393                k,
3394                filters,
3395                condition,
3396                order_by,
3397            } => {
3398                // `content ~> "text"` arrives with an empty vector and the
3399                // SimilarTo filter left in place for us to embed now (no embedder
3400                // at plan time). NEAR arrives with the vector already filled in.
3401                let embedded;
3402                let qvec: &[f32] = if query.is_empty() {
3403                    match filters
3404                        .iter()
3405                        .find(|f| f.op == Operator::SimilarTo)
3406                        .map(|f| &f.value)
3407                    {
3408                        Some(Value::Text(text)) => {
3409                            match self.embedder.as_ref().and_then(|e| e.embed(text).ok()) {
3410                                Some(v) => {
3411                                    embedded = v;
3412                                    &embedded
3413                                }
3414                                // No embedder configured: a semantic query can't run.
3415                                None => return Ok(vec![]),
3416                            }
3417                        }
3418                        _ => query,
3419                    }
3420                } else {
3421                    query
3422                };
3423                let hits = self.index.hybrid_search(qvec, None, None, *k);
3424                let mut scored = self.load_scored_memories(&hits)?;
3425                // The SimilarTo filter is the query itself; apply any others. A
3426                // boolean tree (NEAR combined with OR/NOT) takes over the whole
3427                // post-filter when present.
3428                match condition {
3429                    Some(cond) => {
3430                        scored.retain(|sm| Self::condition_matches(cond, &sm.memory));
3431                    }
3432                    None => {
3433                        scored.retain(|sm| {
3434                            filters
3435                                .iter()
3436                                .filter(|f| f.op != Operator::SimilarTo)
3437                                .all(|f| Self::filter_matches(f, &sm.memory))
3438                        });
3439                    }
3440                }
3441                // Optional entity leg: boost candidates linked to an entity the
3442                // query names. The SimilarTo filter carries the query text.
3443                if self.cognitive_config.entity_boost_enabled
3444                    && let Some(qtext) = filters
3445                        .iter()
3446                        .find(|f| f.op == Operator::SimilarTo)
3447                        .and_then(|f| match &f.value {
3448                            Value::Text(t) => Some(t.clone()),
3449                            _ => None,
3450                        })
3451                {
3452                    self.apply_entity_boost(&mut scored, &qtext);
3453                }
3454                if let Some(ob) = order_by {
3455                    Self::apply_order_by(&mut scored, ob);
3456                }
3457                Ok(scored)
3458            }
3459            QueryPlan::TagScan {
3460                tags,
3461                filters,
3462                limit,
3463                condition,
3464                order_by,
3465            } => {
3466                let k = limit.unwrap_or(20);
3467                let mut scored = if tags.is_empty() {
3468                    // No tag constraint: a bare RECALL, or a metadata-only filter
3469                    // like `WHERE type = semantic`. Full-scan and let the filters
3470                    // below narrow it, instead of returning nothing.
3471                    let pm = self.page_map.read();
3472                    pm.values()
3473                        .filter_map(|&pid| self.storage.load_memory(pid).ok())
3474                        .map(|memory| ScoredMemory { memory, score: 1.0 })
3475                        .collect::<Vec<_>>()
3476                } else {
3477                    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
3478                    // Zero-vector for tag-only search; salience+bitmap still apply.
3479                    let hits = self.index.hybrid_search(&[], Some(&tag_refs), None, k);
3480                    self.load_scored_memories(&hits)?
3481                };
3482                // Apply the WHERE clause: a boolean tree when present (OR/NOT/
3483                // grouping), otherwise the flat AND of metadata filters.
3484                match condition {
3485                    Some(cond) => {
3486                        scored.retain(|sm| Self::condition_matches(cond, &sm.memory));
3487                    }
3488                    None => {
3489                        scored.retain(|sm| {
3490                            filters.iter().all(|f| Self::filter_matches(f, &sm.memory))
3491                        });
3492                    }
3493                }
3494                // ORDER BY sorts before LIMIT so the top-k reflects the requested
3495                // order, not relevance.
3496                if let Some(ob) = order_by {
3497                    Self::apply_order_by(&mut scored, ob);
3498                }
3499                scored.truncate(k);
3500                Ok(scored)
3501            }
3502            QueryPlan::TemporalScan {
3503                start,
3504                end,
3505                order_by,
3506                ..
3507            } => {
3508                let hits = self
3509                    .index
3510                    .hybrid_search(&[], None, Some((*start, *end)), 100);
3511                let mut scored = self.load_scored_memories(&hits)?;
3512                if let Some(ob) = order_by {
3513                    Self::apply_order_by(&mut scored, ob);
3514                }
3515                Ok(scored)
3516            }
3517            QueryPlan::GraphTraversal { start, depth, .. } => {
3518                let (ids, _edges) = self.graph.get_context_subgraph(*start, *depth);
3519                let pm = self.page_map.read();
3520                let scored: Vec<ScoredMemory> = ids
3521                    .iter()
3522                    .filter_map(|id| {
3523                        pm.get(id).and_then(|&pid| {
3524                            self.storage.load_memory(pid).ok().map(|node| ScoredMemory {
3525                                memory: node,
3526                                score: 1.0,
3527                            })
3528                        })
3529                    })
3530                    .collect();
3531                Ok(scored)
3532            }
3533            QueryPlan::PointLookup { id } => {
3534                let page_id = self
3535                    .page_map
3536                    .read()
3537                    .get(id)
3538                    .copied()
3539                    .ok_or(MenteError::MemoryNotFound(*id))?;
3540                let node = self.storage.load_memory(page_id)?;
3541                Ok(vec![ScoredMemory {
3542                    memory: node,
3543                    score: 1.0,
3544                }])
3545            }
3546            _ => Ok(vec![]),
3547        }
3548    }
3549
3550    /// Memory ids that an entity named in the query links to. Tokenizes the query,
3551    /// looks up each token as an `entity:<name>` node in the tag index, and
3552    /// collects the memories those entity nodes were derived from (their outgoing
3553    /// `Derived` neighbors). Cheap: a bitmap lookup per token plus one graph
3554    /// adjacency read per hit, no LLM. Only non-empty once enrichment has created
3555    /// entity nodes and linked them, so a raw store simply gets no boost.
3556    fn entity_boost_ids(&self, query_text: &str) -> std::collections::HashSet<MemoryId> {
3557        let mut ids = std::collections::HashSet::new();
3558        let graph = self.graph.graph();
3559        let mut seen_tokens = std::collections::HashSet::new();
3560        for token in query_text
3561            .split(|c: char| !c.is_alphanumeric())
3562            .map(|w| w.to_lowercase())
3563            .filter(|w| w.len() >= 2)
3564        {
3565            if !seen_tokens.insert(token.clone()) {
3566                continue;
3567            }
3568            for entity_id in self.index.bitmap.query_tag(&format!("entity:{token}")) {
3569                for (mid, edge) in graph.outgoing(entity_id) {
3570                    if edge.edge_type == EdgeType::Derived {
3571                        ids.insert(mid);
3572                    }
3573                }
3574            }
3575        }
3576        ids
3577    }
3578
3579    /// Add `entity_boost_weight` to any candidate a query-named entity links to,
3580    /// then re-sort. A no-op when nothing in the query resolves to an entity.
3581    fn apply_entity_boost(&self, scored: &mut [ScoredMemory], query_text: &str) {
3582        let ids = self.entity_boost_ids(query_text);
3583        if ids.is_empty() {
3584            return;
3585        }
3586        let w = self.cognitive_config.entity_boost_weight;
3587        for sm in scored.iter_mut() {
3588            if ids.contains(&sm.memory.id) {
3589                sm.score += w;
3590            }
3591        }
3592        scored.sort_by(|a, b| {
3593            b.score
3594                .partial_cmp(&a.score)
3595                .unwrap_or(std::cmp::Ordering::Equal)
3596        });
3597    }
3598
3599    /// Sort recall results by an `ORDER BY` clause, applied before `LIMIT`. Only
3600    /// the numeric and temporal fields (salience, confidence, created, accessed)
3601    /// carry a meaningful order; ordering by any other field is a no-op that keeps
3602    /// the existing (relevance) order.
3603    fn apply_order_by(scored: &mut [ScoredMemory], ob: &OrderBy) {
3604        fn key(n: &MemoryNode, field: Field) -> f64 {
3605            match field {
3606                Field::Salience => n.salience as f64,
3607                Field::Confidence => n.confidence as f64,
3608                Field::Created => n.created_at as f64,
3609                Field::Accessed => n.access_count as f64,
3610                _ => 0.0,
3611            }
3612        }
3613        scored.sort_by(|a, b| {
3614            let ord = key(&a.memory, ob.field)
3615                .partial_cmp(&key(&b.memory, ob.field))
3616                .unwrap_or(std::cmp::Ordering::Equal);
3617            if ob.descending { ord.reverse() } else { ord }
3618        });
3619    }
3620
3621    /// Whether a memory satisfies a boolean WHERE tree (OR/NOT/grouping). Leaves
3622    /// defer to `filter_matches`, so a leaf that `filter_matches` treats as
3623    /// permissive stays permissive; `And`/`Or`/`Not` compose those results.
3624    fn condition_matches(c: &Condition, node: &MemoryNode) -> bool {
3625        match c {
3626            Condition::Leaf(f) => Self::filter_matches(f, node),
3627            Condition::And(children) => children.iter().all(|ch| Self::condition_matches(ch, node)),
3628            Condition::Or(children) => children.iter().any(|ch| Self::condition_matches(ch, node)),
3629            Condition::Not(inner) => !Self::condition_matches(inner, node),
3630        }
3631    }
3632
3633    /// Whether a memory satisfies a single MQL filter. Applies the metadata
3634    /// filters the planner attaches to a plan (type, tag, content, time), so
3635    /// `WHERE type = semantic` and similar clauses actually narrow results
3636    /// instead of being ignored. Unhandled field/value combinations are
3637    /// permissive (they do not exclude), so a supported query is never dropped
3638    /// by a clause this does not understand.
3639    fn filter_matches(f: &Filter, node: &MemoryNode) -> bool {
3640        // IN: the field matches any element of the list (reusing per-field
3641        // equality, so `type IN [...]`, `tag IN [...]`, `content IN [...]` all work).
3642        if let Value::List(items) = &f.value {
3643            return items.iter().any(|v| {
3644                Self::filter_matches(
3645                    &Filter {
3646                        field: f.field,
3647                        op: Operator::Eq,
3648                        value: v.clone(),
3649                    },
3650                    node,
3651                )
3652            });
3653        }
3654        match (&f.field, &f.value) {
3655            (Field::Type, Value::MemoryType(t)) => {
3656                if f.op == Operator::Neq {
3657                    node.memory_type != *t
3658                } else {
3659                    node.memory_type == *t
3660                }
3661            }
3662            (Field::Tag, Value::Text(tag)) => {
3663                let has = node.tags.iter().any(|x| x == tag);
3664                if f.op == Operator::Neq { !has } else { has }
3665            }
3666            (Field::Content, Value::Text(s)) => {
3667                let hit = node.content.to_lowercase().contains(&s.to_lowercase());
3668                if f.op == Operator::Neq { !hit } else { hit }
3669            }
3670            (Field::Created, v) => Self::num_cmp(node.created_at as f64, f.op, Self::value_f64(v)),
3671            (Field::Accessed, v) => {
3672                Self::num_cmp(node.accessed_at as f64, f.op, Self::value_f64(v))
3673            }
3674            // AS OF <t>: keep only memories whose validity window contains t.
3675            (Field::ValidAt, v) => Self::value_f64(v)
3676                .map(|t| node.is_valid_at(t as u64))
3677                .unwrap_or(true),
3678            _ => true,
3679        }
3680    }
3681
3682    fn value_f64(v: &Value) -> Option<f64> {
3683        match v {
3684            Value::Number(n) => Some(*n),
3685            Value::Integer(i) => Some(*i as f64),
3686            _ => None,
3687        }
3688    }
3689
3690    fn num_cmp(a: f64, op: Operator, b: Option<f64>) -> bool {
3691        let Some(b) = b else { return true };
3692        match op {
3693            Operator::Gt => a > b,
3694            Operator::Gte => a >= b,
3695            Operator::Lt => a < b,
3696            Operator::Lte => a <= b,
3697            Operator::Eq => (a - b).abs() < f64::EPSILON,
3698            Operator::Neq => (a - b).abs() >= f64::EPSILON,
3699            Operator::SimilarTo => true,
3700            // Set/substring operators do not apply to a numeric comparison; the
3701            // `IN` list case is handled before num_cmp is ever reached.
3702            Operator::In | Operator::Contains => false,
3703        }
3704    }
3705
3706    /// Loads MemoryNodes from storage and pairs them with their search scores.
3707    ///
3708    /// When decay is enabled, salience is recomputed and factored into the
3709    /// final score to prioritize temporally relevant memories.
3710    fn load_scored_memories(&self, hits: &[(MemoryId, f32)]) -> MenteResult<Vec<ScoredMemory>> {
3711        let pm = self.page_map.read();
3712        let now = if self.cognitive_config.decay_on_recall {
3713            std::time::SystemTime::now()
3714                .duration_since(std::time::UNIX_EPOCH)
3715                .unwrap_or_default()
3716                .as_micros() as u64
3717        } else {
3718            0
3719        };
3720
3721        let mut scored = Vec::with_capacity(hits.len());
3722        for &(id, score) in hits {
3723            if let Some(&page_id) = pm.get(&id)
3724                && let Ok(node) = self.storage.load_memory(page_id)
3725            {
3726                let final_score = if self.cognitive_config.decay_on_recall {
3727                    let decayed_salience = self.decayed_salience_at(&node, now);
3728                    // Blend search similarity with decayed salience.
3729                    // 70% similarity, 30% salience — keeps search relevance
3730                    // primary but rewards recently active memories.
3731                    score * 0.7 + decayed_salience * 0.3
3732                } else {
3733                    score
3734                };
3735                scored.push(ScoredMemory {
3736                    memory: node,
3737                    score: final_score,
3738                });
3739            }
3740        }
3741        // Re-sort by blended score when decay is applied.
3742        if self.cognitive_config.decay_on_recall {
3743            scored.sort_unstable_by(|a, b| {
3744                b.score
3745                    .partial_cmp(&a.score)
3746                    .unwrap_or(std::cmp::Ordering::Equal)
3747            });
3748        }
3749        Ok(scored)
3750    }
3751
3752    // -----------------------------------------------------------------------
3753    // Cognitive Engine: Pain Registry
3754    // -----------------------------------------------------------------------
3755
3756    /// Record a pain signal — a recurring failure or frustration pattern.
3757    ///
3758    /// Pain signals are tracked by keywords and surfaced as warnings when
3759    /// similar contexts arise in future queries.
3760    pub fn record_pain(&self, signal: PainSignal) {
3761        if self.cognitive_config.pain_tracking {
3762            self.pain.write().record_pain(signal);
3763        }
3764    }
3765
3766    /// Get pain warnings relevant to the given context keywords.
3767    ///
3768    /// Returns formatted warning text if any pain signals match the keywords.
3769    /// Use this before answering to warn about past failures.
3770    pub fn get_pain_warnings(&self, context_keywords: &[String]) -> Vec<PainSignal> {
3771        if !self.cognitive_config.pain_tracking {
3772            return vec![];
3773        }
3774        let registry = self.pain.read();
3775        registry
3776            .get_pain_for_context(context_keywords)
3777            .into_iter()
3778            .cloned()
3779            .collect()
3780    }
3781
3782    /// Format pain warnings as a human-readable string.
3783    pub fn format_pain_warnings(&self, signals: &[&PainSignal]) -> String {
3784        self.pain.read().format_pain_warnings(signals)
3785    }
3786
3787    /// Decay all pain signals to reduce intensity over time.
3788    pub fn decay_pain(&self) {
3789        let now = std::time::SystemTime::now()
3790            .duration_since(std::time::UNIX_EPOCH)
3791            .unwrap_or_default()
3792            .as_micros() as u64;
3793        self.pain.write().decay_all(now);
3794    }
3795
3796    /// Get all recorded pain signals.
3797    pub fn all_pain_signals(&self) -> Vec<PainSignal> {
3798        self.pain.read().all_signals().to_vec()
3799    }
3800
3801    // -----------------------------------------------------------------------
3802    // Cognitive Engine: Trajectory Tracking
3803    // -----------------------------------------------------------------------
3804
3805    /// Record a conversation turn in the trajectory tracker.
3806    ///
3807    /// Tracks the evolution of topics, decisions, and open questions across
3808    /// a conversation. Used for resume context and topic prediction.
3809    pub fn record_trajectory_turn(&self, turn: TrajectoryNode) {
3810        self.trajectory.write().record_turn(turn);
3811    }
3812
3813    /// Canonicalize recent trajectory topics via the LLM, populating the learned
3814    /// topic cache so future turns collapse phrasings to one canonical label.
3815    /// That label then flows into topic prediction, resume context, and the
3816    /// speculative pre-assembly cache, which is why raw message text stops
3817    /// showing up as a "topic".
3818    ///
3819    /// Runs OFF the per-turn hot path: call it from an async post-turn step or
3820    /// the maintenance sweep. No lock is held across the LLM calls. The engine
3821    /// stays LLM-optional: pass any judge, or do not call this and topics simply
3822    /// stay at their normalized-raw form. Returns the number of new canonical
3823    /// labels learned this pass.
3824    pub async fn canonicalize_trajectory_topics<J: mentedb_cognitive::llm::LlmJudge>(
3825        &self,
3826        judge: J,
3827        limit: usize,
3828    ) -> usize {
3829        // 1. Collect recent uncached topics + the known label set (read lock).
3830        let (pending, mut known) = {
3831            let traj = self.trajectory.read();
3832            (traj.pending_canonicalization(limit), traj.known_topics())
3833        };
3834        if pending.is_empty() {
3835            return 0;
3836        }
3837        // 2. Ask the LLM for a canonical label per topic (no lock held).
3838        let svc = mentedb_cognitive::llm::CognitiveLlmService::new(judge);
3839        let mut learned = Vec::new();
3840        for raw in pending {
3841            if let Ok(label) = svc.canonicalize_topic(&raw, &known).await {
3842                known.push(label.topic.clone());
3843                learned.push((raw, label.topic));
3844            }
3845        }
3846        // 3. Store the learned mappings (write lock).
3847        let n = learned.len();
3848        if n > 0 {
3849            let mut traj = self.trajectory.write();
3850            for (raw, canonical) in learned {
3851                traj.learn_canonical(&raw, &canonical);
3852            }
3853        }
3854        n
3855    }
3856
3857    /// Get a resume context string summarizing the conversation so far.
3858    ///
3859    /// Returns None if no trajectory has been recorded.
3860    pub fn get_resume_context(&self) -> Option<String> {
3861        self.trajectory.read().get_resume_context()
3862    }
3863
3864    /// Predict the next likely topics based on conversation trajectory.
3865    ///
3866    /// Returns up to 3 predicted topic strings based on transition patterns.
3867    pub fn predict_next_topics(&self) -> Vec<String> {
3868        self.trajectory.read().predict_next_topics()
3869    }
3870
3871    /// Get the full trajectory of recorded turns.
3872    pub fn get_trajectory(&self) -> Vec<TrajectoryNode> {
3873        self.trajectory.read().get_trajectory().to_vec()
3874    }
3875
3876    /// Reinforce a transition that led to a speculative cache hit.
3877    pub fn reinforce_transition(&self, hit_topic: &str) {
3878        self.trajectory.write().reinforce_transition(hit_topic);
3879    }
3880
3881    // -----------------------------------------------------------------------
3882    // Cognitive Engine: Cognition Stream
3883    // -----------------------------------------------------------------------
3884
3885    /// Feed a token to the cognition stream for real-time monitoring.
3886    ///
3887    /// Tokens are buffered and analyzed for contradictions with known facts
3888    /// when `check_stream_alerts()` is called.
3889    pub fn feed_stream_token(&self, token: &str) {
3890        self.stream.feed_token(token);
3891    }
3892
3893    /// Check for stream alerts against known facts.
3894    ///
3895    /// Compares the buffered token stream against the provided known facts
3896    /// to detect contradictions, corrections, and reinforcements.
3897    pub fn check_stream_alerts(&self, known_facts: &[(MemoryId, String)]) -> Vec<StreamAlert> {
3898        self.stream.check_alerts(known_facts)
3899    }
3900
3901    /// Drain the token buffer, returning accumulated text.
3902    pub fn drain_stream_buffer(&self) -> String {
3903        self.stream.drain_buffer()
3904    }
3905
3906    // -----------------------------------------------------------------------
3907    // Cognitive Engine: Phantom Tracking
3908    // -----------------------------------------------------------------------
3909
3910    /// Detect phantom memories — entities referenced in content but not stored.
3911    ///
3912    /// Scans content for entity mentions that don't exist in the known entities
3913    /// list, flagging them as knowledge gaps that should be filled.
3914    pub fn detect_phantoms(
3915        &self,
3916        content: &str,
3917        known_entities: &[String],
3918        turn_id: u64,
3919    ) -> Vec<PhantomMemory> {
3920        if !self.cognitive_config.phantom_tracking {
3921            return vec![];
3922        }
3923        self.phantom
3924            .write()
3925            .detect_gaps(content, known_entities, turn_id)
3926    }
3927
3928    /// Resolve a phantom memory (mark it as no longer a gap).
3929    pub fn resolve_phantom(&self, phantom_id: MemoryId) {
3930        self.phantom.write().resolve(phantom_id.into());
3931    }
3932
3933    /// Get all active (unresolved) phantom memories, sorted by priority.
3934    pub fn get_active_phantoms(&self) -> Vec<PhantomMemory> {
3935        self.phantom
3936            .read()
3937            .get_active_phantoms()
3938            .into_iter()
3939            .cloned()
3940            .collect()
3941    }
3942
3943    /// Format phantom warnings as a human-readable string.
3944    pub fn format_phantom_warnings(&self) -> String {
3945        self.phantom.read().format_phantom_warnings()
3946    }
3947
3948    /// Register an entity so the phantom tracker knows it exists.
3949    pub fn register_entity(&self, entity: &str) {
3950        self.phantom.write().register_entity(entity);
3951    }
3952
3953    /// Register multiple entities at once.
3954    pub fn register_entities(&self, entities: &[&str]) {
3955        self.phantom.write().register_entities(entities);
3956    }
3957
3958    // -----------------------------------------------------------------------
3959    // Cognitive Engine: Speculative Cache
3960    // -----------------------------------------------------------------------
3961
3962    /// Try to hit the speculative cache for a query.
3963    ///
3964    /// If a previous prediction matches the current query (by keyword overlap
3965    /// or embedding similarity), returns the pre-assembled context.
3966    pub fn try_speculative_hit(
3967        &self,
3968        query: &str,
3969        query_embedding: Option<&[f32]>,
3970    ) -> Option<CacheEntry> {
3971        if !self.cognitive_config.speculative_cache {
3972            return None;
3973        }
3974        self.speculative.write().try_hit(query, query_embedding)
3975    }
3976
3977    /// Pre-assemble speculative cache entries for predicted topics.
3978    ///
3979    /// The builder function should return `(context_text, memory_ids, optional_embedding)`
3980    /// for each topic prediction.
3981    pub fn pre_assemble_speculative<F>(&self, predictions: Vec<String>, builder: F)
3982    where
3983        F: Fn(&str) -> Option<(String, Vec<MemoryId>, Option<Vec<f32>>)>,
3984    {
3985        if !self.cognitive_config.speculative_cache {
3986            return;
3987        }
3988        // The builder embeds, recalls and loads memories, which can take
3989        // seconds or hang on the network. Build with no lock held so recall's
3990        // cache check and flush never wait on it; lock only to insert.
3991        let pending: Vec<String> = {
3992            let cache = self.speculative.read();
3993            predictions
3994                .into_iter()
3995                .filter(|t| !cache.contains_topic(t))
3996                .collect()
3997        };
3998        let built: Vec<_> = pending
3999            .into_iter()
4000            .filter_map(|topic| builder(&topic).map(|b| (topic, b)))
4001            .collect();
4002        if built.is_empty() {
4003            return;
4004        }
4005        let now = std::time::SystemTime::now()
4006            .duration_since(std::time::UNIX_EPOCH)
4007            .unwrap_or_default()
4008            .as_micros() as u64;
4009        let mut cache = self.speculative.write();
4010        for (topic, b) in built {
4011            cache.insert_built(topic, b, now);
4012        }
4013    }
4014
4015    /// Evict stale entries from the speculative cache.
4016    pub fn evict_stale_speculative(&self, max_age_us: u64) {
4017        let now = std::time::SystemTime::now()
4018            .duration_since(std::time::UNIX_EPOCH)
4019            .unwrap_or_default()
4020            .as_micros() as u64;
4021        self.speculative.write().evict_stale(max_age_us, now);
4022    }
4023
4024    /// Get speculative cache statistics.
4025    pub fn speculative_cache_stats(&self) -> CacheStats {
4026        self.speculative.read().stats()
4027    }
4028
4029    /// Get the current speculative cache entries (predicted topic, pre-assembled
4030    /// context, source memory ids, hit count, and age), for introspection and
4031    /// dashboards. This is the live pre-assembly cache, not aggregate stats.
4032    pub fn speculative_cache_entries(&self) -> Vec<CacheEntry> {
4033        self.speculative.read().entries().to_vec()
4034    }
4035
4036    // -----------------------------------------------------------------------
4037    // Cognitive Engine: Interference Detection
4038    // -----------------------------------------------------------------------
4039
4040    /// Detect interference between a set of memories.
4041    ///
4042    /// Returns pairs of memories that are similar enough to cause confusion,
4043    /// along with disambiguation hints. Use this during context assembly to
4044    /// add disambiguation notes or separate confusable memories.
4045    pub fn detect_interference(&self, memories: &[MemoryNode]) -> Vec<InterferencePair> {
4046        if !self.cognitive_config.interference_detection {
4047            return vec![];
4048        }
4049        self.interference.detect_interference(memories)
4050    }
4051
4052    /// Generate a disambiguation hint for two confusable memories.
4053    pub fn generate_disambiguation(&self, a: &MemoryNode, b: &MemoryNode) -> String {
4054        self.interference.generate_disambiguation(a, b)
4055    }
4056
4057    /// Arrange memory IDs to maximize separation between interfering pairs.
4058    pub fn arrange_with_separation(
4059        memories: Vec<MemoryId>,
4060        pairs: &[InterferencePair],
4061    ) -> Vec<MemoryId> {
4062        InterferenceDetector::arrange_with_separation(memories, pairs)
4063    }
4064
4065    // -----------------------------------------------------------------------
4066    // Cognitive Engine: Entity Resolution
4067    // -----------------------------------------------------------------------
4068
4069    /// Resolve an entity name to its canonical form.
4070    ///
4071    /// Uses cached aliases and rule-based matching (no LLM).
4072    pub fn resolve_entity(&self, name: &str) -> mentedb_cognitive::ResolvedEntity {
4073        self.entity_resolver.read().resolve(name)
4074    }
4075
4076    /// Add an alias mapping for entity resolution.
4077    pub fn add_entity_alias(&self, alias: &str, canonical: &str, confidence: f32) {
4078        self.entity_resolver
4079            .write()
4080            .add_alias(alias, canonical, confidence);
4081    }
4082
4083    /// Get the canonical name for an entity, if known.
4084    pub fn get_canonical_entity(&self, name: &str) -> Option<String> {
4085        self.entity_resolver.read().get_canonical(name).cloned()
4086    }
4087
4088    /// List all known entities in the resolver.
4089    pub fn known_entities(&self) -> Vec<String> {
4090        self.entity_resolver.read().known_entities()
4091    }
4092
4093    // -----------------------------------------------------------------------
4094    // Cognitive Engine: Memory Compression
4095    // -----------------------------------------------------------------------
4096
4097    /// Compress a memory's content, extracting key facts and removing filler.
4098    ///
4099    /// Returns a compressed representation with the original ID, compressed text,
4100    /// compression ratio, and extracted key facts.
4101    pub fn compress_memory(&self, memory: &MemoryNode) -> CompressedMemory {
4102        self.compressor.compress(memory)
4103    }
4104
4105    /// Compress a batch of memories.
4106    pub fn compress_memories(&self, memories: &[MemoryNode]) -> Vec<CompressedMemory> {
4107        self.compressor.compress_batch(memories)
4108    }
4109
4110    /// Estimate token count for a text string.
4111    pub fn estimate_tokens(text: &str) -> usize {
4112        MemoryCompressor::estimate_tokens(text)
4113    }
4114
4115    // -----------------------------------------------------------------------
4116    // Cognitive Engine: Archival Evaluation
4117    // -----------------------------------------------------------------------
4118
4119    /// Evaluate whether a memory should be kept, archived, or deleted.
4120    ///
4121    /// Uses age, salience, and access patterns to make lifecycle decisions.
4122    pub fn evaluate_archival(&self, memory: &MemoryNode) -> ArchivalDecision {
4123        if !self.cognitive_config.archival_evaluation || self.is_decay_exempt(memory) {
4124            return ArchivalDecision::Keep;
4125        }
4126        let now = std::time::SystemTime::now()
4127            .duration_since(std::time::UNIX_EPOCH)
4128            .unwrap_or_default()
4129            .as_micros() as u64;
4130        // Judge on the salience decayed to `now`, not the stored base. Stored
4131        // salience only changes on reinforcement, so a never-reinforced memory
4132        // keeps its base value and archival must derive the current strength.
4133        let effective = self.decayed_salience_at(memory, now);
4134        self.archival.evaluate_effective(
4135            effective,
4136            memory.memory_type,
4137            memory.created_at,
4138            memory.access_count,
4139            now,
4140        )
4141    }
4142
4143    /// Evaluate archival decisions for a batch of memories.
4144    pub fn evaluate_archival_batch(
4145        &self,
4146        memories: &[MemoryNode],
4147    ) -> Vec<(MemoryId, ArchivalDecision)> {
4148        if !self.cognitive_config.archival_evaluation {
4149            return memories
4150                .iter()
4151                .map(|m| (m.id, ArchivalDecision::Keep))
4152                .collect();
4153        }
4154        let now = std::time::SystemTime::now()
4155            .duration_since(std::time::UNIX_EPOCH)
4156            .unwrap_or_default()
4157            .as_micros() as u64;
4158        memories
4159            .iter()
4160            .map(|m| {
4161                if self.is_decay_exempt(m) {
4162                    return (m.id, ArchivalDecision::Keep);
4163                }
4164                let effective = self.decayed_salience_at(m, now);
4165                (
4166                    m.id,
4167                    self.archival.evaluate_effective(
4168                        effective,
4169                        m.memory_type,
4170                        m.created_at,
4171                        m.access_count,
4172                        now,
4173                    ),
4174                )
4175            })
4176            .collect()
4177    }
4178
4179    /// Run archival evaluation on all memories in the database.
4180    ///
4181    /// Returns decisions for each memory. Does NOT apply them — call
4182    /// `invalidate_memory` or `forget` to act on the decisions.
4183    pub fn evaluate_archival_global(&self) -> MenteResult<Vec<(MemoryId, ArchivalDecision)>> {
4184        let pm = self.page_map.read();
4185        let memories: Vec<MemoryNode> = pm
4186            .values()
4187            .filter_map(|pid| self.storage.load_memory(*pid).ok())
4188            .collect();
4189        drop(pm);
4190        // Delegate so decisions use each memory's salience decayed to now, not
4191        // its stored base (see evaluate_archival_batch).
4192        Ok(self.evaluate_archival_batch(&memories))
4193    }
4194
4195    // -----------------------------------------------------------------------
4196    // Sleeptime Enrichment Pipeline
4197    // -----------------------------------------------------------------------
4198
4199    /// Check whether enrichment is pending (triggered by turn count or manual).
4200    pub fn needs_enrichment(&self) -> bool {
4201        if !self.cognitive_config.enrichment_config.enabled {
4202            return false;
4203        }
4204        *self.enrichment_pending.read()
4205    }
4206
4207    /// Get the turn ID when enrichment last completed.
4208    pub fn last_enrichment_turn(&self) -> u64 {
4209        *self.last_enrichment_turn.read()
4210    }
4211
4212    /// Manually trigger enrichment on the next check.
4213    pub fn request_enrichment(&self) {
4214        *self.enrichment_pending.write() = true;
4215    }
4216
4217    /// Distinct (user_id, agent_id) owner pairs that own at least one memory.
4218    ///
4219    /// Enrichment partitions by BOTH owner axes: it runs once per distinct pair
4220    /// so derived knowledge (extracted facts, entities, communities, profile)
4221    /// stays scoped to exactly the user AND agent that owned the source
4222    /// memories, never mixing across either axis.
4223    fn distinct_owner_pairs(&self) -> Vec<(UserId, AgentId)> {
4224        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4225        let mut pairs: Vec<(UserId, AgentId)> = Vec::new();
4226        for pid in &page_ids {
4227            if let Ok(mem) = self.storage.load_memory(*pid) {
4228                let pair = (mem.user_id, mem.agent_id);
4229                if !pairs.contains(&pair) {
4230                    pairs.push(pair);
4231                }
4232            }
4233        }
4234        pairs
4235    }
4236
4237    /// Get episodic memories that haven't been enriched yet.
4238    ///
4239    /// Returns all Episodic memories created after the last enrichment turn,
4240    /// owned by exactly this (`user`, `agent`) pair (exact match on BOTH owner
4241    /// axes, so one owner's episodics are never enriched into another's
4242    /// knowledge), sorted by creation time. These are the candidates for LLM
4243    /// extraction.
4244    pub fn enrichment_candidates(&self, user: UserId, agent: AgentId) -> Vec<MemoryNode> {
4245        let last_turn = *self.last_enrichment_turn.read();
4246        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4247        let mut candidates: Vec<MemoryNode> = page_ids
4248            .iter()
4249            .filter_map(|pid| self.storage.load_memory(*pid).ok())
4250            .filter(|m| {
4251                m.agent_id == agent
4252                    && m.user_id == user
4253                    && m.memory_type == mentedb_core::memory::MemoryType::Episodic
4254                    && !m.tags.contains(&"source:enrichment".to_string())
4255                    && m.created_at > last_turn
4256            })
4257            .collect();
4258        candidates.sort_by_key(|m| m.created_at);
4259        candidates
4260    }
4261
4262    /// All unenriched episodic candidates across every owner, sorted by creation
4263    /// time. This is an owner-agnostic introspection view (the enrichment
4264    /// pipeline itself scopes per owner via [`Self::enrichment_candidates`]); it
4265    /// exists so SDK callers can list pending work without an agent argument.
4266    pub fn all_enrichment_candidates(&self) -> Vec<MemoryNode> {
4267        let mut candidates: Vec<MemoryNode> = self
4268            .distinct_owner_pairs()
4269            .into_iter()
4270            .flat_map(|(user, agent)| self.enrichment_candidates(user, agent))
4271            .collect();
4272        candidates.sort_by_key(|m| m.created_at);
4273        candidates
4274    }
4275
4276    /// Store enrichment results: extracted memories with provenance tracking.
4277    ///
4278    /// Each stored memory gets:
4279    /// - `source:enrichment` tag for identification
4280    /// - Confidence capped at `max_enrichment_confidence`
4281    /// - `Derived` edges back to source episodic memories
4282    ///
4283    /// Returns (memories_stored, edges_created).
4284    pub fn store_enrichment_memories(
4285        &self,
4286        memories: Vec<MemoryNode>,
4287        source_ids: &[MemoryId],
4288    ) -> MenteResult<(usize, usize)> {
4289        let max_conf = self
4290            .cognitive_config
4291            .enrichment_config
4292            .max_enrichment_confidence;
4293        let mut stored = 0usize;
4294        let mut edges = 0usize;
4295
4296        for mut mem in memories {
4297            // Tag as enrichment-generated
4298            if !mem.tags.contains(&"source:enrichment".to_string()) {
4299                mem.tags.push("source:enrichment".to_string());
4300            }
4301            // Cap confidence
4302            if mem.confidence > max_conf {
4303                mem.confidence = max_conf;
4304            }
4305
4306            let mem_id = mem.id;
4307            self.store(mem)?;
4308            stored += 1;
4309
4310            // Create Derived edges back to source episodics
4311            let now = std::time::SystemTime::now()
4312                .duration_since(std::time::UNIX_EPOCH)
4313                .unwrap_or_default()
4314                .as_micros() as u64;
4315            for src_id in source_ids {
4316                let edge = MemoryEdge {
4317                    source: mem_id,
4318                    target: *src_id,
4319                    edge_type: EdgeType::Derived,
4320                    weight: 0.8,
4321                    created_at: now,
4322                    valid_from: None,
4323                    valid_until: None,
4324                    label: Some("enrichment".to_string()),
4325                };
4326                if self.relate(edge).is_ok() {
4327                    edges += 1;
4328                }
4329            }
4330        }
4331
4332        debug!(stored, edges, "enrichment memories stored");
4333        Ok((stored, edges))
4334    }
4335
4336    /// Mark enrichment as complete for the given turn.
4337    pub fn mark_enrichment_complete(&self, turn_id: u64) {
4338        *self.last_enrichment_turn.write() = turn_id;
4339        *self.enrichment_pending.write() = false;
4340        debug!(turn_id, "enrichment cycle complete");
4341    }
4342
4343    /// Get the enrichment configuration.
4344    pub fn enrichment_config(&self) -> &EnrichmentConfig {
4345        &self.cognitive_config.enrichment_config
4346    }
4347
4348    /// Get all unique entity names from stored entity memories owned by `agent`.
4349    ///
4350    /// Returns deduplicated, normalized entity names extracted from
4351    /// `entity:{name}` tags across this agent's stored memories only, so one
4352    /// user's entities never bleed into another's resolution.
4353    pub fn all_entity_names(&self, user: UserId, agent: AgentId) -> Vec<String> {
4354        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4355        let mut names = std::collections::HashSet::new();
4356        for pid in &page_ids {
4357            if let Ok(mem) = self.storage.load_memory(*pid) {
4358                if mem.agent_id != agent || mem.user_id != user {
4359                    continue;
4360                }
4361                for tag in &mem.tags {
4362                    if let Some(name) = tag.strip_prefix("entity:") {
4363                        names.insert(name.to_lowercase().trim().to_string());
4364                    }
4365                }
4366            }
4367        }
4368        let mut sorted: Vec<String> = names.into_iter().collect();
4369        sorted.sort();
4370        sorted
4371    }
4372
4373    /// Get entity names that the EntityResolver hasn't resolved yet.
4374    ///
4375    /// These are the entities that need LLM resolution. The EntityResolver
4376    /// cache handles known entities for free.
4377    pub fn unresolved_entity_names(&self, user: UserId, agent: AgentId) -> Vec<String> {
4378        let all_names = self.all_entity_names(user, agent);
4379        self.entity_resolver.read().unresolved_names(&all_names)
4380    }
4381
4382    /// Get entity names with their memory content for LLM context.
4383    ///
4384    /// Returns (name, content) pairs for entities that need resolution.
4385    /// The content helps the LLM disambiguate (e.g., "Python" near
4386    /// "web framework" vs "Python" near "Monty Python").
4387    pub fn entity_names_with_context(
4388        &self,
4389        user: UserId,
4390        agent: AgentId,
4391    ) -> Vec<(String, Option<String>)> {
4392        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4393        let mut entity_contexts: HashMap<String, String> = HashMap::new();
4394
4395        for pid in &page_ids {
4396            if let Ok(mem) = self.storage.load_memory(*pid) {
4397                if mem.agent_id != agent || mem.user_id != user {
4398                    continue;
4399                }
4400                for tag in &mem.tags {
4401                    if let Some(name) = tag.strip_prefix("entity:") {
4402                        let normalized = name.to_lowercase().trim().to_string();
4403                        // One entity tag per memory (enrichment creates separate memories per entity)
4404                        entity_contexts
4405                            .entry(normalized)
4406                            .and_modify(|existing| {
4407                                // Append content from multiple mentions, cap at ~500 chars
4408                                if existing.len() < 300 {
4409                                    existing.push_str(" | ");
4410                                    let remaining = 500usize.saturating_sub(existing.len());
4411                                    existing.push_str(
4412                                        mentedb_core::text::truncate_on_char_boundary(
4413                                            &mem.content,
4414                                            remaining,
4415                                        ),
4416                                    );
4417                                }
4418                            })
4419                            .or_insert_with(|| {
4420                                mentedb_core::text::truncate_on_char_boundary(&mem.content, 300)
4421                                    .to_string()
4422                            });
4423                        break;
4424                    }
4425                }
4426            }
4427        }
4428
4429        entity_contexts
4430            .into_iter()
4431            .map(|(name, ctx)| (name, Some(ctx)))
4432            .collect()
4433    }
4434
4435    /// Apply LLM entity resolution results: create graph edges and update cache.
4436    ///
4437    /// Takes merge groups from the LLM (via `CognitiveLlmService.resolve_entities()`)
4438    /// and confirmed-different pairs. Creates `entity_link:` edges between entity
4439    /// memories that belong to the same group, learns aliases in the EntityResolver,
4440    /// and negative-caches confirmed-different pairs.
4441    pub fn apply_entity_link_resolutions(
4442        &self,
4443        merge_groups: &[EntityLinkResolution],
4444        separations: &[EntitySeparation],
4445        user: UserId,
4446        agent: AgentId,
4447    ) -> MenteResult<EntityLinkResult> {
4448        let mut result = EntityLinkResult::default();
4449        let now = std::time::SystemTime::now()
4450            .duration_since(std::time::UNIX_EPOCH)
4451            .unwrap_or_default()
4452            .as_micros() as u64;
4453
4454        // Build a map: normalized entity name → list of memory IDs (this
4455        // (user, agent) pair's entities only, so links never span either owner
4456        // axis).
4457        let entity_memory_map = self.build_entity_memory_map(user, agent);
4458
4459        let mut resolver = self.entity_resolver.write();
4460
4461        for group in merge_groups {
4462            // Learn the group in the resolver cache
4463            let mut aliases: Vec<String> = group.aliases.clone();
4464            aliases.retain(|a| a.to_lowercase() != group.canonical.to_lowercase());
4465            resolver.learn_group(&EntityMergeGroup {
4466                canonical: group.canonical.clone(),
4467                aliases,
4468                confidence: group.confidence,
4469            });
4470
4471            // Collect all memory IDs for this merge group
4472            let mut group_memory_ids: Vec<MemoryId> = Vec::new();
4473
4474            // Add memories for the canonical name
4475            let canonical_norm = group.canonical.to_lowercase();
4476            if let Some(ids) = entity_memory_map.get(&canonical_norm) {
4477                group_memory_ids.extend(ids);
4478            }
4479
4480            // Add memories for each alias
4481            for alias in &group.aliases {
4482                let alias_norm = alias.to_lowercase();
4483                if let Some(ids) = entity_memory_map.get(&alias_norm) {
4484                    group_memory_ids.extend(ids);
4485                }
4486            }
4487
4488            group_memory_ids.sort();
4489            group_memory_ids.dedup();
4490
4491            // Create edges between all pairs in the group
4492            let label = format!("entity_link:{}", canonical_norm);
4493            for i in 0..group_memory_ids.len() {
4494                for j in (i + 1)..group_memory_ids.len() {
4495                    let a_id = group_memory_ids[i];
4496                    let b_id = group_memory_ids[j];
4497
4498                    // Check for existing edge
4499                    let graph = self.graph.read_graph();
4500                    let already_linked = graph.outgoing(a_id).iter().any(|(tid, e)| {
4501                        *tid == b_id
4502                            && e.edge_type == EdgeType::Related
4503                            && e.label
4504                                .as_ref()
4505                                .is_some_and(|l| l.starts_with("entity_link:"))
4506                    });
4507                    drop(graph);
4508
4509                    if already_linked {
4510                        continue;
4511                    }
4512
4513                    let edge = MemoryEdge {
4514                        source: a_id,
4515                        target: b_id,
4516                        edge_type: EdgeType::Related,
4517                        weight: group.confidence,
4518                        created_at: now,
4519                        valid_from: None,
4520                        valid_until: None,
4521                        label: Some(label.clone()),
4522                    };
4523                    if self.relate(edge).is_ok() {
4524                        result.edges_created += 1;
4525                    }
4526                    result.linked += 1;
4527                }
4528            }
4529
4530            debug!(
4531                canonical = group.canonical,
4532                aliases = ?group.aliases,
4533                memories = group_memory_ids.len(),
4534                "entity resolution: merged group"
4535            );
4536        }
4537
4538        // Process negative cache entries
4539        for sep in separations {
4540            resolver.mark_different(&sep.name_a, &sep.name_b);
4541            debug!(
4542                a = sep.name_a,
4543                b = sep.name_b,
4544                "entity resolution: confirmed different"
4545            );
4546        }
4547
4548        // Persist resolver state
4549        let cognitive_dir = self.path.join("cognitive");
4550        if cognitive_dir.exists() || std::fs::create_dir_all(&cognitive_dir).is_ok() {
4551            let _ = resolver.save(&cognitive_dir.join("entities.json"));
4552        }
4553
4554        debug!(
4555            linked = result.linked,
4556            edges = result.edges_created,
4557            groups = merge_groups.len(),
4558            separations = separations.len(),
4559            "entity link resolutions applied"
4560        );
4561        Ok(result)
4562    }
4563
4564    /// Link entities using only the sync EntityResolver (cache + rules, no LLM).
4565    ///
4566    /// This is the fast path — links entities that are already known to be
4567    /// the same from previous LLM resolutions. Runs once per owner so entity
4568    /// links never span users; each owner's entities are linked in isolation
4569    /// (nil owned / global entities are linked among themselves).
4570    pub fn link_entities(&self) -> MenteResult<EntityLinkResult> {
4571        let mut total = EntityLinkResult::default();
4572        for (user, agent) in self.distinct_owner_pairs() {
4573            let r = self.link_entities_for(user, agent)?;
4574            total.linked += r.linked;
4575            total.edges_created += r.edges_created;
4576        }
4577        Ok(total)
4578    }
4579
4580    /// Sync entity linking scoped to a single (user, agent) owner pair. See
4581    /// [`Self::link_entities`].
4582    pub(crate) fn link_entities_for(
4583        &self,
4584        user: UserId,
4585        agent: AgentId,
4586    ) -> MenteResult<EntityLinkResult> {
4587        let entity_memory_map = self.build_entity_memory_map(user, agent);
4588        let resolver = self.entity_resolver.read();
4589
4590        // Group entity names by their resolved canonical name
4591        let mut canonical_groups: HashMap<String, Vec<String>> = HashMap::new();
4592        for entity_name in entity_memory_map.keys() {
4593            let resolved = resolver.resolve(entity_name);
4594            if resolved.source != mentedb_cognitive::ResolutionSource::Identity {
4595                canonical_groups
4596                    .entry(resolved.canonical.clone())
4597                    .or_default()
4598                    .push(entity_name.clone());
4599            }
4600        }
4601
4602        drop(resolver);
4603
4604        let mut result = EntityLinkResult::default();
4605        let now = std::time::SystemTime::now()
4606            .duration_since(std::time::UNIX_EPOCH)
4607            .unwrap_or_default()
4608            .as_micros() as u64;
4609
4610        for (canonical, names) in &canonical_groups {
4611            // Collect all memory IDs across all aliases in this group
4612            let mut group_memory_ids: Vec<MemoryId> = Vec::new();
4613            for name in names {
4614                if let Some(ids) = entity_memory_map.get(name) {
4615                    group_memory_ids.extend(ids);
4616                }
4617            }
4618            // Also include the canonical name itself
4619            if let Some(ids) = entity_memory_map.get(canonical) {
4620                group_memory_ids.extend(ids);
4621            }
4622            group_memory_ids.sort();
4623            group_memory_ids.dedup();
4624
4625            if group_memory_ids.len() < 2 {
4626                continue;
4627            }
4628
4629            let label = format!("entity_link:{}", canonical);
4630            for i in 0..group_memory_ids.len() {
4631                for j in (i + 1)..group_memory_ids.len() {
4632                    let a_id = group_memory_ids[i];
4633                    let b_id = group_memory_ids[j];
4634
4635                    let graph = self.graph.read_graph();
4636                    let already_linked = graph.outgoing(a_id).iter().any(|(tid, e)| {
4637                        *tid == b_id
4638                            && e.edge_type == EdgeType::Related
4639                            && e.label
4640                                .as_ref()
4641                                .is_some_and(|l| l.starts_with("entity_link:"))
4642                    });
4643                    drop(graph);
4644
4645                    if already_linked {
4646                        continue;
4647                    }
4648
4649                    let edge = MemoryEdge {
4650                        source: a_id,
4651                        target: b_id,
4652                        edge_type: EdgeType::Related,
4653                        weight: 1.0,
4654                        created_at: now,
4655                        valid_from: None,
4656                        valid_until: None,
4657                        label: Some(label.clone()),
4658                    };
4659                    if self.relate(edge).is_ok() {
4660                        result.edges_created += 1;
4661                    }
4662                    result.linked += 1;
4663                }
4664            }
4665        }
4666
4667        debug!(
4668            linked = result.linked,
4669            edges = result.edges_created,
4670            groups = canonical_groups.len(),
4671            "sync entity linking complete"
4672        );
4673        Ok(result)
4674    }
4675
4676    /// Build a map of normalized entity name → list of MemoryIds, restricted to
4677    /// entity memories owned by exactly this (`user`, `agent`) pair so links and
4678    /// community membership never cross either owner axis.
4679    fn build_entity_memory_map(
4680        &self,
4681        user: UserId,
4682        agent: AgentId,
4683    ) -> HashMap<String, Vec<MemoryId>> {
4684        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4685        let mut map: HashMap<String, Vec<MemoryId>> = HashMap::new();
4686        for pid in &page_ids {
4687            if let Ok(mem) = self.storage.load_memory(*pid) {
4688                if mem.agent_id != agent || mem.user_id != user {
4689                    continue;
4690                }
4691                for tag in &mem.tags {
4692                    if let Some(name) = tag.strip_prefix("entity:") {
4693                        let normalized = name.to_lowercase().trim().to_string();
4694                        // One entity tag per memory (enrichment creates separate memories per entity)
4695                        map.entry(normalized).or_default().push(mem.id);
4696                        break;
4697                    }
4698                }
4699            }
4700        }
4701        map
4702    }
4703
4704    /// Get all entity memory nodes (memories tagged with `entity:{name}`).
4705    pub fn entity_memories(&self) -> Vec<MemoryNode> {
4706        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4707        page_ids
4708            .iter()
4709            .filter_map(|pid| self.storage.load_memory(*pid).ok())
4710            .filter(|m| m.tags.iter().any(|t| t.starts_with("entity:")))
4711            .collect()
4712    }
4713
4714    /// Get entity categories with their member entities for community detection.
4715    ///
4716    /// Returns a map of category → list of (entity_name, context_snippet).
4717    /// Categories come from `entity_type:` tags on entity memories.
4718    pub fn entity_communities(
4719        &self,
4720        user: UserId,
4721        agent: AgentId,
4722    ) -> HashMap<String, Vec<(String, String)>> {
4723        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4724        let mut categories: HashMap<String, Vec<(String, String)>> = HashMap::new();
4725
4726        for pid in &page_ids {
4727            if let Ok(mem) = self.storage.load_memory(*pid) {
4728                // Only cluster this (user, agent) pair's entities so communities
4729                // never mix across either owner axis.
4730                if mem.agent_id != agent || mem.user_id != user {
4731                    continue;
4732                }
4733                // Skip non-entity memories and existing community summaries
4734                if mem.tags.iter().any(|t| t == "community_summary") {
4735                    continue;
4736                }
4737
4738                let entity_name = mem
4739                    .tags
4740                    .iter()
4741                    .find_map(|t| t.strip_prefix("entity:"))
4742                    .map(|n| n.to_string());
4743
4744                if let Some(name) = entity_name {
4745                    let entity_type = mem
4746                        .tags
4747                        .iter()
4748                        .find_map(|t| t.strip_prefix("entity_type:"))
4749                        .unwrap_or("general")
4750                        .to_lowercase();
4751
4752                    let context: String = mem.content.chars().take(200).collect();
4753                    categories
4754                        .entry(entity_type)
4755                        .or_default()
4756                        .push((name, context));
4757                }
4758            }
4759        }
4760
4761        // Only return categories with 2+ entities (meaningful clusters)
4762        categories.retain(|_, members| members.len() >= 2);
4763        categories
4764    }
4765
4766    /// Store a community summary memory with edges to member entities.
4767    ///
4768    /// Creates a `community_summary` tagged memory and `Derived` edges
4769    /// from the summary to each member entity in the category.
4770    pub fn store_community_summary(
4771        &self,
4772        category: &str,
4773        summary: &str,
4774        member_names: &[String],
4775        user: UserId,
4776        agent: AgentId,
4777    ) -> MenteResult<MemoryId> {
4778        if category.is_empty() {
4779            return Err(MenteError::Storage(
4780                "community category cannot be empty".into(),
4781            ));
4782        }
4783
4784        let now = std::time::SystemTime::now()
4785            .duration_since(std::time::UNIX_EPOCH)
4786            .unwrap_or_default()
4787            .as_micros() as u64;
4788
4789        // Check if a community summary already exists for this category AND both
4790        // owner axes, so one owner's summary never overwrites another's.
4791        let community_tag = format!("community:{}", category);
4792        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4793        let mut existing_id = None;
4794        for pid in &page_ids {
4795            if let Ok(mem) = self.storage.load_memory(*pid)
4796                && mem.agent_id == agent
4797                && mem.user_id == user
4798                && mem.tags.iter().any(|t| t == &community_tag)
4799            {
4800                // Update existing summary content
4801                let mut updated = mem.clone();
4802                updated.content = summary.to_string();
4803                if let Some(ref embedder) = self.embedder {
4804                    updated.embedding = embedder
4805                        .embed(summary)
4806                        .unwrap_or_else(|_| updated.embedding.clone());
4807                }
4808                self.update_node(*pid, &updated)?;
4809                existing_id = Some(updated.id);
4810                break;
4811            }
4812        }
4813
4814        let node_id = if let Some(id) = existing_id {
4815            id
4816        } else {
4817            // Create new community summary
4818            let embedding = self
4819                .embedder
4820                .as_ref()
4821                .and_then(|e| e.embed(summary).ok())
4822                .unwrap_or_default();
4823
4824            let mut node =
4825                MemoryNode::new(agent, MemoryType::Semantic, summary.to_string(), embedding)
4826                    .with_user_id(user);
4827            node.tags = vec![
4828                "community_summary".to_string(),
4829                community_tag,
4830                "source:enrichment".to_string(),
4831            ];
4832            node.confidence = 0.7;
4833            let id = node.id;
4834            self.store(node)?;
4835            id
4836        };
4837
4838        // (Re)create Derived edges from summary to member entity memories.
4839        // On update this refreshes edges to reflect current membership.
4840        let entity_map = self.build_entity_memory_map(user, agent);
4841        for name in member_names {
4842            let normalized = name.to_lowercase();
4843            if let Some(member_ids) = entity_map.get(&normalized) {
4844                for member_id in member_ids {
4845                    self.relate(MemoryEdge {
4846                        source: node_id,
4847                        target: *member_id,
4848                        edge_type: EdgeType::Derived,
4849                        weight: 0.8,
4850                        created_at: now,
4851                        valid_from: None,
4852                        valid_until: None,
4853                        label: Some(format!("community_member:{}", category)),
4854                    })?;
4855                }
4856            }
4857        }
4858
4859        Ok(node_id)
4860    }
4861
4862    /// Get existing community summaries owned by exactly this (`user`, `agent`)
4863    /// pair, so each owner only sees its own community summaries.
4864    pub fn community_summaries(&self, user: UserId, agent: AgentId) -> Vec<MemoryNode> {
4865        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4866        page_ids
4867            .iter()
4868            .filter_map(|pid| self.storage.load_memory(*pid).ok())
4869            .filter(|m| {
4870                m.agent_id == agent
4871                    && m.user_id == user
4872                    && m.tags.iter().any(|t| t == "community_summary")
4873            })
4874            .collect()
4875    }
4876
4877    /// Collect all semantic/procedural facts for user profile generation.
4878    ///
4879    /// Returns high-confidence memories suitable for profile building.
4880    pub fn profile_facts(&self, user: UserId, agent: AgentId) -> Vec<String> {
4881        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4882        let mut facts = Vec::new();
4883
4884        for pid in &page_ids {
4885            if let Ok(mem) = self.storage.load_memory(*pid) {
4886                // Only this (user, agent) pair's facts, so a profile is built
4887                // from one owner's knowledge and never mixes in another owner's
4888                // memories on either axis.
4889                if mem.agent_id != agent || mem.user_id != user {
4890                    continue;
4891                }
4892                // Only semantic and procedural memories with decent confidence
4893                if mem.confidence < 0.5 {
4894                    continue;
4895                }
4896                match mem.memory_type {
4897                    MemoryType::Semantic | MemoryType::Procedural => {
4898                        // Skip community summaries and entity nodes
4899                        if mem
4900                            .tags
4901                            .iter()
4902                            .any(|t| t == "community_summary" || t.starts_with("entity:"))
4903                        {
4904                            continue;
4905                        }
4906                        facts.push(mem.content.chars().take(300).collect());
4907                    }
4908                    _ => {}
4909                }
4910            }
4911        }
4912
4913        // Cap at 100 most relevant facts to fit in LLM context
4914        facts.truncate(100);
4915        facts
4916    }
4917
4918    /// Store or update the user profile as an always-scoped memory owned by
4919    /// exactly this (`user`, `agent`) pair.
4920    ///
4921    /// There is exactly one user profile memory (tagged `user_profile`) per
4922    /// owner pair. If one already exists for this pair, it's replaced entirely;
4923    /// one owner's profile never overwrites another's.
4924    pub fn store_user_profile(
4925        &self,
4926        profile: &str,
4927        user: UserId,
4928        agent: AgentId,
4929    ) -> MenteResult<MemoryId> {
4930        // Find existing profile for this owner pair
4931        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4932        for pid in &page_ids {
4933            if let Ok(mem) = self.storage.load_memory(*pid)
4934                && mem.agent_id == agent
4935                && mem.user_id == user
4936                && mem.tags.iter().any(|t| t == "user_profile")
4937            {
4938                // Update in place, bumping created_at so it reflects when the
4939                // profile was last rebuilt. `profile_updated_at` reads created_at;
4940                // without this the "updated" time stayed frozen at first creation
4941                // (a regenerated profile still showed "updated N days ago").
4942                let mut updated = mem.clone();
4943                updated.content = profile.to_string();
4944                updated.created_at = std::time::SystemTime::now()
4945                    .duration_since(std::time::UNIX_EPOCH)
4946                    .unwrap_or_default()
4947                    .as_micros() as u64;
4948                if let Some(ref embedder) = self.embedder {
4949                    updated.embedding = embedder
4950                        .embed(profile)
4951                        .unwrap_or_else(|_| updated.embedding.clone());
4952                }
4953                self.update_node(*pid, &updated)?;
4954                return Ok(updated.id);
4955            }
4956        }
4957
4958        // Create new profile
4959        let embedding = self
4960            .embedder
4961            .as_ref()
4962            .and_then(|e| e.embed(profile).ok())
4963            .unwrap_or_default();
4964
4965        let mut node = MemoryNode::new(agent, MemoryType::Semantic, profile.to_string(), embedding)
4966            .with_user_id(user);
4967        node.tags = vec![
4968            "user_profile".to_string(),
4969            "scope:always".to_string(),
4970            "source:enrichment".to_string(),
4971        ];
4972        node.confidence = 0.8;
4973        let node_id = node.id;
4974        self.store(node)?;
4975
4976        Ok(node_id)
4977    }
4978
4979    /// Get the account-level user profile: the one owned by neither a user nor
4980    /// an agent (both owner axes nil).
4981    ///
4982    /// Deterministic. Since enrichment now builds one profile per (user, agent)
4983    /// owner pair, an account may hold many; this returns only the shared,
4984    /// unowned one, never an arbitrary owner's. A multi-user app that keeps only
4985    /// per-owner profiles gets `None` here; use [`user_profile_for`] to read a
4986    /// specific owner's, and [`profile_owners`] to list which owners have one.
4987    pub fn user_profile(&self) -> Option<MemoryNode> {
4988        self.user_profile_for(UserId::nil(), AgentId::nil())
4989    }
4990
4991    /// Get the user profile owned by exactly this (`user`, `agent`) pair, if one
4992    /// exists. One owner's profile is never returned for another, so a dashboard
4993    /// or a per-turn context primer can show the right profile for a scope.
4994    pub fn user_profile_for(&self, user: UserId, agent: AgentId) -> Option<MemoryNode> {
4995        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
4996        for pid in &page_ids {
4997            if let Ok(mem) = self.storage.load_memory(*pid)
4998                && mem.user_id == user
4999                && mem.agent_id == agent
5000                && mem.tags.iter().any(|t| t == "user_profile")
5001            {
5002                return Some(mem);
5003            }
5004        }
5005        None
5006    }
5007
5008    /// List the owner pairs that have a user profile, most recently built first.
5009    /// Lets a dashboard enumerate the profiles it can show without scanning
5010    /// memory bodies itself.
5011    pub fn profile_owners(&self) -> Vec<(UserId, AgentId)> {
5012        let page_ids: Vec<PageId> = self.page_map.read().values().copied().collect();
5013        let mut owners: Vec<(UserId, AgentId, u64)> = Vec::new();
5014        for pid in &page_ids {
5015            if let Ok(mem) = self.storage.load_memory(*pid)
5016                && mem.tags.iter().any(|t| t == "user_profile")
5017            {
5018                owners.push((mem.user_id, mem.agent_id, mem.created_at));
5019            }
5020        }
5021        owners.sort_by_key(|(_, _, ts)| std::cmp::Reverse(*ts));
5022        owners.into_iter().map(|(u, a, _)| (u, a)).collect()
5023    }
5024}
5025
5026#[cfg(test)]
5027mod mql_execution_tests {
5028    use super::*;
5029
5030    /// Regression: a bare `RECALL` and a `WHERE type = ...` filter must return
5031    /// the matching memories. Before the executor fix these produced an empty
5032    /// TagScan (empty tags, filters ignored) and returned nothing.
5033    #[test]
5034    fn recall_bare_and_by_type_return_matches() {
5035        let dir = tempfile::tempdir().unwrap();
5036        let db = MenteDb::open(dir.path()).unwrap();
5037        db.store(MemoryNode::new(
5038            AgentId::nil(),
5039            MemoryType::Semantic,
5040            "the sky is blue".into(),
5041            vec![0.1_f32; 8],
5042        ))
5043        .unwrap();
5044        db.store(MemoryNode::new(
5045            AgentId::nil(),
5046            MemoryType::Semantic,
5047            "water is wet".into(),
5048            vec![0.2_f32; 8],
5049        ))
5050        .unwrap();
5051        db.store(MemoryNode::new(
5052            AgentId::nil(),
5053            MemoryType::Procedural,
5054            "how to tie a knot".into(),
5055            vec![0.3_f32; 8],
5056        ))
5057        .unwrap();
5058
5059        // Bare RECALL returns all memories (previously empty).
5060        let all = db.recall("RECALL memories LIMIT 50").unwrap();
5061        let all_n: usize = all.blocks.iter().map(|b| b.memories.len()).sum();
5062        assert_eq!(all_n, 3, "bare RECALL should return all three memories");
5063
5064        // A type filter narrows to just that type (previously empty).
5065        let sem = db
5066            .recall("RECALL memories WHERE type = semantic LIMIT 50")
5067            .unwrap();
5068        let sem_mems: Vec<&ScoredMemory> = sem.blocks.iter().flat_map(|b| &b.memories).collect();
5069        assert_eq!(
5070            sem_mems.len(),
5071            2,
5072            "type = semantic should return two memories"
5073        );
5074        assert!(
5075            sem_mems
5076                .iter()
5077                .all(|sm| sm.memory.memory_type == MemoryType::Semantic),
5078            "only semantic memories should come back"
5079        );
5080    }
5081
5082    /// Build a small fixture: three types, two of them tagged "keep", so boolean
5083    /// WHERE clauses have something to include and exclude.
5084    fn boolean_fixture() -> (tempfile::TempDir, MenteDb) {
5085        let dir = tempfile::tempdir().unwrap();
5086        let db = MenteDb::open(dir.path()).unwrap();
5087        let mut sem_keep = MemoryNode::new(
5088            AgentId::nil(),
5089            MemoryType::Semantic,
5090            "semantic kept".into(),
5091            vec![0.1_f32; 8],
5092        );
5093        sem_keep.tags = vec!["keep".into()];
5094        let sem_plain = MemoryNode::new(
5095            AgentId::nil(),
5096            MemoryType::Semantic,
5097            "semantic plain".into(),
5098            vec![0.2_f32; 8],
5099        );
5100        let mut proc_keep = MemoryNode::new(
5101            AgentId::nil(),
5102            MemoryType::Procedural,
5103            "procedural kept".into(),
5104            vec![0.3_f32; 8],
5105        );
5106        proc_keep.tags = vec!["keep".into()];
5107        let anti = MemoryNode::new(
5108            AgentId::nil(),
5109            MemoryType::AntiPattern,
5110            "antipattern plain".into(),
5111            vec![0.4_f32; 8],
5112        );
5113        for n in [sem_keep, sem_plain, proc_keep, anti] {
5114            db.store(n).unwrap();
5115        }
5116        (dir, db)
5117    }
5118
5119    fn contents(db: &MenteDb, mql: &str) -> Vec<String> {
5120        let mut v: Vec<String> = db
5121            .query(mql)
5122            .unwrap()
5123            .into_iter()
5124            .map(|s| s.memory.content)
5125            .collect();
5126        v.sort();
5127        v
5128    }
5129
5130    /// OR must union the branches: `type = semantic OR type = procedural` returns
5131    /// exactly the semantic and procedural rows, never the antipattern.
5132    #[test]
5133    fn or_unions_branches() {
5134        let (_d, db) = boolean_fixture();
5135        let got = contents(
5136            &db,
5137            "RECALL WHERE type = semantic OR type = procedural LIMIT 50",
5138        );
5139        assert_eq!(
5140            got,
5141            vec![
5142                "procedural kept".to_string(),
5143                "semantic kept".to_string(),
5144                "semantic plain".to_string()
5145            ],
5146            "OR should return semantic and procedural, excluding antipattern"
5147        );
5148    }
5149
5150    /// NOT must exclude: `NOT type = semantic` returns everything that is not
5151    /// semantic.
5152    #[test]
5153    fn not_excludes() {
5154        let (_d, db) = boolean_fixture();
5155        let got = contents(&db, "RECALL WHERE NOT type = semantic LIMIT 50");
5156        assert_eq!(
5157            got,
5158            vec![
5159                "antipattern plain".to_string(),
5160                "procedural kept".to_string()
5161            ],
5162            "NOT type = semantic should drop both semantic rows"
5163        );
5164    }
5165
5166    /// AND with NOT: `type = semantic AND NOT tag = keep` isolates the untagged
5167    /// semantic row, proving NOT composes under AND.
5168    #[test]
5169    fn and_not_composes() {
5170        let (_d, db) = boolean_fixture();
5171        let got = contents(
5172            &db,
5173            r#"RECALL WHERE type = semantic AND NOT tag = "keep" LIMIT 50"#,
5174        );
5175        assert_eq!(
5176            got,
5177            vec!["semantic plain".to_string()],
5178            "only the untagged semantic row should remain"
5179        );
5180    }
5181
5182    /// Grouping changes meaning: `(type = semantic OR type = procedural) AND tag =
5183    /// keep` must return only the kept rows of those two types, not the plain
5184    /// semantic one. This is the precedence case a flat AND list cannot express.
5185    #[test]
5186    fn grouping_binds_before_and() {
5187        let (_d, db) = boolean_fixture();
5188        let got = contents(
5189            &db,
5190            r#"RECALL WHERE (type = semantic OR type = procedural) AND tag = "keep" LIMIT 50"#,
5191        );
5192        assert_eq!(
5193            got,
5194            vec!["procedural kept".to_string(), "semantic kept".to_string()],
5195            "grouping must apply the tag filter to both OR branches"
5196        );
5197    }
5198
5199    /// Regression: a pure AND clause still returns the intersection (and stays on
5200    /// the flat-filter fast path, which this exercises end to end).
5201    #[test]
5202    fn pure_and_still_intersects() {
5203        let (_d, db) = boolean_fixture();
5204        let got = contents(
5205            &db,
5206            r#"RECALL WHERE type = semantic AND tag = "keep" LIMIT 50"#,
5207        );
5208        assert_eq!(got, vec!["semantic kept".to_string()]);
5209    }
5210}
5211
5212#[cfg(test)]
5213mod order_by_tests {
5214    use super::*;
5215
5216    fn contents(db: &MenteDb, mql: &str) -> Vec<String> {
5217        db.query(mql)
5218            .unwrap()
5219            .into_iter()
5220            .map(|s| s.memory.content)
5221            .collect()
5222    }
5223
5224    /// ORDER BY sorts by the field and applies before LIMIT, both directions.
5225    #[test]
5226    fn order_by_salience_sorts_before_limit() {
5227        let dir = tempfile::tempdir().unwrap();
5228        let db = MenteDb::open(dir.path()).unwrap();
5229        for (content, sal) in [("low", 0.2_f32), ("high", 0.9), ("mid", 0.5)] {
5230            let mut n = MemoryNode::new(
5231                AgentId::nil(),
5232                MemoryType::Semantic,
5233                content.into(),
5234                vec![0.1_f32; 8],
5235            );
5236            n.salience = sal;
5237            db.store(n).unwrap();
5238        }
5239
5240        // DESC: highest salience first, LIMIT keeps the top 2.
5241        assert_eq!(
5242            contents(
5243                &db,
5244                "RECALL WHERE type = semantic ORDER BY salience DESC LIMIT 2"
5245            ),
5246            vec!["high".to_string(), "mid".to_string()]
5247        );
5248        // ASC: lowest first.
5249        assert_eq!(
5250            contents(
5251                &db,
5252                "RECALL WHERE type = semantic ORDER BY salience ASC LIMIT 2"
5253            ),
5254            vec!["low".to_string(), "mid".to_string()]
5255        );
5256        // No direction defaults to ASC.
5257        assert_eq!(
5258            contents(
5259                &db,
5260                "RECALL WHERE type = semantic ORDER BY salience LIMIT 1"
5261            ),
5262            vec!["low".to_string()]
5263        );
5264    }
5265}
5266
5267#[cfg(test)]
5268mod metrics_snapshot_tests {
5269    use super::*;
5270
5271    /// The metrics snapshot counts writes and reads, sizes the store and index,
5272    /// and reflects buffer-pool activity.
5273    #[test]
5274    fn metrics_track_writes_reads_and_cache() {
5275        let dir = tempfile::tempdir().unwrap();
5276        let db = MenteDb::open(dir.path()).unwrap();
5277        for i in 0..3 {
5278            db.store(MemoryNode::new(
5279                AgentId::nil(),
5280                MemoryType::Semantic,
5281                format!("fact {i}"),
5282                vec![0.1_f32 + i as f32 * 0.1; 8],
5283            ))
5284            .unwrap();
5285        }
5286        // Read paths: recall (assembles a window) and query (raw).
5287        let _ = db.recall("RECALL memories LIMIT 10").unwrap();
5288        let _ = db.query("RECALL LIMIT 10").unwrap();
5289        // Exercise the hybrid vector-search path so search latency is recorded
5290        // (this path is not counted in recalls, only timed).
5291        let _ = db.recall_similar(&[0.15_f32; 8], 3).unwrap();
5292
5293        let m = db.metrics();
5294        assert_eq!(m.stores, 3, "three writes counted");
5295        assert_eq!(m.recalls, 2, "recall + query counted as reads");
5296        assert!(
5297            m.avg_store_latency_us > 0,
5298            "store latency EMA populated after writes"
5299        );
5300        assert!(
5301            m.avg_search_latency_us > 0,
5302            "search latency EMA populated after a hybrid search"
5303        );
5304        assert_eq!(m.memory_count, 3);
5305        assert_eq!(m.vector_index_size, 3, "three vectors indexed");
5306        assert_eq!(m.graph_nodes, 3, "three graph nodes");
5307        assert!(m.storage_bytes > 0, "pages written to disk");
5308        assert!(
5309            m.buffer_pool_hits + m.buffer_pool_misses > 0,
5310            "reads exercised the buffer pool"
5311        );
5312        assert_eq!(m.standing_rules, 0, "no pins yet");
5313    }
5314}
5315
5316#[cfg(test)]
5317mod entity_boost_tests {
5318    use super::*;
5319
5320    /// With a query-named entity linked (via a `Derived` edge) to a lower-ranked
5321    /// memory, the boost lifts that memory above an unrelated higher-scored one.
5322    /// This exercises the whole leg: `entity:<name>` tag lookup, graph adjacency,
5323    /// score bump, and re-sort, on a synthetic corpus with no LLM.
5324    #[test]
5325    fn boost_lifts_entity_linked_memory() {
5326        let dir = tempfile::tempdir().unwrap();
5327        let db = MenteDb::open(dir.path()).unwrap();
5328
5329        let kafka_mem = MemoryNode::new(
5330            AgentId::nil(),
5331            MemoryType::Episodic,
5332            "we chose kafka for the pipeline".into(),
5333            vec![0.1_f32; 8],
5334        );
5335        let kafka_id = kafka_mem.id;
5336        let other_mem = MemoryNode::new(
5337            AgentId::nil(),
5338            MemoryType::Episodic,
5339            "the sky is blue".into(),
5340            vec![0.2_f32; 8],
5341        );
5342        let other_id = other_mem.id;
5343        let mut entity = MemoryNode::new(
5344            AgentId::nil(),
5345            MemoryType::Semantic,
5346            "Kafka, a message broker".into(),
5347            vec![0.3_f32; 8],
5348        );
5349        entity.tags.push("entity:kafka".into());
5350        let entity_id = entity.id;
5351
5352        db.store(kafka_mem.clone()).unwrap();
5353        db.store(other_mem.clone()).unwrap();
5354        db.store(entity).unwrap();
5355        // Entity node -> Derived -> the episodic it was extracted from, exactly as
5356        // enrichment records it.
5357        db.relate(MemoryEdge {
5358            source: entity_id,
5359            target: kafka_id,
5360            edge_type: EdgeType::Derived,
5361            weight: 1.0,
5362            created_at: 0,
5363            valid_from: None,
5364            valid_until: None,
5365            label: Some("enrichment".into()),
5366        })
5367        .unwrap();
5368
5369        // Baseline ranking puts the unrelated memory ahead.
5370        let mut scored = vec![
5371            ScoredMemory {
5372                memory: other_mem,
5373                score: 0.50,
5374            },
5375            ScoredMemory {
5376                memory: kafka_mem,
5377                score: 0.40,
5378            },
5379        ];
5380        db.apply_entity_boost(&mut scored, "did we pick kafka");
5381
5382        assert_eq!(
5383            scored[0].memory.id, kafka_id,
5384            "the kafka-linked memory should rank first after the entity boost"
5385        );
5386        let boosted = scored.iter().find(|s| s.memory.id == kafka_id).unwrap();
5387        assert!(
5388            (boosted.score - 0.55).abs() < 1e-6,
5389            "the linked memory should gain exactly the boost weight"
5390        );
5391        assert!(
5392            scored.iter().any(|s| s.memory.id == other_id),
5393            "the unrelated memory is kept, just reordered"
5394        );
5395    }
5396
5397    /// A query that names no known entity leaves ranking untouched.
5398    #[test]
5399    fn no_entity_in_query_is_a_noop() {
5400        let dir = tempfile::tempdir().unwrap();
5401        let db = MenteDb::open(dir.path()).unwrap();
5402        let a = MemoryNode::new(
5403            AgentId::nil(),
5404            MemoryType::Episodic,
5405            "alpha".into(),
5406            vec![0.1_f32; 8],
5407        );
5408        let b = MemoryNode::new(
5409            AgentId::nil(),
5410            MemoryType::Episodic,
5411            "beta".into(),
5412            vec![0.2_f32; 8],
5413        );
5414        let (aid, bid) = (a.id, b.id);
5415        let mut scored = vec![
5416            ScoredMemory {
5417                memory: a,
5418                score: 0.50,
5419            },
5420            ScoredMemory {
5421                memory: b,
5422                score: 0.40,
5423            },
5424        ];
5425        db.apply_entity_boost(&mut scored, "nothing here resolves to an entity");
5426        assert_eq!(scored[0].memory.id, aid);
5427        assert_eq!(scored[1].memory.id, bid);
5428    }
5429}
5430
5431#[cfg(test)]
5432mod standing_rule_policy_tests {
5433    use super::*;
5434
5435    fn always_rule(content: &str, emb: Vec<f32>, manual: bool) -> MemoryNode {
5436        let mut n = MemoryNode::new(AgentId::nil(), MemoryType::Semantic, content.into(), emb);
5437        n.tags.push("scope:always".into());
5438        if manual {
5439            n.tags.push("source:manual".into());
5440        }
5441        n
5442    }
5443
5444    fn always_count(db: &MenteDb) -> usize {
5445        db.memory_ids()
5446            .iter()
5447            .filter_map(|id| db.get_memory(*id).ok())
5448            .filter(|n| n.tags.iter().any(|t| t == "scope:always"))
5449            .count()
5450    }
5451
5452    /// Regression: un-pinning a rule (removing scope:always and re-storing) must
5453    /// drop the standing-rule count. The tag index used to only add tags, so the
5454    /// count stayed stale (the admin dashboard showed 261 when the account had 0).
5455    #[test]
5456    fn standing_rule_count_drops_after_unpin() {
5457        let dir = tempfile::tempdir().unwrap();
5458        let db = MenteDb::open(dir.path()).unwrap();
5459        let rule = always_rule("always use tabs", vec![0.2_f32; 8], false);
5460        let id = rule.id;
5461        db.store(rule).unwrap();
5462        assert_eq!(db.metrics().standing_rules, 1);
5463
5464        // Un-pin exactly as the prune does: strip the tag, re-store the same id.
5465        let mut m = db.get_memory(id).unwrap();
5466        m.tags.retain(|t| t != "scope:always");
5467        db.store(m).unwrap();
5468
5469        assert_eq!(
5470            db.metrics().standing_rules,
5471            0,
5472            "un-pinned rule must not be counted"
5473        );
5474        assert_eq!(
5475            always_count(&db),
5476            0,
5477            "the node itself no longer carries the tag"
5478        );
5479    }
5480
5481    /// Re-pinning a rule that already exists (same embedding, reworded) does not
5482    /// grow the always-list.
5483    #[test]
5484    fn dedup_skips_near_identical_standing_rule() {
5485        let dir = tempfile::tempdir().unwrap();
5486        let db = MenteDb::open(dir.path()).unwrap();
5487        db.store(always_rule("always use tabs", vec![0.5_f32; 8], true))
5488            .unwrap();
5489        db.store(always_rule("tabs, always", vec![0.5_f32; 8], true))
5490            .unwrap();
5491        assert_eq!(
5492            always_count(&db),
5493            1,
5494            "a near-identical standing rule must not be inserted twice"
5495        );
5496    }
5497
5498    /// A genuinely different standing rule is still pinned.
5499    #[test]
5500    fn dedup_allows_a_distinct_standing_rule() {
5501        let dir = tempfile::tempdir().unwrap();
5502        let db = MenteDb::open(dir.path()).unwrap();
5503        let mut e1 = vec![0.0_f32; 8];
5504        e1[0] = 1.0;
5505        let mut e2 = vec![0.0_f32; 8];
5506        e2[1] = 1.0;
5507        db.store(always_rule("always use tabs", e1, true)).unwrap();
5508        db.store(always_rule("never deploy on friday", e2, true))
5509            .unwrap();
5510        assert_eq!(
5511            always_count(&db),
5512            2,
5513            "a distinct standing rule is still stored"
5514        );
5515    }
5516
5517    /// A hand-pinned always-list beyond the cap is demoted down to always_max.
5518    #[test]
5519    fn prune_caps_the_always_list() {
5520        let dir = tempfile::tempdir().unwrap();
5521        let cfg = CognitiveConfig {
5522            always_max: 3,
5523            always_dedup: false, // isolate the cap from dedup
5524            ..Default::default()
5525        };
5526        let db = MenteDb::open_with_config(dir.path(), cfg).unwrap();
5527        for i in 0..5 {
5528            let mut e = vec![0.0_f32; 8];
5529            e[i] = 1.0;
5530            db.store(always_rule(&format!("manual rule {i}"), e, true))
5531                .unwrap();
5532        }
5533        assert_eq!(always_count(&db), 5);
5534        let report = db.prune_standing_rules().unwrap();
5535        assert_eq!(report.capped, 2, "two rules over the cap are demoted");
5536        assert_eq!(
5537            always_count(&db),
5538            3,
5539            "the always-list is bounded at always_max"
5540        );
5541    }
5542}
5543
5544#[cfg(test)]
5545mod distinct_owner_tests {
5546    use super::*;
5547
5548    /// `distinct_owner_pairs` must enumerate every (user, agent) pair that holds
5549    /// a memory, since enrichment loops over its result to scope derived
5550    /// knowledge per owner pair. This is a unit test (not an integration test)
5551    /// because the method is private.
5552    #[test]
5553    fn distinct_owner_pairs_returns_all_owner_agents() {
5554        let dir = tempfile::tempdir().unwrap();
5555        let db = MenteDb::open(dir.path()).unwrap();
5556        let alice = AgentId::new();
5557        let bob = AgentId::new();
5558
5559        db.store(MemoryNode::new(
5560            alice,
5561            MemoryType::Semantic,
5562            "alice likes falcons".into(),
5563            vec![0.1_f32; 8],
5564        ))
5565        .unwrap();
5566        db.store(MemoryNode::new(
5567            bob,
5568            MemoryType::Semantic,
5569            "bob likes turtles".into(),
5570            vec![0.2_f32; 8],
5571        ))
5572        .unwrap();
5573
5574        let pairs = db.distinct_owner_pairs();
5575        let agents: Vec<AgentId> = pairs.iter().map(|(_, a)| *a).collect();
5576        assert!(
5577            agents.contains(&alice),
5578            "distinct owners must include alice"
5579        );
5580        assert!(agents.contains(&bob), "distinct owners must include bob");
5581    }
5582
5583    /// Owner pairs are distinguished on BOTH axes: the same agent with two
5584    /// different users yields two distinct pairs, so enrichment partitions per
5585    /// user even under a shared agent.
5586    #[test]
5587    fn distinct_owner_pairs_splits_by_user_under_same_agent() {
5588        let dir = tempfile::tempdir().unwrap();
5589        let db = MenteDb::open(dir.path()).unwrap();
5590        let agent = AgentId::new();
5591        let ua = UserId::new();
5592        let ub = UserId::new();
5593
5594        db.store(
5595            MemoryNode::new(
5596                agent,
5597                MemoryType::Semantic,
5598                "user a fact".into(),
5599                vec![0.1_f32; 8],
5600            )
5601            .with_user_id(ua),
5602        )
5603        .unwrap();
5604        db.store(
5605            MemoryNode::new(
5606                agent,
5607                MemoryType::Semantic,
5608                "user b fact".into(),
5609                vec![0.2_f32; 8],
5610            )
5611            .with_user_id(ub),
5612        )
5613        .unwrap();
5614
5615        let pairs = db.distinct_owner_pairs();
5616        assert!(
5617            pairs.contains(&(ua, agent)),
5618            "must include (user a, agent) pair"
5619        );
5620        assert!(
5621            pairs.contains(&(ub, agent)),
5622            "must include (user b, agent) pair"
5623        );
5624        assert_ne!(
5625            (ua, agent),
5626            (ub, agent),
5627            "same agent, different users are distinct owner pairs"
5628        );
5629    }
5630
5631    /// A user profile belongs to exactly one (user, agent) owner. Reading one
5632    /// owner's profile must never return another's, the account-level
5633    /// `user_profile()` must return only the unowned/global one (never an
5634    /// arbitrary owner's), and `profile_owners` must enumerate them all. This is
5635    /// the fix for the owner-blind `user_profile()` that returned whichever
5636    /// profile happened to be scanned first once several owners had one.
5637    #[test]
5638    fn profile_is_owner_scoped_and_enumerable() {
5639        let dir = tempfile::tempdir().unwrap();
5640        let db = MenteDb::open(dir.path()).unwrap();
5641        let agent = AgentId::new();
5642        let alice = UserId::new();
5643        let bob = UserId::new();
5644
5645        db.store_user_profile("alice's profile", alice, agent)
5646            .unwrap();
5647        db.store_user_profile("bob's profile", bob, agent).unwrap();
5648        db.store_user_profile("the shared profile", UserId::nil(), AgentId::nil())
5649            .unwrap();
5650
5651        // Each owner reads exactly its own, never the other's.
5652        assert_eq!(
5653            db.user_profile_for(alice, agent).unwrap().content,
5654            "alice's profile"
5655        );
5656        assert_eq!(
5657            db.user_profile_for(bob, agent).unwrap().content,
5658            "bob's profile"
5659        );
5660
5661        // The account-level accessor returns only the unowned/global profile,
5662        // deterministically, not alice's or bob's.
5663        assert_eq!(db.user_profile().unwrap().content, "the shared profile");
5664
5665        // An owner with no profile gets None, never a fallback to someone else's.
5666        assert!(db.user_profile_for(UserId::new(), agent).is_none());
5667
5668        // Every owner with a profile is enumerable.
5669        let owners = db.profile_owners();
5670        assert_eq!(owners.len(), 3);
5671        assert!(owners.contains(&(alice, agent)));
5672        assert!(owners.contains(&(bob, agent)));
5673        assert!(owners.contains(&(UserId::nil(), AgentId::nil())));
5674    }
5675}
5676
5677#[cfg(test)]
5678mod reranker_integration_tests {
5679    use super::*;
5680    use crate::reranker::{RerankCandidate, Reranker};
5681
5682    fn now_us() -> u64 {
5683        std::time::SystemTime::now()
5684            .duration_since(std::time::UNIX_EPOCH)
5685            .unwrap_or_default()
5686            .as_micros() as u64
5687    }
5688
5689    /// A test reranker that promotes whichever candidate mentions "gardening",
5690    /// regardless of its first-pass score, so the assertion isolates the rerank
5691    /// stage from the vector/BM25 signals.
5692    struct PromoteGardening;
5693    impl Reranker for PromoteGardening {
5694        fn rerank(&self, _query: &str, candidates: &[RerankCandidate<'_>]) -> Vec<(MemoryId, f32)> {
5695            candidates
5696                .iter()
5697                .map(|c| {
5698                    let s = if c.content.contains("gardening") {
5699                        1.0
5700                    } else {
5701                        0.0
5702                    };
5703                    (c.id, s)
5704                })
5705                .collect()
5706        }
5707    }
5708
5709    #[test]
5710    fn reranker_overrides_first_pass_order() {
5711        let dir = tempfile::tempdir().unwrap();
5712        let mut db = MenteDb::open(dir.path()).unwrap();
5713
5714        // A is far from the query vector and lacks the query term; B is closest to
5715        // the query vector AND contains "espresso", so the first pass favors B on
5716        // both signals.
5717        let a = MemoryNode::new(
5718            AgentId::nil(),
5719            MemoryType::Semantic,
5720            "gardening soil and a trowel".into(),
5721            vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
5722        );
5723        let b = MemoryNode::new(
5724            AgentId::nil(),
5725            MemoryType::Semantic,
5726            "espresso machine and grinder".into(),
5727            vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
5728        );
5729        let a_id = a.id;
5730        let b_id = b.id;
5731        db.store(a).unwrap();
5732        db.store(b).unwrap();
5733
5734        let query_emb = vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
5735        let at = now_us();
5736
5737        // First pass: no reranker installed, B wins.
5738        let base = db
5739            .recall_hybrid_at(&query_emb, Some("espresso"), 2, at, None, None, None)
5740            .unwrap();
5741        assert_eq!(
5742            base[0].0, b_id,
5743            "without a reranker the first pass favors B"
5744        );
5745
5746        // Install the reranker; it must override the first-pass order and lift A.
5747        db.set_reranker(Box::new(PromoteGardening));
5748        assert!(db.has_reranker());
5749        let reranked = db
5750            .recall_hybrid_at(&query_emb, Some("espresso"), 2, at, None, None, None)
5751            .unwrap();
5752        assert_eq!(
5753            reranked[0].0, a_id,
5754            "the installed reranker must drive the final order"
5755        );
5756
5757        // Clearing it restores the first-pass order.
5758        db.clear_reranker();
5759        let restored = db
5760            .recall_hybrid_at(&query_emb, Some("espresso"), 2, at, None, None, None)
5761            .unwrap();
5762        assert_eq!(
5763            restored[0].0, b_id,
5764            "clearing the reranker restores first pass"
5765        );
5766    }
5767
5768    /// With no query text the rerank stage is skipped even when a reranker is
5769    /// installed, so a text-blind vector recall is unaffected.
5770    #[test]
5771    fn no_query_text_skips_rerank() {
5772        let dir = tempfile::tempdir().unwrap();
5773        let mut db = MenteDb::open(dir.path()).unwrap();
5774        let a = MemoryNode::new(
5775            AgentId::nil(),
5776            MemoryType::Semantic,
5777            "gardening soil".into(),
5778            vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
5779        );
5780        let b = MemoryNode::new(
5781            AgentId::nil(),
5782            MemoryType::Semantic,
5783            "espresso machine".into(),
5784            vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
5785        );
5786        let b_id = b.id;
5787        db.store(a).unwrap();
5788        db.store(b).unwrap();
5789        db.set_reranker(Box::new(PromoteGardening));
5790
5791        // No query text: pure vector recall closest to B, rerank not applied.
5792        let out = db
5793            .recall_hybrid_at(
5794                &[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
5795                None,
5796                2,
5797                now_us(),
5798                None,
5799                None,
5800                None,
5801            )
5802            .unwrap();
5803        assert_eq!(out[0].0, b_id, "no query text means no rerank");
5804    }
5805
5806    /// With MMR enabled, recall should pull a distinct memory into the top-k
5807    /// ahead of a near-duplicate of the top hit; with MMR off (default), the
5808    /// near-duplicate keeps its by-relevance slot.
5809    #[test]
5810    fn mmr_diversifies_top_k() {
5811        // a: closest to the query. b: a near-duplicate of a (same direction),
5812        // slightly less relevant. c: a distinct direction, less relevant still.
5813        let a_emb = vec![1.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
5814        let b_emb = vec![0.98, 0.16, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
5815        let c_emb = vec![0.1, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
5816        let query = vec![1.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
5817
5818        let fill = |db: &MenteDb| -> (MemoryId, MemoryId, MemoryId) {
5819            let a = MemoryNode::new(
5820                AgentId::nil(),
5821                MemoryType::Semantic,
5822                "alpha".into(),
5823                a_emb.clone(),
5824            );
5825            let b = MemoryNode::new(
5826                AgentId::nil(),
5827                MemoryType::Semantic,
5828                "alpha prime".into(),
5829                b_emb.clone(),
5830            );
5831            let c = MemoryNode::new(
5832                AgentId::nil(),
5833                MemoryType::Semantic,
5834                "distinct".into(),
5835                c_emb.clone(),
5836            );
5837            let (ai, bi, ci) = (a.id, b.id, c.id);
5838            db.store(a).unwrap();
5839            db.store(b).unwrap();
5840            db.store(c).unwrap();
5841            (ai, bi, ci)
5842        };
5843
5844        // Default (mmr_lambda = 1.0): the two nearest fill the top 2, diverse c is out.
5845        let dir1 = tempfile::tempdir().unwrap();
5846        let db1 = MenteDb::open(dir1.path()).unwrap();
5847        let (_a1, _b1, c1) = fill(&db1);
5848        let base = db1
5849            .recall_hybrid_at(&query, None, 2, now_us(), None, None, None)
5850            .unwrap();
5851        let base_ids: Vec<MemoryId> = base.iter().map(|(id, _)| *id).collect();
5852        assert!(
5853            !base_ids.contains(&c1),
5854            "without MMR the diverse memory is out of the top 2"
5855        );
5856
5857        // MMR on (lambda = 0.5): the diverse memory is pulled into the top 2.
5858        let dir2 = tempfile::tempdir().unwrap();
5859        let cfg = CognitiveConfig {
5860            mmr_lambda: 0.5,
5861            ..CognitiveConfig::default()
5862        };
5863        let db2 = MenteDb::open_with_config(dir2.path(), cfg).unwrap();
5864        let (a2, _b2, c2) = fill(&db2);
5865        let mmr = db2
5866            .recall_hybrid_at(&query, None, 2, now_us(), None, None, None)
5867            .unwrap();
5868        let mmr_ids: Vec<MemoryId> = mmr.iter().map(|(id, _)| *id).collect();
5869        assert_eq!(
5870            mmr_ids.first().copied(),
5871            Some(a2),
5872            "most relevant is still first"
5873        );
5874        assert!(
5875            mmr_ids.contains(&c2),
5876            "MMR pulls the diverse memory into the top 2"
5877        );
5878    }
5879
5880    /// The contextual-retrieval hook: a term that appears only in a memory's
5881    /// context (not its content) still finds it via BM25, and the stored content
5882    /// is unchanged.
5883    #[test]
5884    fn contextual_hook_makes_context_terms_findable() {
5885        let dir = tempfile::tempdir().unwrap();
5886        let db = MenteDb::open(dir.path()).unwrap();
5887
5888        // "kubernetes" is in the context, never in the content.
5889        let node = MemoryNode::new(
5890            AgentId::nil(),
5891            MemoryType::Semantic,
5892            "rolled the pods back to the previous tag".into(),
5893            vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
5894        )
5895        .with_context("incident in the kubernetes cluster");
5896        let id = node.id;
5897        db.store(node).unwrap();
5898        // A decoy sharing neither the content nor the context terms.
5899        db.store(MemoryNode::new(
5900            AgentId::nil(),
5901            MemoryType::Semantic,
5902            "lunch plans for friday".into(),
5903            vec![0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1],
5904        ))
5905        .unwrap();
5906
5907        let hits = db
5908            .recall_hybrid_at(
5909                &[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
5910                Some("kubernetes"),
5911                5,
5912                now_us(),
5913                None,
5914                None,
5915                None,
5916            )
5917            .unwrap();
5918        let ids: Vec<MemoryId> = hits.iter().map(|(id, _)| *id).collect();
5919        assert!(
5920            ids.contains(&id),
5921            "a term only in the context must find the memory via BM25"
5922        );
5923        // The stored content is exactly what was written, no context leaked in.
5924        assert_eq!(
5925            db.get_memory(id).unwrap().content,
5926            "rolled the pods back to the previous tag"
5927        );
5928    }
5929}