Skip to main content

shodh_memory/memory/
retrieval.rs

1//! Production-grade retrieval engine for memory search
2//! Integrated with Vamana graph-based ANN and MiniLM embeddings
3//!
4//! Features Hebbian-inspired adaptive learning:
5//! - Outcome feedback: Memories that help complete tasks get reinforced
6//! - Co-activation strengthening: Memories retrieved together form associations
7//! - Time-based decay: Unused associations naturally weaken
8
9use anyhow::{Context, Result};
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tracing::{info, warn};
17
18use super::introspection::ConsolidationEventBuffer;
19use super::storage::{MemoryStorage, SearchCriteria};
20use super::types::*;
21use crate::constants::{
22    PREFETCH_RECENCY_FULL_BOOST, PREFETCH_RECENCY_FULL_HOURS, PREFETCH_RECENCY_PARTIAL_BOOST,
23    PREFETCH_RECENCY_PARTIAL_HOURS, PREFETCH_TEMPORAL_WINDOW_HOURS,
24    VECTOR_SEARCH_CANDIDATE_MULTIPLIER,
25};
26use crate::embeddings::{minilm::MiniLMEmbedder, Embedder};
27use crate::vector_db::vamana::{VamanaConfig, VamanaIndex};
28
29/// Filename for persisted Vamana index (instant startup)
30const VAMANA_INDEX_FILE: &str = "vamana.idx";
31
32/// Multi-modal retrieval engine with production vector search
33///
34/// # Lock Ordering (SHO-72)
35///
36/// To prevent deadlocks, locks MUST be acquired in this order:
37///
38/// 1. `vector_index` - Vector similarity search index
39/// 2. `id_mapping` - Memory ID ↔ Vector ID mapping
40/// 3. `consolidation_events` - Introspection event buffer
41///
42/// **Rules:**
43/// - Never acquire a higher-numbered lock while holding a lower-numbered lock
44/// - For read operations, prefer `read()` over `write()` when possible
45/// - Release locks as soon as possible (don't hold during I/O)
46///
47/// **Note:** Memory graph (Hebbian learning) has been consolidated into GraphMemory
48/// which is managed at the API layer (MultiUserMemoryManager.graph_memories)
49pub struct RetrievalEngine {
50    storage: Arc<MemoryStorage>,
51    embedder: Arc<MiniLMEmbedder>,
52    /// Lock order: 1 - Acquire first
53    vector_index: Arc<RwLock<VamanaIndex>>,
54    /// Lock order: 2
55    id_mapping: Arc<RwLock<IdMapping>>,
56    /// Storage path for persisting vector index and ID mapping
57    storage_path: PathBuf,
58    /// Lock order: 3 - Acquire last (was 4 when graph was here)
59    /// Shared consolidation event buffer for introspection
60    /// Records edge formation, strengthening, and pruning events
61    consolidation_events: Option<Arc<RwLock<ConsolidationEventBuffer>>>,
62}
63
64/// Bidirectional mapping between memory IDs and vector IDs
65///
66/// Supports multiple vectors per memory for chunked embeddings.
67/// When long content is split into chunks, each chunk gets its own vector ID,
68/// but all map back to the same MemoryId.
69#[derive(serde::Serialize, serde::Deserialize, Default)]
70struct IdMapping {
71    /// Maps each memory to ALL its vector IDs (supports chunked embeddings)
72    memory_to_vectors: HashMap<MemoryId, Vec<u32>>,
73    /// Maps each vector ID back to its parent memory
74    vector_to_memory: HashMap<u32, MemoryId>,
75}
76
77impl IdMapping {
78    fn new() -> Self {
79        Self {
80            memory_to_vectors: HashMap::new(),
81            vector_to_memory: HashMap::new(),
82        }
83    }
84
85    /// Insert a single vector for a memory (legacy/simple case)
86    ///
87    /// Idempotent: removes any existing mappings for this memory first
88    /// to prevent orphaned vector IDs from accumulating.
89    fn insert(&mut self, memory_id: MemoryId, vector_id: u32) {
90        // Remove stale mappings to prevent orphans on re-index
91        if let Some(old_ids) = self.memory_to_vectors.remove(&memory_id) {
92            for old_id in old_ids {
93                self.vector_to_memory.remove(&old_id);
94            }
95        }
96        self.vector_to_memory.insert(vector_id, memory_id.clone());
97        self.memory_to_vectors.insert(memory_id, vec![vector_id]);
98    }
99
100    /// Insert multiple vectors for a memory (chunked embedding case)
101    ///
102    /// Idempotent: removes any existing mappings for this memory first
103    /// to prevent orphaned vector IDs from accumulating.
104    fn insert_chunks(&mut self, memory_id: MemoryId, vector_ids: Vec<u32>) {
105        // Remove stale mappings to prevent orphans on re-index
106        if let Some(old_ids) = self.memory_to_vectors.remove(&memory_id) {
107            for old_id in old_ids {
108                self.vector_to_memory.remove(&old_id);
109            }
110        }
111        for &vid in &vector_ids {
112            self.vector_to_memory.insert(vid, memory_id.clone());
113        }
114        self.memory_to_vectors.insert(memory_id, vector_ids);
115    }
116
117    fn get_memory_id(&self, vector_id: u32) -> Option<&MemoryId> {
118        self.vector_to_memory.get(&vector_id)
119    }
120
121    /// Remove a memory and return ALL its vector IDs
122    fn remove_all(&mut self, memory_id: &MemoryId) -> Vec<u32> {
123        if let Some(vector_ids) = self.memory_to_vectors.remove(memory_id) {
124            for vid in &vector_ids {
125                self.vector_to_memory.remove(vid);
126            }
127            vector_ids
128        } else {
129            Vec::new()
130        }
131    }
132
133    /// Number of unique memories in the mapping
134    fn len(&self) -> usize {
135        self.memory_to_vectors.len()
136    }
137
138    fn clear(&mut self) {
139        self.memory_to_vectors.clear();
140        self.vector_to_memory.clear();
141    }
142}
143
144impl RetrievalEngine {
145    /// Create new retrieval engine with shared embedder (CRITICAL: embedder loaded only once)
146    ///
147    /// ATOMIC ARCHITECTURE: RocksDB is the ONLY source of truth.
148    /// - Vector mappings are stored atomically with memories in RocksDB
149    /// - Vamana index is rebuilt from RocksDB on startup (pure in-memory cache)
150    /// - No more file-based IdMapping = no more orphaned memories
151    pub fn new(storage: Arc<MemoryStorage>, embedder: Arc<MiniLMEmbedder>) -> Result<Self> {
152        Self::with_event_buffer(storage, embedder, None)
153    }
154
155    /// Create retrieval engine with event buffer for consolidation introspection
156    ///
157    /// The event buffer is used to record Hebbian learning events:
158    /// - Edge formation (new associations)
159    /// - Edge strengthening (co-activation)
160    /// - Edge potentiation (LTP)
161    /// - Edge pruning (decay below threshold)
162    ///
163    /// ATOMIC STARTUP: Rebuilds Vamana from RocksDB mappings for crash safety.
164    pub fn with_event_buffer(
165        storage: Arc<MemoryStorage>,
166        embedder: Arc<MiniLMEmbedder>,
167        consolidation_events: Option<Arc<RwLock<ConsolidationEventBuffer>>>,
168    ) -> Result<Self> {
169        let storage_path = storage.path().to_path_buf();
170
171        // Initialize Vamana index optimized for 10M+ memories per user
172        let vamana_config = VamanaConfig {
173            dimension: 384,        // MiniLM dimension
174            max_degree: 32,        // Increased for better recall at scale
175            search_list_size: 100, // 2x for better accuracy with 10M vectors
176            alpha: 1.2,
177            use_mmap: true, // Memory-mapped: OS manages paging, RSS stays low at scale
178            ..Default::default()
179        };
180
181        let vamana_storage = storage_path.join("vector_index");
182        std::fs::create_dir_all(&vamana_storage)?;
183        let vector_index = VamanaIndex::with_storage_path(vamana_config, Some(vamana_storage))
184            .context("Failed to initialize Vamana vector index")?;
185        let id_mapping = IdMapping::new();
186
187        // NOTE: Memory graph (Hebbian associations) has been consolidated into GraphMemory
188        // which is managed at the API layer (MultiUserMemoryManager.graph_memories)
189        // This enables persistent storage in RocksDB with proper Hebbian learning
190
191        let engine = Self {
192            storage,
193            embedder,
194            vector_index: Arc::new(RwLock::new(vector_index)),
195            id_mapping: Arc::new(RwLock::new(id_mapping)),
196            storage_path,
197            consolidation_events,
198        };
199
200        // ATOMIC STARTUP: Rebuild Vamana from RocksDB (single source of truth)
201        engine.rebuild_from_rocksdb()?;
202
203        Ok(engine)
204    }
205
206    /// Initialize Vamana index from persisted file or rebuild from RocksDB
207    ///
208    /// INSTANT STARTUP ARCHITECTURE:
209    /// 1. Try loading .vamana file (instant, ~10ms for 500k vectors)
210    /// 2. Fall back to RocksDB rebuild (slow, ~seconds for 500k vectors)
211    ///
212    /// RocksDB remains the source of truth for ID mappings.
213    /// The .vamana file is a cache that can be regenerated.
214    fn rebuild_from_rocksdb(&self) -> Result<()> {
215        let start_time = std::time::Instant::now();
216
217        // Try instant startup from persisted Vamana file
218        let vamana_path = self
219            .storage_path
220            .join("vector_index")
221            .join(VAMANA_INDEX_FILE);
222        if vamana_path.exists() {
223            if let Ok(loaded) = self.try_load_persisted_vamana(&vamana_path) {
224                if loaded {
225                    info!(
226                        "Instant startup: loaded Vamana in {:.2}ms",
227                        start_time.elapsed().as_secs_f64() * 1000.0
228                    );
229                    return Ok(());
230                }
231            }
232        }
233
234        // Fall back to rebuilding from RocksDB
235        info!("No valid .vamana file, rebuilding from RocksDB...");
236
237        // Get all vector mappings from RocksDB
238        let mappings = self.storage.get_all_vector_mappings()?;
239
240        if !mappings.is_empty() {
241            // Fast path: Mappings exist in RocksDB
242            info!(
243                "Loading {} vector mappings from RocksDB (atomic storage)",
244                mappings.len()
245            );
246
247            // LOCK ORDERING: Always acquire vector_index (1) before id_mapping (2)
248            let mut vector_index = self.vector_index.write();
249            let mut id_mapping = self.id_mapping.write();
250            id_mapping.clear();
251            let mut indexed = 0;
252            let mut failed = 0;
253
254            for (memory_id, entry) in &mappings {
255                // Check if this entry has text vectors (current modality)
256                if entry.text_vectors().is_none() {
257                    continue;
258                }
259
260                // Get memory with embeddings from storage
261                if let Ok(memory) = self.storage.get(memory_id) {
262                    if let Some(ref embedding) = memory.experience.embeddings {
263                        // Insert into Vamana and get new vector_id
264                        match vector_index.add_vector(embedding.clone()) {
265                            Ok(new_vector_id) => {
266                                id_mapping.insert(memory_id.clone(), new_vector_id);
267                                indexed += 1;
268                            }
269                            Err(e) => {
270                                tracing::warn!(
271                                    "Failed to index memory {} during rebuild: {}",
272                                    memory_id.0,
273                                    e
274                                );
275                                failed += 1;
276                            }
277                        }
278                    }
279                }
280            }
281
282            let elapsed = start_time.elapsed();
283            info!(
284                "Rebuilt Vamana from RocksDB: {} indexed, {} failed in {:.2}s",
285                indexed,
286                failed,
287                elapsed.as_secs_f64()
288            );
289        } else {
290            // Slow path: No mappings in RocksDB - need full migration
291            // This happens on first run after upgrade to atomic storage
292            info!("No vector mappings in RocksDB - checking for migration...");
293            self.migrate_to_atomic_storage()?;
294        }
295
296        Ok(())
297    }
298
299    /// Migrate existing memories to atomic storage
300    ///
301    /// Called when RocksDB has no vector mappings (first run after upgrade).
302    /// Iterates all memories with embeddings and creates atomic mappings.
303    fn migrate_to_atomic_storage(&self) -> Result<()> {
304        let start_time = std::time::Instant::now();
305
306        // Get all memories from storage
307        let memories = self.storage.get_all()?;
308        let total = memories.len();
309
310        if total == 0 {
311            info!("No memories to migrate");
312            return Ok(());
313        }
314
315        info!("Migrating {} memories to atomic storage...", total);
316
317        // LOCK ORDERING: Always acquire vector_index (1) before id_mapping (2)
318        let mut vector_index = self.vector_index.write();
319        let mut id_mapping = self.id_mapping.write();
320        let mut migrated = 0;
321        let mut skipped = 0;
322        let mut failed = 0;
323
324        for (i, memory) in memories.iter().enumerate() {
325            // Only migrate memories with embeddings
326            if let Some(ref embedding) = memory.experience.embeddings {
327                // Insert into Vamana
328                match vector_index.add_vector(embedding.clone()) {
329                    Ok(vector_id) => {
330                        // Update in-memory mapping
331                        id_mapping.insert(memory.id.clone(), vector_id);
332
333                        // Store mapping in RocksDB for future startups
334                        if let Err(e) = self
335                            .storage
336                            .update_vector_mapping(&memory.id, vec![vector_id])
337                        {
338                            tracing::warn!("Failed to persist mapping for {}: {}", memory.id.0, e);
339                            failed += 1;
340                        } else {
341                            migrated += 1;
342                        }
343                    }
344                    Err(e) => {
345                        tracing::warn!("Failed to index memory {}: {}", memory.id.0, e);
346                        failed += 1;
347                    }
348                }
349            } else {
350                skipped += 1;
351            }
352
353            // Progress logging
354            if (i + 1) % 500 == 0 || i + 1 == total {
355                info!(
356                    "Migration progress: {}/{} ({:.1}%)",
357                    i + 1,
358                    total,
359                    (i + 1) as f64 / total as f64 * 100.0
360                );
361            }
362        }
363
364        let elapsed = start_time.elapsed();
365        info!(
366            "Migration complete: {} migrated, {} skipped (no embeddings), {} failed in {:.2}s",
367            migrated,
368            skipped,
369            failed,
370            elapsed.as_secs_f64()
371        );
372
373        Ok(())
374    }
375
376    /// Try loading Vamana from persisted file for instant startup
377    ///
378    /// Returns Ok(true) if successfully loaded, Ok(false) if should fall back to rebuild.
379    /// Verifies checksum and cross-checks with RocksDB mappings.
380    fn try_load_persisted_vamana(&self, vamana_path: &Path) -> Result<bool> {
381        // Verify file integrity first
382        if !VamanaIndex::verify_index_file(vamana_path)? {
383            warn!("Vamana file checksum mismatch, will rebuild");
384            return Ok(false);
385        }
386
387        // Load the persisted index
388        let mut loaded_index = match VamanaIndex::load_from_file(vamana_path) {
389            Ok(idx) => idx,
390            Err(e) => {
391                warn!("Failed to load Vamana file: {}, will rebuild", e);
392                return Ok(false);
393            }
394        };
395
396        let loaded_count = loaded_index.len();
397
398        // Get mappings from RocksDB to rebuild IdMapping
399        let mappings = self.storage.get_all_vector_mappings()?;
400        let rocksdb_count = mappings
401            .iter()
402            .filter(|(_, e)| e.text_vectors().is_some())
403            .count();
404
405        // Check for significant drift (>10% difference suggests corruption or data loss)
406        let drift_ratio = if loaded_count > 0 {
407            (loaded_count as f64 - rocksdb_count as f64).abs() / loaded_count as f64
408        } else {
409            0.0
410        };
411
412        if drift_ratio > 0.1 && loaded_count > 100 {
413            warn!(
414                "Vamana/RocksDB drift too high ({:.1}%): {} vs {}, will rebuild",
415                drift_ratio * 100.0,
416                loaded_count,
417                rocksdb_count
418            );
419            return Ok(false);
420        }
421
422        // Identify mappings that point to vectors missing from persisted Vamana.
423        // We can recover these without a full rebuild when drift is small.
424        let missing_vector_mappings: Vec<MemoryId> = mappings
425            .iter()
426            .filter_map(|(memory_id, entry)| {
427                let vector_ids = entry.text_vectors()?;
428                if vector_ids.is_empty() {
429                    return None;
430                }
431                let has_in_range_vector =
432                    vector_ids.iter().any(|&vid| (vid as usize) < loaded_count);
433                if has_in_range_vector {
434                    None
435                } else {
436                    Some(memory_id.clone())
437                }
438            })
439            .collect();
440
441        // Replace the vector index with the loaded one.
442        // Restore search_list_size to our configured value since persistence
443        // uses a hardcoded default (75) that is lower than our runtime config (100).
444        {
445            let mut index = self.vector_index.write();
446            loaded_index.config.search_list_size = 100;
447            *index = loaded_index;
448        }
449
450        // Rebuild IdMapping from RocksDB (fast - just HashMap operations)
451        let mut id_mapping = self.id_mapping.write();
452        id_mapping.clear();
453
454        for (memory_id, entry) in mappings.iter() {
455            if let Some(vector_ids) = entry.text_vectors() {
456                if !vector_ids.is_empty() {
457                    // Use the first vector_id for simple case
458                    // For chunked, we'd need to store all of them
459                    if vector_ids.len() == 1 {
460                        id_mapping.insert(memory_id.clone(), vector_ids[0]);
461                    } else {
462                        id_mapping.insert_chunks(memory_id.clone(), vector_ids.clone());
463                    }
464                }
465            }
466        }
467        drop(id_mapping);
468
469        // Recover memories whose mapped vectors are missing from loaded Vamana.
470        // This prevents permanently losing searchable vectors when persisted index
471        // has minor drift but not enough to trigger full rebuild.
472        if !missing_vector_mappings.is_empty() {
473            // LOCK ORDERING: vector_index (1) before id_mapping (2)
474            let mut index = self.vector_index.write();
475            let mut id_mapping = self.id_mapping.write();
476            let mut recovered = 0usize;
477            let mut recovery_failed = 0usize;
478
479            for memory_id in &missing_vector_mappings {
480                match self.storage.get(memory_id) {
481                    Ok(memory) => {
482                        if let Some(ref embedding) = memory.experience.embeddings {
483                            match index.add_vector(embedding.clone()) {
484                                Ok(new_vector_id) => {
485                                    id_mapping.remove_all(memory_id);
486                                    id_mapping.insert(memory_id.clone(), new_vector_id);
487                                    recovered += 1;
488                                }
489                                Err(e) => {
490                                    warn!(
491                                        "Failed to recover missing vector for memory {}: {}",
492                                        memory_id.0, e
493                                    );
494                                    recovery_failed += 1;
495                                }
496                            }
497                        } else {
498                            recovery_failed += 1;
499                        }
500                    }
501                    Err(e) => {
502                        warn!(
503                            "Failed to load memory {} for vector recovery: {}",
504                            memory_id.0, e
505                        );
506                        recovery_failed += 1;
507                    }
508                }
509            }
510
511            if recovered > 0 || recovery_failed > 0 {
512                info!(
513                    "Recovered {} missing vectors from RocksDB mappings ({} failed)",
514                    recovered, recovery_failed
515                );
516            }
517        }
518
519        info!(
520            "Loaded {} vectors from .vamana, {} mappings from RocksDB",
521            self.vector_index.read().len(),
522            self.id_mapping.read().len()
523        );
524
525        Ok(true)
526    }
527
528    /// Set the consolidation event buffer (for late binding after construction)
529    pub fn set_consolidation_events(&mut self, events: Arc<RwLock<ConsolidationEventBuffer>>) {
530        self.consolidation_events = Some(events);
531    }
532
533    /// Save Vamana index to disk for instant startup
534    ///
535    /// HYBRID ARCHITECTURE:
536    /// - RocksDB: Source of truth for memories and ID mappings
537    /// - .vamana file: Persisted graph for instant startup (skip rebuild)
538    ///
539    /// On next startup, if .vamana exists and is valid, we load it directly.
540    /// Otherwise, we fall back to rebuilding from RocksDB.
541    pub fn save(&self) -> Result<()> {
542        let index_path = self.storage_path.join("vector_index");
543        fs::create_dir_all(&index_path)?;
544
545        let vamana_path = index_path.join(VAMANA_INDEX_FILE);
546
547        // LOCK ORDERING: Always acquire vector_index (1) before id_mapping (2)
548        let vector_index = self.vector_index.read();
549        let id_mapping = self.id_mapping.read();
550        let vector_count = id_mapping.len();
551        if vector_count > 0 {
552            // Atomic save: write to .tmp file then rename to avoid partial writes
553            let tmp_path = vamana_path.with_extension("vamana.tmp");
554            match vector_index.save_to_file(&tmp_path) {
555                Ok(()) => {
556                    // Atomic rename — on crash, either the old or new file survives
557                    if let Err(e) = fs::rename(&tmp_path, &vamana_path) {
558                        warn!(
559                            "Failed to rename .vamana.tmp to .vamana: {} (removing tmp)",
560                            e
561                        );
562                        let _ = fs::remove_file(&tmp_path);
563                    } else {
564                        info!(
565                            "Saved Vamana index: {} vectors to {} (instant startup enabled)",
566                            vector_count,
567                            vamana_path.display()
568                        );
569                    }
570                }
571                Err(e) => {
572                    warn!(
573                        "Failed to save Vamana index (will rebuild on restart): {}",
574                        e
575                    );
576                    let _ = fs::remove_file(&tmp_path);
577                }
578            }
579        } else {
580            info!("Vamana index empty, skipping persistence");
581        }
582
583        Ok(())
584    }
585
586    /// Get number of vectors in the index
587    pub fn len(&self) -> usize {
588        self.id_mapping.read().len()
589    }
590
591    /// Check if index is empty
592    pub fn is_empty(&self) -> bool {
593        self.len() == 0
594    }
595
596    /// Get set of all indexed memory IDs (for integrity checking)
597    pub fn get_indexed_memory_ids(&self) -> HashSet<MemoryId> {
598        self.id_mapping
599            .read()
600            .memory_to_vectors
601            .keys()
602            .cloned()
603            .collect()
604    }
605
606    /// Add memory to vector index with atomic RocksDB storage
607    ///
608    /// ATOMIC ARCHITECTURE: This method stores the vector mapping atomically
609    /// in RocksDB alongside the memory data, ensuring no orphaned memories.
610    ///
611    /// For long content, this chunks the text and creates multiple embeddings
612    /// to ensure ALL content is searchable, not just the first 256 tokens.
613    pub fn index_memory(&self, memory: &Memory) -> Result<()> {
614        use crate::embeddings::chunking::{chunk_text, ChunkConfig};
615
616        let text = Self::extract_searchable_text(memory);
617        let chunk_config = ChunkConfig::default();
618        let chunk_result = chunk_text(&text, &chunk_config);
619
620        let vector_ids = if chunk_result.was_chunked {
621            // Long content: embed each chunk separately
622            // Pre-compute all embeddings OUTSIDE the write lock to avoid blocking searches
623            let embeddings: Vec<Vec<f32>> = chunk_result
624                .chunks
625                .iter()
626                .map(|chunk| {
627                    self.embedder
628                        .encode(chunk)
629                        .context("Failed to generate chunk embedding")
630                })
631                .collect::<Result<Vec<_>>>()?;
632
633            // Insert pre-computed vectors under a short write lock
634            let mut ids = Vec::with_capacity(embeddings.len());
635            let mut index = self.vector_index.write();
636            for embedding in embeddings {
637                let vector_id = index
638                    .add_vector(embedding)
639                    .context("Failed to add chunk vector to index")?;
640                ids.push(vector_id);
641            }
642            drop(index);
643
644            // Update in-memory mapping
645            self.id_mapping
646                .write()
647                .insert_chunks(memory.id.clone(), ids.clone());
648
649            tracing::debug!(
650                "Indexed memory {} with {} chunks (original: {} chars)",
651                memory.id.0,
652                chunk_result.chunks.len(),
653                chunk_result.original_length
654            );
655
656            ids
657        } else {
658            // Short content: single embedding (use pre-computed if available)
659            let embedding = if let Some(emb) = &memory.experience.embeddings {
660                emb.clone()
661            } else {
662                self.embedder
663                    .encode(&text)
664                    .context("Failed to generate embedding")?
665            };
666
667            let mut index = self.vector_index.write();
668            let vector_id = index
669                .add_vector(embedding)
670                .context("Failed to add vector to index")?;
671
672            // Update in-memory mapping
673            self.id_mapping.write().insert(memory.id.clone(), vector_id);
674
675            vec![vector_id]
676        };
677
678        // ATOMIC: Store vector mapping in RocksDB
679        // This ensures the mapping survives restarts and can't become orphaned
680        self.storage
681            .update_vector_mapping(&memory.id, vector_ids)
682            .context("Failed to persist vector mapping to RocksDB")?;
683
684        Ok(())
685    }
686
687    /// Re-index an existing memory with updated embeddings
688    ///
689    /// Used when memory content is updated via upsert() to ensure the vector
690    /// index reflects the new content.
691    ///
692    /// Strategy: Remove old vector and add new one (Vamana doesn't support update-in-place)
693    pub fn reindex_memory(&self, memory: &Memory) -> Result<()> {
694        // Check if memory is already indexed (may have multiple vectors from chunking)
695        let existing_vector_ids = {
696            let mapping = self.id_mapping.read();
697            mapping
698                .memory_to_vectors
699                .get(&memory.id)
700                .cloned()
701                .unwrap_or_default()
702        };
703
704        if !existing_vector_ids.is_empty() {
705            // Soft-delete old vectors in Vamana so they're excluded from search results
706            // and counted toward the compaction threshold (30% deletion ratio triggers rebuild).
707            // Without this, reindexed vectors become invisible ghost entries that waste
708            // search candidate slots and never trigger compaction.
709            {
710                let index = self.vector_index.read();
711                for &vid in &existing_vector_ids {
712                    index.mark_deleted(vid);
713                }
714            }
715
716            // Remove old ID mappings
717            let mut mapping = self.id_mapping.write();
718            mapping.memory_to_vectors.remove(&memory.id);
719            for vector_id in existing_vector_ids {
720                mapping.vector_to_memory.remove(&vector_id);
721            }
722        }
723
724        // Add with new embedding (may create multiple chunks)
725        self.index_memory(memory)
726    }
727
728    /// Remove a memory from the vector index
729    ///
730    /// ATOMIC ARCHITECTURE: Removes the vector mapping from RocksDB atomically.
731    /// The in-memory Vamana index is updated immediately, and the RocksDB mapping
732    /// is deleted to ensure consistency on restart.
733    ///
734    /// Returns true if the memory was found and removed, false if not indexed.
735    pub fn remove_memory(&self, memory_id: &MemoryId) -> bool {
736        // Remove from in-memory ID mapping and get the vector IDs
737        let vector_ids = self.id_mapping.write().remove_all(memory_id);
738
739        if !vector_ids.is_empty() {
740            // Mark vectors as deleted in Vamana (soft delete)
741            let index = self.vector_index.read();
742            for vid in &vector_ids {
743                index.mark_deleted(*vid);
744            }
745
746            // NOTE: Memory graph edges are managed in GraphMemory at the API layer
747            // GraphMemory handles cleanup via its own mechanisms
748
749            // ATOMIC: Remove vector mapping from RocksDB
750            if let Err(e) = self.storage.delete_vector_mapping(memory_id) {
751                tracing::warn!(
752                    "Failed to delete vector mapping from RocksDB for {}: {}",
753                    memory_id.0,
754                    e
755                );
756            }
757
758            tracing::debug!(
759                "Removed memory {:?} from vector index ({} vectors)",
760                memory_id,
761                vector_ids.len()
762            );
763            true
764        } else {
765            tracing::debug!("Memory {:?} not found in vector index", memory_id);
766            false
767        }
768    }
769
770    /// Extract searchable text from memory
771    fn extract_searchable_text(memory: &Memory) -> String {
772        // Start with main content
773        let mut text = memory.experience.content.clone();
774
775        // Add entities
776        if !memory.experience.entities.is_empty() {
777            text.push(' ');
778            text.push_str(&memory.experience.entities.join(" "));
779        }
780
781        // Add rich context if available
782        if let Some(context) = &memory.experience.context {
783            // Add conversation topic
784            if let Some(topic) = &context.conversation.topic {
785                text.push(' ');
786                text.push_str(topic);
787            }
788            // Add recent conversation messages
789            if !context.conversation.recent_messages.is_empty() {
790                text.push(' ');
791                text.push_str(&context.conversation.recent_messages.join(" "));
792            }
793            // Add project name
794            if let Some(name) = &context.project.name {
795                text.push(' ');
796                text.push_str(name);
797            }
798
799            // SHO-104: Add emotional context - emotion labels improve semantic matching
800            if let Some(emotion) = &context.emotional.dominant_emotion {
801                text.push(' ');
802                text.push_str(emotion);
803            }
804
805            // SHO-104: Add episode type for episodic grouping
806            if let Some(episode_type) = &context.episode.episode_type {
807                text.push(' ');
808                text.push_str(episode_type);
809            }
810        }
811
812        // Add outcomes
813        if !memory.experience.outcomes.is_empty() {
814            text.push(' ');
815            text.push_str(&memory.experience.outcomes.join(" "));
816        }
817
818        text
819    }
820
821    /// Search for memory IDs only (for cache-aware retrieval)
822    ///
823    /// With chunked embeddings, multiple vectors can map to the same memory.
824    /// This function deduplicates by MemoryId, keeping the highest-scoring chunk.
825    ///
826    /// Returns (MemoryId, similarity_score) pairs
827    pub fn search_ids(&self, query: &Query, limit: usize) -> Result<Vec<(MemoryId, f32)>> {
828        // BUG-006 FIX: Log warning for empty queries
829        let query_embedding = if let Some(embedding) = &query.query_embedding {
830            embedding.clone()
831        } else if let Some(query_text) = &query.query_text {
832            self.embedder
833                .encode(query_text)
834                .context("Failed to generate query embedding")?
835        } else {
836            tracing::warn!("Empty query in search_ids: no query_text or query_embedding provided");
837            return Ok(Vec::new());
838        };
839
840        // TEMPORAL PRE-FILTER: If episode_id is provided, narrow search to that episode
841        // This implements the architecture: Temporal → Graph → Semantic
842        // Episode filtering happens FIRST to "point in the right direction"
843        let episode_candidates: Option<HashSet<MemoryId>> =
844            if let Some(episode_id) = &query.episode_id {
845                let episode_memories = self
846                    .storage
847                    .search(SearchCriteria::ByEpisode(episode_id.clone()))?;
848                if episode_memories.is_empty() {
849                    tracing::debug!(
850                        "No memories found in episode {}, falling back to global search",
851                        episode_id
852                    );
853                    None
854                } else {
855                    tracing::debug!(
856                        "Episode {} has {} memories, using as temporal filter",
857                        episode_id,
858                        episode_memories.len()
859                    );
860                    Some(episode_memories.into_iter().map(|m| m.id).collect())
861                }
862            } else {
863                None
864            };
865
866        // Search vector index - fetch more candidates for chunk deduplication
867        let index = self.vector_index.read();
868        let results = index
869            .search(
870                &query_embedding,
871                limit * VECTOR_SEARCH_CANDIDATE_MULTIPLIER * 2,
872            )
873            .context("Vector search failed")?;
874
875        // Map vector IDs to memory IDs, deduplicating by MemoryId (keep highest similarity)
876        //
877        // CRITICAL FIX: Vamana returns DISTANCE, not similarity.
878        // For NormalizedDotProduct: distance = -dot(a,b)
879        // - Similar vectors have dot ≈ 1.0, so distance ≈ -1.0
880        // - Orthogonal vectors have dot ≈ 0.0, so distance ≈ 0.0
881        // Convert: similarity = -distance (so similarity = dot product = cosine similarity)
882        let id_mapping = self.id_mapping.read();
883        let mut best_scores: std::collections::HashMap<MemoryId, f32> =
884            std::collections::HashMap::new();
885
886        for (vector_id, distance) in results {
887            // Convert distance to similarity: similarity = -distance
888            // For NormalizedDotProduct, this gives us the actual dot product/cosine similarity
889            let similarity = -distance;
890
891            if let Some(memory_id) = id_mapping.get_memory_id(vector_id) {
892                // TEMPORAL FILTER: If episode pre-filter is active, skip memories outside episode
893                if let Some(ref candidates) = episode_candidates {
894                    if !candidates.contains(memory_id) {
895                        continue; // Skip - not in target episode
896                    }
897                }
898
899                // Keep the highest similarity for each memory (best matching chunk)
900                best_scores
901                    .entry(memory_id.clone())
902                    .and_modify(|score| {
903                        if similarity > *score {
904                            *score = similarity;
905                        }
906                    })
907                    .or_insert(similarity);
908            }
909        }
910
911        // Convert to vec and sort by similarity descending (highest first)
912        let mut memory_ids: Vec<(MemoryId, f32)> = best_scores.into_iter().collect();
913        memory_ids.sort_by(|a, b| b.1.total_cmp(&a.1));
914        memory_ids.truncate(limit);
915
916        Ok(memory_ids)
917    }
918
919    /// Get memory from storage by ID
920    pub fn get_from_storage(&self, id: &MemoryId) -> Result<Memory> {
921        self.storage.get(id)
922    }
923
924    /// Search for similar memories by embedding directly (SHO-106)
925    ///
926    /// Used for interference detection to find memories similar to a new memory.
927    /// Optionally excludes a specific memory ID from results.
928    ///
929    /// With chunked embeddings, multiple vectors can map to the same memory.
930    /// This function deduplicates by MemoryId, keeping the highest-scoring chunk.
931    ///
932    /// Returns (MemoryId, similarity_score) pairs
933    pub fn search_by_embedding(
934        &self,
935        embedding: &[f32],
936        limit: usize,
937        exclude_id: Option<&MemoryId>,
938    ) -> Result<Vec<(MemoryId, f32)>> {
939        // Search vector index - fetch more candidates to account for chunk deduplication
940        let index = self.vector_index.read();
941        let results = index
942            .search(embedding, limit * VECTOR_SEARCH_CANDIDATE_MULTIPLIER * 2)
943            .context("Vector search by embedding failed")?;
944
945        // Map vector IDs to memory IDs, deduplicating by MemoryId (keep highest similarity)
946        //
947        // CRITICAL FIX: Vamana returns DISTANCE, not similarity.
948        // Convert: similarity = -distance (for NormalizedDotProduct)
949        let id_mapping = self.id_mapping.read();
950        let mut best_scores: std::collections::HashMap<MemoryId, f32> =
951            std::collections::HashMap::new();
952
953        for (vector_id, distance) in results {
954            // Convert distance to similarity
955            let similarity = -distance;
956
957            if let Some(memory_id) = id_mapping.get_memory_id(vector_id) {
958                // Skip excluded ID
959                if let Some(exclude) = exclude_id {
960                    if memory_id == exclude {
961                        continue;
962                    }
963                }
964
965                // Keep the highest similarity for each memory (best matching chunk)
966                best_scores
967                    .entry(memory_id.clone())
968                    .and_modify(|score| {
969                        if similarity > *score {
970                            *score = similarity;
971                        }
972                    })
973                    .or_insert(similarity);
974            }
975        }
976
977        // Convert to vec and sort by similarity descending (highest first)
978        let mut memory_ids: Vec<(MemoryId, f32)> = best_scores.into_iter().collect();
979        memory_ids.sort_by(|a, b| b.1.total_cmp(&a.1));
980        memory_ids.truncate(limit);
981
982        Ok(memory_ids)
983    }
984
985    /// Search for memories using multiple retrieval modes (zero-copy with Arc)
986    pub fn search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
987        let results = match query.retrieval_mode {
988            // Standard modes
989            RetrievalMode::Similarity => self.similarity_search(query, limit)?,
990            RetrievalMode::Temporal => self.temporal_search(query, limit)?,
991            RetrievalMode::Causal => self.causal_search(query, limit)?,
992            RetrievalMode::Associative => self.associative_search(query, limit)?,
993            RetrievalMode::Hybrid => self.hybrid_search(query, limit)?,
994            // Robotics-specific modes
995            RetrievalMode::Spatial => self.spatial_search(query, limit)?,
996            RetrievalMode::Mission => self.mission_search(query, limit)?,
997            RetrievalMode::ActionOutcome => self.action_outcome_search(query, limit)?,
998        };
999
1000        Ok(results)
1001    }
1002
1003    /// PRODUCTION: Similarity search using Vamana graph-based ANN (sub-millisecond, zero-copy)
1004    fn similarity_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1005        // BUG-006 FIX: Log warning for empty queries
1006        let query_embedding = if let Some(embedding) = &query.query_embedding {
1007            embedding.clone()
1008        } else if let Some(query_text) = &query.query_text {
1009            self.embedder
1010                .encode(query_text)
1011                .context("Failed to generate query embedding")?
1012        } else {
1013            tracing::warn!(
1014                "Empty query in similarity_search: no query_text or query_embedding provided"
1015            );
1016            return Ok(Vec::new());
1017        };
1018
1019        // Search Vamana index with candidate multiplier for filtering headroom
1020        let index = self.vector_index.read();
1021        let results = index
1022            .search(&query_embedding, limit * VECTOR_SEARCH_CANDIDATE_MULTIPLIER)
1023            .context("Vector search failed")?;
1024
1025        // Map vector IDs to memory IDs and fetch memories.
1026        // Deduplicate by MemoryId: chunked memories produce multiple vectors that
1027        // all map to the same MemoryId; without dedup they consume multiple result slots.
1028        let id_mapping = self.id_mapping.read();
1029        let mut memories = Vec::new();
1030        let mut seen_ids = std::collections::HashSet::new();
1031
1032        for (vector_id, _distance) in results {
1033            if let Some(memory_id) = id_mapping.get_memory_id(vector_id) {
1034                if !seen_ids.insert(memory_id.clone()) {
1035                    continue; // Already included this memory from a closer chunk
1036                }
1037                if let Ok(memory) = self.storage.get(memory_id) {
1038                    let shared_memory = Arc::new(memory);
1039                    if self.matches_filters(&shared_memory, query) {
1040                        memories.push(shared_memory);
1041                        if memories.len() >= limit {
1042                            break;
1043                        }
1044                    }
1045                }
1046            }
1047        }
1048
1049        Ok(memories)
1050    }
1051
1052    /// Check if memory matches query filters
1053    ///
1054    /// Delegates to Query::matches() which is the SINGLE source of truth for all filter logic.
1055    /// This ensures consistent filtering across all memory tiers and retrieval modes.
1056    #[inline]
1057    pub fn matches_filters(&self, memory: &Memory, query: &Query) -> bool {
1058        query.matches(memory)
1059    }
1060
1061    fn temporal_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1062        // TEMPORAL HIERARCHY:
1063        // 1. Episode (most specific) - same conversation/session
1064        // 2. Date range (fallback) - within time window
1065
1066        let criteria = if let Some(episode_id) = &query.episode_id {
1067            // Episode-based temporal search: memories in same episode, ordered by sequence
1068            SearchCriteria::ByEpisodeSequence {
1069                episode_id: episode_id.clone(),
1070                min_sequence: None, // Get all in episode
1071                max_sequence: None,
1072            }
1073        } else if let Some((start, end)) = &query.time_range {
1074            // Date-based temporal search
1075            SearchCriteria::ByDate {
1076                start: *start,
1077                end: *end,
1078            }
1079        } else {
1080            // Default: last 7 days
1081            let end = chrono::Utc::now();
1082            let start = end - chrono::Duration::days(7);
1083            SearchCriteria::ByDate { start, end }
1084        };
1085
1086        let mut memories: Vec<SharedMemory> = self
1087            .storage
1088            .search(criteria)?
1089            .into_iter()
1090            .map(Arc::new)
1091            .collect();
1092
1093        memories.retain(|m| self.matches_filters(m, query));
1094
1095        // Sort by sequence if episode-based, otherwise by created_at
1096        if query.episode_id.is_some() {
1097            // Episode search already returns in sequence order from storage
1098            // But verify ordering by sequence_number if available
1099            memories.sort_by(|a, b| {
1100                let seq_a = a
1101                    .experience
1102                    .context
1103                    .as_ref()
1104                    .and_then(|c| c.episode.sequence_number)
1105                    .unwrap_or(0);
1106                let seq_b = b
1107                    .experience
1108                    .context
1109                    .as_ref()
1110                    .and_then(|c| c.episode.sequence_number)
1111                    .unwrap_or(0);
1112                seq_a.cmp(&seq_b)
1113            });
1114        } else {
1115            memories.sort_by(|a, b| b.created_at.cmp(&a.created_at));
1116        }
1117
1118        memories.truncate(limit);
1119        Ok(memories)
1120    }
1121
1122    fn causal_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1123        let seeds = self.similarity_search(query, 3)?;
1124
1125        let mut results = HashSet::new();
1126        let mut to_explore = Vec::new();
1127
1128        for seed in &seeds {
1129            to_explore.push(seed.id.clone());
1130            results.insert(seed.id.clone());
1131        }
1132
1133        while !to_explore.is_empty() && results.len() < limit {
1134            if let Some(current_id) = to_explore.pop() {
1135                if let Ok(memory) = self.storage.get(&current_id) {
1136                    for related_id in &memory.experience.related_memories {
1137                        if !results.contains(related_id) {
1138                            results.insert(related_id.clone());
1139                            to_explore.push(related_id.clone());
1140                        }
1141                    }
1142                }
1143            }
1144        }
1145
1146        let mut memories = Vec::new();
1147        for id in results.into_iter().take(limit) {
1148            if let Ok(memory) = self.storage.get(&id) {
1149                memories.push(Arc::new(memory));
1150            }
1151        }
1152
1153        Ok(memories)
1154    }
1155
1156    fn associative_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1157        // NOTE: Associative search now uses GraphMemory at the API layer
1158        // GraphMemory.find_memory_associations() provides Hebbian-weighted associations
1159        // This method falls back to similarity search as a baseline
1160        self.similarity_search(query, limit)
1161    }
1162
1163    fn hybrid_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1164        let mut all_results: HashMap<MemoryId, SharedMemory> = HashMap::new();
1165        let mut scores: HashMap<MemoryId, f32> = HashMap::new();
1166
1167        // Weight for each retrieval mode (tuned for robotics)
1168        let weights = [
1169            (RetrievalMode::Similarity, 0.5),  // Higher weight for semantic
1170            (RetrievalMode::Temporal, 0.2),    // Recent memories important
1171            (RetrievalMode::Causal, 0.2),      // Context chains
1172            (RetrievalMode::Associative, 0.1), // Associations
1173        ];
1174
1175        for (mode, weight) in weights.iter() {
1176            let mut mode_query = query.clone();
1177            mode_query.retrieval_mode = mode.clone();
1178
1179            let results = match mode {
1180                RetrievalMode::Similarity => self.similarity_search(&mode_query, limit),
1181                RetrievalMode::Temporal => self.temporal_search(&mode_query, limit),
1182                RetrievalMode::Causal => self.causal_search(&mode_query, limit),
1183                RetrievalMode::Associative => self.associative_search(&mode_query, limit),
1184                _ => continue,
1185            };
1186
1187            if let Ok(memories) = results {
1188                for (rank, memory) in memories.into_iter().enumerate() {
1189                    // Rank score: higher rank = higher score
1190                    let score = weight * (1.0 / (rank as f32 + 1.0));
1191
1192                    // Clone ID before moving memory into HashMap to avoid double clone
1193                    let memory_id = memory.id.clone();
1194                    *scores.entry(memory_id.clone()).or_insert(0.0) += score;
1195                    all_results.insert(memory_id, memory);
1196                }
1197            }
1198        }
1199
1200        // Apply Ebbinghaus salience scoring: combines retrieval score with time-based relevance
1201        // This ensures older, less-accessed memories naturally fade in ranking
1202        let mut sorted: Vec<(f32, SharedMemory)> = all_results
1203            .into_iter()
1204            .map(|(id, memory)| {
1205                let retrieval_score = scores.get(&id).copied().unwrap_or(0.0);
1206                // Salience score factors in recency (Ebbinghaus curve) and access frequency
1207                let salience = memory.salience_score_with_access();
1208                // Final score: 70% retrieval relevance, 30% salience (time-based decay)
1209                let final_score = retrieval_score * 0.7 + salience * 0.3;
1210                (final_score, memory)
1211            })
1212            .collect();
1213
1214        sorted.sort_by(|a, b| b.0.total_cmp(&a.0));
1215
1216        Ok(sorted.into_iter().take(limit).map(|(_, m)| m).collect())
1217    }
1218
1219    // ========================================================================
1220    // ROBOTICS-SPECIFIC RETRIEVAL MODES
1221    // ========================================================================
1222
1223    /// Spatial search: Find memories within geographic radius
1224    /// Uses haversine distance for accurate earth-surface calculations
1225    fn spatial_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1226        let geo_filter = query
1227            .geo_filter
1228            .as_ref()
1229            .ok_or_else(|| anyhow::anyhow!("Spatial search requires geo_filter"))?;
1230
1231        let criteria = SearchCriteria::ByLocation {
1232            lat: geo_filter.lat,
1233            lon: geo_filter.lon,
1234            radius_meters: geo_filter.radius_meters,
1235        };
1236
1237        let mut memories: Vec<SharedMemory> = self
1238            .storage
1239            .search(criteria)?
1240            .into_iter()
1241            .map(Arc::new)
1242            .collect();
1243
1244        // Apply additional filters
1245        memories.retain(|m| self.matches_filters(m, query));
1246
1247        // Sort by distance (closest first)
1248        memories.sort_by(|a, b| {
1249            let dist_a = match a.experience.geo_location {
1250                Some(geo) => geo_filter.haversine_distance(geo[0], geo[1]),
1251                None => f64::MAX,
1252            };
1253            let dist_b = match b.experience.geo_location {
1254                Some(geo) => geo_filter.haversine_distance(geo[0], geo[1]),
1255                None => f64::MAX,
1256            };
1257            dist_a.total_cmp(&dist_b)
1258        });
1259
1260        memories.truncate(limit);
1261        Ok(memories)
1262    }
1263
1264    /// Mission search: Retrieve all memories from a specific mission
1265    /// Useful for mission replay, analysis, and learning
1266    fn mission_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1267        let mission_id = query
1268            .mission_id
1269            .as_ref()
1270            .ok_or_else(|| anyhow::anyhow!("Mission search requires mission_id"))?;
1271
1272        let criteria = SearchCriteria::ByMission(mission_id.clone());
1273
1274        let mut memories: Vec<SharedMemory> = self
1275            .storage
1276            .search(criteria)?
1277            .into_iter()
1278            .map(Arc::new)
1279            .collect();
1280
1281        // Apply additional filters
1282        memories.retain(|m| self.matches_filters(m, query));
1283
1284        // Sort by timestamp (chronological order for mission replay)
1285        memories.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1286
1287        memories.truncate(limit);
1288        Ok(memories)
1289    }
1290
1291    /// Action-outcome search: Find memories with specific reward outcomes
1292    /// For reinforcement learning: "What actions led to positive rewards?"
1293    fn action_outcome_search(&self, query: &Query, limit: usize) -> Result<Vec<SharedMemory>> {
1294        // Get reward range or default to positive rewards
1295        let (min_reward, max_reward) = query.reward_range.unwrap_or((0.0, 1.0));
1296
1297        let criteria = SearchCriteria::ByReward {
1298            min: min_reward,
1299            max: max_reward,
1300        };
1301
1302        let mut memories: Vec<SharedMemory> = self
1303            .storage
1304            .search(criteria)?
1305            .into_iter()
1306            .map(Arc::new)
1307            .collect();
1308
1309        // Apply additional filters (action_type, robot_id, etc.)
1310        memories.retain(|m| self.matches_filters(m, query));
1311
1312        // Sort by reward (highest first for learning from best outcomes)
1313        memories.sort_by(|a, b| {
1314            let reward_a = a.experience.reward.unwrap_or(0.0);
1315            let reward_b = b.experience.reward.unwrap_or(0.0);
1316            reward_b.total_cmp(&reward_a)
1317        });
1318
1319        memories.truncate(limit);
1320        Ok(memories)
1321    }
1322
1323    /// Build vector index from existing memories (resumable on failure)
1324    ///
1325    /// Uses incremental indexing so partial progress is preserved:
1326    /// - Skips memories already in the index
1327    /// - On failure, next rebuild/repair continues from where it left off
1328    /// - Logs progress every 1000 memories for monitoring
1329    pub fn rebuild_index(&self) -> Result<()> {
1330        // Phase 1: Collect only memory IDs (16 bytes each — bounded even at 10M)
1331        let all_ids = self.storage.get_all_ids()?;
1332        let total = all_ids.len();
1333
1334        if total == 0 {
1335            tracing::info!("No memories to index");
1336            return Ok(());
1337        }
1338
1339        tracing::info!("Starting resumable index rebuild: {} memories", total);
1340
1341        // Get already-indexed memory IDs to skip
1342        let indexed_ids = self.get_indexed_memory_ids();
1343        let already_indexed = indexed_ids.len();
1344
1345        let mut indexed = 0;
1346        let mut skipped = 0;
1347        let mut failed = 0;
1348        let start_time = std::time::Instant::now();
1349
1350        // Phase 2: Process one memory at a time — O(1) peak memory per iteration
1351        for (i, memory_id) in all_ids.iter().enumerate() {
1352            // Skip already indexed memories (makes rebuild resumable)
1353            if indexed_ids.contains(memory_id) {
1354                skipped += 1;
1355            } else {
1356                // Load single memory from RocksDB, index it, then drop
1357                match self.storage.get(memory_id) {
1358                    Ok(memory) => {
1359                        if memory.is_forgotten() {
1360                            skipped += 1;
1361                        } else {
1362                            match self.index_memory(&memory) {
1363                                Ok(_) => indexed += 1,
1364                                Err(e) => {
1365                                    failed += 1;
1366                                    tracing::warn!(
1367                                        "Failed to index memory {} during rebuild: {}",
1368                                        memory_id.0,
1369                                        e
1370                                    );
1371                                }
1372                            }
1373                        }
1374                    }
1375                    Err(e) => {
1376                        failed += 1;
1377                        tracing::warn!(
1378                            "Failed to load memory {} during rebuild: {}",
1379                            memory_id.0,
1380                            e
1381                        );
1382                    }
1383                }
1384            }
1385
1386            // Log progress every 1000 memories
1387            if (i + 1) % 1000 == 0 || i + 1 == total {
1388                let elapsed = start_time.elapsed().as_secs();
1389                let rate = if elapsed > 0 {
1390                    (indexed + skipped) as f64 / elapsed as f64
1391                } else {
1392                    0.0
1393                };
1394                tracing::info!(
1395                    "Rebuild progress: {}/{} ({:.1}%), {} indexed, {} skipped, {} failed, {:.0}/sec",
1396                    i + 1,
1397                    total,
1398                    (i + 1) as f64 / total as f64 * 100.0,
1399                    indexed,
1400                    skipped,
1401                    failed,
1402                    rate
1403                );
1404            }
1405        }
1406
1407        tracing::info!(
1408            "Index rebuild complete: {} indexed, {} already present, {} failed (total: {})",
1409            indexed,
1410            already_indexed + skipped,
1411            failed,
1412            self.len()
1413        );
1414
1415        Ok(())
1416    }
1417
1418    // NOTE: Memory graph functionality has been consolidated into GraphMemory
1419    // which is managed at the API layer (MultiUserMemoryManager.graph_memories)
1420    // The following methods are preserved for API compatibility but are no-ops:
1421    // - add_to_graph() - GraphMemory handles entity relationships from NER
1422    // - record_coactivation() - Now called directly on GraphMemory in API handlers
1423    // - graph_maintenance() - GraphMemory.apply_decay() handles this
1424    // - graph_stats() - Returns empty stats (use GraphMemory.get_stats() instead)
1425
1426    /// Add memory to knowledge graph - DEPRECATED
1427    /// Use GraphMemory at the API layer instead
1428    #[deprecated(note = "Use GraphMemory at API layer instead")]
1429    pub fn add_to_graph(&self, _memory: &Memory) {
1430        // No-op: GraphMemory handles entity relationships from NER
1431    }
1432
1433    /// Record co-activation of memories - DEPRECATED
1434    /// Use GraphMemory.record_memory_coactivation() at API layer instead
1435    #[deprecated(note = "Use GraphMemory.record_memory_coactivation() at API layer instead")]
1436    pub fn record_coactivation(&self, _memory_ids: &[MemoryId]) {
1437        // No-op: Coactivation is now recorded in GraphMemory at the API layer
1438    }
1439
1440    /// Perform graph maintenance - DEPRECATED
1441    /// Use GraphMemory.apply_decay() at API layer instead
1442    #[deprecated(note = "Use GraphMemory.apply_decay() at API layer instead")]
1443    pub fn graph_maintenance(&self) {
1444        // No-op: GraphMemory handles decay in its own maintenance cycle
1445    }
1446
1447    /// Get memory graph statistics - DEPRECATED
1448    /// Use GraphMemory.get_stats() at API layer instead
1449    #[deprecated(note = "Use GraphMemory.get_stats() at API layer instead")]
1450    pub fn graph_stats(&self) -> MemoryGraphStats {
1451        // Return empty stats - real stats are in GraphMemory
1452        MemoryGraphStats {
1453            node_count: 0,
1454            edge_count: 0,
1455            avg_strength: 0.0,
1456            potentiated_count: 0,
1457        }
1458    }
1459
1460    /// Check if vector index needs rebuild and rebuild if necessary
1461    ///
1462    /// Returns true if rebuild was performed.
1463    ///
1464    /// IMPORTANT: We perform a full rebuild from RocksDB rather than using
1465    /// Vamana's internal `auto_rebuild_if_needed()`. The internal rebuild
1466    /// extracts live vectors and assigns new sequential IDs (0, 1, 2, ...),
1467    /// but does NOT update the RetrievalEngine's id_mapping. This would
1468    /// silently corrupt all search results after compaction — searches
1469    /// would return wrong memories because old vector_id→memory_id mappings
1470    /// no longer match the new vector IDs.
1471    ///
1472    /// By rebuilding from RocksDB (the single source of truth), both the
1473    /// vector index and the id_mapping are rebuilt atomically.
1474    pub fn auto_rebuild_index_if_needed(&self) -> Result<bool> {
1475        // Check if rebuild is needed (read lock only)
1476        {
1477            let index = self.vector_index.read();
1478            if !index.needs_rebuild() || index.is_rebuilding() {
1479                return Ok(false);
1480            }
1481        }
1482
1483        // Full rebuild from RocksDB — rebuilds both vector index and id_mapping
1484        info!("Index rebuild/compaction needed, performing full rebuild from RocksDB");
1485        self.rebuild_from_rocksdb()?;
1486        Ok(true)
1487    }
1488
1489    /// Get vector index degradation info
1490    pub fn index_health(&self) -> IndexHealth {
1491        let index = self.vector_index.read();
1492        IndexHealth {
1493            total_vectors: index.len(),
1494            incremental_inserts: index.incremental_insert_count(),
1495            deleted_count: index.deleted_count(),
1496            deletion_ratio: index.deletion_ratio(),
1497            needs_rebuild: index.needs_rebuild(),
1498            needs_compaction: index.needs_compaction(),
1499            rebuild_threshold: crate::vector_db::vamana::REBUILD_THRESHOLD,
1500            deletion_ratio_threshold: crate::vector_db::vamana::DELETION_RATIO_THRESHOLD,
1501        }
1502    }
1503}
1504
1505/// Health information about the vector index
1506#[derive(Debug, Clone)]
1507pub struct IndexHealth {
1508    pub total_vectors: usize,
1509    pub incremental_inserts: usize,
1510    pub deleted_count: usize,
1511    pub deletion_ratio: f32,
1512    pub needs_rebuild: bool,
1513    pub needs_compaction: bool,
1514    pub rebuild_threshold: usize,
1515    pub deletion_ratio_threshold: f32,
1516}
1517
1518// ============================================================================
1519// OUTCOME FEEDBACK SYSTEM - Hebbian "Fire Together, Wire Together"
1520// ============================================================================
1521
1522/// Outcome of a retrieval operation - used to reinforce or weaken memories
1523///
1524/// When memories are retrieved and used to complete a task, this feedback
1525/// tells the system whether they were helpful, enabling adaptive learning.
1526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1527pub enum RetrievalOutcome {
1528    /// Memory helped complete the task successfully
1529    /// Triggers: +importance boost, +association strength, +access count
1530    Helpful,
1531    /// Memory was misleading or caused errors
1532    /// Triggers: -importance penalty, relationship weakening
1533    Misleading,
1534    /// Memory was retrieved but not actionably useful
1535    /// Triggers: +access count only (neutral)
1536    Neutral,
1537}
1538
1539/// Result of a retrieval with tracking for feedback
1540#[derive(Debug, Clone)]
1541pub struct TrackedRetrieval {
1542    /// The memories that were retrieved
1543    pub memories: Vec<SharedMemory>,
1544    /// Unique ID for this retrieval (for later feedback)
1545    pub retrieval_id: String,
1546    /// Query that produced these results
1547    pub query_fingerprint: u64,
1548    /// Timestamp of retrieval
1549    pub retrieved_at: chrono::DateTime<chrono::Utc>,
1550}
1551
1552impl TrackedRetrieval {
1553    fn new(memories: Vec<SharedMemory>, query: &Query) -> Self {
1554        use std::hash::{Hash, Hasher};
1555        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1556        if let Some(text) = &query.query_text {
1557            text.hash(&mut hasher);
1558        }
1559
1560        Self {
1561            memories,
1562            retrieval_id: uuid::Uuid::new_v4().to_string(),
1563            query_fingerprint: hasher.finish(),
1564            retrieved_at: chrono::Utc::now(),
1565        }
1566    }
1567
1568    /// Get memory IDs for feedback
1569    pub fn memory_ids(&self) -> Vec<MemoryId> {
1570        self.memories.iter().map(|m| m.id.clone()).collect()
1571    }
1572}
1573
1574/// Feedback record for a retrieval
1575#[derive(Debug, Clone, Serialize, Deserialize)]
1576pub struct RetrievalFeedback {
1577    /// Which retrieval this feedback is for
1578    pub retrieval_id: String,
1579    /// The outcome
1580    pub outcome: RetrievalOutcome,
1581    /// Optional task context (what was the user trying to do)
1582    pub task_context: Option<String>,
1583    /// When feedback was provided
1584    pub feedback_at: chrono::DateTime<chrono::Utc>,
1585}
1586
1587impl RetrievalEngine {
1588    // ========================================================================
1589    // OUTCOME FEEDBACK METHODS
1590    // ========================================================================
1591
1592    /// Search with tracking for later feedback
1593    ///
1594    /// Use this when you want to provide feedback on retrieval quality.
1595    /// Returns a TrackedRetrieval that can be used with `reinforce_recall`.
1596    pub fn search_tracked(&self, query: &Query, limit: usize) -> Result<TrackedRetrieval> {
1597        let memories = self.search(query, limit)?;
1598        Ok(TrackedRetrieval::new(memories, query))
1599    }
1600
1601    /// Reinforce memories based on task outcome (core feedback loop)
1602    ///
1603    /// This is THE key method that closes the Hebbian loop:
1604    /// - If outcome is Helpful: strengthen associations, boost importance
1605    /// - If outcome is Misleading: weaken associations, reduce importance
1606    /// - If outcome is Neutral: just record access (mild reinforcement)
1607    ///
1608    /// Call this after a task completes to indicate which memories helped.
1609    pub fn reinforce_recall(
1610        &self,
1611        memory_ids: &[MemoryId],
1612        outcome: RetrievalOutcome,
1613    ) -> Result<ReinforcementStats> {
1614        if memory_ids.is_empty() {
1615            return Ok(ReinforcementStats::default());
1616        }
1617
1618        let mut stats = ReinforcementStats {
1619            memories_processed: memory_ids.len(),
1620            ..Default::default()
1621        };
1622
1623        // Hebbian coactivation: count pair associations for non-misleading outcomes
1624        if !matches!(outcome, RetrievalOutcome::Misleading) && memory_ids.len() >= 2 {
1625            let n = memory_ids.len();
1626            stats.associations_strengthened = n * (n - 1) / 2;
1627        }
1628
1629        match outcome {
1630            RetrievalOutcome::Helpful => {
1631                // Boost importance of helpful memories and PERSIST to storage
1632                for id in memory_ids {
1633                    if let Ok(memory) = self.storage.get(id) {
1634                        // Increment access and apply importance boost
1635                        memory.record_access();
1636                        memory.boost_importance(0.05); // +5% importance
1637
1638                        // PERSIST: Write updated memory back to durable storage
1639                        if self.storage.update(&memory).is_ok() {
1640                            stats.importance_boosts += 1;
1641                        }
1642                    }
1643                }
1644            }
1645            RetrievalOutcome::Misleading => {
1646                // Reduce importance of misleading memories and PERSIST to storage
1647                for id in memory_ids {
1648                    if let Ok(memory) = self.storage.get(id) {
1649                        memory.record_access();
1650                        memory.decay_importance(0.10); // -10% importance
1651
1652                        // PERSIST: Write updated memory back to durable storage
1653                        if self.storage.update(&memory).is_ok() {
1654                            stats.importance_decays += 1;
1655                        }
1656                    }
1657                }
1658                // Don't strengthen associations for misleading memories
1659            }
1660            RetrievalOutcome::Neutral => {
1661                // Just record access, mild reinforcement - PERSIST to storage
1662                for id in memory_ids {
1663                    if let Ok(memory) = self.storage.get(id) {
1664                        memory.record_access();
1665
1666                        // PERSIST: Write access update to storage
1667                        if let Err(e) = self.storage.update(&memory) {
1668                            tracing::warn!(
1669                                "Failed to persist access update for memory {}: {}",
1670                                id.0,
1671                                e
1672                            );
1673                        }
1674                    }
1675                }
1676                // Association strengthening for neutral outcomes is counted above (pair counting)
1677            }
1678        }
1679
1680        stats.outcome = outcome;
1681        Ok(stats)
1682    }
1683
1684    /// Reinforce using a tracked retrieval (convenience wrapper)
1685    pub fn reinforce_tracked(
1686        &self,
1687        tracked: &TrackedRetrieval,
1688        outcome: RetrievalOutcome,
1689    ) -> Result<ReinforcementStats> {
1690        let ids = tracked.memory_ids();
1691        self.reinforce_recall(&ids, outcome)
1692    }
1693
1694    /// Batch reinforce multiple retrievals (for async feedback processing)
1695    pub fn reinforce_batch(
1696        &self,
1697        feedbacks: &[RetrievalFeedback],
1698        retrieval_memories: &HashMap<String, Vec<MemoryId>>,
1699    ) -> Result<Vec<ReinforcementStats>> {
1700        let mut results = Vec::with_capacity(feedbacks.len());
1701
1702        for feedback in feedbacks {
1703            if let Some(memory_ids) = retrieval_memories.get(&feedback.retrieval_id) {
1704                let stats = self.reinforce_recall(memory_ids, feedback.outcome)?;
1705                results.push(stats);
1706            }
1707        }
1708
1709        Ok(results)
1710    }
1711}
1712
1713/// Statistics from a reinforcement operation
1714#[derive(Debug, Clone, Default)]
1715pub struct ReinforcementStats {
1716    /// How many memories were processed
1717    pub memories_processed: usize,
1718    /// How many association edges were strengthened
1719    pub associations_strengthened: usize,
1720    /// How many importance boosts were applied
1721    pub importance_boosts: usize,
1722    /// How many importance decays were applied
1723    pub importance_decays: usize,
1724    /// The outcome that triggered this reinforcement
1725    pub outcome: RetrievalOutcome,
1726    /// How many persistence operations failed (non-zero indicates data loss risk)
1727    pub persist_failures: usize,
1728}
1729
1730impl Default for RetrievalOutcome {
1731    fn default() -> Self {
1732        Self::Neutral
1733    }
1734}
1735
1736// NOTE: MemoryGraph has been consolidated into GraphMemory (src/graph_memory.rs)
1737// which provides persistent storage in RocksDB and proper Hebbian learning.
1738// All graph-based memory associations now go through GraphMemory at the API layer.
1739
1740/// Statistics about the memory graph (for backwards compatibility)
1741///
1742/// Real statistics are available from GraphMemory.get_stats()
1743#[derive(Debug, Clone, Default)]
1744pub struct MemoryGraphStats {
1745    pub node_count: usize,
1746    pub edge_count: usize,
1747    pub avg_strength: f32,
1748    pub potentiated_count: usize,
1749}
1750
1751// ============================================================================
1752// ANTICIPATORY PREFETCH - Context-aware cache warming
1753// ============================================================================
1754
1755/// Context signals used to anticipate which memories will be needed
1756#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1757pub struct PrefetchContext {
1758    /// Current project/workspace being worked on
1759    pub project_id: Option<String>,
1760    /// Current file path being edited
1761    pub current_file: Option<String>,
1762    /// Recent entities mentioned
1763    pub recent_entities: Vec<String>,
1764    /// Current time of day (for temporal patterns)
1765    pub hour_of_day: Option<u32>,
1766    /// Day of week (0=Sunday)
1767    pub day_of_week: Option<u32>,
1768    /// Recent query patterns (for predictive prefetch)
1769    pub recent_queries: Vec<String>,
1770    /// Current task type (coding, debugging, reviewing)
1771    pub task_type: Option<String>,
1772
1773    // SHO-104: Episode context for episodic prefetching
1774    /// Current episode ID - memories in same episode are highly relevant
1775    pub episode_id: Option<String>,
1776    /// Current emotional valence - for mood-congruent retrieval
1777    pub emotional_valence: Option<f32>,
1778}
1779
1780impl PrefetchContext {
1781    /// Create context from RichContext
1782    pub fn from_rich_context(ctx: &super::types::RichContext) -> Self {
1783        Self {
1784            project_id: ctx.project.project_id.clone(),
1785            current_file: ctx.code.current_file.clone(),
1786            recent_entities: ctx.conversation.mentioned_entities.clone(),
1787            hour_of_day: ctx
1788                .temporal
1789                .time_of_day
1790                .as_ref()
1791                .and_then(|t| t.parse().ok()),
1792            day_of_week: ctx
1793                .temporal
1794                .day_of_week
1795                .as_ref()
1796                .and_then(|d| match d.as_str() {
1797                    "Sunday" => Some(0),
1798                    "Monday" => Some(1),
1799                    "Tuesday" => Some(2),
1800                    "Wednesday" => Some(3),
1801                    "Thursday" => Some(4),
1802                    "Friday" => Some(5),
1803                    "Saturday" => Some(6),
1804                    _ => None,
1805                }),
1806            recent_queries: Vec::new(),
1807            task_type: ctx.project.current_task.clone(),
1808            // SHO-104: Episode and emotional context
1809            episode_id: ctx.episode.episode_id.clone(),
1810            emotional_valence: if ctx.emotional.valence != 0.0 {
1811                Some(ctx.emotional.valence)
1812            } else {
1813                None
1814            },
1815        }
1816    }
1817
1818    /// Create context from current system state
1819    pub fn from_current_time() -> Self {
1820        let now = chrono::Utc::now();
1821        Self {
1822            hour_of_day: Some(now.hour()),
1823            day_of_week: Some(now.weekday().num_days_from_sunday()),
1824            ..Default::default()
1825        }
1826    }
1827}
1828
1829/// Result of a prefetch operation
1830#[derive(Debug, Clone, Default)]
1831pub struct PrefetchResult {
1832    /// Memory IDs that were prefetched
1833    pub prefetched_ids: Vec<MemoryId>,
1834    /// Why these memories were selected
1835    pub reason: PrefetchReason,
1836    /// How many were already cached
1837    pub cache_hits: usize,
1838    /// How many were fetched from storage
1839    pub fetches: usize,
1840}
1841
1842/// Reason for prefetching specific memories
1843#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1844pub enum PrefetchReason {
1845    /// Project-based: memories from same project
1846    Project(String),
1847    /// File-based: memories about related files
1848    RelatedFiles,
1849    /// Entity-based: memories mentioning same entities
1850    SharedEntities,
1851    /// Temporal: memories from similar time patterns
1852    TemporalPattern,
1853    /// Association: strongly associated with recent memories
1854    AssociatedMemories,
1855    /// Predicted: predicted from query patterns
1856    QueryPrediction,
1857    #[default]
1858    /// Unknown or multiple reasons
1859    Mixed,
1860}
1861
1862/// Anticipatory prefetch engine
1863///
1864/// Pre-warms the memory cache based on contextual signals:
1865/// - Project: "I'm working on auth module" → prefetch auth-related memories
1866/// - File: "I opened user.rs" → prefetch memories about user.rs and imports
1867/// - Temporal: "It's Monday morning" → prefetch Monday morning patterns
1868/// - Association: "I just accessed memory A" → prefetch A's strong associations
1869pub struct AnticipatoryPrefetch {
1870    /// Maximum memories to prefetch at once
1871    max_prefetch: usize,
1872}
1873
1874impl Default for AnticipatoryPrefetch {
1875    fn default() -> Self {
1876        Self::new()
1877    }
1878}
1879
1880impl AnticipatoryPrefetch {
1881    pub fn new() -> Self {
1882        Self { max_prefetch: 20 }
1883    }
1884
1885    /// Create with custom prefetch limit
1886    pub fn with_limit(max_prefetch: usize) -> Self {
1887        Self { max_prefetch }
1888    }
1889
1890    /// Generate prefetch query based on context
1891    ///
1892    /// This is the main entry point - given a context, determine what memories
1893    /// are likely to be needed soon and return a query to fetch them.
1894    pub fn generate_prefetch_query(&self, context: &PrefetchContext) -> Option<Query> {
1895        // Priority 1: Project-based prefetch (strongest signal)
1896        if let Some(project_id) = &context.project_id {
1897            return Some(self.project_query(project_id));
1898        }
1899
1900        // Priority 2: Entity-based prefetch
1901        if !context.recent_entities.is_empty() {
1902            return Some(self.entity_query(&context.recent_entities));
1903        }
1904
1905        // Priority 3: File-based prefetch
1906        if let Some(file_path) = &context.current_file {
1907            return Some(self.file_query(file_path));
1908        }
1909
1910        // Priority 4: Temporal pattern prefetch
1911        if let (Some(hour), Some(day)) = (context.hour_of_day, context.day_of_week) {
1912            return Some(self.temporal_query(hour, day));
1913        }
1914
1915        None
1916    }
1917
1918    /// Generate query for project-related memories
1919    fn project_query(&self, project_id: &str) -> Query {
1920        Query {
1921            query_text: Some(format!("project:{}", project_id)),
1922            max_results: self.max_prefetch,
1923            retrieval_mode: super::types::RetrievalMode::Similarity,
1924            ..Default::default()
1925        }
1926    }
1927
1928    /// Generate query for entity-related memories
1929    fn entity_query(&self, entities: &[String]) -> Query {
1930        let query_text = entities.join(" ");
1931        Query {
1932            query_text: Some(query_text),
1933            max_results: self.max_prefetch,
1934            retrieval_mode: super::types::RetrievalMode::Similarity,
1935            ..Default::default()
1936        }
1937    }
1938
1939    /// Generate query for file-related memories
1940    fn file_query(&self, file_path: &str) -> Query {
1941        // Extract filename for broader search
1942        let filename = std::path::Path::new(file_path)
1943            .file_name()
1944            .and_then(|n| n.to_str())
1945            .unwrap_or(file_path);
1946
1947        Query {
1948            query_text: Some(format!("file {} code", filename)),
1949            max_results: self.max_prefetch,
1950            retrieval_mode: super::types::RetrievalMode::Similarity,
1951            ..Default::default()
1952        }
1953    }
1954
1955    /// Generate query for temporal pattern matching
1956    fn temporal_query(&self, hour: u32, _day: u32) -> Query {
1957        let now = chrono::Utc::now();
1958
1959        // Find similar time window
1960        let start_hour = if hour >= PREFETCH_TEMPORAL_WINDOW_HOURS as u32 {
1961            hour - PREFETCH_TEMPORAL_WINDOW_HOURS as u32
1962        } else {
1963            0
1964        };
1965        let end_hour = (hour + PREFETCH_TEMPORAL_WINDOW_HOURS as u32).min(23);
1966
1967        // Calculate time range for today at similar hours
1968        let start = now
1969            .with_hour(start_hour)
1970            .unwrap_or(now)
1971            .with_minute(0)
1972            .unwrap_or(now);
1973        let end = now
1974            .with_hour(end_hour)
1975            .unwrap_or(now)
1976            .with_minute(59)
1977            .unwrap_or(now);
1978
1979        Query {
1980            time_range: Some((start, end)),
1981            max_results: self.max_prefetch,
1982            retrieval_mode: super::types::RetrievalMode::Temporal,
1983            ..Default::default()
1984        }
1985    }
1986
1987    // NOTE: association_prefetch_ids has been removed.
1988    // Association-based prefetching should use GraphMemory.find_memory_associations()
1989    // at the API layer where GraphMemory is available.
1990
1991    /// Score how relevant a memory is to the current context
1992    ///
1993    /// Higher scores mean more likely to be needed soon.
1994    pub fn relevance_score(&self, memory: &Memory, context: &PrefetchContext) -> f32 {
1995        let mut score = 0.0;
1996
1997        // Project match (strong signal)
1998        if let Some(project_id) = &context.project_id {
1999            if let Some(ctx) = &memory.experience.context {
2000                if ctx.project.project_id.as_ref() == Some(project_id) {
2001                    score += 0.4;
2002                }
2003            }
2004        }
2005
2006        // Entity overlap
2007        let memory_entities: HashSet<_> = memory.experience.entities.iter().collect();
2008        let context_entities: HashSet<_> = context.recent_entities.iter().collect();
2009        let overlap = memory_entities.intersection(&context_entities).count();
2010        if overlap > 0 {
2011            score += 0.2 * (overlap as f32 / context_entities.len().max(1) as f32);
2012        }
2013
2014        // File relevance
2015        if let Some(current_file) = &context.current_file {
2016            if memory.experience.content.contains(current_file) {
2017                score += 0.2;
2018            }
2019            // Also check related files in context
2020            if let Some(ctx) = &memory.experience.context {
2021                if ctx.code.related_files.iter().any(|f| f == current_file) {
2022                    score += 0.1;
2023                }
2024            }
2025        }
2026
2027        // Temporal relevance (same hour of day)
2028        if let Some(hour) = context.hour_of_day {
2029            let memory_hour = memory.created_at.hour();
2030            if (memory_hour as i32 - hour as i32).abs() <= PREFETCH_TEMPORAL_WINDOW_HOURS as i32 {
2031                score += 0.1;
2032            }
2033        }
2034
2035        // Recency boost (using centralized constants)
2036        let age_hours = (chrono::Utc::now() - memory.created_at).num_hours();
2037        if age_hours < PREFETCH_RECENCY_FULL_HOURS {
2038            score += PREFETCH_RECENCY_FULL_BOOST;
2039        } else if age_hours < PREFETCH_RECENCY_PARTIAL_HOURS {
2040            score += PREFETCH_RECENCY_PARTIAL_BOOST;
2041        }
2042
2043        // SHO-104: Emotional arousal boost - high-arousal memories are more salient
2044        // Research: Emotionally arousing events are better remembered (LaBar & Cabeza, 2006)
2045        if let Some(ctx) = &memory.experience.context {
2046            // High arousal memories get a relevance boost
2047            if ctx.emotional.arousal > 0.6 {
2048                score += 0.1 * ctx.emotional.arousal;
2049            }
2050
2051            // Source credibility affects relevance - more credible = more relevant
2052            if ctx.source.credibility > 0.8 {
2053                score += 0.05;
2054            }
2055
2056            // Episode context: same episode = highly relevant
2057            if let Some(current_episode) = &context.episode_id {
2058                if ctx.episode.episode_id.as_ref() == Some(current_episode) {
2059                    score += 0.3; // Strong boost for same-episode memories
2060                }
2061            }
2062
2063            // Mood-congruent retrieval: similar emotional valence boosts relevance
2064            // Research: We recall happy memories when happy, sad when sad
2065            if let Some(current_valence) = context.emotional_valence {
2066                let valence_diff = (ctx.emotional.valence - current_valence).abs();
2067                if valence_diff < 0.3 {
2068                    // Same emotional valence quadrant
2069                    score += 0.1 * (1.0 - valence_diff / 0.3);
2070                }
2071            }
2072        }
2073
2074        score.min(1.0)
2075    }
2076}
2077
2078use chrono::{Datelike, Timelike};
2079
2080#[cfg(test)]
2081mod tests {
2082    use super::*;
2083
2084    #[test]
2085    fn test_id_mapping_basic() {
2086        let mut mapping = IdMapping::new();
2087        let memory_id = MemoryId(uuid::Uuid::new_v4());
2088
2089        mapping.insert(memory_id.clone(), 42);
2090
2091        assert_eq!(mapping.len(), 1);
2092        assert_eq!(mapping.get_memory_id(42), Some(&memory_id));
2093    }
2094
2095    #[test]
2096    fn test_id_mapping_chunks() {
2097        let mut mapping = IdMapping::new();
2098        let memory_id = MemoryId(uuid::Uuid::new_v4());
2099
2100        mapping.insert_chunks(memory_id.clone(), vec![1, 2, 3]);
2101
2102        assert_eq!(mapping.len(), 1);
2103        assert_eq!(mapping.get_memory_id(1), Some(&memory_id));
2104        assert_eq!(mapping.get_memory_id(2), Some(&memory_id));
2105        assert_eq!(mapping.get_memory_id(3), Some(&memory_id));
2106    }
2107
2108    #[test]
2109    fn test_id_mapping_remove_all() {
2110        let mut mapping = IdMapping::new();
2111        let memory_id = MemoryId(uuid::Uuid::new_v4());
2112
2113        mapping.insert_chunks(memory_id.clone(), vec![1, 2, 3]);
2114        let removed = mapping.remove_all(&memory_id);
2115
2116        assert_eq!(removed.len(), 3);
2117        assert_eq!(mapping.len(), 0);
2118        assert!(mapping.get_memory_id(1).is_none());
2119    }
2120
2121    #[test]
2122    fn test_id_mapping_clear() {
2123        let mut mapping = IdMapping::new();
2124        mapping.insert(MemoryId(uuid::Uuid::new_v4()), 1);
2125        mapping.insert(MemoryId(uuid::Uuid::new_v4()), 2);
2126
2127        mapping.clear();
2128
2129        assert_eq!(mapping.len(), 0);
2130    }
2131
2132    #[test]
2133    fn test_retrieval_outcome_default() {
2134        let outcome = RetrievalOutcome::default();
2135        assert_eq!(outcome, RetrievalOutcome::Neutral);
2136    }
2137
2138    #[test]
2139    fn test_reinforcement_stats_default() {
2140        let stats = ReinforcementStats::default();
2141
2142        assert_eq!(stats.memories_processed, 0);
2143        assert_eq!(stats.associations_strengthened, 0);
2144        assert_eq!(stats.importance_boosts, 0);
2145        assert_eq!(stats.importance_decays, 0);
2146    }
2147
2148    #[test]
2149    fn test_memory_graph_stats_default() {
2150        let stats = MemoryGraphStats::default();
2151
2152        assert_eq!(stats.node_count, 0);
2153        assert_eq!(stats.edge_count, 0);
2154        assert_eq!(stats.avg_strength, 0.0);
2155        assert_eq!(stats.potentiated_count, 0);
2156    }
2157
2158    #[test]
2159    fn test_prefetch_context_default() {
2160        let ctx = PrefetchContext::default();
2161
2162        assert!(ctx.project_id.is_none());
2163        assert!(ctx.current_file.is_none());
2164        assert!(ctx.recent_entities.is_empty());
2165    }
2166
2167    #[test]
2168    fn test_prefetch_context_from_current_time() {
2169        let ctx = PrefetchContext::from_current_time();
2170
2171        assert!(ctx.hour_of_day.is_some());
2172        assert!(ctx.day_of_week.is_some());
2173    }
2174
2175    #[test]
2176    fn test_anticipatory_prefetch_new() {
2177        let prefetch = AnticipatoryPrefetch::new();
2178        assert_eq!(prefetch.max_prefetch, 20);
2179    }
2180
2181    #[test]
2182    fn test_anticipatory_prefetch_with_limit() {
2183        let prefetch = AnticipatoryPrefetch::with_limit(50);
2184        assert_eq!(prefetch.max_prefetch, 50);
2185    }
2186
2187    #[test]
2188    fn test_generate_prefetch_query_project() {
2189        let prefetch = AnticipatoryPrefetch::new();
2190        let ctx = PrefetchContext {
2191            project_id: Some("my-project".to_string()),
2192            ..Default::default()
2193        };
2194
2195        let query = prefetch.generate_prefetch_query(&ctx);
2196
2197        assert!(query.is_some());
2198        let query = query.unwrap();
2199        assert!(query.query_text.unwrap().contains("my-project"));
2200    }
2201
2202    #[test]
2203    fn test_generate_prefetch_query_entities() {
2204        let prefetch = AnticipatoryPrefetch::new();
2205        let ctx = PrefetchContext {
2206            recent_entities: vec!["Rust".to_string(), "memory".to_string()],
2207            ..Default::default()
2208        };
2209
2210        let query = prefetch.generate_prefetch_query(&ctx);
2211
2212        assert!(query.is_some());
2213        let query = query.unwrap();
2214        let text = query.query_text.unwrap();
2215        assert!(text.contains("Rust"));
2216        assert!(text.contains("memory"));
2217    }
2218
2219    #[test]
2220    fn test_generate_prefetch_query_file() {
2221        let prefetch = AnticipatoryPrefetch::new();
2222        let ctx = PrefetchContext {
2223            current_file: Some("/src/memory/retrieval.rs".to_string()),
2224            ..Default::default()
2225        };
2226
2227        let query = prefetch.generate_prefetch_query(&ctx);
2228
2229        assert!(query.is_some());
2230        let query = query.unwrap();
2231        assert!(query.query_text.unwrap().contains("retrieval.rs"));
2232    }
2233
2234    #[test]
2235    fn test_generate_prefetch_query_temporal() {
2236        let prefetch = AnticipatoryPrefetch::new();
2237        let ctx = PrefetchContext {
2238            hour_of_day: Some(14),
2239            day_of_week: Some(1),
2240            ..Default::default()
2241        };
2242
2243        let query = prefetch.generate_prefetch_query(&ctx);
2244
2245        assert!(query.is_some());
2246        let query = query.unwrap();
2247        assert!(query.time_range.is_some());
2248    }
2249
2250    #[test]
2251    fn test_generate_prefetch_query_empty() {
2252        let prefetch = AnticipatoryPrefetch::new();
2253        let ctx = PrefetchContext::default();
2254
2255        let query = prefetch.generate_prefetch_query(&ctx);
2256
2257        assert!(query.is_none());
2258    }
2259
2260    #[test]
2261    fn test_prefetch_reason_default() {
2262        let reason = PrefetchReason::default();
2263        assert!(matches!(reason, PrefetchReason::Mixed));
2264    }
2265
2266    #[test]
2267    fn test_prefetch_result_default() {
2268        let result = PrefetchResult::default();
2269
2270        assert!(result.prefetched_ids.is_empty());
2271        assert_eq!(result.cache_hits, 0);
2272        assert_eq!(result.fetches, 0);
2273    }
2274
2275    #[test]
2276    fn test_index_health_struct() {
2277        let health = IndexHealth {
2278            total_vectors: 1000,
2279            incremental_inserts: 100,
2280            deleted_count: 50,
2281            deletion_ratio: 0.05,
2282            needs_rebuild: false,
2283            needs_compaction: false,
2284            rebuild_threshold: 500,
2285            deletion_ratio_threshold: 0.2,
2286        };
2287
2288        assert_eq!(health.total_vectors, 1000);
2289        assert!(!health.needs_rebuild);
2290    }
2291
2292    #[test]
2293    fn test_id_mapping_insert_is_idempotent() {
2294        let mut mapping = IdMapping::new();
2295        let memory_id = MemoryId(uuid::Uuid::new_v4());
2296
2297        // First insert
2298        mapping.insert(memory_id.clone(), 10);
2299        assert_eq!(mapping.len(), 1);
2300        assert_eq!(mapping.get_memory_id(10), Some(&memory_id));
2301
2302        // Second insert for same memory with different vector_id (simulates re-index)
2303        mapping.insert(memory_id.clone(), 20);
2304        assert_eq!(mapping.len(), 1);
2305        assert_eq!(mapping.get_memory_id(20), Some(&memory_id));
2306        // Old vector_id should be cleaned up (no orphan)
2307        assert!(
2308            mapping.get_memory_id(10).is_none(),
2309            "old vector_id should be removed to prevent orphan"
2310        );
2311        assert_eq!(mapping.memory_to_vectors[&memory_id], vec![20]);
2312    }
2313
2314    #[test]
2315    fn test_id_mapping_insert_chunks_is_idempotent() {
2316        let mut mapping = IdMapping::new();
2317        let memory_id = MemoryId(uuid::Uuid::new_v4());
2318
2319        // First insert: 3 chunks
2320        mapping.insert_chunks(memory_id.clone(), vec![1, 2, 3]);
2321        assert_eq!(mapping.len(), 1);
2322        assert_eq!(mapping.memory_to_vectors[&memory_id], vec![1, 2, 3]);
2323
2324        // Second insert: 2 different chunks (simulates re-index after content change)
2325        mapping.insert_chunks(memory_id.clone(), vec![10, 11]);
2326        assert_eq!(mapping.len(), 1);
2327        assert_eq!(mapping.memory_to_vectors[&memory_id], vec![10, 11]);
2328        // Old vector_ids should be cleaned up
2329        assert!(mapping.get_memory_id(1).is_none(), "old chunk 1 orphaned");
2330        assert!(mapping.get_memory_id(2).is_none(), "old chunk 2 orphaned");
2331        assert!(mapping.get_memory_id(3).is_none(), "old chunk 3 orphaned");
2332        // New ones should be present
2333        assert_eq!(mapping.get_memory_id(10), Some(&memory_id));
2334        assert_eq!(mapping.get_memory_id(11), Some(&memory_id));
2335    }
2336
2337    #[test]
2338    fn test_id_mapping_vector_count_stable_after_reinsert() {
2339        let mut mapping = IdMapping::new();
2340        let m1 = MemoryId(uuid::Uuid::new_v4());
2341        let m2 = MemoryId(uuid::Uuid::new_v4());
2342
2343        mapping.insert(m1.clone(), 1);
2344        mapping.insert(m2.clone(), 2);
2345        assert_eq!(mapping.vector_to_memory.len(), 2);
2346
2347        // Re-insert m1 with new vector - total vector count should stay at 2
2348        mapping.insert(m1.clone(), 3);
2349        assert_eq!(
2350            mapping.vector_to_memory.len(),
2351            2,
2352            "vector_to_memory should not grow on re-insert"
2353        );
2354    }
2355}