Skip to main content

wm_memory/
recall.rs

1//! Memory Recall Engine — Hybrid vector + FTS search (Phase N10).
2//!
3//! Auto-embeds memories at write time using the `Embedder` trait,
4//! and fuses Tantivy BM25 + vector cosine similarity at recall time.
5//!
6//! # Architecture
7//!
8//! ```text
9//! RecallEngine
10//! ├── embedder: Arc<dyn Embedder>
11//! ├── vector_store: VectorStore
12//! ├── search_engine: SearchEngine (Tantivy BM25)
13//! ├── embedding_cache: HashMap<content_hash, Vec<f32>>
14//! └── hybrid_search(query, limit) → Vec<RecallResult>
15//!     1. Embed query → query vector
16//!     2. Tantivy BM25 search → text scores
17//!     3. Vector cosine similarity → vector scores
18//!     4. Fuse: w1*BM25 + w2*vector + w3*importance
19//!     5. Return ranked results
20//! ```
21//!
22//! # Environment Variables
23//!
24//! | Variable | Default | Description |
25//! |----------|---------|-------------|
26//! | `WM_RECALL_BM25_WEIGHT` | `0.5` | Weight for BM25 text score |
27//! | `WM_RECALL_VECTOR_WEIGHT` | `0.3` | Weight for vector cosine similarity |
28//! | `WM_RECALL_IMPORTANCE_WEIGHT` | `0.2` | Weight for memory importance |
29//! | `WM_TRUST_WEIGHT` | `0.0` | Post-fusion trust multiplier (source_trust 0.7 neutral) |
30//! | `WM_RECALL_CONFORMAL_ALPHA` | unset | Conformal set miscoverage level (unset = off) |
31
32#![allow(clippy::missing_const_for_fn)]
33
34use std::collections::HashMap;
35use std::sync::{Arc, Mutex};
36
37use uuid::Uuid;
38use wm_core::{CoreError, Galaxy, Result};
39
40use crate::associations::AssociationStore;
41use crate::embedder::Embedder;
42use crate::memory::content_hash;
43use crate::search::{SearchEngine, SearchResult};
44use crate::store::MemoryStore;
45use crate::vector::{VectorSearchResult, VectorStore};
46
47// ── Recall Result ─────────────────────────────────────────────────────
48
49/// A single recall result with fused scores.
50#[derive(Debug, Clone)]
51pub struct RecallResult {
52    /// Memory UUID.
53    pub memory_id: Uuid,
54    /// Galaxy the memory belongs to.
55    pub galaxy: Galaxy,
56    /// Fused relevance score (0.0–1.0).
57    pub score: f32,
58    /// BM25 text score (normalized 0.0–1.0).
59    pub bm25_score: f32,
60    /// Vector cosine similarity (0.0–1.0).
61    pub vector_score: f32,
62    /// Memory importance (0.0–1.0).
63    pub importance: f32,
64    /// Graph-traversal contribution (V8 S6 third fusion phase) — 0.0
65    /// unless the result was injected or boosted by walking association
66    /// edges from a fused seed.
67    pub graph_score: f32,
68    /// Trust multiplier actually applied to `score` in fusion (V8 S8) —
69    /// 1.0 when `WM_TRUST_WEIGHT` is off, so disclosure never lies.
70    pub trust_factor: f32,
71    /// Distinct corroborating sessions (bridging counter) — 0 unless the
72    /// `WM_CORROBORATION_WEIGHT` knob is on, in which case the count fed
73    /// the post-fusion boost. Disclosure never lies either way.
74    pub corroboration: u32,
75    /// Conformal-set membership (V8 S8) — meaningful only when the
76    /// disclosure says `active`; always `false` otherwise.
77    pub in_conformal_set: bool,
78    /// Content snippet.
79    pub content: String,
80}
81
82// ── Recall Config ─────────────────────────────────────────────────────
83
84/// Configuration for the recall engine.
85#[derive(Debug, Clone)]
86pub struct RecallConfig {
87    /// Weight for BM25 text score (default 0.5).
88    pub bm25_weight: f32,
89    /// Weight for vector cosine similarity (default 0.3).
90    pub vector_weight: f32,
91    /// Weight for memory importance (default 0.2).
92    pub importance_weight: f32,
93    /// Post-fusion graph-traversal boost multiplier (V8 S6, default 0.0 =
94    /// OFF — evidence-gated like trust weighting). NOT part of the
95    /// normalized fusion sum: when > 0, the top fused seeds are expanded
96    /// one hop through association edges, neighbors are injected or
97    /// boosted by `seed_score * edge_weight * graph_weight`, and results
98    /// carry the contribution in `RecallResult::graph_score`.
99    pub graph_weight: f32,
100    /// Post-fusion trust multiplier (V8 S8, default 0.0 = OFF).
101    ///
102    /// Like the graph weight, deliberately OUTSIDE the normalized fusion
103    /// sum: when > 0, every fused score is scaled by
104    /// `1 + weight * (source_trust − 0.7)` (user-confirmed ranks up,
105    /// tool-ingested 0.7 unchanged, low trust down), and the factor is
106    /// disclosed per-result in `RecallResult::trust_factor`.
107    pub trust_weight: f32,
108    /// Post-fusion corroboration multiplier (bridging counter, default 0.0
109    /// = OFF). Like trust, deliberately OUTSIDE the normalized fusion sum:
110    /// when > 0, every fused score is scaled by the saturating
111    /// `corroboration_boost` over the distinct-session count, and the count
112    /// is disclosed per-result in `RecallResult::corroboration`.
113    pub corroboration_weight: f32,
114    /// Conformal-set miscoverage level (V8 S8, default None = OFF). When
115    /// set in (0, 1), fused results are graded against a calibrated
116    /// prediction set and the search carries a `ConformalSetInfo`
117    /// disclosure — `active` with a real threshold, or `uncalibrated`
118    /// until feedback samples exist.
119    pub conformal_alpha: Option<f32>,
120    /// Whether to cache embeddings (default true).
121    pub cache_embeddings: bool,
122    /// Maximum cache entries (default 1000).
123    pub max_cache_entries: usize,
124    /// Tantivy IndexWriter heap size in bytes (default 50MB).
125    /// Note: writer is now owned by SearchEngine; this field is kept for API compatibility.
126    #[allow(dead_code)]
127    pub writer_heap_size: usize,
128    /// Post-fusion promotion-on-read (S5 promotion-on-read, default false = OFF).
129    /// When enabled (via WM_PROMOTION_ON_READ=1 or config), top recall hits trigger
130    /// `Memory::recall()`, updating accessed_at, access_count, recall_count, and
131    /// applying Hebbian neuro_score boosting + novelty decay.
132    pub promotion_on_read: bool,
133    /// Association-weighted recall reranking (S10, default false = OFF).
134    /// When enabled (via WM_ASSOCIATION_RERANK=1 or config), candidate recall
135    /// scores receive an association connectivity boost based on active edges.
136    pub association_rerank: bool,
137}
138
139impl Default for RecallConfig {
140    fn default() -> Self {
141        Self {
142            bm25_weight: 0.5,
143            vector_weight: 0.3,
144            importance_weight: 0.2,
145            graph_weight: 0.0,
146            trust_weight: 0.0,
147            corroboration_weight: 0.0,
148            conformal_alpha: None,
149            cache_embeddings: true,
150            max_cache_entries: 1000,
151            writer_heap_size: 50_000_000,
152            promotion_on_read: false,
153            association_rerank: false,
154        }
155    }
156}
157
158impl RecallConfig {
159    /// Create config from environment variables.
160    ///
161    /// Weights are clamped to [0.0, 1.0] and normalized to sum to 1.0.
162    /// NaN and Infinity values are rejected (default is kept).
163    #[must_use]
164    pub fn from_env() -> Self {
165        let mut config = Self::default();
166
167        if let Ok(v) = std::env::var("WM_RECALL_BM25_WEIGHT") {
168            if let Ok(w) = v.parse::<f32>() {
169                if w.is_finite() && w >= 0.0 {
170                    config.bm25_weight = w.min(1.0);
171                }
172            }
173        }
174        if let Ok(v) = std::env::var("WM_RECALL_VECTOR_WEIGHT") {
175            if let Ok(w) = v.parse::<f32>() {
176                if w.is_finite() && w >= 0.0 {
177                    config.vector_weight = w.min(1.0);
178                }
179            }
180        }
181        if let Ok(v) = std::env::var("WM_RECALL_IMPORTANCE_WEIGHT") {
182            if let Ok(w) = v.parse::<f32>() {
183                if w.is_finite() && w >= 0.0 {
184                    config.importance_weight = w.min(1.0);
185                }
186            }
187        }
188        // Graph traversal boost — deliberately OUTSIDE the normalization
189        // sum (it is a post-fusion multiplier, not a fourth signal).
190        if let Ok(v) = std::env::var("WM_RECALL_GRAPH_WEIGHT") {
191            if let Ok(w) = v.parse::<f32>() {
192                if w.is_finite() && w >= 0.0 {
193                    config.graph_weight = w.min(1.0);
194                }
195            }
196        }
197        // Trust weighting (V8 S8) — same post-fusion treatment: a
198        // multiplier, not a fusion signal. Same env the tool-side
199        // post-hoc path reads, so semantics are shared.
200        if let Ok(v) = std::env::var("WM_TRUST_WEIGHT") {
201            if let Ok(w) = v.parse::<f32>() {
202                if w.is_finite() && w >= 0.0 {
203                    config.trust_weight = w.min(1.0);
204                }
205            }
206        }
207        // Corroboration boost (bridging counter) — same post-fusion
208        // treatment: a multiplier, not a fusion signal. Off by default.
209        if let Ok(v) = std::env::var("WM_CORROBORATION_WEIGHT") {
210            if let Ok(w) = v.parse::<f32>() {
211                if w.is_finite() && w >= 0.0 {
212                    config.corroboration_weight = w.min(1.0);
213                }
214            }
215        }
216        // Conformal sets (V8 S8) — an alpha in (0, 1) enables calibrated
217        // membership grading; anything else (unset, invalid) stays off.
218        if let Ok(v) = std::env::var("WM_RECALL_CONFORMAL_ALPHA") {
219            if let Ok(a) = v.parse::<f32>() {
220                if a.is_finite() && a > 0.0 && a < 1.0 {
221                    config.conformal_alpha = Some(a);
222                }
223            }
224        }
225        // Promotion-on-read (S5 promotion-on-read) — opt-in via
226        // WM_PROMOTION_ON_READ=1. Off by default to keep benchmarks byte-identical.
227        if let Ok(v) = std::env::var("WM_PROMOTION_ON_READ") {
228            if v == "1" || v.eq_ignore_ascii_case("true") {
229                config.promotion_on_read = true;
230            }
231        }
232        // Association rerank (S10) — opt-in via WM_ASSOCIATION_RERANK=1.
233        // Off by default to keep benchmarks byte-identical.
234        if let Ok(v) = std::env::var("WM_ASSOCIATION_RERANK") {
235            if v == "1" || v.eq_ignore_ascii_case("true") {
236                config.association_rerank = true;
237            }
238        }
239
240        // Normalize weights to sum to 1.0 if they don't already
241        let sum = config.bm25_weight + config.vector_weight + config.importance_weight;
242        if sum > 0.0 && (sum - 1.0).abs() > 0.01 {
243            config.bm25_weight /= sum;
244            config.vector_weight /= sum;
245            config.importance_weight /= sum;
246        }
247
248        config
249    }
250
251    /// Validate that weights sum to approximately 1.0.
252    #[must_use]
253    pub fn weights_normalized(&self) -> bool {
254        let sum = self.bm25_weight + self.vector_weight + self.importance_weight;
255        (sum - 1.0).abs() < 0.01
256    }
257}
258
259// ── Recall Engine ─────────────────────────────────────────────────────
260
261/// Hybrid recall engine combining BM25 + vector search.
262///
263/// Wraps a `MemoryStore`, `SearchEngine`, `VectorStore`, and `Embedder`
264/// to provide fused search at recall time and auto-embedding at write time.
265pub struct RecallEngine {
266    store: Arc<MemoryStore>,
267    search_engine: Arc<SearchEngine>,
268    vector_store: Mutex<VectorStore>,
269    embedder: Arc<dyn Embedder>,
270    config: RecallConfig,
271    embedding_cache: Mutex<HashMap<String, Vec<f32>>>,
272    /// Conformal-set state (V8 S8) — `None` unless
273    /// `WM_RECALL_CONFORMAL_ALPHA` is configured.
274    conformal: Mutex<Option<crate::recall_conformal::RecallConformal>>,
275}
276
277impl RecallEngine {
278    /// Create a new recall engine.
279    ///
280    /// Returns an error if the Tantivy IndexWriter cannot be created.
281    pub fn new(
282        store: Arc<MemoryStore>,
283        search_engine: Arc<SearchEngine>,
284        vector_store: VectorStore,
285        embedder: Arc<dyn Embedder>,
286        config: RecallConfig,
287    ) -> Result<Self> {
288        let conformal =
289            Mutex::new(config.conformal_alpha.and_then(|alpha| {
290                crate::recall_conformal::RecallConformal::new(alpha, store.clone())
291            }));
292        Ok(Self {
293            store,
294            search_engine,
295            vector_store: Mutex::new(vector_store),
296            embedder,
297            config,
298            embedding_cache: Mutex::new(HashMap::new()),
299            conformal,
300        })
301    }
302
303    /// Record one relevance-feedback sample into the conformal calibrator
304    /// (V8 S8). Errors honestly when the knob is off — there is no
305    /// calibrated set to feed.
306    pub fn record_relevance_feedback(&self, score: f32, relevant: bool) -> Result<usize> {
307        let mut guard = self
308            .conformal
309            .lock()
310            .map_err(|e| CoreError::Memory(format!("recall conformal lock: {e}")))?;
311        match guard.as_mut() {
312            Some(rc) => Ok(rc.record_feedback(score, relevant)),
313            None => Err(CoreError::InvalidArgs(
314                "conformal calibration is not enabled — set WM_RECALL_CONFORMAL_ALPHA in (0,1)"
315                    .into(),
316            )),
317        }
318    }
319
320    /// Conformal disclosure for a completed search: grades the results
321    /// against the calibrated set (marking `in_conformal_set`) and returns
322    /// the set-level info. `Ok(None)` when the knob is off — no claim is
323    /// made at all.
324    // The lock must span the whole grading: membership is read from the
325    // same fitted state that produced the threshold, and a concurrent
326    // record_feedback/fit must not split the disclosure from the marks.
327    #[allow(clippy::significant_drop_tightening)]
328    pub fn conformal_disclosure(
329        &self,
330        results: &mut [RecallResult],
331    ) -> Result<Option<crate::recall_conformal::ConformalSetInfo>> {
332        use crate::recall_conformal::ConformalSetInfo;
333        let guard = self
334            .conformal
335            .lock()
336            .map_err(|e| CoreError::Memory(format!("recall conformal lock: {e}")))?;
337        let Some(rc) = guard.as_ref() else {
338            return Ok(None);
339        };
340        let coverage_target = Some(f64::from(1.0 - rc.alpha()));
341        let info = if rc.is_fitted() {
342            let threshold = rc.threshold();
343            let mut set_size = 0usize;
344            for r in results.iter_mut() {
345                r.in_conformal_set = rc.membership(r.score) == Some(true);
346                if r.in_conformal_set {
347                    set_size += 1;
348                }
349            }
350            ConformalSetInfo {
351                status: "active".into(),
352                alpha: Some(f64::from(rc.alpha())),
353                coverage_target,
354                calibration_samples: Some(rc.sample_count()),
355                threshold,
356                set_size: Some(set_size),
357                hint: None,
358            }
359        } else {
360            ConformalSetInfo {
361                status: "uncalibrated".into(),
362                alpha: Some(f64::from(rc.alpha())),
363                coverage_target,
364                calibration_samples: Some(rc.sample_count()),
365                threshold: None,
366                set_size: None,
367                hint: Some(format!(
368                    "record ≥ {} relevance-feedback samples to calibrate",
369                    crate::recall_conformal::MIN_SAMPLES
370                )),
371            }
372        };
373        Ok(Some(info))
374    }
375
376    /// Get the configuration.
377    #[must_use]
378    pub fn config(&self) -> &RecallConfig {
379        &self.config
380    }
381
382    /// Whether the embedder is a real backend (not a stub).
383    ///
384    /// When false, hybrid search would produce garbage vectors and
385    /// `store_with_embedding` should be avoided in favor of plain BM25.
386    #[must_use]
387    pub fn embedder_is_real(&self) -> bool {
388        self.embedder.backend_name() != "stub"
389    }
390
391    // ── Write path: auto-embed ─────────────────────────────────────────
392
393    /// Persistent cache key for a text: embedder namespace + content hash.
394    /// Vectors differ across models, so the namespace rides the key.
395    fn embedding_cache_key(&self, embedded_text: &str) -> String {
396        format!(
397            "{}:{}",
398            self.embedder.cache_namespace(),
399            content_hash(embedded_text)
400        )
401    }
402
403    /// Embed content and cache the result.
404    ///
405    /// Returns the embedding vector. Lookup order: in-memory LRU, then the
406    /// persistent content-hash cache (survives restarts — re-runs and
407    /// re-ingest warm-start instead of re-embedding), then the embedder.
408    fn embed_content(&self, content: &str) -> Result<Vec<f32>> {
409        let hash = content_hash(content);
410
411        // Check in-memory cache
412        if self.config.cache_embeddings {
413            let cache = self
414                .embedding_cache
415                .lock()
416                .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
417            if let Some(vec) = cache.get(&hash) {
418                return Ok(vec.clone());
419            }
420        }
421
422        // Check the persistent cache (v26 "Tier 2", finally wired).
423        let cache_key = self.embedding_cache_key(content);
424        match self.store.get_embedding_cache(&cache_key) {
425            Ok(Some(vector)) => {
426                if self.config.cache_embeddings {
427                    if let Ok(mut cache) = self.embedding_cache.lock() {
428                        cache.insert(hash, vector.clone());
429                    }
430                }
431                return Ok(vector);
432            }
433            Ok(None) => {}
434            Err(error) => tracing::warn!("embedding cache read failed: {error}"),
435        }
436
437        // Embed
438        let embedding = self.embedder.embed(content)?;
439
440        // Persist (non-fatal: a cache write failure must not fail the ingest)
441        if let Err(error) = self.store.put_embedding_cache(&cache_key, &embedding) {
442            tracing::warn!("embedding cache write failed: {error}");
443        }
444
445        // Cache in memory
446        if self.config.cache_embeddings {
447            let mut cache = self
448                .embedding_cache
449                .lock()
450                .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
451            if cache.len() >= self.config.max_cache_entries {
452                // Evict ~10% of entries (simple strategy)
453                let to_remove: Vec<String> = cache.keys().take(cache.len() / 10).cloned().collect();
454                for key in to_remove {
455                    cache.remove(&key);
456                }
457            }
458            cache.insert(hash, embedding.clone());
459        }
460
461        Ok(embedding)
462    }
463
464    /// Store a memory with auto-embedding.
465    ///
466    /// 1. Embeds the content using the configured embedder.
467    /// 2. Stores the memory in the given galaxy.
468    /// 3. Stores the embedding in the Embeddings galaxy.
469    /// 4. Adds the embedding to the vector store.
470    /// 5. Indexes the content in Tantivy.
471    pub fn store_with_embedding(&self, galaxy: Galaxy, memory: &crate::Memory) -> Result<()> {
472        // 1. Embed content
473        let embedding = self.embed_content(&memory.content)?;
474
475        // 2. Store memory
476        self.store.put(galaxy, memory)?;
477
478        // 3. Store embedding
479        self.store.put_embedding(memory.metadata.id, &embedding)?;
480
481        // 4. Add to vector store
482        {
483            let mut vs = self
484                .vector_store
485                .lock()
486                .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
487            vs.add(memory.metadata.id, galaxy, embedding);
488        }
489
490        // 5. Index in Tantivy
491        let timestamp = memory.metadata.created_at.timestamp();
492        let tags: Vec<String> = memory.metadata.tags.clone();
493        {
494            let mut writer = self.search_engine.writer()?;
495            self.search_engine.add_document(
496                &mut writer,
497                &memory.metadata.id.to_string(),
498                galaxy.db_name(),
499                &memory.content,
500                &tags,
501                timestamp,
502            )?;
503            self.search_engine.commit(&mut writer)?;
504        }
505
506        Ok(())
507    }
508
509    /// Batch-store memories with auto-embedding in a single HTTP call + single Tantivy commit.
510    ///
511    /// Like `store_with_embedding` but for multiple memories at once:
512    /// 1. Embeds all content via `embed_batch()` (single HTTP call).
513    /// 2. Stores all memories to LMDB.
514    /// 3. Stores all embeddings.
515    /// 4. Adds all to the vector store.
516    /// 5. Indexes all in Tantivy with a single writer + commit.
517    ///
518    /// Returns the number of memories successfully stored.
519    pub fn store_batch_with_embedding(
520        &self,
521        entries: &[(Galaxy, &crate::Memory)],
522    ) -> Result<usize> {
523        if entries.is_empty() {
524            return Ok(0);
525        }
526
527        // 1. Resolve the persistent cache first (v26 "Tier 2", wired):
528        //    only the misses reach the embedder, chunked as before; hits
529        //    are reassembled in order. Cache keys ride the EMBEDDED text
530        //    (the chunked form), matching the single-item path.
531        const MAX_CHARS_PER_CHUNK: usize = 1500; // ~465 tokens worst case (3.21 chars/token)
532        const MAX_CHARS_PER_ITEM: usize = 1500; // same limit for individual items
533        let contents: Vec<String> = entries
534            .iter()
535            .map(|(_, m)| {
536                if m.content.len() > MAX_CHARS_PER_ITEM {
537                    m.content.chars().take(MAX_CHARS_PER_ITEM).collect()
538                } else {
539                    m.content.clone()
540                }
541            })
542            .collect();
543        let content_refs: Vec<&str> = contents.iter().map(String::as_str).collect();
544        let cache_keys: Vec<String> = content_refs
545            .iter()
546            .map(|c| self.embedding_cache_key(c))
547            .collect();
548        let mut embeddings: Vec<Option<Vec<f32>>> =
549            match self.store.get_embedding_cache_batch(&cache_keys) {
550                Ok(cached) => cached,
551                Err(error) => {
552                    tracing::warn!("embedding cache batch read failed: {error}");
553                    vec![None; cache_keys.len()]
554                }
555            };
556        let misses: Vec<(usize, &str)> = content_refs
557            .iter()
558            .enumerate()
559            .filter(|(i, _)| embeddings[*i].is_none())
560            .map(|(i, content)| (i, *content))
561            .collect();
562
563        // Chunked embedding for the misses only. Two stopping rules: the
564        // char cap (HTTP token limits) and the embedder's preferred batch
565        // size in texts (local engines want big batches so the session
566        // pool fans out efficiently).
567        let max_batch_texts = self.embedder.preferred_max_batch_texts();
568        let mut chunk: Vec<&str> = Vec::new();
569        let mut chunk_positions: Vec<usize> = Vec::new();
570        let mut chunk_chars: usize = 0;
571        let mut embed_chunk = |chunk: &[&str], positions: &[usize], store: &Self| -> Result<()> {
572            let chunk_vecs = store.embedder.embed_batch(chunk)?;
573            if chunk_vecs.len() != chunk.len() {
574                return Err(CoreError::Memory(format!(
575                    "embed_batch returned {} vectors for {} inputs (chunk)",
576                    chunk_vecs.len(),
577                    chunk.len()
578                )));
579            }
580            for (pos, vector) in positions.iter().zip(chunk_vecs) {
581                embeddings[*pos] = Some(vector);
582            }
583            Ok(())
584        };
585        for &(position, content) in &misses {
586            let content_chars = content.len();
587            let flush = !chunk.is_empty()
588                && (chunk_chars + content_chars > MAX_CHARS_PER_CHUNK
589                    || chunk.len() >= max_batch_texts);
590            if flush {
591                embed_chunk(&chunk, &chunk_positions, self)?;
592                chunk.clear();
593                chunk_positions.clear();
594                chunk_chars = 0;
595            }
596            chunk.push(content);
597            chunk_positions.push(position);
598            chunk_chars += content_chars;
599        }
600        if !chunk.is_empty() {
601            embed_chunk(&chunk, &chunk_positions, self)?;
602        }
603
604        // Persist the freshly embedded vectors (one transaction; a cache
605        // write failure must not fail the ingest).
606        let fresh: Vec<(String, Vec<f32>)> = misses
607            .iter()
608            .filter_map(|&(position, _)| {
609                embeddings[position]
610                    .clone()
611                    .map(|vector| (cache_keys[position].clone(), vector))
612            })
613            .collect();
614        if let Err(error) = self.store.put_embedding_cache_batch(&fresh) {
615            tracing::warn!("embedding cache batch write failed: {error}");
616        }
617
618        // 2. Store all memories to LMDB + embeddings
619        for (i, (galaxy, memory)) in entries.iter().enumerate() {
620            let Some(ref embedding) = embeddings[i] else {
621                return Err(CoreError::Memory(format!(
622                    "embedding missing for entry {i} after cache resolution"
623                )));
624            };
625            self.store.put(*galaxy, memory)?;
626            self.store.put_embedding(memory.metadata.id, embedding)?;
627        }
628
629        // 3. Add all to vector store
630        {
631            let mut vs = self
632                .vector_store
633                .lock()
634                .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
635            for (i, (galaxy, memory)) in entries.iter().enumerate() {
636                if let Some(ref embedding) = embeddings[i] {
637                    vs.add(memory.metadata.id, *galaxy, embedding.clone());
638                }
639            }
640        }
641
642        // 4. Index all in Tantivy with a single commit
643        {
644            let mut writer = self.search_engine.writer()?;
645            for (galaxy, memory) in entries {
646                let timestamp = memory.metadata.created_at.timestamp();
647                let tags: Vec<String> = memory.metadata.tags.clone();
648                self.search_engine.add_document(
649                    &mut writer,
650                    &memory.metadata.id.to_string(),
651                    galaxy.db_name(),
652                    &memory.content,
653                    &tags,
654                    timestamp,
655                )?;
656            }
657            self.search_engine.commit(&mut writer)?;
658        }
659
660        // 5. Fill the in-memory cache for the misses (hits already ride
661        //    the persistent layer; no need to burn LRU slots on them).
662        //    Keyed on the ORIGINAL content hash, matching embed_content.
663        if self.config.cache_embeddings {
664            let mut cache = self
665                .embedding_cache
666                .lock()
667                .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
668            for &(position, _) in &misses {
669                let Some(ref vector) = embeddings[position] else {
670                    continue;
671                };
672                let hash = content_hash(&entries[position].1.content);
673                if cache.len() >= self.config.max_cache_entries {
674                    let to_remove: Vec<String> =
675                        cache.keys().take(cache.len() / 10).cloned().collect();
676                    for key in to_remove {
677                        cache.remove(&key);
678                    }
679                }
680                cache.insert(hash, vector.clone());
681            }
682        }
683
684        Ok(entries.len())
685    }
686
687    // ── Read path: hybrid search ───────────────────────────────────────
688
689    /// Hybrid search combining BM25 + vector similarity.
690    ///
691    /// Weights: `bm25_weight * BM25 + vector_weight * cosine + importance_weight * importance`
692    #[must_use]
693    pub fn hybrid_search(
694        &self,
695        query: &str,
696        limit: usize,
697        galaxy_filter: Option<Galaxy>,
698    ) -> Vec<RecallResult> {
699        self.hybrid_search_with_disclosure(query, limit, galaxy_filter)
700            .0
701    }
702
703    /// Hybrid search plus the V8 S8 disclosure: `(results, conformal)`.
704    /// `conformal` is `None` when `WM_RECALL_CONFORMAL_ALPHA` is unset —
705    /// no calibrated claim exists, so none is made.
706    pub fn hybrid_search_with_disclosure(
707        &self,
708        query: &str,
709        limit: usize,
710        galaxy_filter: Option<Galaxy>,
711    ) -> (
712        Vec<RecallResult>,
713        Option<crate::recall_conformal::ConformalSetInfo>,
714    ) {
715        // 1. Embed query
716        let query_vec = match self.embedder.embed_query(query) {
717            Ok(v) => v,
718            Err(_) => return (Vec::new(), None),
719        };
720
721        // 2. BM25 search (get more than limit for fusion)
722        let bm25_limit = limit * 3;
723        let bm25_results = self
724            .search_engine
725            .search_in_galaxy(query, galaxy_filter, bm25_limit)
726            .unwrap_or_default();
727
728        // 3. Vector search
729        let vector_results = {
730            let Ok(vs) = self.vector_store.lock() else {
731                return (Vec::new(), None);
732            };
733            vs.search(&query_vec, bm25_limit, galaxy_filter)
734        };
735
736        // 4. Fuse results (trust weighting applied inside when enabled)
737        let fused = self.fuse_results(&bm25_results, &vector_results, limit);
738
739        // 4b. Validity filter (V8 Slice B) — off unless
740        // WM_VALIDITY_ENFORCE=1; knob-off this retains everything and the
741        // surface is byte-identical.
742        let fused = if crate::memory::validity_enforced() {
743            fused
744                .into_iter()
745                .filter(|r| {
746                    self.find_memory_anywhere(r.memory_id)
747                        .is_none_or(|mem| mem.metadata.validity.is_current())
748                })
749                .collect()
750        } else {
751            fused
752        };
753
754        // 5. Graph expansion (V8 S6 third fusion phase) — off unless
755        // WM_RECALL_GRAPH_WEIGHT > 0.
756        let mut expanded = self.expand_with_graph(fused, limit);
757
758        // 5b. Corroboration boost (bridging counter) — off unless
759        // WM_CORROBORATION_WEIGHT > 0. Knob-off counts stay 0 and scores
760        // are byte-identical; knob-on the distinct-session count feeds the
761        // saturating boost and is disclosed per-result.
762        if self.config.corroboration_weight > 0.0 {
763            for r in &mut expanded {
764                if let Some(mem) = self.find_memory_anywhere(r.memory_id) {
765                    let n = mem.metadata.corroborated_by.len();
766                    r.corroboration = n.min(u32::MAX as usize) as u32;
767                    r.score = crate::memory::corroboration_boost(
768                        r.score,
769                        n,
770                        self.config.corroboration_weight,
771                    );
772                }
773            }
774            expanded.sort_by(|a, b| {
775                b.score
776                    .partial_cmp(&a.score)
777                    .unwrap_or(std::cmp::Ordering::Equal)
778            });
779        }
780
781        // 5d. Association-weighted recall reranking (S10) — off unless
782        // WM_ASSOCIATION_RERANK=1 or config.association_rerank is true.
783        // Knob-off scores are byte-identical; knob-on candidates receive a
784        // bounded connectivity boost based on active cross-galaxy association degree.
785        if self.config.association_rerank {
786            if let Ok(assoc_store) = AssociationStore::open(self.store.env()) {
787                let env = self.store.env();
788                for r in &mut expanded {
789                    let outgoing = assoc_store.find_from(env, r.memory_id).unwrap_or_default();
790                    let incoming = assoc_store.find_to(env, r.memory_id).unwrap_or_default();
791                    let active_edges = outgoing
792                        .iter()
793                        .chain(incoming.iter())
794                        .filter(|e| e.weight >= 0.2)
795                        .count();
796                    if active_edges > 0 {
797                        let boost = (active_edges as f32 * 0.05).min(0.25);
798                        r.score *= 1.0 + boost;
799                    }
800                }
801                expanded.sort_by(|a, b| {
802                    b.score
803                        .partial_cmp(&a.score)
804                        .unwrap_or(std::cmp::Ordering::Equal)
805                });
806            }
807        }
808
809        // 5c. Promotion-on-read (S5 Hebbian reinforcement on hit path) —
810        // off unless WM_PROMOTION_ON_READ=1 or config.promotion_on_read is true.
811        // When enabled, top hits returned to the caller are promoted in LMDB.
812        if self.config.promotion_on_read {
813            for r in expanded.iter().take(limit) {
814                if let Err(e) = self.promote_memory(r.galaxy, r.memory_id) {
815                    tracing::warn!(
816                        error = %e,
817                        memory_id = %r.memory_id,
818                        galaxy = %r.galaxy.db_name(),
819                        "promotion on read failed"
820                    );
821                }
822            }
823        }
824
825        // 6. Conformal grading (V8 S8) — off unless
826        // WM_RECALL_CONFORMAL_ALPHA is set; honest disclosure either way.
827        match self.conformal_disclosure(&mut expanded) {
828            Ok(info) => (expanded, info),
829            Err(e) => {
830                tracing::warn!(error = %e, "recall conformal disclosure failed");
831                (expanded, None)
832            }
833        }
834    }
835
836    /// Pure vector search (no BM25).
837    #[must_use]
838    pub fn vector_search(
839        &self,
840        query: &str,
841        limit: usize,
842        galaxy_filter: Option<Galaxy>,
843    ) -> Vec<RecallResult> {
844        let query_vec = match self.embedder.embed_query(query) {
845            Ok(v) => v,
846            Err(_) => return Vec::new(),
847        };
848
849        let vector_results = {
850            let Ok(vs) = self.vector_store.lock() else {
851                return Vec::new();
852            };
853            vs.search(&query_vec, limit, galaxy_filter)
854        };
855
856        vector_results
857            .into_iter()
858            .map(|vr| {
859                let content = self.get_memory_content(vr.memory_id, vr.galaxy);
860                RecallResult {
861                    memory_id: vr.memory_id,
862                    galaxy: vr.galaxy,
863                    score: vr.score,
864                    bm25_score: 0.0,
865                    vector_score: vr.score,
866                    importance: 0.0,
867                    graph_score: 0.0,
868                    trust_factor: 1.0,
869                    in_conformal_set: false,
870                    corroboration: 0,
871                    content,
872                }
873            })
874            .collect()
875    }
876
877    /// Pure BM25 search (no vector).
878    #[must_use]
879    pub fn text_search(&self, query: &str, limit: usize) -> Vec<RecallResult> {
880        let bm25_results = self.search_engine.search(query, limit).unwrap_or_default();
881
882        bm25_results
883            .into_iter()
884            .filter_map(|sr| {
885                let memory_id = Uuid::parse_str(&sr.memory_id).ok()?;
886                let galaxy = Galaxy::from_db_name(&sr.galaxy)?;
887                Some(RecallResult {
888                    memory_id,
889                    galaxy,
890                    score: sr.score,
891                    bm25_score: sr.score,
892                    vector_score: 0.0,
893                    importance: 0.0,
894                    graph_score: 0.0,
895                    trust_factor: 1.0,
896                    in_conformal_set: false,
897                    corroboration: 0,
898                    content: sr.content,
899                })
900            })
901            .collect()
902    }
903
904    // ── Fusion ─────────────────────────────────────────────────────────
905
906    /// Fuse BM25 and vector results into a single ranked list.
907    fn fuse_results(
908        &self,
909        bm25_results: &[SearchResult],
910        vector_results: &[VectorSearchResult],
911        limit: usize,
912    ) -> Vec<RecallResult> {
913        fuse_results_inner(
914            bm25_results,
915            vector_results,
916            limit,
917            self.config.bm25_weight,
918            self.config.vector_weight,
919            self.config.importance_weight,
920            self.config.trust_weight,
921            |id, galaxy| self.get_memory_content(id, galaxy),
922            |id, galaxy| self.get_memory_importance(id, galaxy),
923            |id, galaxy| self.get_memory_source_trust(id, galaxy),
924        )
925    }
926
927    /// Expand fused results one hop through association edges (V8 S6 —
928    /// the third fusion phase).
929    ///
930    /// From the top-3 fused seeds, walk outgoing + incoming edges (weight
931    /// ≥ 0.2): neighbors already present get a score boost, absent ones
932    /// are injected (privacy-guarded) with `seed_score * edge_weight *
933    /// graph_weight` as their contribution, disclosed per-result in
934    /// `graph_score`. Inert until `WM_RECALL_GRAPH_WEIGHT > 0`; the base
935    /// fusion is byte-identical when the knob is off.
936    fn expand_with_graph(&self, mut results: Vec<RecallResult>, limit: usize) -> Vec<RecallResult> {
937        if self.config.graph_weight <= 0.0 || results.is_empty() {
938            return results;
939        }
940        let Ok(assoc_store) = AssociationStore::open(self.store.env()) else {
941            return results;
942        };
943        let env = self.store.env();
944        let seeds: Vec<RecallResult> = results.iter().take(3).cloned().collect();
945        for seed in seeds {
946            let outgoing = assoc_store
947                .find_from(env, seed.memory_id)
948                .unwrap_or_default();
949            let incoming = assoc_store.find_to(env, seed.memory_id).unwrap_or_default();
950            for edge in outgoing.into_iter().chain(incoming) {
951                if edge.weight < 0.2 {
952                    continue;
953                }
954                let neighbor_id = if edge.source == seed.memory_id {
955                    edge.target
956                } else {
957                    edge.source
958                };
959                if neighbor_id == seed.memory_id {
960                    continue;
961                }
962                // Validity-aware graph phase (V8 Slice B, knob-gated):
963                // non-current neighbors contribute nothing while enforced.
964                // Knob-off this block never runs and fusion is byte-identical.
965                if crate::memory::validity_enforced()
966                    && self
967                        .find_memory_anywhere(neighbor_id)
968                        .is_some_and(|mem| !mem.metadata.validity.is_current())
969                {
970                    continue;
971                }
972                let contribution = seed.score * edge.weight * self.config.graph_weight;
973                if self.config.promotion_on_read {
974                    let mut activated_edge = edge.clone();
975                    activated_edge.activate();
976                    let _ = assoc_store.put(env, &activated_edge);
977                }
978                if let Some(existing) = results.iter_mut().find(|r| r.memory_id == neighbor_id) {
979                    existing.score += contribution;
980                    existing.graph_score += contribution;
981                } else if let Some(mem) = self.find_memory_anywhere(neighbor_id) {
982                    // Injected neighbors honor the privacy flag — the main
983                    // path must never gain a side door through the graph.
984                    // Same for validity while enforced (Slice B).
985                    if mem.metadata.is_private {
986                        continue;
987                    }
988                    if crate::memory::validity_enforced() && !mem.metadata.validity.is_current() {
989                        continue;
990                    }
991                    results.push(RecallResult {
992                        memory_id: neighbor_id,
993                        galaxy: mem.metadata.galaxy,
994                        score: contribution,
995                        bm25_score: 0.0,
996                        vector_score: 0.0,
997                        importance: mem.metadata.importance,
998                        graph_score: contribution,
999                        trust_factor: 1.0,
1000                        in_conformal_set: false,
1001                        corroboration: 0,
1002                        content: mem.content.chars().take(400).collect(),
1003                    });
1004                }
1005            }
1006        }
1007        results.sort_by(|a, b| {
1008            b.score
1009                .partial_cmp(&a.score)
1010                .unwrap_or(std::cmp::Ordering::Equal)
1011        });
1012        results.truncate(limit.max(3));
1013        results
1014    }
1015
1016    /// Resolve a memory id across the memory galaxies (S9 cross-galaxy traversal).
1017    fn find_memory_anywhere(&self, id: Uuid) -> Option<crate::memory::Memory> {
1018        self.store
1019            .find_across_galaxies(id)
1020            .ok()
1021            .flatten()
1022            .map(|(_, m)| m)
1023    }
1024
1025    /// Promote a memory on recall hit: calls `Memory::recall()` to apply Hebbian
1026    /// strengthening and updates accessed_at/access_count/recall_count in the store.
1027    pub fn promote_memory(&self, galaxy: Galaxy, id: Uuid) -> Result<bool> {
1028        if let Some(mut mem) = self.store.get(galaxy, id)? {
1029            mem.recall();
1030            self.store.put(galaxy, &mem)?;
1031            Ok(true)
1032        } else {
1033            Ok(false)
1034        }
1035    }
1036
1037    // ── Helpers ────────────────────────────────────────────────────────
1038
1039    /// Get memory content by ID.
1040    fn get_memory_content(&self, id: Uuid, galaxy: Galaxy) -> String {
1041        self.store
1042            .get(galaxy, id)
1043            .ok()
1044            .flatten()
1045            .map(|m| m.content)
1046            .unwrap_or_default()
1047    }
1048
1049    /// Whether a memory is flagged `is_private` (missing memories count as
1050    /// private — they cannot be verified visible).
1051    #[must_use]
1052    pub fn is_private(&self, id: Uuid, galaxy: Galaxy) -> bool {
1053        self.store
1054            .get(galaxy, id)
1055            .ok()
1056            .flatten()
1057            .is_none_or(|m| m.metadata.is_private)
1058    }
1059
1060    /// Get memory importance by ID.
1061    fn get_memory_importance(&self, id: Uuid, galaxy: Galaxy) -> f32 {
1062        self.store
1063            .get(galaxy, id)
1064            .ok()
1065            .flatten()
1066            .map_or(0.0, |m| m.metadata.importance)
1067    }
1068
1069    /// Get memory `source_trust` by ID (V8 S8 trust-into-fusion).
1070    /// Missing memories resolve to 0.7 — the tool-ingested neutral point —
1071    /// so an absent row is trust-neutral rather than trust-maximal.
1072    fn get_memory_source_trust(&self, id: Uuid, galaxy: Galaxy) -> f32 {
1073        self.store
1074            .get(galaxy, id)
1075            .ok()
1076            .flatten()
1077            .map_or(0.7, |m| m.metadata.source_trust)
1078    }
1079
1080    /// Get the number of cached embeddings.
1081    #[must_use]
1082    pub fn cache_size(&self) -> usize {
1083        self.embedding_cache.lock().map_or(0, |c| c.len())
1084    }
1085
1086    /// Clear the embedding cache.
1087    pub fn clear_cache(&self) {
1088        if let Ok(mut c) = self.embedding_cache.lock() {
1089            c.clear();
1090        }
1091    }
1092
1093    /// Get the number of vectors in the vector store.
1094    #[must_use]
1095    pub fn vector_count(&self) -> usize {
1096        self.vector_store.lock().map_or(0, |c| c.len())
1097    }
1098}
1099
1100// ── Fusion implementation ─────────────────────────────────────────────
1101
1102/// Inner fusion logic, extracted for testability without a full engine.
1103#[allow(clippy::too_many_arguments)]
1104fn fuse_results_inner(
1105    bm25_results: &[SearchResult],
1106    vector_results: &[VectorSearchResult],
1107    limit: usize,
1108    bm25_weight: f32,
1109    vector_weight: f32,
1110    importance_weight: f32,
1111    trust_weight: f32,
1112    mut get_content: impl FnMut(Uuid, Galaxy) -> String,
1113    mut get_importance: impl FnMut(Uuid, Galaxy) -> f32,
1114    mut get_source_trust: impl FnMut(Uuid, Galaxy) -> f32,
1115) -> Vec<RecallResult> {
1116    // Normalize BM25 scores
1117    let max_bm25 = bm25_results
1118        .iter()
1119        .map(|r| r.score)
1120        .fold(0.0_f32, f32::max)
1121        .max(0.001);
1122
1123    // Build lookup maps
1124    let mut bm25_map: HashMap<Uuid, (f32, String, Galaxy)> = HashMap::new();
1125    for sr in bm25_results {
1126        if let Ok(id) = Uuid::parse_str(&sr.memory_id) {
1127            match Galaxy::from_db_name(&sr.galaxy) {
1128                Some(galaxy) => {
1129                    let normalized = sr.score / max_bm25;
1130                    bm25_map.insert(id, (normalized, sr.content.clone(), galaxy));
1131                }
1132                None => {
1133                    tracing::warn!(
1134                        "Skipping BM25 result with unknown galaxy '{}' (memory_id={})",
1135                        sr.galaxy,
1136                        sr.memory_id
1137                    );
1138                }
1139            }
1140        }
1141    }
1142
1143    let mut vector_map: HashMap<Uuid, (f32, Galaxy)> = HashMap::new();
1144    for vr in vector_results {
1145        vector_map.insert(vr.memory_id, (vr.score, vr.galaxy));
1146    }
1147
1148    // Collect all unique memory IDs
1149    let mut all_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1150    all_ids.extend(bm25_map.keys());
1151    all_ids.extend(vector_map.keys());
1152
1153    // Fuse scores
1154    let mut results: Vec<RecallResult> = all_ids
1155        .into_iter()
1156        .map(|id| {
1157            let (bm25_score, content, galaxy_bm25) = bm25_map
1158                .get(&id)
1159                .map_or((0.0, String::new(), Galaxy::Codex), |(s, c, g)| {
1160                    (*s, c.clone(), *g)
1161                });
1162
1163            let (vector_score, galaxy_vec) = vector_map
1164                .get(&id)
1165                .map_or((0.0, Galaxy::Codex), |(s, g)| (*s, *g));
1166
1167            let galaxy = if bm25_score > 0.0 {
1168                galaxy_bm25
1169            } else {
1170                galaxy_vec
1171            };
1172
1173            let content = if content.is_empty() {
1174                get_content(id, galaxy)
1175            } else {
1176                content
1177            };
1178
1179            let importance = get_importance(id, galaxy);
1180
1181            let fused = bm25_weight.mul_add(
1182                bm25_score,
1183                vector_weight.mul_add(vector_score, importance_weight * importance),
1184            );
1185
1186            // Trust weighting (V8 S8): post-fusion multiplier, applied
1187            // here so every consumer of the hybrid path sees the same
1188            // ranking. Factor disclosed per-result; 1.0 when the knob is
1189            // off (byte-identical base fusion). Plain float ops by
1190            // design — mul_add would change rounding and with it the
1191            // ranking (the deterministic-scorer allow class, AGENTS.md).
1192            #[allow(clippy::suboptimal_flops)]
1193            let (score, trust_factor) = if trust_weight > 0.0 {
1194                let source_trust = get_source_trust(id, galaxy);
1195                let factor = (1.0 + trust_weight * (source_trust.clamp(0.0, 1.0) - 0.7)).max(0.0);
1196                (fused * factor, factor)
1197            } else {
1198                (fused, 1.0)
1199            };
1200
1201            RecallResult {
1202                memory_id: id,
1203                galaxy,
1204                score,
1205                bm25_score,
1206                vector_score,
1207                importance,
1208                graph_score: 0.0,
1209                trust_factor,
1210                in_conformal_set: false,
1211                corroboration: 0,
1212                content,
1213            }
1214        })
1215        .collect();
1216
1217    // Sort by fused score descending
1218    results.sort_by(|a, b| {
1219        b.score
1220            .partial_cmp(&a.score)
1221            .unwrap_or(std::cmp::Ordering::Equal)
1222    });
1223    results.truncate(limit);
1224    results
1225}
1226
1227// ── Tests ─────────────────────────────────────────────────────────────
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232    use crate::associations::{Association, LinkType};
1233    use crate::embedder::StubEmbedder;
1234
1235    /// S6 acceptance harness: a real store + Tantivy index + engine. Only
1236    /// `indexed` memories are BM25-findable; graph-only neighbors are NOT
1237    /// indexed, so their presence in hybrid results proves traversal.
1238    struct GraphHarness {
1239        _dir: tempfile::TempDir,
1240        store: Arc<MemoryStore>,
1241        engine_with_graph: RecallEngine,
1242        engine_plain: RecallEngine,
1243    }
1244
1245    fn graph_harness() -> GraphHarness {
1246        let dir = tempfile::tempdir().unwrap();
1247        let lmdb = dir.path().join("lmdb");
1248        std::fs::create_dir_all(&lmdb).unwrap();
1249        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1250        let tantivy = dir.path().join("tantivy");
1251        std::fs::create_dir_all(&tantivy).unwrap();
1252        let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1253
1254        // Seed: A (indexed, the query hit), B (graph neighbor, NOT
1255        // indexed), C (indexed, unconnected). Edge A --0.8--> B.
1256        let a = Memory::new(Galaxy::Codex, "kumquat governance ratchet".into());
1257        let mut b = Memory::new(Galaxy::Codex, "the follow-up decision".into());
1258        let c = Memory::new(Galaxy::Codex, "kumquat harvest notes".into());
1259        b.metadata.is_private = false;
1260        let (id_a, id_b, id_c) = (a.metadata.id, b.metadata.id, c.metadata.id);
1261        store.put(Galaxy::Codex, &a).unwrap();
1262        store.put(Galaxy::Codex, &b).unwrap();
1263        store.put(Galaxy::Codex, &c).unwrap();
1264
1265        let mut writer = search.writer().unwrap();
1266        for (id, content) in [
1267            (id_a, "kumquat governance ratchet"),
1268            (id_c, "kumquat harvest notes"),
1269        ] {
1270            search
1271                .add_document(
1272                    &mut writer,
1273                    &id.to_string(),
1274                    "codex",
1275                    content,
1276                    &[],
1277                    1_700_000_000,
1278                )
1279                .unwrap();
1280        }
1281        search.commit(&mut writer).unwrap();
1282
1283        let env = store.env();
1284        let assocs = AssociationStore::open(env).unwrap();
1285        assocs
1286            .put(env, &Association::new(id_a, id_b, LinkType::Related, 0.8))
1287            .unwrap();
1288
1289        let store_for_engine = store.clone();
1290        let search_for_engine = search.clone();
1291        let mk_engine = move |graph_weight: f32| {
1292            let config = RecallConfig {
1293                bm25_weight: 1.0,
1294                vector_weight: 0.0,
1295                importance_weight: 0.0,
1296                graph_weight,
1297                ..RecallConfig::default()
1298            };
1299            RecallEngine::new(
1300                store_for_engine.clone(),
1301                search_for_engine.clone(),
1302                VectorStore::new(),
1303                Arc::new(StubEmbedder::default()),
1304                config,
1305            )
1306            .unwrap()
1307        };
1308        GraphHarness {
1309            _dir: dir,
1310            store,
1311            engine_with_graph: mk_engine(0.5),
1312            engine_plain: mk_engine(0.0),
1313        }
1314    }
1315
1316    /// Counts embedder invocations; delegates to the stub. The persistent
1317    /// embedding-cache acceptance is measured in CALLS, not assumptions.
1318    struct CountingEmbedder {
1319        inner: StubEmbedder,
1320        calls: std::sync::atomic::AtomicUsize,
1321    }
1322
1323    impl CountingEmbedder {
1324        fn new() -> Self {
1325            Self {
1326                inner: StubEmbedder::default(),
1327                calls: std::sync::atomic::AtomicUsize::new(0),
1328            }
1329        }
1330
1331        fn call_count(&self) -> usize {
1332            self.calls.load(std::sync::atomic::Ordering::SeqCst)
1333        }
1334    }
1335
1336    impl Embedder for CountingEmbedder {
1337        fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1338            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1339            self.inner.embed_batch(texts)
1340        }
1341        fn dimension(&self) -> usize {
1342            self.inner.dimension()
1343        }
1344        fn is_available(&self) -> bool {
1345            true
1346        }
1347        fn backend_name(&self) -> &'static str {
1348            "stub-counting"
1349        }
1350    }
1351
1352    fn engine_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
1353        let dir = tempfile::tempdir().unwrap();
1354        let lmdb = dir.path().join("lmdb");
1355        std::fs::create_dir_all(&lmdb).unwrap();
1356        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1357        let tantivy = dir.path().join("tantivy");
1358        std::fs::create_dir_all(&tantivy).unwrap();
1359        let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1360        (dir, store, search)
1361    }
1362
1363    fn mk_engine(
1364        store: &Arc<MemoryStore>,
1365        search: &Arc<SearchEngine>,
1366        embedder: Arc<dyn Embedder>,
1367    ) -> RecallEngine {
1368        RecallEngine::new(
1369            store.clone(),
1370            search.clone(),
1371            VectorStore::new(),
1372            embedder,
1373            RecallConfig::default(),
1374        )
1375        .unwrap()
1376    }
1377
1378    #[test]
1379    fn embedding_cache_warm_starts_reingest_across_engine_restart() {
1380        // V8 ship list #2: the content-hash vector cache persists in the
1381        // store, so a fresh engine over the same store re-ingests identical
1382        // content with ZERO embedder calls (v26 Tier-2, wired).
1383        let (_dir, store, search) = engine_fixture();
1384
1385        let contents: Vec<String> = (0..12)
1386            .map(|i| format!("cache warm-start probe number {i} with distinct wording {i}"))
1387            .collect();
1388        let entries: Vec<(Galaxy, crate::Memory)> = contents
1389            .iter()
1390            .map(|c| (Galaxy::Codex, crate::Memory::new(Galaxy::Codex, c.clone())))
1391            .collect();
1392        let refs: Vec<(Galaxy, &crate::Memory)> = entries.iter().map(|(g, m)| (*g, m)).collect();
1393
1394        let first = Arc::new(CountingEmbedder::new());
1395        let engine = mk_engine(&store, &search, first.clone());
1396        assert_eq!(engine.store_batch_with_embedding(&refs).unwrap(), 12);
1397        let first_calls = first.call_count();
1398        assert!(first_calls > 0, "first ingest must embed");
1399        assert_eq!(store.embedding_cache_count().unwrap(), 12);
1400
1401        // Fresh engine over the SAME store (restart semantics: empty
1402        // in-memory cache, persistent layer intact).
1403        let second = Arc::new(CountingEmbedder::new());
1404        let engine2 = mk_engine(&store, &search, second.clone());
1405        let entries2: Vec<(Galaxy, crate::Memory)> = contents
1406            .iter()
1407            .map(|c| (Galaxy::Codex, crate::Memory::new(Galaxy::Codex, c.clone())))
1408            .collect();
1409        let refs2: Vec<(Galaxy, &crate::Memory)> = entries2.iter().map(|(g, m)| (*g, m)).collect();
1410        assert_eq!(engine2.store_batch_with_embedding(&refs2).unwrap(), 12);
1411        assert_eq!(
1412            second.call_count(),
1413            0,
1414            "re-ingest of identical content must serve from the persistent cache"
1415        );
1416        assert_eq!(store.embedding_cache_count().unwrap(), 12);
1417    }
1418
1419    #[test]
1420    fn embedding_cache_scopes_vectors_by_embedder_namespace() {
1421        // Switching models must never serve stale vectors: the cache key
1422        // carries the embedder namespace, so a "different model" is a miss.
1423        let (_dir, store, search) = engine_fixture();
1424
1425        let content = "namespace isolation probe";
1426        let first = Arc::new(CountingEmbedder::new());
1427        let engine = mk_engine(&store, &search, first.clone());
1428        let mem = crate::Memory::new(Galaxy::Codex, content.into());
1429        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
1430        assert_eq!(first.call_count(), 1);
1431
1432        // A second engine whose embedder reports a DIFFERENT namespace
1433        // must re-embed the same content.
1434        struct OtherNamespaceEmbedder(StubEmbedder);
1435        impl Embedder for OtherNamespaceEmbedder {
1436            fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1437                self.0.embed_batch(texts)
1438            }
1439            fn dimension(&self) -> usize {
1440                self.0.dimension()
1441            }
1442            fn is_available(&self) -> bool {
1443                true
1444            }
1445            fn backend_name(&self) -> &'static str {
1446                "stub-other"
1447            }
1448        }
1449        let second = Arc::new(OtherNamespaceEmbedder(StubEmbedder::default()));
1450        let engine2 = mk_engine(&store, &search, second);
1451        let mem2 = crate::Memory::new(Galaxy::Codex, content.into());
1452        engine2.store_with_embedding(Galaxy::Codex, &mem2).unwrap();
1453
1454        // Two cache entries: one per namespace.
1455        assert_eq!(store.embedding_cache_count().unwrap(), 2);
1456    }
1457
1458    #[test]
1459    fn graph_expansion_injects_unindexed_neighbors_and_boosts_connected() {
1460        let h = graph_harness();
1461
1462        // Knob off: base fusion only — B is invisible (not indexed).
1463        let plain = h.engine_plain.hybrid_search("kumquat", 10, None);
1464        assert!(plain.iter().all(|r| r.memory_id != {
1465            h.store
1466                .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1467                .unwrap()
1468                .unwrap()
1469        }));
1470        assert!(plain.iter().all(|r| r.graph_score == 0.0));
1471
1472        // Knob on: B is injected purely via the A→B edge, carrying its
1473        // graph contribution; A keeps the top fused score.
1474        let expanded = h.engine_with_graph.hybrid_search("kumquat", 10, None);
1475        let id_b = h
1476            .store
1477            .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1478            .unwrap()
1479            .unwrap();
1480        let b = expanded
1481            .iter()
1482            .find(|r| r.memory_id == id_b)
1483            .expect("graph expansion must surface the unindexed neighbor");
1484        assert!(b.graph_score > 0.0, "injected neighbor: {b:?}");
1485        assert_eq!(b.bm25_score, 0.0, "B had no BM25 hit — pure graph entry");
1486        let a_score = expanded
1487            .iter()
1488            .find(|r| r.content.contains("ratchet"))
1489            .unwrap()
1490            .score;
1491        assert!(a_score >= b.score, "seed outranks its 1-hop neighbor");
1492    }
1493
1494    #[test]
1495    fn graph_expansion_honors_the_privacy_flag() {
1496        let h = graph_harness();
1497        let id_b = h
1498            .store
1499            .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1500            .unwrap()
1501            .unwrap();
1502        // Flip B private → the graph must not open a side door to it.
1503        let mut b = h.store.get(Galaxy::Codex, id_b).unwrap().unwrap();
1504        b.metadata.is_private = true;
1505        h.store.put(Galaxy::Codex, &b).unwrap();
1506        let expanded = h.engine_with_graph.hybrid_search("kumquat", 10, None);
1507        assert!(
1508            expanded.iter().all(|r| r.memory_id != id_b),
1509            "private memory must not be graph-injected"
1510        );
1511    }
1512
1513    #[test]
1514    fn config_default_graph_weight_is_off() {
1515        let config = RecallConfig::default();
1516        assert_eq!(config.graph_weight, 0.0, "evidence-gated: default off");
1517        assert!(config.weights_normalized());
1518    }
1519
1520    // ── RecallConfig tests ─────────────────────────────────────────────
1521
1522    #[test]
1523    fn config_default_weights() {
1524        let config = RecallConfig::default();
1525        assert!(config.weights_normalized());
1526        assert_eq!(config.bm25_weight, 0.5);
1527        assert_eq!(config.vector_weight, 0.3);
1528        assert_eq!(config.importance_weight, 0.2);
1529    }
1530
1531    #[test]
1532    fn config_custom_weights() {
1533        let config = RecallConfig {
1534            bm25_weight: 0.6,
1535            vector_weight: 0.3,
1536            importance_weight: 0.1,
1537            ..Default::default()
1538        };
1539        assert!(config.weights_normalized());
1540    }
1541
1542    #[test]
1543    fn config_unnormalized_weights() {
1544        let config = RecallConfig {
1545            bm25_weight: 0.7,
1546            vector_weight: 0.5,
1547            importance_weight: 0.2,
1548            ..Default::default()
1549        };
1550        assert!(!config.weights_normalized());
1551    }
1552
1553    #[test]
1554    fn config_from_env_uses_defaults() {
1555        // No env vars set — should use defaults
1556        let config = RecallConfig::from_env();
1557        assert_eq!(config.bm25_weight, 0.5);
1558        assert_eq!(config.vector_weight, 0.3);
1559        assert_eq!(config.importance_weight, 0.2);
1560    }
1561
1562    /// Bridging counter: knob-off the fused ranking is byte-identical with
1563    /// or without corroboration stamps; knob-on the corroborated memory is
1564    /// boosted and the count is disclosed per-result.
1565    #[test]
1566    fn corroboration_boost_is_knob_gated_and_disclosed() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let lmdb = dir.path().join("lmdb");
1569        std::fs::create_dir_all(&lmdb).unwrap();
1570        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1571        let tantivy = dir.path().join("tantivy");
1572        std::fs::create_dir_all(&tantivy).unwrap();
1573        let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1574
1575        let mut backed = Memory::new(Galaxy::Codex, "zanzibar treaty terms".into());
1576        backed.metadata.corroborated_by = vec![Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
1577        let plain = Memory::new(Galaxy::Codex, "zanzibar treaty terms".into());
1578        let (id_backed, id_plain) = (backed.metadata.id, plain.metadata.id);
1579        store.put(Galaxy::Codex, &backed).unwrap();
1580        store.put(Galaxy::Codex, &plain).unwrap();
1581        let mut writer = search.writer().unwrap();
1582        for (id, content) in [
1583            (id_backed, "zanzibar treaty terms"),
1584            (id_plain, "zanzibar treaty terms"),
1585        ] {
1586            search
1587                .add_document(
1588                    &mut writer,
1589                    &id.to_string(),
1590                    "codex",
1591                    content,
1592                    &[],
1593                    1_700_000_000,
1594                )
1595                .unwrap();
1596        }
1597        search.commit(&mut writer).unwrap();
1598
1599        let mk = |weight: f32| {
1600            RecallEngine::new(
1601                store.clone(),
1602                search.clone(),
1603                VectorStore::new(),
1604                Arc::new(StubEmbedder::default()),
1605                RecallConfig {
1606                    bm25_weight: 1.0,
1607                    vector_weight: 0.0,
1608                    importance_weight: 0.0,
1609                    corroboration_weight: weight,
1610                    ..RecallConfig::default()
1611                },
1612            )
1613            .unwrap()
1614        };
1615        // Knob off: identical scores, zero disclosure.
1616        let off = mk(0.0).hybrid_search("zanzibar treaty", 10, None);
1617        let (b_off, p_off) = (
1618            off.iter().find(|r| r.memory_id == id_backed).unwrap(),
1619            off.iter().find(|r| r.memory_id == id_plain).unwrap(),
1620        );
1621        assert!((b_off.score - p_off.score).abs() < 1e-6);
1622        assert_eq!(b_off.corroboration, 0);
1623        // Knob on: 3-session backing boosts (factor 1 + 3/5) + disclosed.
1624        let on = mk(1.0).hybrid_search("zanzibar treaty", 10, None);
1625        let (b_on, p_on) = (
1626            on.iter().find(|r| r.memory_id == id_backed).unwrap(),
1627            on.iter().find(|r| r.memory_id == id_plain).unwrap(),
1628        );
1629        assert_eq!(b_on.corroboration, 3);
1630        assert_eq!(p_on.corroboration, 0);
1631        let expected = b_off.score * 1.6;
1632        assert!((b_on.score - expected).abs() < 1e-4, "{b_on:?}");
1633        assert!((p_on.score - p_off.score).abs() < 1e-6);
1634        assert!(on[0].memory_id == id_backed, "boosted memory ranks first");
1635    }
1636
1637    // ── RecallResult tests ─────────────────────────────────────────────
1638    #[test]
1639    fn recall_result_fields() {
1640        let result = RecallResult {
1641            memory_id: Uuid::new_v4(),
1642            galaxy: Galaxy::Codex,
1643            score: 0.85,
1644            bm25_score: 0.7,
1645            vector_score: 0.9,
1646            importance: 0.5,
1647            graph_score: 0.0,
1648            trust_factor: 1.0,
1649            in_conformal_set: false,
1650            corroboration: 0,
1651            content: "test content".into(),
1652        };
1653        assert_eq!(result.score, 0.85);
1654        assert_eq!(result.bm25_score, 0.7);
1655        assert_eq!(result.vector_score, 0.9);
1656    }
1657
1658    // ── RecallEngine unit tests (with stub embedder) ───────────────────
1659
1660    fn fuse(
1661        bm25: &[SearchResult],
1662        vector: &[VectorSearchResult],
1663        limit: usize,
1664    ) -> Vec<RecallResult> {
1665        fuse_results_inner(
1666            bm25,
1667            vector,
1668            limit,
1669            0.5,
1670            0.3,
1671            0.2,
1672            0.0,
1673            |_, _| String::new(),
1674            |_, _| 0.0,
1675            |_, _| 0.7,
1676        )
1677    }
1678
1679    #[test]
1680    fn engine_config_default() {
1681        let config = RecallConfig::default();
1682        assert_eq!(config.bm25_weight, 0.5);
1683    }
1684
1685    #[test]
1686    fn engine_cache_concept() {
1687        // Cache is tested via embed_content_caches_result below
1688        let config = RecallConfig::default();
1689        assert!(config.cache_embeddings);
1690    }
1691
1692    // ── Fusion logic tests ─────────────────────────────────────────────
1693
1694    #[test]
1695    fn fuse_results_empty() {
1696        let results = fuse(&[], &[], 10);
1697        assert!(results.is_empty());
1698    }
1699
1700    #[test]
1701    fn fuse_results_bm25_only() {
1702        let id = Uuid::new_v4();
1703        let bm25 = vec![SearchResult {
1704            memory_id: id.to_string(),
1705            galaxy: Galaxy::Codex.db_name().to_string(),
1706            score: 5.0,
1707            normalized_score: 0.0,
1708            content: "test".into(),
1709        }];
1710        let results = fuse(&bm25, &[], 10);
1711        assert_eq!(results.len(), 1);
1712        assert!(results[0].bm25_score > 0.0);
1713        assert_eq!(results[0].vector_score, 0.0);
1714    }
1715
1716    #[test]
1717    fn fuse_results_vector_only() {
1718        let id = Uuid::new_v4();
1719        let vector = vec![VectorSearchResult {
1720            memory_id: id,
1721            galaxy: Galaxy::Codex,
1722            score: 0.85,
1723        }];
1724        let results = fuse(&[], &vector, 10);
1725        assert_eq!(results.len(), 1);
1726        assert_eq!(results[0].bm25_score, 0.0);
1727        assert!(results[0].vector_score > 0.0);
1728    }
1729
1730    #[test]
1731    fn fuse_results_both_sources() {
1732        let id = Uuid::new_v4();
1733        let bm25 = vec![SearchResult {
1734            memory_id: id.to_string(),
1735            galaxy: Galaxy::Codex.db_name().to_string(),
1736            score: 5.0,
1737            normalized_score: 0.0,
1738            content: "test content".into(),
1739        }];
1740        let vector = vec![VectorSearchResult {
1741            memory_id: id,
1742            galaxy: Galaxy::Codex,
1743            score: 0.85,
1744        }];
1745        let results = fuse(&bm25, &vector, 10);
1746        assert_eq!(results.len(), 1);
1747        assert!(results[0].bm25_score > 0.0);
1748        assert!(results[0].vector_score > 0.0);
1749        assert!(results[0].score > results[0].bm25_score * 0.5);
1750    }
1751
1752    #[test]
1753    fn fuse_results_sorted_by_score() {
1754        let id1 = Uuid::new_v4();
1755        let id2 = Uuid::new_v4();
1756        let bm25 = vec![
1757            SearchResult {
1758                memory_id: id1.to_string(),
1759                galaxy: Galaxy::Codex.db_name().to_string(),
1760                score: 3.0,
1761                normalized_score: 0.0,
1762                content: "lower".into(),
1763            },
1764            SearchResult {
1765                memory_id: id2.to_string(),
1766                galaxy: Galaxy::Codex.db_name().to_string(),
1767                score: 8.0,
1768                normalized_score: 0.0,
1769                content: "higher".into(),
1770            },
1771        ];
1772        let results = fuse(&bm25, &[], 10);
1773        assert_eq!(results.len(), 2);
1774        assert!(results[0].score >= results[1].score);
1775    }
1776
1777    #[test]
1778    fn fuse_results_truncated_to_limit() {
1779        let bm25: Vec<SearchResult> = (0..20)
1780            .map(|i| SearchResult {
1781                memory_id: Uuid::new_v4().to_string(),
1782                galaxy: Galaxy::Codex.db_name().to_string(),
1783                score: 1.0 + i as f32,
1784                normalized_score: 0.0,
1785                content: format!("content {i}"),
1786            })
1787            .collect();
1788        let results = fuse(&bm25, &[], 5);
1789        assert_eq!(results.len(), 5);
1790    }
1791
1792    #[test]
1793    fn fuse_results_normalizes_bm25() {
1794        let id = Uuid::new_v4();
1795        let bm25 = vec![SearchResult {
1796            memory_id: id.to_string(),
1797            galaxy: Galaxy::Codex.db_name().to_string(),
1798            score: 100.0,
1799            normalized_score: 0.0,
1800            content: "test".into(),
1801        }];
1802        let results = fuse(&bm25, &[], 10);
1803        assert!((results[0].bm25_score - 1.0).abs() < 0.01);
1804    }
1805
1806    // ── Embedding cache tests ──────────────────────────────────────────
1807
1808    #[test]
1809    fn embed_content_caches_result() {
1810        let embedder = StubEmbedder::new(384);
1811        let content = "test content for caching";
1812        let vec1 = embedder.embed(content).unwrap();
1813        let vec2 = embedder.embed(content).unwrap();
1814        assert_eq!(vec1, vec2);
1815    }
1816
1817    #[test]
1818    fn embed_content_different_content_different_result() {
1819        let embedder = StubEmbedder::new(384);
1820        let vec1 = embedder.embed("content one").unwrap();
1821        let vec2 = embedder.embed("content two").unwrap();
1822        assert_ne!(vec1, vec2);
1823    }
1824
1825    // ── Weight configuration tests ─────────────────────────────────────
1826
1827    #[test]
1828    fn fuse_with_zero_bm25_weight() {
1829        let id = Uuid::new_v4();
1830        let bm25 = vec![SearchResult {
1831            memory_id: id.to_string(),
1832            galaxy: Galaxy::Codex.db_name().to_string(),
1833            score: 5.0,
1834            normalized_score: 0.0,
1835            content: "test".into(),
1836        }];
1837        let results = fuse_results_inner(
1838            &bm25,
1839            &[],
1840            10,
1841            0.5,
1842            0.3,
1843            0.2,
1844            0.0,
1845            |_, _| String::new(),
1846            |_, _| 0.0,
1847            |_, _| 0.7,
1848        );
1849        assert!((results[0].score - 0.5).abs() < 0.01);
1850    }
1851
1852    #[test]
1853    fn fuse_with_zero_vector_weight() {
1854        let id = Uuid::new_v4();
1855        let vector = vec![VectorSearchResult {
1856            memory_id: id,
1857            galaxy: Galaxy::Codex,
1858            score: 0.9,
1859        }];
1860        let results = fuse_results_inner(
1861            &[],
1862            &vector,
1863            10,
1864            0.5,
1865            0.3,
1866            0.2,
1867            0.0,
1868            |_, _| String::new(),
1869            |_, _| 0.0,
1870            |_, _| 0.7,
1871        );
1872        assert!((results[0].score - 0.27).abs() < 0.01);
1873    }
1874
1875    #[test]
1876    fn trust_weight_zero_is_byte_identical_to_no_weight() {
1877        let id = Uuid::new_v4();
1878        let bm25 = vec![SearchResult {
1879            memory_id: id.to_string(),
1880            galaxy: Galaxy::Codex.db_name().to_string(),
1881            score: 5.0,
1882            normalized_score: 0.0,
1883            content: "test".into(),
1884        }];
1885        // Knob off: the low-trust getter is never consulted, the score is
1886        // the plain fused value, and the disclosure never lies.
1887        let results = fuse_results_inner(
1888            &bm25,
1889            &[],
1890            10,
1891            0.5,
1892            0.3,
1893            0.2,
1894            0.0,
1895            |_, _| String::new(),
1896            |_, _| 0.0,
1897            |_, _| 0.4,
1898        );
1899        assert!((results[0].score - 0.5).abs() < 0.01);
1900        assert!((results[0].trust_factor - 1.0).abs() < f32::EPSILON);
1901        assert!(!results[0].in_conformal_set);
1902    }
1903
1904    #[test]
1905    fn trust_weight_orders_high_trust_above_low() {
1906        // Two candidates with identical fused scores; only source_trust
1907        // differs. With weight 0.5: factor = 1 + 0.5*(trust − 0.7).
1908        let high = Uuid::new_v4();
1909        let low = Uuid::new_v4();
1910        let mk = |id: &Uuid| SearchResult {
1911            memory_id: id.to_string(),
1912            galaxy: Galaxy::Codex.db_name().to_string(),
1913            score: 5.0,
1914            normalized_score: 0.0,
1915            content: "test".into(),
1916        };
1917        let bm25 = vec![mk(&high), mk(&low)];
1918        let mut trust_calls = 0;
1919        let results = fuse_results_inner(
1920            &bm25,
1921            &[],
1922            10,
1923            0.5,
1924            0.3,
1925            0.2,
1926            0.5,
1927            |_, _| String::new(),
1928            |_, _| 0.0,
1929            |id, _| {
1930                trust_calls += 1;
1931                if id == high { 1.0 } else { 0.4 }
1932            },
1933        );
1934        let hi = results.iter().find(|r| r.memory_id == high).unwrap();
1935        let lo = results.iter().find(|r| r.memory_id == low).unwrap();
1936        assert!(
1937            hi.score > lo.score,
1938            "user-confirmed (1.0) must outrank low-trust (0.4) at equal fused score"
1939        );
1940        // Factors disclosed: 1 + 0.5*(1.0−0.7) = 1.15; 1 + 0.5*(0.4−0.7) = 0.85.
1941        assert!((hi.trust_factor - 1.15).abs() < 0.001);
1942        assert!((lo.trust_factor - 0.85).abs() < 0.001);
1943        assert!(trust_calls >= 2, "getter consulted per candidate");
1944        // Neutral 0.7 stays exactly neutral even with the knob on.
1945        let neutral = Uuid::new_v4();
1946        let bm25_neutral = vec![SearchResult {
1947            memory_id: neutral.to_string(),
1948            galaxy: Galaxy::Codex.db_name().to_string(),
1949            score: 5.0,
1950            normalized_score: 0.0,
1951            content: "test".into(),
1952        }];
1953        let res_n = fuse_results_inner(
1954            &bm25_neutral,
1955            &[],
1956            10,
1957            0.5,
1958            0.3,
1959            0.2,
1960            0.5,
1961            |_, _| String::new(),
1962            |_, _| 0.0,
1963            |_, _| 0.7,
1964        );
1965        assert!((res_n[0].trust_factor - 1.0).abs() < 0.001);
1966    }
1967
1968    #[test]
1969    fn config_defaults_keep_both_s8_knobs_off() {
1970        // Evidence-gated defaults: trust weighting and conformal sets ship
1971        // OFF — the base fusion must be untouched unless the operator opts
1972        // in. (Env parsing for the knobs follows the same guard pattern as
1973        // WM_RECALL_GRAPH_WEIGHT: finite, clamped to range, else default.)
1974        let cfg = RecallConfig::default();
1975        assert_eq!(cfg.trust_weight, 0.0);
1976        assert_eq!(cfg.conformal_alpha, None);
1977        let env_cfg = RecallConfig::from_env();
1978        assert_eq!(env_cfg.trust_weight, 0.0, "unset env stays off");
1979        assert_eq!(env_cfg.conformal_alpha, None, "unset env stays off");
1980    }
1981
1982    // ── GalaxyExt tests ────────────────────────────────────────────────
1983
1984    #[test]
1985    fn galaxy_from_db_name_valid() {
1986        assert_eq!(Galaxy::from_db_name("codex"), Some(Galaxy::Codex));
1987    }
1988
1989    #[test]
1990    fn galaxy_from_db_name_invalid() {
1991        assert_eq!(Galaxy::from_db_name("nonexistent"), None);
1992    }
1993
1994    // ── Integration tests (end-to-end with temp-dir LMDB + Tantivy) ────
1995
1996    use crate::Memory;
1997    use tempfile::tempdir;
1998
1999    fn setup_engine() -> (tempfile::TempDir, RecallEngine) {
2000        let tmp = tempdir().unwrap();
2001        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
2002        let tantivy_path = tmp.path().join("tantivy");
2003        std::fs::create_dir_all(&tantivy_path).unwrap();
2004        let search = Arc::new(SearchEngine::open(&tantivy_path).unwrap());
2005        let vector_store = VectorStore::new();
2006        let embedder: Arc<dyn Embedder> = Arc::new(StubEmbedder::new(384));
2007        let engine = RecallEngine::new(
2008            store,
2009            search,
2010            vector_store,
2011            embedder,
2012            RecallConfig::default(),
2013        )
2014        .unwrap();
2015        (tmp, engine)
2016    }
2017
2018    #[test]
2019    fn integration_store_and_hybrid_search_roundtrip() {
2020        let (_tmp, engine) = setup_engine();
2021
2022        let mem1 = Memory::new(
2023            Galaxy::Codex,
2024            "Rust programming language is fast and safe".into(),
2025        )
2026        .with_importance(0.8)
2027        .with_tags(vec!["rust".into(), "programming".into()]);
2028        let mem2 = Memory::new(Galaxy::Codex, "Python is great for data science".into())
2029            .with_importance(0.5)
2030            .with_tags(vec!["python".into(), "data".into()]);
2031        let mem3 = Memory::new(
2032            Galaxy::Codex,
2033            "The Rust ownership model prevents memory leaks".into(),
2034        )
2035        .with_importance(0.9)
2036        .with_tags(vec!["rust".into(), "memory".into()]);
2037
2038        engine.store_with_embedding(Galaxy::Codex, &mem1).unwrap();
2039        engine.store_with_embedding(Galaxy::Codex, &mem2).unwrap();
2040        engine.store_with_embedding(Galaxy::Codex, &mem3).unwrap();
2041
2042        // Search for "rust" — should find mem1 and mem3 (both contain "rust")
2043        let results = engine.hybrid_search("rust", 10, None);
2044        assert!(!results.is_empty(), "hybrid search should return results");
2045
2046        // All results should contain "rust" in content or be vector-similar
2047        let top_contents: Vec<&str> = results.iter().map(|r| r.content.as_str()).collect();
2048        assert!(
2049            top_contents.iter().any(|c| c.contains("Rust")),
2050            "top results should include Rust content, got: {top_contents:?}"
2051        );
2052    }
2053
2054    #[test]
2055    fn integration_bm25_and_vector_both_contribute() {
2056        let (_tmp, engine) = setup_engine();
2057
2058        // Store memories with distinct content
2059        for i in 0..5 {
2060            let mem = Memory::new(
2061                Galaxy::Codex,
2062                format!("memory about topic {i} with unique content"),
2063            )
2064            .with_importance(0.5);
2065            engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2066        }
2067
2068        // Search for a term that exists in all memories
2069        let results = engine.hybrid_search("memory", 10, None);
2070        assert!(!results.is_empty(), "should find memories");
2071
2072        // BM25 should have contributed (all contain "memory")
2073        let has_bm25 = results.iter().any(|r| r.bm25_score > 0.0);
2074        assert!(has_bm25, "BM25 should contribute to fused results");
2075    }
2076
2077    #[test]
2078    fn integration_vector_search_only() {
2079        let (_tmp, engine) = setup_engine();
2080
2081        let content = "unique searchable content for vector test";
2082        let mem = Memory::new(Galaxy::Codex, content.into()).with_importance(0.7);
2083        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2084
2085        // StubEmbedder is hash-based — same text produces same vector
2086        let results = engine.vector_search(content, 10, None);
2087        assert_eq!(results.len(), 1);
2088        assert_eq!(results[0].memory_id, mem.metadata.id);
2089        assert!(results[0].vector_score > 0.0);
2090    }
2091
2092    #[test]
2093    fn integration_text_search_only() {
2094        let (_tmp, engine) = setup_engine();
2095
2096        let mem = Memory::new(Galaxy::Codex, "specific text about rust ownership".into())
2097            .with_tags(vec!["rust".into()]);
2098        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2099
2100        let results = engine.text_search("rust", 10);
2101        assert!(!results.is_empty(), "text search should find results");
2102        assert!(results.iter().any(|r| r.bm25_score > 0.0));
2103    }
2104
2105    #[test]
2106    fn integration_batch_store_with_embedding() {
2107        let (_tmp, engine) = setup_engine();
2108
2109        let mem1 = Memory::new(Galaxy::Codex, "alpha beta gamma".into());
2110        let mem2 = Memory::new(Galaxy::Codex, "delta epsilon zeta".into());
2111        let mem3 = Memory::new(Galaxy::Codex, "eta theta iota".into());
2112
2113        let entries = vec![
2114            (Galaxy::Codex, &mem1),
2115            (Galaxy::Codex, &mem2),
2116            (Galaxy::Codex, &mem3),
2117        ];
2118
2119        let count = engine.store_batch_with_embedding(&entries).unwrap();
2120        assert_eq!(count, 3);
2121
2122        // All three should be searchable via BM25
2123        let results = engine.text_search("alpha", 10);
2124        assert!(
2125            !results.is_empty(),
2126            "batch-stored memory should be searchable"
2127        );
2128
2129        // All three should be in the vector store
2130        let vresults = engine.vector_search("alpha beta gamma", 10, None);
2131        assert_eq!(
2132            vresults.len(),
2133            1,
2134            "vector search should find the exact match"
2135        );
2136        assert_eq!(vresults[0].memory_id, mem1.metadata.id);
2137    }
2138
2139    #[test]
2140    fn integration_batch_store_empty() {
2141        let (_tmp, engine) = setup_engine();
2142        let entries: Vec<(Galaxy, &Memory)> = vec![];
2143        let count = engine.store_batch_with_embedding(&entries).unwrap();
2144        assert_eq!(count, 0);
2145    }
2146
2147    #[test]
2148    fn integration_galaxy_filter() {
2149        let (_tmp, engine) = setup_engine();
2150
2151        let mem_codex = Memory::new(Galaxy::Codex, "codex memory about rust".into());
2152        let mem_research = Memory::new(Galaxy::Research, "research memory about rust".into());
2153
2154        engine
2155            .store_with_embedding(Galaxy::Codex, &mem_codex)
2156            .unwrap();
2157        engine
2158            .store_with_embedding(Galaxy::Research, &mem_research)
2159            .unwrap();
2160
2161        let results = engine.hybrid_search("rust", 10, Some(Galaxy::Codex));
2162        assert!(!results.is_empty());
2163        assert!(
2164            results.iter().all(|r| r.galaxy == Galaxy::Codex),
2165            "all results should be from Codex galaxy"
2166        );
2167    }
2168
2169    #[test]
2170    fn integration_empty_search() {
2171        let (_tmp, engine) = setup_engine();
2172        let results = engine.hybrid_search("nonexistent", 10, None);
2173        assert!(results.is_empty());
2174    }
2175
2176    #[test]
2177    fn integration_cache_populated_after_store() {
2178        let (_tmp, engine) = setup_engine();
2179
2180        let mem = Memory::new(Galaxy::Codex, "content to be cached".into());
2181        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2182
2183        // The embedding cache should have one entry
2184        assert_eq!(engine.cache_size(), 1);
2185    }
2186
2187    #[test]
2188    fn integration_vector_count_tracks_stores() {
2189        let (_tmp, engine) = setup_engine();
2190
2191        assert_eq!(engine.vector_count(), 0);
2192
2193        for i in 0..3 {
2194            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
2195            engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2196        }
2197
2198        assert_eq!(engine.vector_count(), 3);
2199    }
2200
2201    #[test]
2202    fn integration_importance_affects_ranking() {
2203        let (_tmp, engine) = setup_engine();
2204
2205        // Two memories with same content keyword but different importance
2206        let mem_low =
2207            Memory::new(Galaxy::Codex, "rust programming basics".into()).with_importance(0.1);
2208        let mem_high =
2209            Memory::new(Galaxy::Codex, "rust programming advanced".into()).with_importance(0.9);
2210
2211        engine
2212            .store_with_embedding(Galaxy::Codex, &mem_low)
2213            .unwrap();
2214        engine
2215            .store_with_embedding(Galaxy::Codex, &mem_high)
2216            .unwrap();
2217
2218        let results = engine.hybrid_search("rust", 10, None);
2219        assert_eq!(results.len(), 2);
2220
2221        // The higher-importance memory should generally rank higher
2222        // (both have similar BM25 and vector scores, importance breaks the tie)
2223        let high_idx = results
2224            .iter()
2225            .position(|r| r.memory_id == mem_high.metadata.id)
2226            .unwrap();
2227        let low_idx = results
2228            .iter()
2229            .position(|r| r.memory_id == mem_low.metadata.id)
2230            .unwrap();
2231        assert!(
2232            high_idx < low_idx,
2233            "higher importance memory should rank higher"
2234        );
2235    }
2236
2237    #[test]
2238    fn config_from_env_rejects_nan_weights() {
2239        // Test the validation logic directly rather than via env vars
2240        // (wm-memory has forbid(unsafe_code), can't use set_var)
2241        let mut config = RecallConfig::default();
2242        let w: f32 = "NaN".parse().unwrap();
2243        if w.is_finite() && w >= 0.0 {
2244            config.bm25_weight = w.min(1.0);
2245        }
2246        assert_eq!(
2247            config.bm25_weight, 0.5,
2248            "NaN should be rejected, default kept"
2249        );
2250    }
2251
2252    #[test]
2253    fn config_from_env_rejects_negative_weights() {
2254        let mut config = RecallConfig::default();
2255        let w: f32 = "-0.5".parse().unwrap();
2256        if w.is_finite() && w >= 0.0 {
2257            config.vector_weight = w.min(1.0);
2258        }
2259        assert_eq!(
2260            config.vector_weight, 0.3,
2261            "Negative should be rejected, default kept"
2262        );
2263    }
2264
2265    #[test]
2266    fn config_from_env_clamps_weights_to_1() {
2267        let mut config = RecallConfig::default();
2268        let w: f32 = "5.0".parse().unwrap();
2269        if w.is_finite() && w >= 0.0 {
2270            config.importance_weight = w.min(1.0);
2271        }
2272        assert_eq!(
2273            config.importance_weight, 1.0,
2274            "Weight should be clamped to 1.0"
2275        );
2276    }
2277
2278    #[test]
2279    fn config_from_env_normalizes_weights() {
2280        let mut config = RecallConfig {
2281            bm25_weight: 0.8,
2282            vector_weight: 0.8,
2283            importance_weight: 0.8,
2284            ..Default::default()
2285        };
2286        let sum = config.bm25_weight + config.vector_weight + config.importance_weight;
2287        if sum > 0.0 && (sum - 1.0).abs() > 0.01 {
2288            config.bm25_weight /= sum;
2289            config.vector_weight /= sum;
2290            config.importance_weight /= sum;
2291        }
2292        assert!(
2293            config.weights_normalized(),
2294            "Weights should be normalized to sum to 1.0"
2295        );
2296    }
2297
2298    #[test]
2299    fn config_from_env_rejects_infinity() {
2300        let mut config = RecallConfig::default();
2301        let w: f32 = "inf".parse().unwrap();
2302        if w.is_finite() && w >= 0.0 {
2303            config.bm25_weight = w.min(1.0);
2304        }
2305        assert_eq!(
2306            config.bm25_weight, 0.5,
2307            "Infinity should be rejected, default kept"
2308        );
2309    }
2310
2311    #[test]
2312    fn test_promotion_on_read_config_default() {
2313        let default_config = RecallConfig::default();
2314        assert!(!default_config.promotion_on_read);
2315
2316        let custom_config = RecallConfig {
2317            promotion_on_read: true,
2318            ..Default::default()
2319        };
2320        assert!(custom_config.promotion_on_read);
2321    }
2322
2323    #[test]
2324    fn test_promote_memory_updates_hebbian_score_and_counts() {
2325        let tmp = tempfile::tempdir().unwrap();
2326        let store_dir = tmp.path().join("store");
2327        std::fs::create_dir_all(&store_dir).unwrap();
2328        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2329        let index_dir = tmp.path().join("index");
2330        std::fs::create_dir_all(&index_dir).unwrap();
2331        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2332        let vector_store = VectorStore::new();
2333        let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2334        let config = RecallConfig {
2335            promotion_on_read: true,
2336            ..Default::default()
2337        };
2338        let engine =
2339            RecallEngine::new(store.clone(), search_engine, vector_store, embedder, config)
2340                .unwrap();
2341
2342        let mut mem = crate::Memory::new(Galaxy::Codex, "promotion on read test".to_string());
2343        mem.metadata.neuro_score = 0.5;
2344        mem.metadata.novelty_score = 1.0;
2345        let mem_id = mem.metadata.id;
2346        store.put(Galaxy::Codex, &mem).unwrap();
2347
2348        // Promote memory
2349        let promoted = engine.promote_memory(Galaxy::Codex, mem_id).unwrap();
2350        assert!(promoted);
2351
2352        let reloaded = store.get(Galaxy::Codex, mem_id).unwrap().unwrap();
2353        assert_eq!(reloaded.metadata.recall_count, 1);
2354        assert_eq!(reloaded.metadata.access_count, 1);
2355        assert!(
2356            reloaded.metadata.neuro_score > 0.5,
2357            "neuro_score should increase via Hebbian boost"
2358        );
2359        assert!(
2360            reloaded.metadata.novelty_score < 1.0,
2361            "novelty_score should decay on recall"
2362        );
2363    }
2364
2365    #[test]
2366    fn test_hybrid_search_triggers_promotion_on_read() {
2367        let tmp = tempfile::tempdir().unwrap();
2368        let store_dir = tmp.path().join("store");
2369        std::fs::create_dir_all(&store_dir).unwrap();
2370        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2371        let index_dir = tmp.path().join("index");
2372        std::fs::create_dir_all(&index_dir).unwrap();
2373        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2374        let vector_store = VectorStore::new();
2375        let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2376        let config = RecallConfig {
2377            promotion_on_read: true,
2378            ..Default::default()
2379        };
2380        let engine = RecallEngine::new(
2381            store.clone(),
2382            search_engine.clone(),
2383            vector_store,
2384            embedder,
2385            config,
2386        )
2387        .unwrap();
2388
2389        let mut mem = crate::Memory::new(Galaxy::Codex, "tokio army swarm tactics".to_string());
2390        mem.metadata.neuro_score = 0.5;
2391        mem.metadata.novelty_score = 1.0;
2392        let mem_id = mem.metadata.id;
2393        store.put(Galaxy::Codex, &mem).unwrap();
2394
2395        let mut writer = search_engine.writer().unwrap();
2396        search_engine
2397            .add_document(
2398                &mut writer,
2399                &mem_id.to_string(),
2400                "codex",
2401                "tokio army swarm tactics",
2402                &[],
2403                1_700_000_000,
2404            )
2405            .unwrap();
2406        search_engine.commit(&mut writer).unwrap();
2407
2408        // Perform search with promotion_on_read active
2409        let (results, _) =
2410            engine.hybrid_search_with_disclosure("tokio army", 5, Some(Galaxy::Codex));
2411        assert!(!results.is_empty());
2412        assert_eq!(results[0].memory_id, mem_id);
2413
2414        let reloaded = store.get(Galaxy::Codex, mem_id).unwrap().unwrap();
2415        assert_eq!(reloaded.metadata.recall_count, 1);
2416        assert!(reloaded.metadata.neuro_score > 0.5);
2417    }
2418
2419    #[test]
2420    fn test_s10_association_rerank() {
2421        let (_tmp, mut engine) = setup_engine();
2422        let env = engine.store.env();
2423        let assoc_store = AssociationStore::open(env).unwrap();
2424
2425        // Memory A: solo node
2426        let mem_a = Memory::new(Galaxy::Codex, "alpha query topic node".into());
2427        engine.store_with_embedding(Galaxy::Codex, &mem_a).unwrap();
2428
2429        // Memory B: connected to C
2430        let mem_b = Memory::new(Galaxy::Codex, "beta query topic node".into());
2431        let id_b = mem_b.metadata.id;
2432        engine.store_with_embedding(Galaxy::Codex, &mem_b).unwrap();
2433
2434        // Target memory C connected to B
2435        let mem_c = Memory::new(Galaxy::Research, "gamma target node".into());
2436        let id_c = mem_c.metadata.id;
2437        engine.store.put(Galaxy::Research, &mem_c).unwrap();
2438
2439        let edge = crate::associations::Association::new(
2440            id_b,
2441            id_c,
2442            crate::associations::LinkType::Related,
2443            0.8,
2444        );
2445        assoc_store.put(env, &edge).unwrap();
2446
2447        // Search with association_rerank = false (default)
2448        let results_default = engine.hybrid_search("query topic", 10, None);
2449        assert!(!results_default.is_empty());
2450
2451        // Search with association_rerank = true
2452        engine.config.association_rerank = true;
2453        let results_rerank = engine.hybrid_search("query topic", 10, None);
2454        assert!(!results_rerank.is_empty());
2455
2456        // Memory B should receive the association boost
2457        let score_b_default = results_default
2458            .iter()
2459            .find(|r| r.memory_id == id_b)
2460            .unwrap()
2461            .score;
2462        let score_b_rerank = results_rerank
2463            .iter()
2464            .find(|r| r.memory_id == id_b)
2465            .unwrap()
2466            .score;
2467        assert!(score_b_rerank > score_b_default);
2468    }
2469}