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 ¤t_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}